diff --git a/README.md b/README.md index 52c0102..287bb48 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,48 @@ **Repository type:** module (domain). -`govoplan-addresses` is the planned reusable address and recipient-source -module. It should own long-lived address directories and make them available to -campaigns, mail, forms, reporting, portal, and postbox modules through platform +`govoplan-addresses` is the reusable address and recipient-source module. It +owns long-lived address directories and makes them available to campaigns, +mail, forms, reporting, portal, and postbox modules through platform capabilities. The campaign module may import campaign-local recipient tables, but reusable address management belongs here. +## Current State + +Milestone 1 is implemented. The module now owns persistent local address books +and contact CRUD under `/api/v1/addresses`, contributes `/address-book` to the +WebUI, and registers address permissions, role templates, database migrations, +tenant summaries, and uninstall guards. + +The first UI supports user, group, tenant, and system-scoped address books, +multi-value contact methods, soft deletion, restore, read-only lookup/search, +and vCard import/export for common contact fields. Imported vCards preserve +source payload and revision metadata for later sync/conflict work. + +The backend and WebUI also support classical address lists: reusable groupings +of contacts or specific contact methods within one address book. Campaigns can +import address books and address lists through the core-mediated +`addresses.recipient_source` capability without importing address-module +internals. Broader operational `Verteiler` with mixed users, identities, +groups, functions, raw recipients, and nested lists belong in +`govoplan-dist-lists`. + +The backend now also contains connector-neutral sync infrastructure. Address +books can be bound to external sources, sync attempts can record status, +tokens, ETags, revisions, diagnostics, tombstones, and conflicts, and read-only +or one-way-import sources make the owning address book read-only for normal +write paths. CardDAV discovery, source binding, dry-run preview, inbound vCard +sync, outbound create/update/delete for writable CardDAV sources, diagnostics, +tombstones, conflict persistence, source disconnect/delete UX, and a first sync +inspection UI are implemented. The conflict review UI compares stored local and +remote field payloads, can apply a stored remote vCard payload, and supports +manual per-field local/remote merge choices. + ## Boundary -`govoplan-addresses` should own: +`govoplan-addresses` owns: - Adrema-style person, organization, household, and postal-address records - reusable email address lists and postal-letter recipient views @@ -31,21 +62,46 @@ It must not own: - SMTP/IMAP transport - file storage - global identity authentication or RBAC evaluation +- operational distribution lists/`Verteiler` with mixed recipient types -## First Capability +## First Capabilities -The first useful contract should be a read-only recipient-source capability, -for example `addresses.recipientSource`. +The module exposes three core-mediated capabilities: -It should let a consumer request a stable snapshot containing: +- `addresses.lookup`: read-only contact/recipient lookup for autocomplete. +- `addresses.recipient_source`: immutable recipient snapshots for campaign, + reporting, mail-build, forms, portal, and postbox workflows. +- `addresses.contact_writer`: address-book-scoped write decisions and contact + creation for local or otherwise writable sources. + +`addresses.recipient_source` returns: - source id and display label - normalized recipient rows -- email and postal address fields -- legal-basis and consent metadata +- email recipient fields - source update marker - provenance fields suitable for audit and campaign reports +Recipient sources currently include complete address books and classical +address lists. Address-list source IDs use `addresses:address_list:` and +preserve the address-list entry ID in recipient provenance. Address-list entries +may point at a whole contact, a concrete email address, or a concrete postal +address. Email-oriented consumers snapshot email targets and whole-contact +entries with a usable email address; postal-only entries remain valid list +members for later postal/document workflows. + Consumers must store their own immutable snapshot when they need historical evidence. The addresses module remains the owner of the reusable source, not of -the consumer's historical records. +the consumer's historical records. Consumers must resolve these capabilities +through the platform registry and must not import address ORM/service internals. + +`addresses.contact_writer` returns an explicit decision before a consumer shows +or executes write actions: allowed/blocked, reason, user-facing message, +required scopes, source kind, read-only state, and provenance. The decision is +address-book specific; broader policy modules may later contribute to the same +decision path, but consumers should not import or duplicate policy logic. + +## Design Documents + +- [Address module architecture](docs/ADDRESS_MODULE_ARCHITECTURE.md) +- [Implementation plan](docs/IMPLEMENTATION_PLAN.md) diff --git a/docs/ADDRESS_MODULE_ARCHITECTURE.md b/docs/ADDRESS_MODULE_ARCHITECTURE.md new file mode 100644 index 0000000..3ae21c5 --- /dev/null +++ b/docs/ADDRESS_MODULE_ARCHITECTURE.md @@ -0,0 +1,193 @@ +# GovOPlaN Addresses Module Architecture + +## Decision + +`govoplan-addresses` owns reusable contact and recipient-source data. Campaigns, +mail, scheduling, portal, postbox, forms, reporting, and other modules consume +address data through core-mediated capabilities and APIs, not by importing +address-module internals. + +The implementation reference for contact data is vCard. CardDAV is the primary +address-book sync protocol. LDAP/Active Directory, Exchange/Microsoft 365, +Google Contacts, CSV/XLSX, LDIF, and batch vCard import/export are connector +targets layered on top of the same local model and sync contracts. + +## Ownership + +`govoplan-addresses` owns: + +- scoped address books +- contacts, organizations, households, and postal/email/phone address data +- vCard import/export and vCard-compatible field mapping +- reusable recipient sources and classical address lists +- contact tags, categories, communication preferences, consent, and legal basis +- deduplication, merge, address quality checks, and suppression lists +- contact provenance, audit history, soft delete, and restore +- external-source bindings, sync state, conflicts, and read-only source markers + +It does not own: + +- campaign-local recipient snapshots and evidence +- mail transport, mailbox access, or delivery queues +- calendar events or iCalendar event storage +- global identity authentication or authorization decisions +- organization structure or internal function assignments +- operational distribution lists/`Verteiler` with mixed users, identities, + groups, functions, roles, raw recipients, and nested lists + +## Scopes + +Address books can live in these scopes: + +- `user`: personal address books and remembered contacts +- `group`: team/shared address books +- `tenant`: tenant-wide directories and approved shared lists +- `system`: platform-wide public/shared directories where policy allows it + +The scope determines visibility, default permissions, sync credentials, and +whether downstream modules may reuse or mutate entries. + +## Data Model Principles + +The canonical model should preserve enough vCard semantics to round-trip common +fields: + +- name components and formatted names +- nicknames and display names +- email addresses, phone numbers, postal addresses, URLs, notes, categories +- organizations, titles, roles, departments, and relationships +- birthday/anniversary where allowed by policy +- photos/avatars where storage and privacy policy allow them +- calendar or scheduling addresses where present +- source IDs, revisions, ETags, sync tokens, and provenance + +The model should support both normalized query fields and a preserved original +representation for import/export and conflict handling. + +The local baseline implements scoped address books, contacts, normalized +email/phone/postal-address tables, tags, source kind/reference fields, +first-class source payload/revision fields, and provenance JSON. Imported +vCards preserve raw source payload and revision metadata for audit/debugging. +Sync sources, attempt state, tombstones, conflicts, and diagnostics are now +first-class backend tables and API resources. Connector-specific diffing, +CardDAV discovery, and conflict-resolution UI remain part of the connector +milestones. + +## Capabilities + +The first stable capabilities are: + +- `addresses.recipient_source`: return immutable recipient snapshots for + campaigns, forms, reporting, and other send/build workflows. +- `addresses.lookup`: provide read-only lookup and autocomplete for mail, + campaign, scheduling, postbox, portal, and case workflows. +- `addresses.contact_writer`: provide address-book-scoped write target decisions + and contact creation for local or otherwise writable sources. + +Capabilities use DTOs and source IDs. Consumers must not receive ORM objects or +write address tables directly. Consumers that need historical evidence must +store their own immutable snapshot with source ID, source revision, and +provenance; they must not treat live address records as historical evidence. + +`addresses.recipient_source` exposes both complete address books and classical +address lists. Address-book sources use `addresses:address_book:`. +Address-list sources use `addresses:address_list:` and include the +address-list entry ID in each recipient's provenance. The current snapshot DTO +is email-recipient oriented; postal-only list entries are valid address-list +members but are skipped by the email recipient-source path until postal +recipient DTOs are added. + +The writer capability is intentionally address-book specific. It answers +whether the current principal may perform an operation such as `create_contact`, +`update_contact`, or `delete_contact` against a concrete address book. The +decision payload includes: + +- `allowed` +- stable `reason` +- user-facing `message` +- required scopes +- source kind +- read-only state +- scope and tenant provenance + +Policy modules or connector sync state may later add inputs to this decision, +but consumers must continue to call the address capability/API instead of +importing policy logic or address services directly. Disabled or read-only UI +actions should surface the returned `message` on hover. + +## Sync Model + +Every synced address book tracks or can track: + +- connector type and external account/source +- external address-book ID and display name +- local address-book scope +- sync direction: read-only, one-way import, one-way export, two-way +- sync token, ETag/revision, last successful sync, last attempted sync +- deleted markers/tombstones +- conflict status and resolution decision +- connector diagnostics and rate-limit/backoff state + +Sync conflict UX must show the local value, remote value, source, timestamp, and +available action. Silent overwrite is not acceptable. + +Sync infrastructure is intentionally connector-neutral. CardDAV, LDAP, +Exchange/Microsoft 365, Google Contacts, CSV/XLSX/LDIF import profiles, and +future connectors must write through `addresses_sync_sources` and related +records instead of inventing connector-specific status tables. Connector jobs +may mark a source `running`, `succeeded`, `failed`, or `conflict`; read-only and +one-way-import sources propagate a read-only decision to the owning address +book, which in turn blocks normal contact writes through the existing writer +capability/API. + +The first CardDAV implementation supports discovery, source binding, dry-run +preview, inbound vCard sync, outbound create/update/delete for writable +sources, sync-token/full-sync fallback, tombstones, diagnostics, and persisted +conflicts. Outbound writes use ETag preconditions; stale local state must become +a conflict instead of silently overwriting remote data. The first conflict +review UI compares stored local and remote field payloads and can apply a +stored remote vCard payload or a manual per-field local/remote merge payload. +Source disconnect/delete removes the source binding and related sync records +while keeping local contacts. + +## Connector Direction + +Implement connectors in this order: + +1. vCard import/export and batch import. +2. CardDAV address books. +3. LDAP/Active Directory read-only directories. +4. Exchange/Microsoft 365 and Google Contacts. +5. CSV/XLSX/LDIF import mapping profiles. + +Connector runtime behavior should reuse shared connector concepts where useful: +configured endpoints, credentials, dry-run, diagnostics, rate limits, and audit +events. + +## Cross-Module Integration + +Campaigns should consume `addresses.recipient_source` through the platform +registry and freeze snapshots into campaign versions. Mail should consume +`addresses.lookup` for autocomplete and `addresses.contact_writer` for "add +contact" workflows. Scheduling should use lookup for attendees and organizers. +Portal, postbox, cases, forms, and reporting should link to contact records by +stable IDs while keeping their own domain evidence. Cross-module UI must hide +write actions when no writable target exists, or show the writer decision +message when a disabled action remains visible for context. + +Operational distribution lists belong in `govoplan-dist-lists`. They may later +consume address lists as one entry type, but they own mixed recipient expansion +for users, identities, organization units, groups, functions, roles, raw +recipients, and nested lists. Workflow and Tasks own `Umlauf` execution state; +distribution lists define who is included, not how work circulates. + +## Deferred Work + +The following are valuable but not required for the first functional milestone: + +- automatic deduplication and merge suggestions +- two-way sync conflict UI +- Microsoft/Google connectors +- household and relationship editing +- advanced consent-policy automation +- contact activity timeline across all modules diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..294906a --- /dev/null +++ b/docs/IMPLEMENTATION_PLAN.md @@ -0,0 +1,236 @@ +# GovOPlaN Addresses Implementation Plan + +## Milestone 1: Functional Local Address Books + +Goal: make `govoplan-addresses` useful without external sync. + +Primary issue: `govoplan-addresses#3`. + +Status: implemented. The persistent backend, router, migration, permissions, +role templates, lookup endpoint, restore support, multi-value contact editor, +and first WebUI are in place. + +Tasks: + +- [x] add backend tables and migrations for address books, contacts, contact + methods, postal addresses, tags, and provenance +- [x] add permissions and role templates for viewing and managing address books +- [x] implement address-book CRUD API +- [x] implement contact CRUD API +- [x] implement scoped WebUI views for user, group, tenant, and system books +- [x] support soft delete +- [x] add restore API/UI for soft-deleted address books and contacts +- [x] expose read-only contact lookup for the WebUI + +Exit criteria: + +- [x] an admin can create tenant/system books +- [x] a user can create a personal book and contacts +- [x] address data survives restart and appears in the address-book UI +- [x] disabled or read-only actions explain why they are unavailable + +## Milestone 2: vCard Foundation + +Goal: make the model standards-based before adding sync. + +Primary issue: `govoplan-addresses#4`. + +Status: implemented. The module can import and export common vCard fields for +local books, preserves imported raw vCard payload and revision metadata in +first-class source fields, reports field/card import issues, and has round-trip +and partial-import tests. Full two-way connector conflict resolution remains in +the sync milestones. + +Tasks: + +- [x] define vCard-compatible DTOs +- [x] preserve original imported vCard data and normalized query fields +- [x] import `.vcf` files into a selected address book +- [x] export contacts/address books as vCard +- [x] add validation for common vCard fields +- [x] add tests for round-trip import/export of names, emails, phones, postal + addresses, organization fields, notes, categories, and URLs +- [x] add field/card validation messages for batch import +- [x] promote original payload and revision handling into first-class contact + source fields + +Exit criteria: + +- [x] common vCard files can be imported and exported without losing core fields +- [x] normalized search fields are populated during import +- [x] invalid entries produce field-level actionable validation messages + +## Milestone 3: Core Capabilities + +Goal: allow other modules to use addresses without dependencies. + +Primary issue: `govoplan-addresses#5`. + +Tasks: + +- [x] implement `addresses.lookup` +- [x] implement `addresses.recipient_source` +- [x] define immutable recipient snapshot DTOs +- [x] expose source provenance in capability responses +- [x] expose classical address lists as `addresses.recipient_source` sources +- [x] add module presence/capability tests +- [x] document consumer rules for campaign, mail, scheduling, portal, postbox, and + reporting + +Exit criteria: + +- [x] campaign can request a recipient source via core-mediated capability +- [x] mail/scheduling can request autocomplete candidates via core-mediated lookup +- [x] consumers do not import `govoplan_addresses` + +## Milestone 4: Campaign Integration + +Goal: replace campaign-local reusable address concepts with address-module +sources. + +Primary issue: `govoplan-campaign#55`. + +Tasks: + +- [x] add campaign recipient-source picker when `addresses.recipient_source` exists +- [x] snapshot selected address-source rows into the campaign version +- [x] store source ID, revision, and provenance in campaign evidence +- [x] show stale-source warnings when an address source changed after selection +- [x] keep campaign import for one-off local recipient data +- [x] define classical address lists as reusable address-domain sources +- [x] add address-list selection and management UI in addresses/campaign +- [ ] define segments/dynamic filters before exposing them as campaign sources + Deferred deliberately: classical address lists are stable now; dynamic + segments need their own filter model, stale-source semantics, and audit + evidence before campaign can snapshot them safely. + +Exit criteria: + +- [x] campaign works without addresses installed +- [x] campaign offers address-source selection when addresses is installed +- [x] built campaigns remain auditable after source contacts change + +## Milestone 5: Mail And Scheduling Integration + +Goal: make contacts visible where users naturally need them. + +Primary issues: `govoplan-mail#13` and `govoplan-scheduling#2`. + +Tasks: + +- [x] add mail recipient/autocomplete integration through `addresses.lookup` + at API/capability level; visible compose UI reuse follows when mail + compose exists +- [x] expose `addresses.contact_writer` for explicit address-book write + decisions, required scopes, read-only/source reasons, and provenance +- [ ] add "add sender/contact" actions in consuming UIs by using + `addresses.contact_writer` +- [x] add scheduling attendee/organizer lookup integration +- [x] preserve module independence when addresses is absent + +Exit criteria: + +- [x] mail and scheduling build without addresses installed +- [x] when addresses is installed, lookup improves recipient/attendee entry +- [x] address write decisions expose why actions are unavailable for read-only + sources, deleted books, missing scopes, or unsupported operations +- [ ] consuming UI write actions are hidden or explained with the + `addresses.contact_writer` decision payload + +## Milestone 6: Sync Infrastructure + +Goal: prepare external address books without committing to all connectors at +once. + +Primary issue: `govoplan-addresses#6`. + +Status: implemented for the first connector path. The connector-neutral backend +substrate is in place: sync sources, source status, read-only propagation, +tombstones, conflicts, diagnostics, attempt transitions, dry-run preview, +audit-event emission, and a first sync inspection UI. + +Tasks: + +- [x] add address-source connector configuration +- [x] add sync state, ETags, revisions, tokens, tombstones, and conflict records +- [x] add sync diagnostics +- [x] add audit events for sync attempts, conflicts, previews, completions, and + resolutions +- [x] support read-only and writable source flags +- [x] add dry-run and preview for connector changes +- [x] add admin/user UI for source status, diagnostics, tombstones, and conflicts + +Exit criteria: + +- [x] a connector can report planned creates/updates/deletes before applying them +- [x] sync failures are visible in the UI +- [x] conflicts are persisted and do not silently overwrite data + +## Milestone 7: CardDAV + +Goal: implement the first real standards-based sync connector. + +Primary issue: `govoplan-addresses#7`. + +Status: implemented for the first standards-based sync path. Discovery, source +binding, full/sync-token inbound sync, outbound create/update/delete for +writable sources, dry-run preview, diagnostics, tombstones, and persisted +conflict records are implemented. Source disconnect/delete UX and conflict +review are implemented; stored remote vCard payloads can be applied from the +review UI, and manual per-field local/remote merge choices are supported. + +Tasks: + +- [x] add CardDAV account/address-book discovery +- [x] sync vCard resources into scoped address books +- [x] support read-only and writable source flags +- [x] push local creates, updates, and deletes to writable CardDAV sources with + ETag preconditions +- [x] handle ETag changes, deletes, and conflicts +- [x] add connection discovery and diagnostics +- [x] add source disconnect/delete UX that keeps local contacts +- [x] add conflict review UI with local/remote field comparison +- [x] apply stored remote vCard payloads from conflict resolution +- [x] support manual per-field local/remote merge choices for stored vCard payloads + +Exit criteria: + +- [x] a CardDAV source can be connected, synced, and inspected +- [x] CardDAV source disconnect/delete UI +- [x] contacts can be refreshed without duplicating entries +- [x] conflict and permission states are visible to the user +- [x] outbound CardDAV writes for writable remote books +- [x] field-level conflict review UI +- [x] manual per-field merge editing + +## Milestone 8: Additional Connectors And Advanced Address Features + +Goal: expand beyond CardDAV after the model and sync engine are stable. + +Primary issues: `govoplan-addresses#8`, `govoplan-addresses#9`, +`govoplan-addresses#10`, and `govoplan-connectors#8`. + +Tasks: + +- LDAP/Active Directory read-only directory connector +- Exchange/Microsoft 365 contacts connector +- Google Contacts connector +- CSV/XLSX/LDIF import mapping profiles +- classical address-list UI and static/dynamic address-domain segments +- operational distribution lists move to `govoplan-dist-lists` +- consent, legal-basis, suppression, and communication preferences +- deduplication and merge workflow +- address quality checks and normalization +- relationship/household/organization editing + +Exit criteria: + +- each connector or advanced feature can be enabled independently +- users can understand where data came from and whether they may edit it +- downstream modules can safely use contacts without owning them + +## First Implementation Recommendation + +Start with Milestone 1 and enough of Milestone 2 to define the data model +correctly. Do not start CardDAV before the local vCard-compatible storage and +API are stable. diff --git a/package.json b/package.json new file mode 100644 index 0000000..b42dad1 --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "@govoplan/addresses-webui", + "version": "0.1.8", + "private": true, + "type": "module", + "main": "webui/src/index.ts", + "module": "webui/src/index.ts", + "types": "webui/src/index.ts", + "exports": { + ".": { + "types": "./webui/src/index.ts", + "import": "./webui/src/index.ts" + }, + "./styles/addresses.css": "./webui/src/styles/addresses.css" + }, + "files": [ + "webui/src", + "README.md" + ], + "peerDependencies": { + "@govoplan/core-webui": "^0.1.8", + "lucide-react": "^1.23.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..cfd3d73 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "govoplan-addresses" +version = "0.1.8" +description = "GovOPlaN reusable address and recipient-source module." +readme = "README.md" +requires-python = ">=3.12" +authors = [{ name = "GovOPlaN" }] +dependencies = [ + "defusedxml>=0.7.1", + "govoplan-core>=0.1.8", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +govoplan_addresses = ["py.typed"] + +[project.entry-points."govoplan.modules"] +addresses = "govoplan_addresses.backend.manifest:get_manifest" diff --git a/src/govoplan_addresses/__init__.py b/src/govoplan_addresses/__init__.py new file mode 100644 index 0000000..329f299 --- /dev/null +++ b/src/govoplan_addresses/__init__.py @@ -0,0 +1,2 @@ +"""GovOPlaN addresses module.""" + diff --git a/src/govoplan_addresses/backend/__init__.py b/src/govoplan_addresses/backend/__init__.py new file mode 100644 index 0000000..1af5a51 --- /dev/null +++ b/src/govoplan_addresses/backend/__init__.py @@ -0,0 +1,2 @@ +"""Backend integration for the GovOPlaN addresses module.""" + diff --git a/src/govoplan_addresses/backend/capabilities.py b/src/govoplan_addresses/backend/capabilities.py new file mode 100644 index 0000000..f55759d --- /dev/null +++ b/src/govoplan_addresses/backend/capabilities.py @@ -0,0 +1,629 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any + +from sqlalchemy import func + +from govoplan_core.auth import ApiPrincipal +from govoplan_core.db.base import utcnow +from govoplan_addresses.backend.db.models import AddressBook, AddressList, AddressListEntry, Contact, ContactEmail, ContactPhone +from govoplan_addresses.backend.schemas import ContactCreateRequest +from govoplan_addresses.backend.service import ( + AddressBookError, + create_contact, + get_visible_address_book, + get_visible_address_list, + list_address_books, + list_address_list_entries, + list_address_lists, + list_contacts, +) + + +CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup" +CAPABILITY_ADDRESSES_RECIPIENT_SOURCE = "addresses.recipient_source" +CAPABILITY_ADDRESSES_CONTACT_WRITER = "addresses.contact_writer" + +CONTACT_WRITE_OPERATIONS = ("create_contact", "update_contact", "delete_contact") + + +@dataclass(frozen=True, slots=True) +class AddressLookupCandidate: + contact_id: str + address_book_id: str + display_name: str + email: str | None + email_label: str | None = None + organization: str | None = None + role_title: str | None = None + tags: tuple[str, ...] = () + source_kind: str = "local" + source_ref: str | None = None + source_revision: str | None = None + provenance: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class RecipientSnapshotItem: + contact_id: str + display_name: str + email: str + email_label: str | None = None + fields: dict[str, Any] = field(default_factory=dict) + provenance: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class RecipientSourceRef: + source_id: str + source_label: str + source_kind: str + source_revision: str + recipient_count: int = 0 + provenance: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class RecipientSourceSnapshot: + source_id: str + source_label: str + source_kind: str + source_revision: str + generated_at: str + recipients: tuple[RecipientSnapshotItem, ...] + provenance: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class AddressBookWriteDecision: + address_book_id: str + address_book_label: str | None + operation: str + allowed: bool + reason: str + message: str + scope_type: str | None = None + scope_id: str | None = None + tenant_id: str | None = None + source_kind: str | None = None + read_only: bool = False + required_scopes: tuple[str, ...] = () + provenance: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class ContactWriteResult: + contact_id: str + address_book_id: str + display_name: str + email: str | None = None + source_kind: str = "local" + provenance: dict[str, Any] = field(default_factory=dict) + + +class AddressWriterError(ValueError): + def __init__(self, decision: AddressBookWriteDecision): + super().__init__(decision.message) + self.decision = decision + + +class AddressesLookupCapability: + def lookup( + self, + session: Any, + principal: ApiPrincipal, + *, + query: str, + limit: int = 25, + address_book_id: str | None = None, + ) -> tuple[AddressLookupCandidate, ...]: + normalized_limit = max(1, min(limit, 100)) + contacts = list_contacts( + session, + principal, + address_book_id=address_book_id, + query=query, + limit=normalized_limit, + ) + candidates: list[AddressLookupCandidate] = [] + for contact in contacts: + email_rows: list[ContactEmail | None] = list(contact.emails) or [None] + for email in email_rows: + candidates.append(_lookup_candidate(contact, email)) + if len(candidates) >= normalized_limit: + return tuple(candidates) + return tuple(candidates) + + +class AddressesContactWriterCapability: + def list_write_targets( + self, + session: Any, + principal: ApiPrincipal, + *, + operation: str = "create_contact", + ) -> tuple[AddressBookWriteDecision, ...]: + books = list_address_books(session, principal) + return tuple(_address_book_write_decision(principal, book, operation=operation) for book in books) + + def can_write_to_address_book( + self, + session: Any, + principal: ApiPrincipal, + *, + address_book_id: str, + operation: str = "create_contact", + ) -> AddressBookWriteDecision: + try: + book = get_visible_address_book(session, principal, address_book_id, include_deleted=True) + except AddressBookError: + return _blocked_write_decision( + address_book_id=address_book_id, + operation=operation, + reason="not_visible", + message="Address book is not visible to the current principal.", + ) + return _address_book_write_decision(principal, book, operation=operation) + + def create_contact( + self, + session: Any, + principal: ApiPrincipal, + *, + address_book_id: str, + payload: ContactCreateRequest | dict[str, Any], + provenance: dict[str, Any] | None = None, + ) -> ContactWriteResult: + decision = self.can_write_to_address_book(session, principal, address_book_id=address_book_id, operation="create_contact") + if not decision.allowed: + raise AddressWriterError(decision) + request = payload if isinstance(payload, ContactCreateRequest) else ContactCreateRequest.model_validate(payload) + if provenance: + request = request.model_copy(update={"provenance": {**request.provenance, **provenance}}) + contact = create_contact(session, principal, address_book_id, request) + return _contact_write_result(contact) + + +class AddressesRecipientSourceCapability: + def list_sources( + self, + session: Any, + principal: ApiPrincipal, + ) -> tuple[RecipientSourceRef, ...]: + from govoplan_addresses.backend.service import address_book_contact_counts, address_list_entry_counts + + books = list_address_books(session, principal) + counts = address_book_contact_counts(session, [book.id for book in books]) + contact_updated_at = _address_book_contact_updated_at(session, [book.id for book in books]) + book_sources = tuple( + _recipient_source_ref( + book, + recipient_count=counts.get(book.id, 0), + latest_contact_updated_at=contact_updated_at.get(book.id), + ) + for book in books + ) + address_lists = list_address_lists(session, principal) + address_list_counts = address_list_entry_counts(session, [address_list.id for address_list in address_lists]) + address_list_updated_at = _address_list_updated_at(session, [address_list.id for address_list in address_lists]) + list_sources = tuple( + _address_list_source_ref( + address_list, + recipient_count=address_list_counts.get(address_list.id, 0), + latest_entry_updated_at=address_list_updated_at.get(address_list.id), + ) + for address_list in address_lists + ) + return book_sources + list_sources + + def snapshot_address_book( + self, + session: Any, + principal: ApiPrincipal, + *, + address_book_id: str, + ) -> RecipientSourceSnapshot: + book = get_visible_address_book(session, principal, address_book_id) + contacts = _active_address_book_contacts(session, book.id) + recipients: list[RecipientSnapshotItem] = [] + for contact in contacts: + for email in contact.emails: + recipients.append(_recipient_snapshot_item(contact, email)) + revision = _recipient_source_revision(book, _address_book_contact_updated_at(session, [book.id]).get(book.id)) + return RecipientSourceSnapshot( + source_id=f"addresses:address_book:{book.id}", + source_label=book.name, + source_kind=book.source_kind, + source_revision=revision, + generated_at=utcnow().isoformat(), + recipients=tuple(recipients), + provenance={ + "module": "addresses", + "address_book_id": book.id, + "scope_type": book.scope_type, + "scope_id": book.scope_id, + "tenant_id": book.tenant_id, + }, + ) + + def snapshot_address_list( + self, + session: Any, + principal: ApiPrincipal, + *, + address_list_id: str, + ) -> RecipientSourceSnapshot: + address_list = get_visible_address_list(session, principal, address_list_id) + entries = list_address_list_entries(session, principal, address_list.id) + recipients: list[RecipientSnapshotItem] = [] + for entry in entries: + email = _entry_email(entry) + if email is None: + continue + recipients.append(_recipient_snapshot_item(entry.contact, email, address_list=address_list, address_list_entry=entry)) + revision = _address_list_source_revision(address_list, _address_list_updated_at(session, [address_list.id]).get(address_list.id)) + return RecipientSourceSnapshot( + source_id=f"addresses:address_list:{address_list.id}", + source_label=address_list.name, + source_kind="address_list", + source_revision=revision, + generated_at=utcnow().isoformat(), + recipients=tuple(recipients), + provenance={ + "module": "addresses", + "address_book_id": address_list.address_book_id, + "address_list_id": address_list.id, + "scope_type": address_list.address_book.scope_type, + "scope_id": address_list.address_book.scope_id, + "tenant_id": address_list.tenant_id, + }, + ) + + def snapshot( + self, + session: Any, + principal: ApiPrincipal, + *, + source_id: str, + ) -> RecipientSourceSnapshot: + book_prefix = "addresses:address_book:" + if source_id.startswith(book_prefix): + return self.snapshot_address_book(session, principal, address_book_id=source_id.removeprefix(book_prefix)) + list_prefix = "addresses:address_list:" + if source_id.startswith(list_prefix): + return self.snapshot_address_list(session, principal, address_list_id=source_id.removeprefix(list_prefix)) + raise ValueError(f"Unsupported addresses recipient source id: {source_id}") + + +def lookup_capability(_context: Any) -> AddressesLookupCapability: + return AddressesLookupCapability() + + +def recipient_source_capability(_context: Any) -> AddressesRecipientSourceCapability: + return AddressesRecipientSourceCapability() + + +def contact_writer_capability(_context: Any) -> AddressesContactWriterCapability: + return AddressesContactWriterCapability() + + +def _operation_required_scopes(operation: str) -> tuple[str, ...] | None: + if operation in ("create_contact", "update_contact"): + return ("addresses:address_book:read", "addresses:contact:write") + if operation == "delete_contact": + return ("addresses:address_book:read", "addresses:contact:delete") + return None + + +def _address_book_write_decision(principal: ApiPrincipal, book: AddressBook, *, operation: str) -> AddressBookWriteDecision: + required_scopes = _operation_required_scopes(operation) + if required_scopes is None: + return _blocked_write_decision( + address_book_id=book.id, + operation=operation, + reason="unsupported_operation", + message=f"Unsupported address-book write operation: {operation}", + book=book, + ) + missing_scopes = tuple(scope for scope in required_scopes if not principal.has(scope)) + if missing_scopes: + return _blocked_write_decision( + address_book_id=book.id, + operation=operation, + reason="missing_scope", + message=f"Missing scope: {missing_scopes[0]}", + book=book, + required_scopes=required_scopes, + ) + if book.deleted_at is not None: + return _blocked_write_decision( + address_book_id=book.id, + operation=operation, + reason="address_book_deleted", + message="Address book is deleted.", + book=book, + required_scopes=required_scopes, + ) + if book.read_only: + return _blocked_write_decision( + address_book_id=book.id, + operation=operation, + reason="address_book_read_only", + message="Address book is read-only.", + book=book, + required_scopes=required_scopes, + ) + return AddressBookWriteDecision( + address_book_id=book.id, + address_book_label=book.name, + operation=operation, + allowed=True, + reason="allowed", + message="Address book accepts this write operation.", + scope_type=book.scope_type, + scope_id=book.scope_id, + tenant_id=book.tenant_id, + source_kind=book.source_kind, + read_only=book.read_only, + required_scopes=required_scopes, + provenance=_address_book_provenance(book), + ) + + +def _blocked_write_decision( + *, + address_book_id: str, + operation: str, + reason: str, + message: str, + book: AddressBook | None = None, + required_scopes: tuple[str, ...] = (), +) -> AddressBookWriteDecision: + return AddressBookWriteDecision( + address_book_id=address_book_id, + address_book_label=book.name if book else None, + operation=operation, + allowed=False, + reason=reason, + message=message, + scope_type=book.scope_type if book else None, + scope_id=book.scope_id if book else None, + tenant_id=book.tenant_id if book else None, + source_kind=book.source_kind if book else None, + read_only=book.read_only if book else False, + required_scopes=required_scopes, + provenance=_address_book_provenance(book) if book else {"module": "addresses", "address_book_id": address_book_id}, + ) + + +def _lookup_candidate(contact: Contact, email: ContactEmail | None) -> AddressLookupCandidate: + return AddressLookupCandidate( + contact_id=contact.id, + address_book_id=contact.address_book_id, + display_name=contact.display_name, + email=email.email if email is not None else None, + email_label=email.label if email is not None else None, + organization=contact.organization, + role_title=contact.role_title, + tags=tuple(contact.tags or ()), + source_kind=contact.source_kind, + source_ref=contact.source_ref, + source_revision=contact.source_revision, + provenance=_contact_provenance(contact), + ) + + +def _recipient_snapshot_item( + contact: Contact, + email: ContactEmail, + *, + address_list: AddressList | None = None, + address_list_entry: AddressListEntry | None = None, +) -> RecipientSnapshotItem: + primary_phone = next((phone.phone for phone in contact.phones if phone.is_primary), contact.phones[0].phone if contact.phones else None) + provenance = { + **_contact_provenance(contact), + "email_id": email.id, + "email_label": email.label, + "email_primary": email.is_primary, + } + 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, + "address_list_name": address_list.name, + "address_list_entry_kind": address_list_entry.target_kind, + } + ) + return RecipientSnapshotItem( + contact_id=contact.id, + display_name=contact.display_name, + email=email.email, + email_label=email.label, + fields={ + "given_name": contact.given_name, + "family_name": contact.family_name, + "organization": contact.organization, + "role_title": contact.role_title, + "phone": primary_phone, + "tags": list(contact.tags or ()), + }, + provenance=provenance, + ) + + +def _contact_provenance(contact: Contact) -> dict[str, Any]: + return { + "module": "addresses", + "contact_id": contact.id, + "address_book_id": contact.address_book_id, + "source_kind": contact.source_kind, + "source_ref": contact.source_ref, + "source_revision": contact.source_revision, + } + + +def _address_book_provenance(book: AddressBook) -> dict[str, Any]: + return { + "module": "addresses", + "address_book_id": book.id, + "scope_type": book.scope_type, + "scope_id": book.scope_id, + "tenant_id": book.tenant_id, + "source_kind": book.source_kind, + "source_ref": book.source_ref, + } + + +def _entry_email(entry: AddressListEntry) -> ContactEmail | None: + if entry.contact_postal_address_id is not None: + return None + if entry.contact_email is not None: + return entry.contact_email + return next((email for email in entry.contact.emails if email.is_primary), entry.contact.emails[0] if entry.contact.emails else None) + + +def _contact_write_result(contact: Contact) -> ContactWriteResult: + primary_email = next((email.email for email in contact.emails if email.is_primary), contact.emails[0].email if contact.emails else None) + return ContactWriteResult( + contact_id=contact.id, + address_book_id=contact.address_book_id, + display_name=contact.display_name, + email=primary_email, + source_kind=contact.source_kind, + provenance={ + **_contact_provenance(contact), + "contact_provenance": contact.provenance, + }, + ) + + +def _recipient_source_ref(book: AddressBook, *, recipient_count: int, latest_contact_updated_at: datetime | None = None) -> RecipientSourceRef: + return RecipientSourceRef( + source_id=f"addresses:address_book:{book.id}", + source_label=book.name, + source_kind=book.source_kind, + source_revision=_recipient_source_revision(book, latest_contact_updated_at), + recipient_count=recipient_count, + provenance={ + "module": "addresses", + "address_book_id": book.id, + "scope_type": book.scope_type, + "scope_id": book.scope_id, + "tenant_id": book.tenant_id, + }, + ) + + +def _address_list_source_ref(address_list: AddressList, *, recipient_count: int, latest_entry_updated_at: datetime | None = None) -> RecipientSourceRef: + return RecipientSourceRef( + source_id=f"addresses:address_list:{address_list.id}", + source_label=address_list.name, + source_kind="address_list", + source_revision=_address_list_source_revision(address_list, latest_entry_updated_at), + recipient_count=recipient_count, + provenance={ + "module": "addresses", + "address_book_id": address_list.address_book_id, + "address_list_id": address_list.id, + "scope_type": address_list.address_book.scope_type, + "scope_id": address_list.address_book.scope_id, + "tenant_id": address_list.tenant_id, + }, + ) + + +def _active_address_book_contacts(session: Any, address_book_id: str) -> list[Contact]: + return ( + session.query(Contact) + .filter(Contact.address_book_id == address_book_id, Contact.deleted_at.is_(None)) + .order_by(Contact.display_name.asc(), Contact.id.asc()) + .all() + ) + + +def _address_book_contact_updated_at(session: Any, address_book_ids: list[str]) -> dict[str, datetime]: + if not address_book_ids: + return {} + revisions: dict[str, datetime] = {} + contact_rows = ( + session.query(Contact.address_book_id, func.max(Contact.updated_at)) + .filter(Contact.address_book_id.in_(address_book_ids), Contact.deleted_at.is_(None)) + .group_by(Contact.address_book_id) + .all() + ) + deletion_rows = ( + session.query(Contact.address_book_id, func.max(Contact.deleted_at)) + .filter(Contact.address_book_id.in_(address_book_ids), Contact.deleted_at.is_not(None)) + .group_by(Contact.address_book_id) + .all() + ) + email_rows = ( + session.query(Contact.address_book_id, func.max(ContactEmail.updated_at)) + .join(Contact, ContactEmail.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() + ) + phone_rows = ( + session.query(Contact.address_book_id, func.max(ContactPhone.updated_at)) + .join(Contact, ContactPhone.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]: + if updated_at is None: + continue + key = str(address_book_id) + revisions[key] = max(revisions.get(key, updated_at), updated_at) + return revisions + + +def _address_list_updated_at(session: Any, address_list_ids: list[str]) -> dict[str, datetime]: + if not address_list_ids: + return {} + revisions: dict[str, datetime] = {} + entry_rows = ( + session.query(AddressListEntry.address_list_id, func.max(AddressListEntry.updated_at)) + .filter(AddressListEntry.address_list_id.in_(address_list_ids)) + .group_by(AddressListEntry.address_list_id) + .all() + ) + contact_rows = ( + session.query(AddressListEntry.address_list_id, func.max(Contact.updated_at)) + .join(Contact, AddressListEntry.contact_id == Contact.id) + .filter(AddressListEntry.address_list_id.in_(address_list_ids), Contact.deleted_at.is_(None)) + .group_by(AddressListEntry.address_list_id) + .all() + ) + email_rows = ( + session.query(AddressListEntry.address_list_id, func.max(ContactEmail.updated_at)) + .join(ContactEmail, AddressListEntry.contact_email_id == ContactEmail.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]: + if updated_at is None: + continue + key = str(address_list_id) + revisions[key] = max(revisions.get(key, updated_at), updated_at) + return revisions + + +def _recipient_source_revision(book: AddressBook, latest_contact_updated_at: datetime | None = None) -> str: + stamps: list[datetime] = [book.updated_at] + if latest_contact_updated_at is not None: + stamps.append(latest_contact_updated_at) + return max(stamps).isoformat() + + +def _address_list_source_revision(address_list: AddressList, latest_entry_updated_at: datetime | None = None) -> str: + stamps: list[datetime] = [address_list.updated_at] + if latest_entry_updated_at is not None: + stamps.append(latest_entry_updated_at) + return max(stamps).isoformat() diff --git a/src/govoplan_addresses/backend/carddav.py b/src/govoplan_addresses/backend/carddav.py new file mode 100644 index 0000000..30277d9 --- /dev/null +++ b/src/govoplan_addresses/backend/carddav.py @@ -0,0 +1,488 @@ +from __future__ import annotations + +import base64 +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass, field +from typing import Any, Mapping, Protocol + +from defusedxml import ElementTree as SafeElementTree + + +class AddressCardDAVError(RuntimeError): + pass + + +class AddressCardDAVSyncUnsupported(AddressCardDAVError): + pass + + +class AddressCardDAVNotFound(AddressCardDAVError): + pass + + +class AddressCardDAVPreconditionFailed(AddressCardDAVError): + pass + + +class AddressCardDAVTransport(Protocol): + def __call__(self, method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: int) -> tuple[int, Mapping[str, str], bytes]: + ... + + +@dataclass(frozen=True, slots=True) +class AddressCardDAVObject: + href: str + etag: str | None = None + address_data: str | None = None + deleted: bool = False + + +@dataclass(frozen=True, slots=True) +class AddressCardDAVReportResult: + objects: list[AddressCardDAVObject] = field(default_factory=list) + sync_token: str | None = None + ctag: str | None = None + + +@dataclass(frozen=True, slots=True) +class AddressCardDAVAddressBook: + collection_url: str + href: str + display_name: str | None = None + description: str | None = None + ctag: str | None = None + sync_token: str | None = None + + +@dataclass(frozen=True, slots=True) +class AddressCardDAVWriteResult: + href: str + etag: str | None = None + status: int = 0 + + +@dataclass(frozen=True, slots=True) +class _DiscoveryResponse: + href: str + display_name: str | None = None + description: str | None = None + ctag: str | None = None + sync_token: str | None = None + is_addressbook: bool = False + principal_hrefs: tuple[str, ...] = () + addressbook_home_set_hrefs: tuple[str, ...] = () + + +@dataclass(slots=True) +class _DiscoveryDraft: + display_name: str | None = None + description: str | None = None + ctag: str | None = None + sync_token: str | None = None + is_addressbook: bool = False + principal_hrefs: list[str] = field(default_factory=list) + addressbook_home_set_hrefs: list[str] = field(default_factory=list) + + +class AddressCardDAVClient: + def __init__( + self, + *, + collection_url: str, + username: str | None = None, + password: str | None = None, + bearer_token: str | None = None, + timeout: int = 30, + transport: AddressCardDAVTransport | None = None, + ) -> None: + self.collection_url = ensure_collection_url(collection_url) + self.username = username + self.password = password + self.bearer_token = bearer_token + self.timeout = timeout + self.transport = transport or urllib_transport + + def propfind_collection(self) -> AddressCardDAVReportResult: + body = b""" + + + + + + +""" + payload = self.request("PROPFIND", self.collection_url, body=body, depth="0", expected={207}) + return parse_multistatus(payload) + + def discover_addressbooks(self) -> list[AddressCardDAVAddressBook]: + start_url = self.collection_url + addressbooks: dict[str, AddressCardDAVAddressBook] = {} + home_urls: list[str] = [] + principal_urls: list[str] = [] + visited_urls: set[tuple[str, str]] = set() + errors: list[str] = [] + + def add_home_href(base_url: str, href: str) -> None: + url = ensure_collection_url(absolute_dav_url(base_url, href)) + if url not in home_urls: + home_urls.append(url) + + def add_principal_href(base_url: str, href: str) -> None: + url = ensure_collection_url(absolute_dav_url(base_url, href)) + if url not in principal_urls: + principal_urls.append(url) + + def add_addressbook(base_url: str, response: _DiscoveryResponse) -> None: + if not response.is_addressbook: + return + url = ensure_collection_url(absolute_dav_url(base_url, response.href or base_url)) + addressbooks[url] = AddressCardDAVAddressBook( + collection_url=url, + href=response.href, + display_name=response.display_name, + description=response.description, + ctag=response.ctag, + sync_token=response.sync_token, + ) + + def propfind(url: str, depth: str) -> list[_DiscoveryResponse]: + key = (url, depth) + if key in visited_urls: + return [] + visited_urls.add(key) + return self.propfind_discovery(url, depth=depth) + + try: + for response in propfind(start_url, "0"): + add_addressbook(start_url, response) + for href in response.addressbook_home_set_hrefs: + add_home_href(start_url, href) + for href in response.principal_hrefs: + add_principal_href(start_url, href) + except AddressCardDAVError as exc: + errors.append(str(exc)) + + for principal_url in principal_urls[:6]: + try: + for response in propfind(principal_url, "0"): + for href in response.addressbook_home_set_hrefs: + add_home_href(principal_url, href) + except AddressCardDAVError as exc: + errors.append(str(exc)) + + if not home_urls: + home_urls.append(start_url) + + for home_url in home_urls: + try: + for response in propfind(home_url, "1"): + add_addressbook(home_url, response) + except AddressCardDAVError as exc: + errors.append(str(exc)) + + if not addressbooks and errors: + raise AddressCardDAVError(f"CardDAV discovery did not find any address books: {errors[0]}") + return sorted(addressbooks.values(), key=lambda item: ((item.display_name or item.collection_url).lower(), item.collection_url)) + + def propfind_discovery(self, url: str, *, depth: str) -> list[_DiscoveryResponse]: + body = b""" + + + + + + + + + + + +""" + payload = self.request("PROPFIND", ensure_collection_url(url), body=body, depth=depth, expected={207}) + return parse_discovery_multistatus(payload) + + def list_objects(self) -> AddressCardDAVReportResult: + body = b""" + + + + + +""" + payload = self.request("REPORT", self.collection_url, body=body, depth="1", expected={207}) + return parse_multistatus(payload) + + def sync_collection(self, sync_token: str) -> AddressCardDAVReportResult: + body = f""" + + {xml_escape(sync_token)} + 1 + + + + +""".encode("utf-8") + try: + payload = self.request("REPORT", self.collection_url, body=body, depth="1", expected={207}) + except AddressCardDAVError as exc: + raise AddressCardDAVSyncUnsupported(str(exc)) from exc + return parse_multistatus(payload) + + def fetch_object(self, href: str) -> str: + payload = self.request("GET", self.object_url(href), body=None, depth=None, expected={200}) + return payload.decode("utf-8") + + def put_object(self, href: str, vcard: str, *, etag: str | None = None, create: bool = False, overwrite: bool = False) -> AddressCardDAVWriteResult: + headers = {"Content-Type": "text/vcard; charset=utf-8"} + if create: + headers["If-None-Match"] = "*" + elif etag and not overwrite: + headers["If-Match"] = etag + elif not overwrite: + raise AddressCardDAVPreconditionFailed(f"PUT {href} requires an ETag or explicit overwrite.") + status, response_headers, _payload = self.request_raw( + "PUT", + self.object_url(href), + body=vcard.encode("utf-8"), + depth=None, + expected={200, 201, 204}, + extra_headers=headers, + ) + return AddressCardDAVWriteResult(href=href, etag=response_etag(response_headers), status=status) + + def delete_object(self, href: str, *, etag: str | None = None, overwrite: bool = False) -> AddressCardDAVWriteResult: + headers: dict[str, str] = {} + if etag and not overwrite: + headers["If-Match"] = etag + elif not overwrite: + raise AddressCardDAVPreconditionFailed(f"DELETE {href} requires an ETag or explicit overwrite.") + status, response_headers, _payload = self.request_raw( + "DELETE", + self.object_url(href), + body=None, + depth=None, + expected={200, 202, 204, 404}, + extra_headers=headers, + ) + return AddressCardDAVWriteResult(href=href, etag=response_etag(response_headers), status=status) + + def object_url(self, href: str) -> str: + return urllib.parse.urljoin(self.collection_url, href) + + def request(self, method: str, url: str, *, body: bytes | None, depth: str | None, expected: set[int]) -> bytes: + _status, _headers, payload = self.request_raw(method, url, body=body, depth=depth, expected=expected) + return payload + + def request_raw( + self, + method: str, + url: str, + *, + body: bytes | None, + depth: str | None, + expected: set[int], + extra_headers: Mapping[str, str] | None = None, + ) -> tuple[int, Mapping[str, str], bytes]: + headers: dict[str, str] = { + "Accept": "application/xml,text/vcard,*/*", + "User-Agent": "govoplan-addresses-carddav/0.1", + } + if body is not None: + headers["Content-Type"] = "application/xml; charset=utf-8" + if depth is not None: + headers["Depth"] = depth + if self.bearer_token: + headers["Authorization"] = f"Bearer {self.bearer_token}" + elif self.username and self.password: + token = base64.b64encode(f"{self.username}:{self.password}".encode("utf-8")).decode("ascii") + headers["Authorization"] = f"Basic {token}" + if extra_headers: + headers.update(dict(extra_headers)) + status, response_headers, payload = self.transport(method, url, headers, body, self.timeout) + if status not in expected: + if status == 412: + raise AddressCardDAVPreconditionFailed(f"{method} {url} returned HTTP {status}") + if status == 404: + raise AddressCardDAVNotFound(f"{method} {url} returned HTTP {status}") + raise AddressCardDAVError(f"{method} {url} returned HTTP {status}") + return status, response_headers, payload + + +def urllib_transport(method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: int) -> tuple[int, Mapping[str, str], bytes]: + url = validate_http_url(url) + try: + request = urllib.request.Request(url, data=body, headers=dict(headers), method=method) + with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - validated CardDAV HTTP(S) URL. # nosec B310 + return response.status, dict(response.headers.items()), response.read() + except urllib.error.HTTPError as exc: + return exc.code, dict(exc.headers.items()), exc.read() + except urllib.error.URLError as exc: + raise AddressCardDAVError(f"{method} {url} failed: {exc.reason}") from exc + except ValueError as exc: + raise AddressCardDAVError(f"{method} {url} failed: {exc}") from exc + + +def parse_multistatus(payload: bytes) -> AddressCardDAVReportResult: + try: + root = SafeElementTree.fromstring(payload) + except SafeElementTree.ParseError as exc: + raise AddressCardDAVError(f"Invalid CardDAV XML response: {exc}") from exc + objects: list[AddressCardDAVObject] = [] + sync_token = first_child_text(root, "sync-token") + ctag = first_child_text(root, "getctag") + for response in child_elements(root, "response"): + href = first_child_text(response, "href") + if not href: + continue + deleted = False + etag = None + address_data = None + for propstat in child_elements(response, "propstat"): + status = first_child_text(propstat, "status") or "" + prop = first_child(propstat, "prop") + if prop is None: + continue + if " 404 " in status or status.endswith(" 404"): + deleted = True + continue + if " 200 " not in status and not status.endswith(" 200"): + continue + etag = first_child_text(prop, "getetag") or etag + address_data = first_child_text(prop, "address-data") or address_data + sync_token = first_child_text(prop, "sync-token") or sync_token + ctag = first_child_text(prop, "getctag") or ctag + objects.append(AddressCardDAVObject(href=href, etag=strip_weak_etag(etag), address_data=address_data, deleted=deleted)) + return AddressCardDAVReportResult(objects=objects, sync_token=sync_token, ctag=ctag) + + +def parse_discovery_multistatus(payload: bytes) -> list[_DiscoveryResponse]: + try: + root = SafeElementTree.fromstring(payload) + except SafeElementTree.ParseError as exc: + raise AddressCardDAVError(f"Invalid CardDAV XML response: {exc}") from exc + return [parsed for response in child_elements(root, "response") if (parsed := _parse_discovery_response(response)) is not None] + + +def _parse_discovery_response(response: Any) -> _DiscoveryResponse | None: + href = first_child_text(response, "href") + if not href: + return None + draft = _DiscoveryDraft() + for propstat in child_elements(response, "propstat"): + _apply_discovery_propstat(draft, propstat) + return _DiscoveryResponse( + href=href, + display_name=draft.display_name, + description=draft.description, + ctag=draft.ctag, + sync_token=draft.sync_token, + is_addressbook=draft.is_addressbook, + principal_hrefs=dedupe_tuple(draft.principal_hrefs), + addressbook_home_set_hrefs=dedupe_tuple(draft.addressbook_home_set_hrefs), + ) + + +def _apply_discovery_propstat(draft: _DiscoveryDraft, propstat: Any) -> None: + if not discovery_propstat_is_success(propstat): + return + prop = first_child(propstat, "prop") + if prop is None: + return + for item in prop: + _apply_discovery_property(draft, item) + + +def discovery_propstat_is_success(propstat: Any) -> bool: + status = first_child_text(propstat, "status") or "" + return not status or " 200 " in status or status.endswith(" 200") or " 207 " in status + + +def _apply_discovery_property(draft: _DiscoveryDraft, item: Any) -> None: + name = local_name(item.tag) + text = item.text.strip() if item.text else "" + if name == "displayname" and text: + draft.display_name = text + elif name == "addressbook-description" and text: + draft.description = text + elif name == "getctag" and text: + draft.ctag = text + elif name == "sync-token" and text: + draft.sync_token = text + elif name == "resourcetype": + draft.is_addressbook = draft.is_addressbook or any(local_name(child.tag) == "addressbook" for child in item) + elif name in {"current-user-principal", "principal-URL"}: + draft.principal_hrefs.extend(nested_href_texts(item)) + elif name == "addressbook-home-set": + draft.addressbook_home_set_hrefs.extend(nested_href_texts(item)) + + +def dedupe_tuple(values: list[str]) -> tuple[str, ...]: + return tuple(dict.fromkeys(values)) + + +def ensure_collection_url(value: str) -> str: + value = value.strip() + if value and "://" not in value and not value.startswith("/"): + value = f"https://{value}" + url = validate_http_url(value) + return url if url.endswith("/") else f"{url}/" + + +def validate_http_url(value: str) -> str: + parsed = urllib.parse.urlparse(value) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise AddressCardDAVError("CardDAV URL must be an absolute HTTP(S) URL") + if parsed.username or parsed.password: + raise AddressCardDAVError("CardDAV URL must not include embedded credentials") + return urllib.parse.urlunparse(parsed) + + +def absolute_dav_url(base_url: str, href: str) -> str: + return urllib.parse.urljoin(ensure_collection_url(base_url), href) + + +def strip_weak_etag(value: str | None) -> str | None: + return value.strip() if value else None + + +def response_etag(headers: Mapping[str, str]) -> str | None: + for key, value in headers.items(): + if key.lower() == "etag": + return strip_weak_etag(value) + return None + + +def xml_escape(value: str) -> str: + return value.replace("&", "&").replace("<", "<").replace(">", ">") + + +def first_child(element: Any, name: str) -> Any | None: + for child in element: + if local_name(child.tag) == name: + return child + return None + + +def first_child_text(element: Any, name: str) -> str | None: + found = first_child(element, name) + if found is None or found.text is None: + return None + return found.text.strip() + + +def child_elements(element: Any, name: str) -> list[Any]: + return [child for child in element if local_name(child.tag) == name] + + +def nested_href_texts(element: Any) -> list[str]: + hrefs: list[str] = [] + for child in element.iter(): + if local_name(child.tag) == "href" and child.text and child.text.strip(): + hrefs.append(child.text.strip()) + return hrefs + + +def local_name(tag: str) -> str: + return tag.rsplit("}", 1)[-1] if "}" in tag else tag diff --git a/src/govoplan_addresses/backend/db/__init__.py b/src/govoplan_addresses/backend/db/__init__.py new file mode 100644 index 0000000..05c9798 --- /dev/null +++ b/src/govoplan_addresses/backend/db/__init__.py @@ -0,0 +1,2 @@ +"""Address module database models.""" + diff --git a/src/govoplan_addresses/backend/db/models.py b/src/govoplan_addresses/backend/db/models.py new file mode 100644 index 0000000..6bde025 --- /dev/null +++ b/src/govoplan_addresses/backend/db/models.py @@ -0,0 +1,330 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any + +from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from govoplan_core.db.base import Base, TimestampMixin + + +def new_uuid() -> str: + return str(uuid.uuid4()) + + +class AddressBook(Base, TimestampMixin): + __tablename__ = "addresses_address_books" + __table_args__ = ( + Index("ix_addresses_address_books_scope", "tenant_id", "scope_type", "scope_id"), + Index( + "uq_addresses_address_books_active_name", + "tenant_id", + "scope_type", + "scope_id", + "name", + unique=True, + sqlite_where=text("deleted_at IS NULL"), + postgresql_where=text("deleted_at IS NULL"), + ), + ) + + 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) + scope_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True) + scope_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + source_kind: Mapped[str] = mapped_column(String(30), default="local", nullable=False, index=True) + source_ref: Mapped[str | None] = mapped_column(String(1000)) + read_only: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + sync_status: Mapped[str | None] = mapped_column(String(30)) + sync_error: Mapped[str | None] = mapped_column(Text) + created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + updated_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True) + + contacts: Mapped[list["Contact"]] = relationship(back_populates="address_book", cascade="all, delete-orphan") + address_lists: Mapped[list["AddressList"]] = relationship(back_populates="address_book", cascade="all, delete-orphan") + sync_sources: Mapped[list["AddressSyncSource"]] = relationship(back_populates="address_book", cascade="all, delete-orphan") + + +class Contact(Base, TimestampMixin): + __tablename__ = "addresses_contacts" + __table_args__ = ( + Index("ix_addresses_contacts_book_name", "address_book_id", "display_name"), + Index("ix_addresses_contacts_tenant_name", "tenant_id", "display_name"), + ) + + 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) + address_book_id: Mapped[str] = mapped_column( + ForeignKey("addresses_address_books.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + display_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + given_name: Mapped[str | None] = mapped_column(String(255)) + family_name: Mapped[str | None] = mapped_column(String(255)) + organization: Mapped[str | None] = mapped_column(String(255), index=True) + role_title: Mapped[str | None] = mapped_column(String(255)) + note: Mapped[str | None] = mapped_column(Text) + tags: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) + source_kind: Mapped[str] = mapped_column(String(30), default="local", nullable=False, index=True) + source_ref: Mapped[str | None] = mapped_column(String(1000)) + source_payload_kind: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) + source_payload_raw: Mapped[str | None] = mapped_column(Text, nullable=True) + source_revision: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) + provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + updated_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True) + + address_book: Mapped[AddressBook] = relationship(back_populates="contacts") + emails: Mapped[list["ContactEmail"]] = relationship(back_populates="contact", cascade="all, delete-orphan", order_by="ContactEmail.order_index") + phones: Mapped[list["ContactPhone"]] = relationship(back_populates="contact", cascade="all, delete-orphan", order_by="ContactPhone.order_index") + postal_addresses: Mapped[list["ContactPostalAddress"]] = relationship( + back_populates="contact", + cascade="all, delete-orphan", + order_by="ContactPostalAddress.order_index", + ) + address_list_entries: Mapped[list["AddressListEntry"]] = relationship(back_populates="contact", cascade="all, delete-orphan") + + +class ContactEmail(Base, TimestampMixin): + __tablename__ = "addresses_contact_emails" + __table_args__ = ( + Index("ix_addresses_contact_emails_lookup", "email"), + Index("ix_addresses_contact_emails_contact_primary", "contact_id", "is_primary"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + contact_id: Mapped[str] = mapped_column(ForeignKey("addresses_contacts.id", ondelete="CASCADE"), nullable=False, index=True) + label: Mapped[str | None] = mapped_column(String(80)) + email: Mapped[str] = mapped_column(String(320), nullable=False, index=True) + is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + contact: Mapped[Contact] = relationship(back_populates="emails") + address_list_entries: Mapped[list["AddressListEntry"]] = relationship(back_populates="contact_email") + + +class ContactPhone(Base, TimestampMixin): + __tablename__ = "addresses_contact_phones" + __table_args__ = (Index("ix_addresses_contact_phones_contact_primary", "contact_id", "is_primary"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + contact_id: Mapped[str] = mapped_column(ForeignKey("addresses_contacts.id", ondelete="CASCADE"), nullable=False, index=True) + label: Mapped[str | None] = mapped_column(String(80)) + phone: Mapped[str] = mapped_column(String(100), nullable=False) + is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + contact: Mapped[Contact] = relationship(back_populates="phones") + + +class ContactPostalAddress(Base, TimestampMixin): + __tablename__ = "addresses_contact_postal_addresses" + __table_args__ = (Index("ix_addresses_contact_postal_addresses_contact_primary", "contact_id", "is_primary"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + contact_id: Mapped[str] = mapped_column(ForeignKey("addresses_contacts.id", ondelete="CASCADE"), nullable=False, index=True) + label: Mapped[str | None] = mapped_column(String(80)) + street: Mapped[str | None] = mapped_column(String(500)) + postal_code: Mapped[str | None] = mapped_column(String(40)) + locality: Mapped[str | None] = mapped_column(String(255)) + region: Mapped[str | None] = mapped_column(String(255)) + country: Mapped[str | None] = mapped_column(String(255)) + is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + contact: Mapped[Contact] = relationship(back_populates="postal_addresses") + address_list_entries: Mapped[list["AddressListEntry"]] = relationship(back_populates="contact_postal_address") + + +class AddressList(Base, TimestampMixin): + __tablename__ = "addresses_address_lists" + __table_args__ = ( + Index("ix_addresses_address_lists_book_name", "address_book_id", "name"), + Index( + "uq_addresses_address_lists_active_name", + "address_book_id", + "name", + unique=True, + sqlite_where=text("deleted_at IS NULL"), + postgresql_where=text("deleted_at IS NULL"), + ), + ) + + 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) + address_book_id: Mapped[str] = mapped_column(ForeignKey("addresses_address_books.id", ondelete="CASCADE"), nullable=False, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + source_kind: Mapped[str] = mapped_column(String(30), default="local", nullable=False, index=True) + source_ref: Mapped[str | None] = mapped_column(String(1000)) + read_only: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + updated_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True) + + address_book: Mapped[AddressBook] = relationship(back_populates="address_lists") + entries: Mapped[list["AddressListEntry"]] = relationship( + back_populates="address_list", + cascade="all, delete-orphan", + order_by="AddressListEntry.order_index", + ) + + +class AddressListEntry(Base, TimestampMixin): + __tablename__ = "addresses_address_list_entries" + __table_args__ = ( + Index("ix_addresses_address_list_entries_list_order", "address_list_id", "order_index"), + Index("ix_addresses_address_list_entries_contact", "contact_id"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + address_list_id: Mapped[str] = mapped_column(ForeignKey("addresses_address_lists.id", ondelete="CASCADE"), nullable=False, index=True) + contact_id: Mapped[str] = mapped_column(ForeignKey("addresses_contacts.id", ondelete="CASCADE"), nullable=False, index=True) + contact_email_id: Mapped[str | None] = mapped_column(ForeignKey("addresses_contact_emails.id", ondelete="SET NULL"), nullable=True, index=True) + contact_postal_address_id: Mapped[str | None] = mapped_column( + ForeignKey("addresses_contact_postal_addresses.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + target_kind: Mapped[str] = mapped_column(String(30), default="contact", nullable=False, index=True) + label: Mapped[str | None] = mapped_column(String(255)) + order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True) + + address_list: Mapped[AddressList] = relationship(back_populates="entries") + contact: Mapped[Contact] = relationship(back_populates="address_list_entries") + contact_email: Mapped[ContactEmail | None] = relationship(back_populates="address_list_entries") + contact_postal_address: Mapped[ContactPostalAddress | None] = relationship(back_populates="address_list_entries") + + +class AddressSyncSource(Base, TimestampMixin): + __tablename__ = "addresses_sync_sources" + __table_args__ = ( + Index("ix_addresses_sync_sources_book_status", "address_book_id", "status"), + Index("ix_addresses_sync_sources_connector", "tenant_id", "connector_type"), + ) + + 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) + address_book_id: Mapped[str] = mapped_column(ForeignKey("addresses_address_books.id", ondelete="CASCADE"), nullable=False, index=True) + connector_type: Mapped[str] = mapped_column(String(60), nullable=False, index=True) + display_name: Mapped[str] = mapped_column(String(255), nullable=False) + external_account_ref: Mapped[str | None] = mapped_column(String(1000), nullable=True) + external_address_book_ref: Mapped[str | None] = mapped_column(String(1000), nullable=True) + sync_direction: Mapped[str] = mapped_column(String(30), default="read_only", nullable=False, index=True) + read_only: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True) + status: Mapped[str] = mapped_column(String(30), default="idle", nullable=False, index=True) + sync_token: Mapped[str | None] = mapped_column(Text, nullable=True) + etag: Mapped[str | None] = mapped_column(String(1000), nullable=True) + remote_revision: Mapped[str | None] = mapped_column(String(1000), nullable=True) + last_attempted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + last_success_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + last_error: Mapped[str | None] = mapped_column(Text, nullable=True) + last_diagnostic: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) + created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + updated_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True) + + address_book: Mapped[AddressBook] = relationship(back_populates="sync_sources") + tombstones: Mapped[list["AddressSyncTombstone"]] = relationship(back_populates="sync_source", cascade="all, delete-orphan") + conflicts: Mapped[list["AddressSyncConflict"]] = relationship(back_populates="sync_source", cascade="all, delete-orphan") + diagnostics: Mapped[list["AddressSyncDiagnostic"]] = relationship(back_populates="sync_source", cascade="all, delete-orphan") + + +class AddressSyncTombstone(Base, TimestampMixin): + __tablename__ = "addresses_sync_tombstones" + __table_args__ = ( + Index("ix_addresses_sync_tombstones_source_remote", "sync_source_id", "remote_uid"), + Index("ix_addresses_sync_tombstones_source_href", "sync_source_id", "resource_href"), + ) + + 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) + sync_source_id: Mapped[str] = mapped_column(ForeignKey("addresses_sync_sources.id", ondelete="CASCADE"), nullable=False, index=True) + address_book_id: Mapped[str] = mapped_column(ForeignKey("addresses_address_books.id", ondelete="CASCADE"), nullable=False, index=True) + contact_id: Mapped[str | None] = mapped_column(ForeignKey("addresses_contacts.id", ondelete="SET NULL"), nullable=True, index=True) + remote_uid: Mapped[str | None] = mapped_column(String(1000), nullable=True) + resource_href: Mapped[str | None] = mapped_column(String(1000), nullable=True) + local_deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + remote_deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True) + + sync_source: Mapped[AddressSyncSource] = relationship(back_populates="tombstones") + address_book: Mapped[AddressBook] = relationship() + contact: Mapped[Contact | None] = relationship() + + +class AddressSyncConflict(Base, TimestampMixin): + __tablename__ = "addresses_sync_conflicts" + __table_args__ = ( + Index("ix_addresses_sync_conflicts_source_status", "sync_source_id", "status"), + Index("ix_addresses_sync_conflicts_contact_status", "contact_id", "status"), + ) + + 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) + sync_source_id: Mapped[str] = mapped_column(ForeignKey("addresses_sync_sources.id", ondelete="CASCADE"), nullable=False, index=True) + address_book_id: Mapped[str] = mapped_column(ForeignKey("addresses_address_books.id", ondelete="CASCADE"), nullable=False, index=True) + contact_id: Mapped[str | None] = mapped_column(ForeignKey("addresses_contacts.id", ondelete="SET NULL"), nullable=True, index=True) + remote_uid: Mapped[str | None] = mapped_column(String(1000), nullable=True) + resource_href: Mapped[str | None] = mapped_column(String(1000), nullable=True) + field_path: Mapped[str] = mapped_column(String(500), nullable=False) + local_value: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) + remote_value: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) + local_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + remote_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + status: Mapped[str] = mapped_column(String(30), default="open", nullable=False, index=True) + resolution: Mapped[str | None] = mapped_column(String(60), nullable=True) + resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + resolved_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True) + + sync_source: Mapped[AddressSyncSource] = relationship(back_populates="conflicts") + address_book: Mapped[AddressBook] = relationship() + contact: Mapped[Contact | None] = relationship() + + +class AddressSyncDiagnostic(Base, TimestampMixin): + __tablename__ = "addresses_sync_diagnostics" + __table_args__ = ( + Index("ix_addresses_sync_diagnostics_source_created", "sync_source_id", "created_at"), + Index("ix_addresses_sync_diagnostics_source_severity", "sync_source_id", "severity"), + ) + + 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) + sync_source_id: Mapped[str] = mapped_column(ForeignKey("addresses_sync_sources.id", ondelete="CASCADE"), nullable=False, index=True) + severity: Mapped[str] = mapped_column(String(30), default="info", nullable=False, index=True) + code: Mapped[str] = mapped_column(String(120), nullable=False, index=True) + message: Mapped[str] = mapped_column(Text, nullable=False) + details: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) + + sync_source: Mapped[AddressSyncSource] = relationship(back_populates="diagnostics") + + +__all__ = [ + "AddressBook", + "AddressList", + "AddressListEntry", + "AddressSyncConflict", + "AddressSyncDiagnostic", + "AddressSyncSource", + "AddressSyncTombstone", + "Contact", + "ContactEmail", + "ContactPhone", + "ContactPostalAddress", + "new_uuid", +] diff --git a/src/govoplan_addresses/backend/manifest.py b/src/govoplan_addresses/backend/manifest.py new file mode 100644 index 0000000..6ad2952 --- /dev/null +++ b/src/govoplan_addresses/backend/manifest.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +from pathlib import Path + +from govoplan_addresses.backend.capabilities import ( + CAPABILITY_ADDRESSES_CONTACT_WRITER, + CAPABILITY_ADDRESSES_LOOKUP, + CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, +) +from govoplan_addresses.backend.db import models as addresses_models # noqa: F401 - populate address ORM metadata +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.modules import ( + DocumentationTopic, + FrontendModule, + FrontendRoute, + MigrationSpec, + ModuleContext, + ModuleInterfaceProvider, + ModuleManifest, + NavItem, + PermissionDefinition, + RoleTemplate, +) +from govoplan_core.db.base import Base + + +def _permission(scope: str, label: str, description: str) -> PermissionDefinition: + module_id, resource, action = scope.split(":", 2) + return PermissionDefinition( + scope=scope, + label=label, + description=description, + category="Addresses", + level="tenant", + module_id=module_id, + resource=resource, + action=action, + ) + + +PERMISSIONS = ( + _permission("addresses:address_book:read", "View address books", "List address books visible to the current principal."), + _permission("addresses:address_book:write", "Manage address books", "Create and edit local address books."), + _permission("addresses:address_book:delete", "Delete address books", "Soft-delete local address books."), + _permission("addresses:address_book:admin", "Administer address books", "Manage system-scoped address books and future sync sources."), + _permission("addresses:address_list:read", "View address lists", "List reusable address lists and their entries."), + _permission("addresses:address_list:write", "Manage address lists", "Create and edit reusable address lists."), + _permission("addresses:address_list:delete", "Delete address lists", "Soft-delete reusable address lists."), + _permission("addresses:contact:read", "View contacts", "List and lookup contacts in visible address books."), + _permission("addresses:contact:write", "Manage contacts", "Create and edit local contacts."), + _permission("addresses:contact:delete", "Delete contacts", "Soft-delete local contacts."), + _permission("addresses:sync:read", "View address sync", "Inspect address sync sources, conflicts, tombstones, and diagnostics."), + _permission("addresses:sync:write", "Manage address sync", "Bind address books to external sources and record sync state."), + _permission("addresses:sync:admin", "Administer address sync", "Administer address sync connectors and future destructive sync operations."), +) + + +ROLE_TEMPLATES = ( + RoleTemplate( + slug="address_book_manager", + name="Address book manager", + description="Manage visible local address books and contacts.", + permissions=( + "addresses:address_book:read", + "addresses:address_book:write", + "addresses:address_book:delete", + "addresses:address_list:read", + "addresses:address_list:write", + "addresses:address_list:delete", + "addresses:contact:read", + "addresses:contact:write", + "addresses:contact:delete", + "addresses:sync:read", + "addresses:sync:write", + ), + ), + RoleTemplate( + slug="address_book_reader", + name="Address book reader", + description="Read visible address books and contacts.", + permissions=("addresses:address_book:read", "addresses:address_list:read", "addresses:contact:read", "addresses:sync:read"), + ), +) + + +def _tenant_summary(session, tenant_id: str) -> dict[str, int]: + from govoplan_addresses.backend.db.models import AddressBook, AddressList, AddressSyncSource, Contact + + return { + "address_books": session.query(AddressBook).filter(AddressBook.tenant_id == tenant_id, AddressBook.deleted_at.is_(None)).count(), + "address_lists": session.query(AddressList).filter(AddressList.tenant_id == tenant_id, AddressList.deleted_at.is_(None)).count(), + "contacts": session.query(Contact).filter(Contact.tenant_id == tenant_id, Contact.deleted_at.is_(None)).count(), + "sync_sources": session.query(AddressSyncSource).filter(AddressSyncSource.tenant_id == tenant_id, AddressSyncSource.enabled.is_(True)).count(), + } + + +def _addresses_router(_context: ModuleContext): + from govoplan_addresses.backend.router import router + + return router + + +manifest = ModuleManifest( + id="addresses", + name="Addresses", + version="0.1.8", + required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), + optional_dependencies=("campaigns", "mail", "forms", "reporting", "portal", "postbox"), + provides_interfaces=( + ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_LOOKUP, version="0.1.8"), + ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, version="0.1.8"), + ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_CONTACT_WRITER, version="0.1.8"), + ), + permissions=PERMISSIONS, + route_factory=_addresses_router, + role_templates=ROLE_TEMPLATES, + tenant_summary_providers=(_tenant_summary,), + nav_items=(NavItem(path="/address-book", label="Address Book", icon="book-user", required_any=("addresses:contact:read",), order=80),), + frontend=FrontendModule( + module_id="addresses", + package_name="@govoplan/addresses-webui", + routes=(FrontendRoute(path="/address-book", component="AddressBookPage", required_any=("addresses:contact:read",), order=80),), + nav_items=(NavItem(path="/address-book", label="Address Book", icon="book-user", required_any=("addresses:contact:read",), order=80),), + ), + migration_spec=MigrationSpec( + module_id="addresses", + metadata=Base.metadata, + script_location=str(Path(__file__).with_name("migrations") / "versions"), + retirement_supported=True, + retirement_provider=drop_table_retirement_provider( + addresses_models.AddressSyncDiagnostic, + addresses_models.AddressSyncConflict, + addresses_models.AddressSyncTombstone, + addresses_models.AddressSyncSource, + addresses_models.AddressListEntry, + addresses_models.AddressList, + addresses_models.ContactPostalAddress, + addresses_models.ContactPhone, + addresses_models.ContactEmail, + addresses_models.Contact, + addresses_models.AddressBook, + label="Addresses", + ), + retirement_notes="Destructive retirement drops address-owned database tables after the installer captures a database snapshot.", + ), + capability_factories={ + CAPABILITY_ADDRESSES_LOOKUP: lambda context: __import__("govoplan_addresses.backend.capabilities", fromlist=["lookup_capability"]).lookup_capability(context), + CAPABILITY_ADDRESSES_RECIPIENT_SOURCE: lambda context: __import__( + "govoplan_addresses.backend.capabilities", + fromlist=["recipient_source_capability"], + ).recipient_source_capability(context), + CAPABILITY_ADDRESSES_CONTACT_WRITER: lambda context: __import__( + "govoplan_addresses.backend.capabilities", + fromlist=["contact_writer_capability"], + ).contact_writer_capability(context), + }, + uninstall_guard_providers=( + persistent_table_uninstall_guard( + addresses_models.AddressSyncDiagnostic, + addresses_models.AddressSyncConflict, + addresses_models.AddressSyncTombstone, + addresses_models.AddressSyncSource, + addresses_models.AddressListEntry, + addresses_models.AddressList, + addresses_models.AddressBook, + addresses_models.Contact, + addresses_models.ContactEmail, + addresses_models.ContactPhone, + addresses_models.ContactPostalAddress, + label="Addresses", + ), + ), + documentation=( + DocumentationTopic( + id="addresses.boundary", + title="Reusable address ownership", + summary="Reusable person, organization, household, postal, and email recipient sources belong to the addresses module.", + body=( + "Campaigns may keep immutable campaign-local recipient snapshots, but durable address directories, " + "recipient-source definitions, consent metadata, provenance, deduplication, and import/export workflows " + "are owned by govoplan-addresses." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("tenant_admin", "operator", "module_admin"), + related_modules=("campaigns", "mail", "forms", "reporting", "portal", "postbox"), + order=30, + ), + ), +) + + +def get_manifest() -> ModuleManifest: + return manifest diff --git a/src/govoplan_addresses/backend/migrations/__init__.py b/src/govoplan_addresses/backend/migrations/__init__.py new file mode 100644 index 0000000..11b89c0 --- /dev/null +++ b/src/govoplan_addresses/backend/migrations/__init__.py @@ -0,0 +1,2 @@ +"""Address module migration package.""" + diff --git a/src/govoplan_addresses/backend/migrations/versions/__init__.py b/src/govoplan_addresses/backend/migrations/versions/__init__.py new file mode 100644 index 0000000..655bfad --- /dev/null +++ b/src/govoplan_addresses/backend/migrations/versions/__init__.py @@ -0,0 +1,2 @@ +"""Address module Alembic revisions.""" + diff --git a/src/govoplan_addresses/backend/migrations/versions/b8c9d0e1f2a3_v018_addresses_baseline.py b/src/govoplan_addresses/backend/migrations/versions/b8c9d0e1f2a3_v018_addresses_baseline.py new file mode 100644 index 0000000..be4710d --- /dev/null +++ b/src/govoplan_addresses/backend/migrations/versions/b8c9d0e1f2a3_v018_addresses_baseline.py @@ -0,0 +1,169 @@ +"""v0.1.8 addresses baseline + +Revision ID: b8c9d0e1f2a3 +Revises: None +Create Date: 2026-07-13 00:00:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "b8c9d0e1f2a3" +down_revision = None +branch_labels = None +depends_on = "4f2a9c8e7b6d" + + +def upgrade() -> None: + op.create_table( + "addresses_address_books", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=True), + sa.Column("scope_type", sa.String(length=20), nullable=False), + sa.Column("scope_id", sa.String(length=36), nullable=True), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("source_kind", sa.String(length=30), nullable=False), + sa.Column("source_ref", sa.String(length=1000), nullable=True), + sa.Column("read_only", sa.Boolean(), nullable=False), + sa.Column("sync_status", sa.String(length=30), nullable=True), + sa.Column("sync_error", sa.Text(), nullable=True), + sa.Column("created_by_account_id", sa.String(length=36), nullable=True), + sa.Column("updated_by_account_id", sa.String(length=36), nullable=True), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("metadata", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_addresses_address_books")), + ) + op.create_index(op.f("ix_addresses_address_books_created_by_account_id"), "addresses_address_books", ["created_by_account_id"], unique=False) + op.create_index(op.f("ix_addresses_address_books_deleted_at"), "addresses_address_books", ["deleted_at"], unique=False) + op.create_index("ix_addresses_address_books_scope", "addresses_address_books", ["tenant_id", "scope_type", "scope_id"], unique=False) + op.create_index(op.f("ix_addresses_address_books_scope_id"), "addresses_address_books", ["scope_id"], unique=False) + op.create_index(op.f("ix_addresses_address_books_scope_type"), "addresses_address_books", ["scope_type"], unique=False) + op.create_index(op.f("ix_addresses_address_books_source_kind"), "addresses_address_books", ["source_kind"], unique=False) + op.create_index(op.f("ix_addresses_address_books_tenant_id"), "addresses_address_books", ["tenant_id"], unique=False) + op.create_index(op.f("ix_addresses_address_books_updated_by_account_id"), "addresses_address_books", ["updated_by_account_id"], unique=False) + op.create_index( + "uq_addresses_address_books_active_name", + "addresses_address_books", + ["tenant_id", "scope_type", "scope_id", "name"], + unique=True, + sqlite_where=sa.text("deleted_at IS NULL"), + postgresql_where=sa.text("deleted_at IS NULL"), + ) + + op.create_table( + "addresses_contacts", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=True), + sa.Column("address_book_id", sa.String(length=36), nullable=False), + sa.Column("display_name", sa.String(length=255), nullable=False), + sa.Column("given_name", sa.String(length=255), nullable=True), + sa.Column("family_name", sa.String(length=255), nullable=True), + sa.Column("organization", sa.String(length=255), nullable=True), + sa.Column("role_title", sa.String(length=255), nullable=True), + sa.Column("note", sa.Text(), nullable=True), + sa.Column("tags", sa.JSON(), nullable=False), + sa.Column("source_kind", sa.String(length=30), nullable=False), + sa.Column("source_ref", sa.String(length=1000), nullable=True), + sa.Column("provenance", sa.JSON(), nullable=False), + sa.Column("created_by_account_id", sa.String(length=36), nullable=True), + sa.Column("updated_by_account_id", sa.String(length=36), nullable=True), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("metadata", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["address_book_id"], + ["addresses_address_books.id"], + name=op.f("fk_addresses_contacts_address_book_id_addresses_address_books"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_addresses_contacts")), + ) + op.create_index(op.f("ix_addresses_contacts_address_book_id"), "addresses_contacts", ["address_book_id"], unique=False) + op.create_index("ix_addresses_contacts_book_name", "addresses_contacts", ["address_book_id", "display_name"], unique=False) + op.create_index(op.f("ix_addresses_contacts_created_by_account_id"), "addresses_contacts", ["created_by_account_id"], unique=False) + op.create_index(op.f("ix_addresses_contacts_deleted_at"), "addresses_contacts", ["deleted_at"], unique=False) + op.create_index(op.f("ix_addresses_contacts_display_name"), "addresses_contacts", ["display_name"], unique=False) + op.create_index(op.f("ix_addresses_contacts_organization"), "addresses_contacts", ["organization"], unique=False) + op.create_index(op.f("ix_addresses_contacts_source_kind"), "addresses_contacts", ["source_kind"], unique=False) + op.create_index(op.f("ix_addresses_contacts_tenant_id"), "addresses_contacts", ["tenant_id"], unique=False) + op.create_index("ix_addresses_contacts_tenant_name", "addresses_contacts", ["tenant_id", "display_name"], unique=False) + op.create_index(op.f("ix_addresses_contacts_updated_by_account_id"), "addresses_contacts", ["updated_by_account_id"], unique=False) + + op.create_table( + "addresses_contact_emails", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("contact_id", sa.String(length=36), nullable=False), + sa.Column("label", sa.String(length=80), nullable=True), + sa.Column("email", sa.String(length=320), nullable=False), + sa.Column("is_primary", sa.Boolean(), nullable=False), + sa.Column("order_index", sa.Integer(), 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"], name=op.f("fk_addresses_contact_emails_contact_id_addresses_contacts"), ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_addresses_contact_emails")), + ) + op.create_index(op.f("ix_addresses_contact_emails_contact_id"), "addresses_contact_emails", ["contact_id"], unique=False) + op.create_index("ix_addresses_contact_emails_contact_primary", "addresses_contact_emails", ["contact_id", "is_primary"], unique=False) + op.create_index(op.f("ix_addresses_contact_emails_email"), "addresses_contact_emails", ["email"], unique=False) + op.create_index("ix_addresses_contact_emails_lookup", "addresses_contact_emails", ["email"], unique=False) + + op.create_table( + "addresses_contact_phones", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("contact_id", sa.String(length=36), nullable=False), + sa.Column("label", sa.String(length=80), nullable=True), + sa.Column("phone", sa.String(length=100), nullable=False), + sa.Column("is_primary", sa.Boolean(), nullable=False), + sa.Column("order_index", sa.Integer(), 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"], name=op.f("fk_addresses_contact_phones_contact_id_addresses_contacts"), ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_addresses_contact_phones")), + ) + op.create_index(op.f("ix_addresses_contact_phones_contact_id"), "addresses_contact_phones", ["contact_id"], unique=False) + op.create_index("ix_addresses_contact_phones_contact_primary", "addresses_contact_phones", ["contact_id", "is_primary"], unique=False) + + op.create_table( + "addresses_contact_postal_addresses", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("contact_id", sa.String(length=36), nullable=False), + sa.Column("label", sa.String(length=80), nullable=True), + sa.Column("street", sa.String(length=500), nullable=True), + sa.Column("postal_code", sa.String(length=40), nullable=True), + sa.Column("locality", sa.String(length=255), nullable=True), + sa.Column("region", sa.String(length=255), nullable=True), + sa.Column("country", sa.String(length=255), nullable=True), + sa.Column("is_primary", sa.Boolean(), nullable=False), + sa.Column("order_index", sa.Integer(), 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"], + name=op.f("fk_addresses_contact_postal_addresses_contact_id_addresses_contacts"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_addresses_contact_postal_addresses")), + ) + op.create_index(op.f("ix_addresses_contact_postal_addresses_contact_id"), "addresses_contact_postal_addresses", ["contact_id"], unique=False) + op.create_index( + "ix_addresses_contact_postal_addresses_contact_primary", + "addresses_contact_postal_addresses", + ["contact_id", "is_primary"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_table("addresses_contact_postal_addresses") + op.drop_table("addresses_contact_phones") + op.drop_table("addresses_contact_emails") + op.drop_table("addresses_contacts") + op.drop_table("addresses_address_books") + diff --git a/src/govoplan_addresses/backend/migrations/versions/c9d0e1f2a4b_v019_addresses_source_payload.py b/src/govoplan_addresses/backend/migrations/versions/c9d0e1f2a4b_v019_addresses_source_payload.py new file mode 100644 index 0000000..04473c8 --- /dev/null +++ b/src/govoplan_addresses/backend/migrations/versions/c9d0e1f2a4b_v019_addresses_source_payload.py @@ -0,0 +1,32 @@ +"""v0.1.9 addresses source payload metadata + +Revision ID: c9d0e1f2a4b +Revises: b8c9d0e1f2a3 +Create Date: 2026-07-13 00:00:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "c9d0e1f2a4b" +down_revision = "b8c9d0e1f2a3" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("addresses_contacts", sa.Column("source_payload_kind", sa.String(length=40), nullable=True)) + op.add_column("addresses_contacts", sa.Column("source_payload_raw", sa.Text(), nullable=True)) + op.add_column("addresses_contacts", sa.Column("source_revision", sa.String(length=255), nullable=True)) + op.create_index(op.f("ix_addresses_contacts_source_payload_kind"), "addresses_contacts", ["source_payload_kind"], unique=False) + op.create_index(op.f("ix_addresses_contacts_source_revision"), "addresses_contacts", ["source_revision"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_addresses_contacts_source_revision"), table_name="addresses_contacts") + op.drop_index(op.f("ix_addresses_contacts_source_payload_kind"), table_name="addresses_contacts") + op.drop_column("addresses_contacts", "source_revision") + op.drop_column("addresses_contacts", "source_payload_raw") + op.drop_column("addresses_contacts", "source_payload_kind") diff --git a/src/govoplan_addresses/backend/migrations/versions/d0e1f2a4b5c_addresses_address_lists.py b/src/govoplan_addresses/backend/migrations/versions/d0e1f2a4b5c_addresses_address_lists.py new file mode 100644 index 0000000..9483793 --- /dev/null +++ b/src/govoplan_addresses/backend/migrations/versions/d0e1f2a4b5c_addresses_address_lists.py @@ -0,0 +1,115 @@ +"""addresses address lists + +Revision ID: d0e1f2a4b5c +Revises: c9d0e1f2a4b +Create Date: 2026-07-13 00:00:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "d0e1f2a4b5c" +down_revision = "c9d0e1f2a4b" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "addresses_address_lists", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=True), + sa.Column("address_book_id", sa.String(length=36), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("source_kind", sa.String(length=30), nullable=False), + sa.Column("source_ref", sa.String(length=1000), nullable=True), + sa.Column("read_only", sa.Boolean(), nullable=False), + sa.Column("created_by_account_id", sa.String(length=36), nullable=True), + sa.Column("updated_by_account_id", sa.String(length=36), nullable=True), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("metadata", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["address_book_id"], + ["addresses_address_books.id"], + name=op.f("fk_addresses_address_lists_address_book_id_addresses_address_books"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_addresses_address_lists")), + ) + op.create_index(op.f("ix_addresses_address_lists_address_book_id"), "addresses_address_lists", ["address_book_id"], unique=False) + op.create_index("ix_addresses_address_lists_book_name", "addresses_address_lists", ["address_book_id", "name"], unique=False) + op.create_index(op.f("ix_addresses_address_lists_created_by_account_id"), "addresses_address_lists", ["created_by_account_id"], unique=False) + op.create_index(op.f("ix_addresses_address_lists_deleted_at"), "addresses_address_lists", ["deleted_at"], unique=False) + op.create_index(op.f("ix_addresses_address_lists_source_kind"), "addresses_address_lists", ["source_kind"], unique=False) + op.create_index(op.f("ix_addresses_address_lists_tenant_id"), "addresses_address_lists", ["tenant_id"], unique=False) + op.create_index(op.f("ix_addresses_address_lists_updated_by_account_id"), "addresses_address_lists", ["updated_by_account_id"], unique=False) + op.create_index( + "uq_addresses_address_lists_active_name", + "addresses_address_lists", + ["address_book_id", "name"], + unique=True, + sqlite_where=sa.text("deleted_at IS NULL"), + postgresql_where=sa.text("deleted_at IS NULL"), + ) + + op.create_table( + "addresses_address_list_entries", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("address_list_id", sa.String(length=36), nullable=False), + sa.Column("contact_id", sa.String(length=36), nullable=False), + sa.Column("contact_email_id", sa.String(length=36), nullable=True), + sa.Column("contact_postal_address_id", sa.String(length=36), nullable=True), + sa.Column("target_kind", sa.String(length=30), nullable=False), + sa.Column("label", sa.String(length=255), nullable=True), + sa.Column("order_index", sa.Integer(), nullable=False), + sa.Column("metadata", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["address_list_id"], + ["addresses_address_lists.id"], + name=op.f("fk_addresses_address_list_entries_address_list_id_addresses_address_lists"), + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["contact_email_id"], + ["addresses_contact_emails.id"], + name=op.f("fk_addresses_address_list_entries_contact_email_id_addresses_contact_emails"), + ondelete="SET NULL", + ), + sa.ForeignKeyConstraint( + ["contact_id"], + ["addresses_contacts.id"], + name=op.f("fk_addresses_address_list_entries_contact_id_addresses_contacts"), + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["contact_postal_address_id"], + ["addresses_contact_postal_addresses.id"], + name=op.f("fk_addresses_address_list_entries_contact_postal_address_id_addresses_contact_postal_addresses"), + ondelete="SET NULL", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_addresses_address_list_entries")), + ) + op.create_index(op.f("ix_addresses_address_list_entries_address_list_id"), "addresses_address_list_entries", ["address_list_id"], unique=False) + op.create_index("ix_addresses_address_list_entries_contact", "addresses_address_list_entries", ["contact_id"], unique=False) + op.create_index(op.f("ix_addresses_address_list_entries_contact_email_id"), "addresses_address_list_entries", ["contact_email_id"], unique=False) + op.create_index(op.f("ix_addresses_address_list_entries_contact_id"), "addresses_address_list_entries", ["contact_id"], unique=False) + op.create_index( + op.f("ix_addresses_address_list_entries_contact_postal_address_id"), + "addresses_address_list_entries", + ["contact_postal_address_id"], + unique=False, + ) + op.create_index("ix_addresses_address_list_entries_list_order", "addresses_address_list_entries", ["address_list_id", "order_index"], unique=False) + op.create_index(op.f("ix_addresses_address_list_entries_target_kind"), "addresses_address_list_entries", ["target_kind"], unique=False) + + +def downgrade() -> None: + op.drop_table("addresses_address_list_entries") + op.drop_table("addresses_address_lists") diff --git a/src/govoplan_addresses/backend/migrations/versions/e1f2a4b5c6d_addresses_sync_infrastructure.py b/src/govoplan_addresses/backend/migrations/versions/e1f2a4b5c6d_addresses_sync_infrastructure.py new file mode 100644 index 0000000..127e4dd --- /dev/null +++ b/src/govoplan_addresses/backend/migrations/versions/e1f2a4b5c6d_addresses_sync_infrastructure.py @@ -0,0 +1,193 @@ +"""addresses sync infrastructure + +Revision ID: e1f2a4b5c6d +Revises: d0e1f2a4b5c +Create Date: 2026-07-14 00:00:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "e1f2a4b5c6d" +down_revision = "d0e1f2a4b5c" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "addresses_sync_sources", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=True), + sa.Column("address_book_id", sa.String(length=36), nullable=False), + sa.Column("connector_type", sa.String(length=60), nullable=False), + sa.Column("display_name", sa.String(length=255), nullable=False), + sa.Column("external_account_ref", sa.String(length=1000), nullable=True), + sa.Column("external_address_book_ref", sa.String(length=1000), nullable=True), + sa.Column("sync_direction", sa.String(length=30), nullable=False), + sa.Column("read_only", sa.Boolean(), nullable=False), + sa.Column("enabled", sa.Boolean(), nullable=False), + sa.Column("status", sa.String(length=30), nullable=False), + sa.Column("sync_token", sa.Text(), nullable=True), + sa.Column("etag", sa.String(length=1000), nullable=True), + sa.Column("remote_revision", sa.String(length=1000), nullable=True), + sa.Column("last_attempted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_success_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_error", sa.Text(), nullable=True), + sa.Column("last_diagnostic", sa.JSON(), nullable=True), + sa.Column("created_by_account_id", sa.String(length=36), nullable=True), + sa.Column("updated_by_account_id", sa.String(length=36), nullable=True), + sa.Column("metadata", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["address_book_id"], + ["addresses_address_books.id"], + name=op.f("fk_addresses_sync_sources_address_book_id_addresses_address_books"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_addresses_sync_sources")), + ) + op.create_index(op.f("ix_addresses_sync_sources_address_book_id"), "addresses_sync_sources", ["address_book_id"], unique=False) + op.create_index("ix_addresses_sync_sources_book_status", "addresses_sync_sources", ["address_book_id", "status"], unique=False) + op.create_index("ix_addresses_sync_sources_connector", "addresses_sync_sources", ["tenant_id", "connector_type"], unique=False) + op.create_index(op.f("ix_addresses_sync_sources_connector_type"), "addresses_sync_sources", ["connector_type"], unique=False) + op.create_index(op.f("ix_addresses_sync_sources_created_by_account_id"), "addresses_sync_sources", ["created_by_account_id"], unique=False) + op.create_index(op.f("ix_addresses_sync_sources_enabled"), "addresses_sync_sources", ["enabled"], unique=False) + op.create_index(op.f("ix_addresses_sync_sources_last_attempted_at"), "addresses_sync_sources", ["last_attempted_at"], unique=False) + op.create_index(op.f("ix_addresses_sync_sources_last_success_at"), "addresses_sync_sources", ["last_success_at"], unique=False) + op.create_index(op.f("ix_addresses_sync_sources_status"), "addresses_sync_sources", ["status"], unique=False) + op.create_index(op.f("ix_addresses_sync_sources_sync_direction"), "addresses_sync_sources", ["sync_direction"], unique=False) + op.create_index(op.f("ix_addresses_sync_sources_tenant_id"), "addresses_sync_sources", ["tenant_id"], unique=False) + op.create_index(op.f("ix_addresses_sync_sources_updated_by_account_id"), "addresses_sync_sources", ["updated_by_account_id"], unique=False) + + op.create_table( + "addresses_sync_tombstones", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=True), + sa.Column("sync_source_id", sa.String(length=36), nullable=False), + sa.Column("address_book_id", sa.String(length=36), nullable=False), + sa.Column("contact_id", sa.String(length=36), nullable=True), + sa.Column("remote_uid", sa.String(length=1000), nullable=True), + sa.Column("resource_href", sa.String(length=1000), nullable=True), + sa.Column("local_deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("remote_deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("synced_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("metadata", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["address_book_id"], + ["addresses_address_books.id"], + name=op.f("fk_addresses_sync_tombstones_address_book_id_addresses_address_books"), + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["contact_id"], + ["addresses_contacts.id"], + name=op.f("fk_addresses_sync_tombstones_contact_id_addresses_contacts"), + ondelete="SET NULL", + ), + sa.ForeignKeyConstraint( + ["sync_source_id"], + ["addresses_sync_sources.id"], + name=op.f("fk_addresses_sync_tombstones_sync_source_id_addresses_sync_sources"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_addresses_sync_tombstones")), + ) + op.create_index(op.f("ix_addresses_sync_tombstones_address_book_id"), "addresses_sync_tombstones", ["address_book_id"], unique=False) + op.create_index(op.f("ix_addresses_sync_tombstones_contact_id"), "addresses_sync_tombstones", ["contact_id"], unique=False) + op.create_index(op.f("ix_addresses_sync_tombstones_local_deleted_at"), "addresses_sync_tombstones", ["local_deleted_at"], unique=False) + op.create_index(op.f("ix_addresses_sync_tombstones_remote_deleted_at"), "addresses_sync_tombstones", ["remote_deleted_at"], unique=False) + op.create_index(op.f("ix_addresses_sync_tombstones_synced_at"), "addresses_sync_tombstones", ["synced_at"], unique=False) + op.create_index("ix_addresses_sync_tombstones_source_href", "addresses_sync_tombstones", ["sync_source_id", "resource_href"], unique=False) + op.create_index("ix_addresses_sync_tombstones_source_remote", "addresses_sync_tombstones", ["sync_source_id", "remote_uid"], unique=False) + op.create_index(op.f("ix_addresses_sync_tombstones_sync_source_id"), "addresses_sync_tombstones", ["sync_source_id"], unique=False) + op.create_index(op.f("ix_addresses_sync_tombstones_tenant_id"), "addresses_sync_tombstones", ["tenant_id"], unique=False) + + op.create_table( + "addresses_sync_conflicts", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=True), + sa.Column("sync_source_id", sa.String(length=36), nullable=False), + sa.Column("address_book_id", sa.String(length=36), nullable=False), + sa.Column("contact_id", sa.String(length=36), nullable=True), + sa.Column("remote_uid", sa.String(length=1000), nullable=True), + sa.Column("resource_href", sa.String(length=1000), nullable=True), + sa.Column("field_path", sa.String(length=500), nullable=False), + sa.Column("local_value", sa.JSON(), nullable=True), + sa.Column("remote_value", sa.JSON(), nullable=True), + sa.Column("local_updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("remote_updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("status", sa.String(length=30), nullable=False), + sa.Column("resolution", sa.String(length=60), nullable=True), + sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("resolved_by_account_id", sa.String(length=36), nullable=True), + sa.Column("metadata", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["address_book_id"], + ["addresses_address_books.id"], + name=op.f("fk_addresses_sync_conflicts_address_book_id_addresses_address_books"), + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["contact_id"], + ["addresses_contacts.id"], + name=op.f("fk_addresses_sync_conflicts_contact_id_addresses_contacts"), + ondelete="SET NULL", + ), + sa.ForeignKeyConstraint( + ["sync_source_id"], + ["addresses_sync_sources.id"], + name=op.f("fk_addresses_sync_conflicts_sync_source_id_addresses_sync_sources"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_addresses_sync_conflicts")), + ) + op.create_index(op.f("ix_addresses_sync_conflicts_address_book_id"), "addresses_sync_conflicts", ["address_book_id"], unique=False) + op.create_index(op.f("ix_addresses_sync_conflicts_contact_id"), "addresses_sync_conflicts", ["contact_id"], unique=False) + op.create_index("ix_addresses_sync_conflicts_contact_status", "addresses_sync_conflicts", ["contact_id", "status"], unique=False) + op.create_index(op.f("ix_addresses_sync_conflicts_resolved_at"), "addresses_sync_conflicts", ["resolved_at"], unique=False) + op.create_index(op.f("ix_addresses_sync_conflicts_resolved_by_account_id"), "addresses_sync_conflicts", ["resolved_by_account_id"], unique=False) + op.create_index(op.f("ix_addresses_sync_conflicts_status"), "addresses_sync_conflicts", ["status"], unique=False) + op.create_index("ix_addresses_sync_conflicts_source_status", "addresses_sync_conflicts", ["sync_source_id", "status"], unique=False) + op.create_index(op.f("ix_addresses_sync_conflicts_sync_source_id"), "addresses_sync_conflicts", ["sync_source_id"], unique=False) + op.create_index(op.f("ix_addresses_sync_conflicts_tenant_id"), "addresses_sync_conflicts", ["tenant_id"], unique=False) + + op.create_table( + "addresses_sync_diagnostics", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=True), + sa.Column("sync_source_id", sa.String(length=36), nullable=False), + sa.Column("severity", sa.String(length=30), nullable=False), + sa.Column("code", sa.String(length=120), nullable=False), + sa.Column("message", sa.Text(), nullable=False), + sa.Column("details", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["sync_source_id"], + ["addresses_sync_sources.id"], + name=op.f("fk_addresses_sync_diagnostics_sync_source_id_addresses_sync_sources"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_addresses_sync_diagnostics")), + ) + op.create_index(op.f("ix_addresses_sync_diagnostics_code"), "addresses_sync_diagnostics", ["code"], unique=False) + op.create_index(op.f("ix_addresses_sync_diagnostics_severity"), "addresses_sync_diagnostics", ["severity"], unique=False) + op.create_index("ix_addresses_sync_diagnostics_source_created", "addresses_sync_diagnostics", ["sync_source_id", "created_at"], unique=False) + op.create_index("ix_addresses_sync_diagnostics_source_severity", "addresses_sync_diagnostics", ["sync_source_id", "severity"], unique=False) + op.create_index(op.f("ix_addresses_sync_diagnostics_sync_source_id"), "addresses_sync_diagnostics", ["sync_source_id"], unique=False) + op.create_index(op.f("ix_addresses_sync_diagnostics_tenant_id"), "addresses_sync_diagnostics", ["tenant_id"], unique=False) + + +def downgrade() -> None: + op.drop_table("addresses_sync_diagnostics") + op.drop_table("addresses_sync_conflicts") + op.drop_table("addresses_sync_tombstones") + op.drop_table("addresses_sync_sources") diff --git a/src/govoplan_addresses/backend/router.py b/src/govoplan_addresses/backend/router.py new file mode 100644 index 0000000..c5fa203 --- /dev/null +++ b/src/govoplan_addresses/backend/router.py @@ -0,0 +1,1136 @@ +from __future__ import annotations + +from dataclasses import asdict +import re + +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status +from sqlalchemy.orm import Session + +from govoplan_core.audit.logging import audit_from_principal +from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope +from govoplan_core.db.session import get_session +from govoplan_addresses.backend.carddav import AddressCardDAVError +from govoplan_addresses.backend.db.models import ( + AddressBook, + AddressList, + AddressListEntry, + AddressSyncConflict, + AddressSyncDiagnostic, + AddressSyncSource, + AddressSyncTombstone, + Contact, + ContactPostalAddress, +) +from govoplan_addresses.backend.capabilities import AddressesContactWriterCapability +from govoplan_addresses.backend.schemas import ( + AddressBookCreateRequest, + AddressBookListResponse, + AddressBookResponse, + AddressBookUpdateRequest, + AddressBookWriteDecisionResponse, + AddressBookWriteTargetsResponse, + AddressListCreateRequest, + AddressListEntryCreateRequest, + AddressListEntryListResponse, + AddressListEntryResponse, + AddressListListResponse, + AddressListResponse, + AddressListUpdateRequest, + AddressLookupResponse, + AddressCardDavAddressBookResponse, + AddressCardDavDiscoveryRequest, + AddressCardDavDiscoveryResponse, + AddressCardDavSourceCreateRequest, + AddressSyncAttemptFinishRequest, + AddressSyncConflictCreateRequest, + AddressSyncConflictListResponse, + AddressSyncConflictResolveRequest, + AddressSyncConflictResponse, + AddressSyncDiagnosticCreateRequest, + AddressSyncDiagnosticListResponse, + AddressSyncDiagnosticResponse, + AddressSyncSourceCreateRequest, + AddressSyncSourceListResponse, + AddressSyncSourceResponse, + AddressSyncSourceUpdateRequest, + AddressSyncRunRequest, + AddressSyncPlanItemResponse, + AddressSyncPlanResponse, + AddressSyncTombstoneCreateRequest, + AddressSyncTombstoneListResponse, + AddressSyncTombstoneResponse, + ContactCreateRequest, + ContactListResponse, + ContactResponse, + ContactUpdateRequest, + VCardImportIssue, + VCardImportRequest, + VCardImportResponse, +) +from govoplan_addresses.backend.service import ( + AddressBookError, + address_book_contact_counts, + address_list_entry_counts, + create_address_book, + create_address_list, + create_address_list_entry, + create_carddav_sync_source, + create_contact, + create_sync_source, + delete_address_book, + delete_address_list, + delete_address_list_entry, + delete_contact, + delete_sync_source, + discover_carddav_address_books, + export_address_book_vcard, + export_contact_vcard, + finish_sync_attempt, + import_vcards, + list_address_list_entries, + list_address_lists, + list_address_books, + list_contacts, + list_sync_conflicts, + list_sync_diagnostics, + list_sync_sources, + list_sync_tombstones, + record_sync_conflict, + record_sync_diagnostic, + record_sync_tombstone, + restore_address_book, + restore_address_list, + restore_contact, + resolve_sync_conflict, + preview_sync_source, + run_sync_source, + start_sync_attempt, + update_address_book, + update_address_list, + update_contact, + update_sync_source, +) + +router = APIRouter(prefix="/addresses", tags=["addresses"]) + + +def _require_scope(principal: ApiPrincipal, scope: str) -> None: + if not has_scope(principal, scope): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}") + + +def _error(exc: AddressBookError) -> HTTPException: + message = str(exc) + status_code = status.HTTP_404_NOT_FOUND if message.endswith("not found.") else status.HTTP_422_UNPROCESSABLE_CONTENT + return HTTPException(status_code=status_code, detail=message) + + +def _book_response(book: AddressBook, *, contact_count: int = 0) -> AddressBookResponse: + return AddressBookResponse.model_validate( + { + "id": book.id, + "tenant_id": book.tenant_id, + "scope_type": book.scope_type, + "scope_id": book.scope_id, + "name": book.name, + "description": book.description, + "source_kind": book.source_kind, + "source_ref": book.source_ref, + "read_only": book.read_only, + "sync_status": book.sync_status, + "sync_error": book.sync_error, + "contact_count": contact_count, + "deleted_at": book.deleted_at, + "created_at": book.created_at, + "updated_at": book.updated_at, + } + ) + + +def _contact_response(contact: Contact) -> ContactResponse: + return ContactResponse.model_validate(contact) + + +def _address_list_response(address_list: AddressList, *, entry_count: int = 0) -> AddressListResponse: + return AddressListResponse.model_validate( + { + "id": address_list.id, + "tenant_id": address_list.tenant_id, + "address_book_id": address_list.address_book_id, + "name": address_list.name, + "description": address_list.description, + "source_kind": address_list.source_kind, + "source_ref": address_list.source_ref, + "read_only": address_list.read_only, + "entry_count": entry_count, + "deleted_at": address_list.deleted_at, + "created_at": address_list.created_at, + "updated_at": address_list.updated_at, + } + ) + + +def _address_list_entry_response(entry: AddressListEntry) -> AddressListEntryResponse: + return AddressListEntryResponse.model_validate( + { + "id": entry.id, + "address_list_id": entry.address_list_id, + "contact_id": entry.contact_id, + "contact_email_id": entry.contact_email_id, + "contact_postal_address_id": entry.contact_postal_address_id, + "target_kind": entry.target_kind, + "label": entry.label, + "order_index": entry.order_index, + "contact_display_name": entry.contact.display_name, + "email": entry.contact_email.email if entry.contact_email is not None else None, + "postal_address": _postal_address_summary(entry.contact_postal_address), + "created_at": entry.created_at, + "updated_at": entry.updated_at, + } + ) + + +def _write_decision_response(decision) -> AddressBookWriteDecisionResponse: + payload = asdict(decision) + payload["required_scopes"] = list(decision.required_scopes) + return AddressBookWriteDecisionResponse.model_validate(payload) + + +def _sync_source_response(sync_source: AddressSyncSource) -> AddressSyncSourceResponse: + return AddressSyncSourceResponse.model_validate( + { + "id": sync_source.id, + "tenant_id": sync_source.tenant_id, + "address_book_id": sync_source.address_book_id, + "connector_type": sync_source.connector_type, + "display_name": sync_source.display_name, + "external_account_ref": sync_source.external_account_ref, + "external_address_book_ref": sync_source.external_address_book_ref, + "sync_direction": sync_source.sync_direction, + "read_only": sync_source.read_only, + "enabled": sync_source.enabled, + "status": sync_source.status, + "sync_token": sync_source.sync_token, + "etag": sync_source.etag, + "remote_revision": sync_source.remote_revision, + "last_attempted_at": sync_source.last_attempted_at, + "last_success_at": sync_source.last_success_at, + "last_error": sync_source.last_error, + "last_diagnostic": sync_source.last_diagnostic, + "metadata": sync_source.metadata_ or {}, + "created_at": sync_source.created_at, + "updated_at": sync_source.updated_at, + } + ) + + +def _sync_diagnostic_response(diagnostic: AddressSyncDiagnostic) -> AddressSyncDiagnosticResponse: + return AddressSyncDiagnosticResponse.model_validate( + { + "id": diagnostic.id, + "tenant_id": diagnostic.tenant_id, + "sync_source_id": diagnostic.sync_source_id, + "severity": diagnostic.severity, + "code": diagnostic.code, + "message": diagnostic.message, + "details": diagnostic.details or {}, + "created_at": diagnostic.created_at, + "updated_at": diagnostic.updated_at, + } + ) + + +def _sync_tombstone_response(tombstone: AddressSyncTombstone) -> AddressSyncTombstoneResponse: + return AddressSyncTombstoneResponse.model_validate( + { + "id": tombstone.id, + "tenant_id": tombstone.tenant_id, + "sync_source_id": tombstone.sync_source_id, + "address_book_id": tombstone.address_book_id, + "contact_id": tombstone.contact_id, + "remote_uid": tombstone.remote_uid, + "resource_href": tombstone.resource_href, + "local_deleted_at": tombstone.local_deleted_at, + "remote_deleted_at": tombstone.remote_deleted_at, + "synced_at": tombstone.synced_at, + "metadata": tombstone.metadata_ or {}, + "created_at": tombstone.created_at, + "updated_at": tombstone.updated_at, + } + ) + + +def _sync_conflict_response(conflict: AddressSyncConflict) -> AddressSyncConflictResponse: + return AddressSyncConflictResponse.model_validate( + { + "id": conflict.id, + "tenant_id": conflict.tenant_id, + "sync_source_id": conflict.sync_source_id, + "address_book_id": conflict.address_book_id, + "contact_id": conflict.contact_id, + "remote_uid": conflict.remote_uid, + "resource_href": conflict.resource_href, + "field_path": conflict.field_path, + "local_value": conflict.local_value, + "remote_value": conflict.remote_value, + "local_updated_at": conflict.local_updated_at, + "remote_updated_at": conflict.remote_updated_at, + "status": conflict.status, + "resolution": conflict.resolution, + "resolved_at": conflict.resolved_at, + "resolved_by_account_id": conflict.resolved_by_account_id, + "metadata": conflict.metadata_ or {}, + "created_at": conflict.created_at, + "updated_at": conflict.updated_at, + } + ) + + +def _sync_plan_response(plan) -> AddressSyncPlanResponse: + return AddressSyncPlanResponse( + sync_source=_sync_source_response(plan.sync_source), + stats=plan.stats, + items=[ + AddressSyncPlanItemResponse( + action=item.action, + href=item.href, + remote_uid=item.remote_uid, + contact_id=item.contact_id, + display_name=item.display_name, + etag=item.etag, + message=item.message, + ) + for item in plan.items + ], + ) + + +def _audit_address_sync( + session: Session, + principal: ApiPrincipal, + *, + action: str, + object_type: str, + object_id: str, + details: dict, +) -> None: + audit_from_principal( + session, + principal, + action=action, + object_type=object_type, + object_id=object_id, + details=details, + ) + + +def _safe_filename(value: str) -> str: + slug = re.sub(r"[^A-Za-z0-9_.-]+", "-", value.strip()).strip("-") + return slug or "address-book" + + +def _postal_address_summary(address: ContactPostalAddress | None) -> str | None: + if address is None: + return None + parts = [ + address.street, + " ".join(part for part in (address.postal_code, address.locality) if part), + address.region, + address.country, + ] + return ", ".join(part for part in parts if part) or None + + +@router.get("/address-books", response_model=AddressBookListResponse) +def api_list_address_books( + include_deleted: bool = Query(default=False), + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:address_book:read") + books = list_address_books(session, principal, include_deleted=include_deleted) + counts = address_book_contact_counts(session, [book.id for book in books], include_deleted=include_deleted) + return AddressBookListResponse(address_books=[_book_response(book, contact_count=counts.get(book.id, 0)) for book in books]) + + +@router.post("/address-books", response_model=AddressBookResponse, status_code=status.HTTP_201_CREATED) +def api_create_address_book( + payload: AddressBookCreateRequest, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:address_book:write") + try: + book = create_address_book(session, principal, payload, allow_system=has_scope(principal, "addresses:address_book:admin")) + session.commit() + session.refresh(book) + return _book_response(book) + except AddressBookError as exc: + session.rollback() + raise _error(exc) from exc + + +@router.patch("/address-books/{book_id}", response_model=AddressBookResponse) +def api_update_address_book( + book_id: str, + payload: AddressBookUpdateRequest, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:address_book:write") + try: + book = update_address_book(session, principal, book_id, payload) + session.commit() + session.refresh(book) + counts = address_book_contact_counts(session, [book.id]) + return _book_response(book, contact_count=counts.get(book.id, 0)) + except AddressBookError as exc: + session.rollback() + raise _error(exc) from exc + + +@router.delete("/address-books/{book_id}", status_code=status.HTTP_204_NO_CONTENT) +def api_delete_address_book( + book_id: str, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:address_book:delete") + try: + delete_address_book(session, principal, book_id) + session.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) + except AddressBookError as exc: + session.rollback() + raise _error(exc) from exc + + +@router.post("/address-books/{book_id}/restore", response_model=AddressBookResponse) +def api_restore_address_book( + book_id: str, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:address_book:write") + try: + book = restore_address_book(session, principal, book_id) + session.commit() + session.refresh(book) + counts = address_book_contact_counts(session, [book.id]) + return _book_response(book, contact_count=counts.get(book.id, 0)) + except AddressBookError as exc: + session.rollback() + raise _error(exc) from exc + + +@router.get("/contacts", response_model=ContactListResponse) +def api_list_contacts( + address_book_id: str | None = Query(default=None), + query: str | None = Query(default=None), + limit: int = Query(default=200, ge=1, le=500), + include_deleted: bool = Query(default=False), + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:contact:read") + try: + contacts = list_contacts(session, principal, address_book_id=address_book_id, query=query, limit=limit, include_deleted=include_deleted) + return ContactListResponse(contacts=[_contact_response(contact) for contact in contacts]) + except AddressBookError as exc: + raise _error(exc) from exc + + +@router.get("/lookup", response_model=AddressLookupResponse) +def api_lookup_addresses( + query: str = Query(min_length=1), + limit: int = Query(default=25, ge=1, le=100), + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:contact:read") + contacts = list_contacts(session, principal, query=query, limit=limit) + return AddressLookupResponse(contacts=[_contact_response(contact) for contact in contacts]) + + +@router.get("/address-lists", response_model=AddressListListResponse) +def api_list_address_lists( + address_book_id: str | None = Query(default=None), + include_deleted: bool = Query(default=False), + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:address_list:read") + try: + address_lists = list_address_lists(session, principal, address_book_id=address_book_id, include_deleted=include_deleted) + counts = address_list_entry_counts(session, [address_list.id for address_list in address_lists]) + return AddressListListResponse( + address_lists=[_address_list_response(address_list, entry_count=counts.get(address_list.id, 0)) for address_list in address_lists] + ) + except AddressBookError as exc: + raise _error(exc) from exc + + +@router.post("/address-books/{book_id}/address-lists", response_model=AddressListResponse, status_code=status.HTTP_201_CREATED) +def api_create_address_list( + book_id: str, + payload: AddressListCreateRequest, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:address_list:write") + try: + address_list = create_address_list(session, principal, book_id, payload) + session.commit() + session.refresh(address_list) + return _address_list_response(address_list) + except AddressBookError as exc: + session.rollback() + raise _error(exc) from exc + + +@router.patch("/address-lists/{address_list_id}", response_model=AddressListResponse) +def api_update_address_list( + address_list_id: str, + payload: AddressListUpdateRequest, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:address_list:write") + try: + address_list = update_address_list(session, principal, address_list_id, payload) + session.commit() + session.refresh(address_list) + counts = address_list_entry_counts(session, [address_list.id]) + return _address_list_response(address_list, entry_count=counts.get(address_list.id, 0)) + except AddressBookError as exc: + session.rollback() + raise _error(exc) from exc + + +@router.delete("/address-lists/{address_list_id}", status_code=status.HTTP_204_NO_CONTENT) +def api_delete_address_list( + address_list_id: str, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:address_list:delete") + try: + delete_address_list(session, principal, address_list_id) + session.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) + except AddressBookError as exc: + session.rollback() + raise _error(exc) from exc + + +@router.post("/address-lists/{address_list_id}/restore", response_model=AddressListResponse) +def api_restore_address_list( + address_list_id: str, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:address_list:write") + try: + address_list = restore_address_list(session, principal, address_list_id) + session.commit() + session.refresh(address_list) + counts = address_list_entry_counts(session, [address_list.id]) + return _address_list_response(address_list, entry_count=counts.get(address_list.id, 0)) + except AddressBookError as exc: + session.rollback() + raise _error(exc) from exc + + +@router.get("/address-lists/{address_list_id}/entries", response_model=AddressListEntryListResponse) +def api_list_address_list_entries( + address_list_id: str, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:address_list:read") + try: + entries = list_address_list_entries(session, principal, address_list_id) + return AddressListEntryListResponse(entries=[_address_list_entry_response(entry) for entry in entries]) + except AddressBookError as exc: + raise _error(exc) from exc + + +@router.post("/address-lists/{address_list_id}/entries", response_model=AddressListEntryResponse, status_code=status.HTTP_201_CREATED) +def api_create_address_list_entry( + address_list_id: str, + payload: AddressListEntryCreateRequest, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:address_list:write") + try: + entry = create_address_list_entry(session, principal, address_list_id, payload) + session.commit() + session.refresh(entry) + return _address_list_entry_response(entry) + except AddressBookError as exc: + session.rollback() + raise _error(exc) from exc + + +@router.delete("/address-list-entries/{entry_id}", status_code=status.HTTP_204_NO_CONTENT) +def api_delete_address_list_entry( + entry_id: str, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:address_list:write") + try: + delete_address_list_entry(session, principal, entry_id) + session.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) + except AddressBookError as exc: + session.rollback() + raise _error(exc) from exc + + +@router.get("/write-targets", response_model=AddressBookWriteTargetsResponse) +def api_list_write_targets( + operation: str = Query(default="create_contact"), + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:address_book:read") + decisions = AddressesContactWriterCapability().list_write_targets(session, principal, operation=operation) + return AddressBookWriteTargetsResponse(targets=[_write_decision_response(decision) for decision in decisions]) + + +@router.get("/address-books/{book_id}/write-decision", response_model=AddressBookWriteDecisionResponse) +def api_get_address_book_write_decision( + book_id: str, + operation: str = Query(default="create_contact"), + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:address_book:read") + decision = AddressesContactWriterCapability().can_write_to_address_book(session, principal, address_book_id=book_id, operation=operation) + return _write_decision_response(decision) + + +@router.post("/carddav/discover", response_model=AddressCardDavDiscoveryResponse) +def api_discover_carddav_address_books( + payload: AddressCardDavDiscoveryRequest, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:sync:read") + try: + address_books = discover_carddav_address_books(session, principal, payload) + return AddressCardDavDiscoveryResponse( + address_books=[ + AddressCardDavAddressBookResponse( + collection_url=item.collection_url, + href=item.href, + display_name=item.display_name, + description=item.description, + ctag=item.ctag, + sync_token=item.sync_token, + ) + for item in address_books + ] + ) + except (AddressBookError, AddressCardDAVError) as exc: + raise _error(AddressBookError(str(exc))) from exc + + +@router.post("/address-books/{book_id}/carddav/sources", response_model=AddressSyncSourceResponse, status_code=status.HTTP_201_CREATED) +def api_create_carddav_sync_source( + book_id: str, + payload: AddressCardDavSourceCreateRequest, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:sync:write") + try: + sync_source = create_carddav_sync_source(session, principal, book_id, payload) + _audit_address_sync( + session, + principal, + action="addresses.sync_source_created", + object_type="address_sync_source", + object_id=sync_source.id, + details={"address_book_id": book_id, "connector_type": "carddav", "sync_direction": sync_source.sync_direction}, + ) + session.commit() + session.refresh(sync_source) + return _sync_source_response(sync_source) + except (AddressBookError, AddressCardDAVError) as exc: + session.rollback() + raise _error(AddressBookError(str(exc))) from exc + + +@router.get("/sync-sources", response_model=AddressSyncSourceListResponse) +def api_list_sync_sources( + address_book_id: str | None = Query(default=None), + include_disabled: bool = Query(default=False), + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:sync:read") + try: + sync_sources = list_sync_sources(session, principal, address_book_id=address_book_id, include_disabled=include_disabled) + return AddressSyncSourceListResponse(sync_sources=[_sync_source_response(sync_source) for sync_source in sync_sources]) + except AddressBookError as exc: + raise _error(exc) from exc + + +@router.post("/sync-sources/{sync_source_id}/preview", response_model=AddressSyncPlanResponse) +def api_preview_sync_source( + sync_source_id: str, + payload: AddressSyncRunRequest, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:sync:read") + try: + plan = preview_sync_source( + session, + principal, + sync_source_id, + force_full=payload.force_full, + password=payload.password.get_secret_value() if payload.password else None, + bearer_token=payload.bearer_token.get_secret_value() if payload.bearer_token else None, + ) + _audit_address_sync( + session, + principal, + action="addresses.sync_previewed", + object_type="address_sync_source", + object_id=sync_source_id, + details=plan.stats.model_dump(), + ) + session.commit() + return _sync_plan_response(plan) + except (AddressBookError, AddressCardDAVError) as exc: + session.rollback() + raise _error(AddressBookError(str(exc))) from exc + + +@router.post("/sync-sources/{sync_source_id}/run", response_model=AddressSyncPlanResponse) +def api_run_sync_source( + sync_source_id: str, + payload: AddressSyncRunRequest, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:sync:write") + try: + plan = run_sync_source( + session, + principal, + sync_source_id, + force_full=payload.force_full, + password=payload.password.get_secret_value() if payload.password else None, + bearer_token=payload.bearer_token.get_secret_value() if payload.bearer_token else None, + ) + _audit_address_sync( + session, + principal, + action="addresses.sync_completed", + object_type="address_sync_source", + object_id=sync_source_id, + details=plan.stats.model_dump(), + ) + session.commit() + return _sync_plan_response(plan) + except (AddressBookError, AddressCardDAVError) as exc: + session.rollback() + raise _error(AddressBookError(str(exc))) from exc + + +@router.post("/address-books/{book_id}/sync-sources", response_model=AddressSyncSourceResponse, status_code=status.HTTP_201_CREATED) +def api_create_sync_source( + book_id: str, + payload: AddressSyncSourceCreateRequest, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:sync:write") + try: + sync_source = create_sync_source(session, principal, book_id, payload) + _audit_address_sync( + session, + principal, + action="addresses.sync_source_created", + object_type="address_sync_source", + object_id=sync_source.id, + details={"address_book_id": book_id, "connector_type": sync_source.connector_type, "sync_direction": sync_source.sync_direction}, + ) + session.commit() + session.refresh(sync_source) + return _sync_source_response(sync_source) + except AddressBookError as exc: + session.rollback() + raise _error(exc) from exc + + +@router.patch("/sync-sources/{sync_source_id}", response_model=AddressSyncSourceResponse) +def api_update_sync_source( + sync_source_id: str, + payload: AddressSyncSourceUpdateRequest, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:sync:write") + try: + sync_source = update_sync_source(session, principal, sync_source_id, payload) + _audit_address_sync( + session, + principal, + action="addresses.sync_source_updated", + object_type="address_sync_source", + object_id=sync_source.id, + details={"connector_type": sync_source.connector_type, "sync_direction": sync_source.sync_direction, "enabled": sync_source.enabled}, + ) + session.commit() + session.refresh(sync_source) + return _sync_source_response(sync_source) + except AddressBookError as exc: + session.rollback() + raise _error(exc) from exc + + +@router.delete("/sync-sources/{sync_source_id}", status_code=status.HTTP_204_NO_CONTENT) +def api_delete_sync_source( + sync_source_id: str, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:sync:write") + try: + delete_sync_source(session, principal, sync_source_id) + _audit_address_sync( + session, + principal, + action="addresses.sync_source_deleted", + object_type="address_sync_source", + object_id=sync_source_id, + details={"sync_source_id": sync_source_id}, + ) + session.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) + except AddressBookError as exc: + session.rollback() + raise _error(exc) from exc + + +@router.post("/sync-sources/{sync_source_id}/attempts/start", response_model=AddressSyncSourceResponse) +def api_start_sync_attempt( + sync_source_id: str, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:sync:write") + try: + sync_source = start_sync_attempt(session, principal, sync_source_id) + _audit_address_sync( + session, + principal, + action="addresses.sync_started", + object_type="address_sync_source", + object_id=sync_source.id, + details={"connector_type": sync_source.connector_type}, + ) + session.commit() + session.refresh(sync_source) + return _sync_source_response(sync_source) + except AddressBookError as exc: + session.rollback() + raise _error(exc) from exc + + +@router.post("/sync-sources/{sync_source_id}/attempts/finish", response_model=AddressSyncSourceResponse) +def api_finish_sync_attempt( + sync_source_id: str, + payload: AddressSyncAttemptFinishRequest, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:sync:write") + try: + sync_source = finish_sync_attempt(session, principal, sync_source_id, payload) + _audit_address_sync( + session, + principal, + action="addresses.sync_finished", + object_type="address_sync_source", + object_id=sync_source.id, + details={"connector_type": sync_source.connector_type, "status": sync_source.status, "error": sync_source.last_error}, + ) + session.commit() + session.refresh(sync_source) + return _sync_source_response(sync_source) + except AddressBookError as exc: + session.rollback() + raise _error(exc) from exc + + +@router.get("/sync-sources/{sync_source_id}/diagnostics", response_model=AddressSyncDiagnosticListResponse) +def api_list_sync_diagnostics( + sync_source_id: str, + limit: int = Query(default=100, ge=1, le=500), + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:sync:read") + try: + diagnostics = list_sync_diagnostics(session, principal, sync_source_id, limit=limit) + return AddressSyncDiagnosticListResponse(diagnostics=[_sync_diagnostic_response(diagnostic) for diagnostic in diagnostics]) + except AddressBookError as exc: + raise _error(exc) from exc + + +@router.post("/sync-sources/{sync_source_id}/diagnostics", response_model=AddressSyncDiagnosticResponse, status_code=status.HTTP_201_CREATED) +def api_record_sync_diagnostic( + sync_source_id: str, + payload: AddressSyncDiagnosticCreateRequest, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:sync:write") + try: + diagnostic = record_sync_diagnostic(session, principal, sync_source_id, payload) + session.commit() + session.refresh(diagnostic) + return _sync_diagnostic_response(diagnostic) + except AddressBookError as exc: + session.rollback() + raise _error(exc) from exc + + +@router.get("/sync-sources/{sync_source_id}/tombstones", response_model=AddressSyncTombstoneListResponse) +def api_list_sync_tombstones( + sync_source_id: str, + limit: int = Query(default=200, ge=1, le=1000), + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:sync:read") + try: + tombstones = list_sync_tombstones(session, principal, sync_source_id, limit=limit) + return AddressSyncTombstoneListResponse(tombstones=[_sync_tombstone_response(tombstone) for tombstone in tombstones]) + except AddressBookError as exc: + raise _error(exc) from exc + + +@router.post("/sync-sources/{sync_source_id}/tombstones", response_model=AddressSyncTombstoneResponse, status_code=status.HTTP_201_CREATED) +def api_record_sync_tombstone( + sync_source_id: str, + payload: AddressSyncTombstoneCreateRequest, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:sync:write") + try: + tombstone = record_sync_tombstone(session, principal, sync_source_id, payload) + session.commit() + session.refresh(tombstone) + return _sync_tombstone_response(tombstone) + except AddressBookError as exc: + session.rollback() + raise _error(exc) from exc + + +@router.get("/sync-sources/{sync_source_id}/conflicts", response_model=AddressSyncConflictListResponse) +def api_list_sync_conflicts( + sync_source_id: str, + status_filter: str | None = Query(default="open", alias="status"), + limit: int = Query(default=200, ge=1, le=1000), + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:sync:read") + try: + conflicts = list_sync_conflicts(session, principal, sync_source_id, status_filter=status_filter, limit=limit) + return AddressSyncConflictListResponse(conflicts=[_sync_conflict_response(conflict) for conflict in conflicts]) + except AddressBookError as exc: + raise _error(exc) from exc + + +@router.post("/sync-sources/{sync_source_id}/conflicts", response_model=AddressSyncConflictResponse, status_code=status.HTTP_201_CREATED) +def api_record_sync_conflict( + sync_source_id: str, + payload: AddressSyncConflictCreateRequest, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:sync:write") + try: + conflict = record_sync_conflict(session, principal, sync_source_id, payload) + session.commit() + session.refresh(conflict) + return _sync_conflict_response(conflict) + except AddressBookError as exc: + session.rollback() + raise _error(exc) from exc + + +@router.post("/sync-conflicts/{conflict_id}/resolve", response_model=AddressSyncConflictResponse) +def api_resolve_sync_conflict( + conflict_id: str, + payload: AddressSyncConflictResolveRequest, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:sync:write") + try: + conflict = resolve_sync_conflict(session, principal, conflict_id, payload) + _audit_address_sync( + session, + principal, + action="addresses.sync_conflict_resolved", + object_type="address_sync_conflict", + object_id=conflict.id, + details={"sync_source_id": conflict.sync_source_id, "status": conflict.status, "resolution": conflict.resolution}, + ) + session.commit() + session.refresh(conflict) + return _sync_conflict_response(conflict) + except AddressBookError as exc: + session.rollback() + raise _error(exc) from exc + + +@router.post("/address-books/{book_id}/contacts", response_model=ContactResponse, status_code=status.HTTP_201_CREATED) +def api_create_contact( + book_id: str, + payload: ContactCreateRequest, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:contact:write") + try: + contact = create_contact(session, principal, book_id, payload) + session.commit() + session.refresh(contact) + return _contact_response(contact) + except AddressBookError as exc: + session.rollback() + raise _error(exc) from exc + + +@router.patch("/contacts/{contact_id}", response_model=ContactResponse) +def api_update_contact( + contact_id: str, + payload: ContactUpdateRequest, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:contact:write") + try: + contact = update_contact(session, principal, contact_id, payload) + session.commit() + session.refresh(contact) + return _contact_response(contact) + except AddressBookError as exc: + session.rollback() + raise _error(exc) from exc + + +@router.delete("/contacts/{contact_id}", status_code=status.HTTP_204_NO_CONTENT) +def api_delete_contact( + contact_id: str, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:contact:delete") + try: + delete_contact(session, principal, contact_id) + session.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) + except AddressBookError as exc: + session.rollback() + raise _error(exc) from exc + + +@router.post("/contacts/{contact_id}/restore", response_model=ContactResponse) +def api_restore_contact( + contact_id: str, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:contact:write") + try: + contact = restore_contact(session, principal, contact_id) + session.commit() + session.refresh(contact) + return _contact_response(contact) + except AddressBookError as exc: + session.rollback() + raise _error(exc) from exc + + +@router.post("/address-books/{book_id}/vcards/import", response_model=VCardImportResponse, status_code=status.HTTP_201_CREATED) +def api_import_address_book_vcards( + book_id: str, + payload: VCardImportRequest, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:contact:write") + try: + result = import_vcards(session, principal, book_id, payload.content) + session.commit() + for contact in result.contacts: + session.refresh(contact) + return VCardImportResponse( + imported=len(result.contacts), + skipped=result.skipped, + contacts=[_contact_response(contact) for contact in result.contacts], + issues=[ + VCardImportIssue( + index=issue.index, + message=issue.message, + severity=issue.severity, + field=issue.field, + line=issue.line, + ) + for issue in result.issues + ], + ) + except AddressBookError as exc: + session.rollback() + raise _error(AddressBookError(str(exc))) from exc + + +@router.get("/address-books/{book_id}/vcards/export") +def api_export_address_book_vcards( + book_id: str, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:contact:read") + try: + book, content = export_address_book_vcard(session, principal, book_id) + filename = f"{_safe_filename(book.name)}.vcf" + return Response( + content=content, + media_type="text/vcard", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + except AddressBookError as exc: + raise _error(exc) from exc + + +@router.get("/contacts/{contact_id}/vcard") +def api_export_contact_vcard( + contact_id: str, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:contact:read") + try: + contact, content = export_contact_vcard(session, principal, contact_id) + filename = f"{_safe_filename(contact.display_name)}.vcf" + return Response( + content=content, + media_type="text/vcard", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + except AddressBookError as exc: + raise _error(exc) from exc diff --git a/src/govoplan_addresses/backend/schemas.py b/src/govoplan_addresses/backend/schemas.py new file mode 100644 index 0000000..0772080 --- /dev/null +++ b/src/govoplan_addresses/backend/schemas.py @@ -0,0 +1,510 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, SecretStr + + +AddressBookScope = Literal["user", "group", "tenant", "system"] +AddressSyncDirection = Literal["read_only", "import", "export", "two_way"] +AddressSyncStatus = Literal["idle", "running", "succeeded", "failed", "conflict", "disabled"] +AddressSyncDiagnosticSeverity = Literal["debug", "info", "warning", "error"] +AddressSyncConflictStatus = Literal["open", "resolved", "ignored"] +AddressSyncConflictResolution = Literal["keep_local", "use_remote", "merge", "manual", "ignored"] +AddressCardDavAuthType = Literal["none", "basic", "bearer"] +AddressSyncPlanAction = Literal["create", "update", "delete", "remote_create", "remote_update", "remote_delete", "conflict", "unchanged", "error"] + + +class ContactEmailPayload(BaseModel): + label: str | None = Field(default=None, max_length=80) + email: str = Field(max_length=320) + is_primary: bool = False + + +class ContactPhonePayload(BaseModel): + label: str | None = Field(default=None, max_length=80) + phone: str = Field(max_length=100) + is_primary: bool = False + + +class ContactPostalAddressPayload(BaseModel): + label: str | None = Field(default=None, max_length=80) + street: str | None = Field(default=None, max_length=500) + postal_code: str | None = Field(default=None, max_length=40) + locality: str | None = Field(default=None, max_length=255) + region: str | None = Field(default=None, max_length=255) + country: str | None = Field(default=None, max_length=255) + is_primary: bool = False + + +class AddressBookCreateRequest(BaseModel): + scope_type: AddressBookScope = "user" + group_id: str | None = Field(default=None, max_length=36) + name: str = Field(min_length=1, max_length=255) + description: str | None = None + + +class AddressBookUpdateRequest(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=255) + description: str | None = None + + +class AddressBookResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + tenant_id: str | None = None + scope_type: AddressBookScope + scope_id: str | None = None + name: str + description: str | None = None + source_kind: str + source_ref: str | None = None + read_only: bool + sync_status: str | None = None + sync_error: str | None = None + contact_count: int = 0 + deleted_at: datetime | None = None + created_at: datetime + updated_at: datetime + + +class AddressBookListResponse(BaseModel): + address_books: list[AddressBookResponse] + + +class AddressListCreateRequest(BaseModel): + name: str = Field(min_length=1, max_length=255) + description: str | None = None + + +class AddressListUpdateRequest(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=255) + description: str | None = None + + +class AddressListEntryCreateRequest(BaseModel): + contact_id: str = Field(max_length=36) + contact_email_id: str | None = Field(default=None, max_length=36) + contact_postal_address_id: str | None = Field(default=None, max_length=36) + label: str | None = Field(default=None, max_length=255) + + +class AddressListResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + tenant_id: str | None = None + address_book_id: str + name: str + description: str | None = None + source_kind: str + source_ref: str | None = None + read_only: bool + entry_count: int = 0 + deleted_at: datetime | None = None + created_at: datetime + updated_at: datetime + + +class AddressListListResponse(BaseModel): + address_lists: list[AddressListResponse] + + +class AddressListEntryResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + address_list_id: str + contact_id: str + contact_email_id: str | None = None + contact_postal_address_id: str | None = None + target_kind: str + label: str | None = None + order_index: int + contact_display_name: str + email: str | None = None + postal_address: str | None = None + created_at: datetime + updated_at: datetime + + +class AddressListEntryListResponse(BaseModel): + entries: list[AddressListEntryResponse] + + +class ContactCreateRequest(BaseModel): + display_name: str | None = Field(default=None, max_length=255) + given_name: str | None = Field(default=None, max_length=255) + family_name: str | None = Field(default=None, max_length=255) + organization: str | None = Field(default=None, max_length=255) + role_title: str | None = Field(default=None, max_length=255) + note: str | None = None + tags: list[str] = Field(default_factory=list) + emails: list[ContactEmailPayload] = Field(default_factory=list) + phones: list[ContactPhonePayload] = Field(default_factory=list) + postal_addresses: list[ContactPostalAddressPayload] = Field(default_factory=list) + provenance: dict[str, Any] = Field(default_factory=dict) + + +class ContactUpdateRequest(BaseModel): + display_name: str | None = Field(default=None, max_length=255) + given_name: str | None = Field(default=None, max_length=255) + family_name: str | None = Field(default=None, max_length=255) + organization: str | None = Field(default=None, max_length=255) + role_title: str | None = Field(default=None, max_length=255) + note: str | None = None + tags: list[str] | None = None + emails: list[ContactEmailPayload] | None = None + phones: list[ContactPhonePayload] | None = None + postal_addresses: list[ContactPostalAddressPayload] | None = None + provenance: dict[str, Any] | None = None + + +class ContactEmailResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + label: str | None = None + email: str + is_primary: bool + + +class ContactPhoneResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + label: str | None = None + phone: str + is_primary: bool + + +class ContactPostalAddressResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + label: str | None = None + street: str | None = None + postal_code: str | None = None + locality: str | None = None + region: str | None = None + country: str | None = None + is_primary: bool + + +class ContactResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + tenant_id: str | None = None + address_book_id: str + display_name: str + given_name: str | None = None + family_name: str | None = None + organization: str | None = None + role_title: str | None = None + note: str | None = None + tags: list[str] + source_kind: str + source_ref: str | None = None + source_payload_kind: str | None = None + source_revision: str | None = None + provenance: dict[str, Any] + emails: list[ContactEmailResponse] + phones: list[ContactPhoneResponse] + postal_addresses: list[ContactPostalAddressResponse] + deleted_at: datetime | None = None + created_at: datetime + updated_at: datetime + + +class ContactListResponse(BaseModel): + contacts: list[ContactResponse] + + +class AddressLookupResponse(BaseModel): + contacts: list[ContactResponse] + + +class AddressBookWriteDecisionResponse(BaseModel): + address_book_id: str + address_book_label: str | None = None + operation: str + allowed: bool + reason: str + message: str + scope_type: AddressBookScope | None = None + scope_id: str | None = None + tenant_id: str | None = None + source_kind: str | None = None + read_only: bool = False + required_scopes: list[str] = Field(default_factory=list) + provenance: dict[str, Any] = Field(default_factory=dict) + + +class AddressBookWriteTargetsResponse(BaseModel): + targets: list[AddressBookWriteDecisionResponse] = Field(default_factory=list) + + +class AddressSyncSourceCreateRequest(BaseModel): + connector_type: str = Field(min_length=1, max_length=60) + display_name: str = Field(min_length=1, max_length=255) + external_account_ref: str | None = Field(default=None, max_length=1000) + external_address_book_ref: str | None = Field(default=None, max_length=1000) + sync_direction: AddressSyncDirection = "read_only" + read_only: bool | None = None + enabled: bool = True + sync_token: str | None = None + etag: str | None = Field(default=None, max_length=1000) + remote_revision: str | None = Field(default=None, max_length=1000) + metadata: dict[str, Any] = Field(default_factory=dict) + + +class AddressSyncSourceUpdateRequest(BaseModel): + display_name: str | None = Field(default=None, min_length=1, max_length=255) + external_account_ref: str | None = Field(default=None, max_length=1000) + external_address_book_ref: str | None = Field(default=None, max_length=1000) + sync_direction: AddressSyncDirection | None = None + read_only: bool | None = None + enabled: bool | None = None + sync_token: str | None = None + etag: str | None = Field(default=None, max_length=1000) + remote_revision: str | None = Field(default=None, max_length=1000) + metadata: dict[str, Any] | None = None + + +class AddressSyncAttemptFinishRequest(BaseModel): + status: Literal["succeeded", "failed", "conflict"] + sync_token: str | None = None + etag: str | None = Field(default=None, max_length=1000) + remote_revision: str | None = Field(default=None, max_length=1000) + error: str | None = None + diagnostic: dict[str, Any] | None = None + + +class AddressSyncSourceResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + tenant_id: str | None = None + address_book_id: str + connector_type: str + display_name: str + external_account_ref: str | None = None + external_address_book_ref: str | None = None + sync_direction: str + read_only: bool + enabled: bool + status: str + sync_token: str | None = None + etag: str | None = None + remote_revision: str | None = None + last_attempted_at: datetime | None = None + last_success_at: datetime | None = None + last_error: str | None = None + last_diagnostic: dict[str, Any] | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + created_at: datetime + updated_at: datetime + + +class AddressSyncSourceListResponse(BaseModel): + sync_sources: list[AddressSyncSourceResponse] + + +class AddressSyncDiagnosticCreateRequest(BaseModel): + severity: AddressSyncDiagnosticSeverity = "info" + code: str = Field(min_length=1, max_length=120) + message: str = Field(min_length=1) + details: dict[str, Any] = Field(default_factory=dict) + + +class AddressSyncDiagnosticResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + tenant_id: str | None = None + sync_source_id: str + severity: str + code: str + message: str + details: dict[str, Any] = Field(default_factory=dict) + created_at: datetime + updated_at: datetime + + +class AddressSyncDiagnosticListResponse(BaseModel): + diagnostics: list[AddressSyncDiagnosticResponse] + + +class AddressSyncTombstoneCreateRequest(BaseModel): + contact_id: str | None = Field(default=None, max_length=36) + remote_uid: str | None = Field(default=None, max_length=1000) + resource_href: str | None = Field(default=None, max_length=1000) + local_deleted_at: datetime | None = None + remote_deleted_at: datetime | None = None + synced_at: datetime | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + +class AddressSyncTombstoneResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + tenant_id: str | None = None + sync_source_id: str + address_book_id: str + contact_id: str | None = None + remote_uid: str | None = None + resource_href: str | None = None + local_deleted_at: datetime | None = None + remote_deleted_at: datetime | None = None + synced_at: datetime | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + created_at: datetime + updated_at: datetime + + +class AddressSyncTombstoneListResponse(BaseModel): + tombstones: list[AddressSyncTombstoneResponse] + + +class AddressSyncConflictCreateRequest(BaseModel): + contact_id: str | None = Field(default=None, max_length=36) + remote_uid: str | None = Field(default=None, max_length=1000) + resource_href: str | None = Field(default=None, max_length=1000) + field_path: str = Field(min_length=1, max_length=500) + local_value: dict[str, Any] | None = None + remote_value: dict[str, Any] | None = None + local_updated_at: datetime | None = None + remote_updated_at: datetime | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + +class AddressSyncConflictResolveRequest(BaseModel): + resolution: AddressSyncConflictResolution + status: Literal["resolved", "ignored"] = "resolved" + merged_payload: ContactCreateRequest | None = None + + +class AddressSyncConflictResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + tenant_id: str | None = None + sync_source_id: str + address_book_id: str + contact_id: str | None = None + remote_uid: str | None = None + resource_href: str | None = None + field_path: str + local_value: dict[str, Any] | None = None + remote_value: dict[str, Any] | None = None + local_updated_at: datetime | None = None + remote_updated_at: datetime | None = None + status: str + resolution: str | None = None + resolved_at: datetime | None = None + resolved_by_account_id: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + created_at: datetime + updated_at: datetime + + +class AddressSyncConflictListResponse(BaseModel): + conflicts: list[AddressSyncConflictResponse] + + +class AddressCardDavDiscoveryRequest(BaseModel): + url: str = Field(min_length=1, max_length=2000) + auth_type: AddressCardDavAuthType = "none" + username: str | None = Field(default=None, max_length=320) + password: SecretStr | None = None + bearer_token: SecretStr | None = None + credential_ref: str | None = Field(default=None, max_length=1000) + source_id: str | None = Field(default=None, max_length=36) + + +class AddressCardDavAddressBookResponse(BaseModel): + collection_url: str + href: str + display_name: str | None = None + description: str | None = None + ctag: str | None = None + sync_token: str | None = None + + +class AddressCardDavDiscoveryResponse(BaseModel): + address_books: list[AddressCardDavAddressBookResponse] + + +class AddressCardDavSourceCreateRequest(BaseModel): + collection_url: str = Field(min_length=1, max_length=2000) + display_name: str | None = Field(default=None, max_length=255) + auth_type: AddressCardDavAuthType = "none" + username: str | None = Field(default=None, max_length=320) + password: SecretStr | None = None + bearer_token: SecretStr | None = None + credential_ref: str | None = Field(default=None, max_length=1000) + sync_direction: AddressSyncDirection = "read_only" + read_only: bool | None = None + sync_token: str | None = None + etag: str | None = Field(default=None, max_length=1000) + remote_revision: str | None = Field(default=None, max_length=1000) + + +class AddressSyncRunRequest(BaseModel): + force_full: bool = False + password: SecretStr | None = None + bearer_token: SecretStr | None = None + + +class AddressSyncPlanStats(BaseModel): + created: int = 0 + updated: int = 0 + deleted: int = 0 + conflicts: int = 0 + unchanged: int = 0 + errors: int = 0 + fetched: int = 0 + full_sync: bool = False + used_sync_token: bool = False + sync_token: str | None = None + etag: str | None = None + remote_revision: str | None = None + + +class AddressSyncPlanItemResponse(BaseModel): + action: AddressSyncPlanAction + href: str | None = None + remote_uid: str | None = None + contact_id: str | None = None + display_name: str | None = None + etag: str | None = None + message: str | None = None + + +class AddressSyncPlanResponse(BaseModel): + sync_source: AddressSyncSourceResponse + stats: AddressSyncPlanStats + items: list[AddressSyncPlanItemResponse] + + +class VCardImportRequest(BaseModel): + content: str = Field(min_length=1) + + +class VCardImportIssue(BaseModel): + index: int + message: str + severity: Literal["warning", "error"] = "error" + field: str | None = None + line: int | None = None + + +class VCardImportResponse(BaseModel): + imported: int + skipped: int + contacts: list[ContactResponse] + issues: list[VCardImportIssue] = Field(default_factory=list) diff --git a/src/govoplan_addresses/backend/service.py b/src/govoplan_addresses/backend/service.py new file mode 100644 index 0000000..aa7d806 --- /dev/null +++ b/src/govoplan_addresses/backend/service.py @@ -0,0 +1,1993 @@ +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass, field +import os +import urllib.parse +from typing import Any + +from sqlalchemy import and_, false, func, or_ +from sqlalchemy.orm import Session + +from govoplan_core.auth import ApiPrincipal +from govoplan_core.core.change_sequence import record_change +from govoplan_core.db.base import utcnow +from govoplan_core.security.secrets import decrypt_secret, encrypt_secret +from govoplan_addresses.backend.carddav import ( + AddressCardDAVAddressBook, + AddressCardDAVClient, + AddressCardDAVError, + AddressCardDAVPreconditionFailed, + AddressCardDAVReportResult, + AddressCardDAVSyncUnsupported, + ensure_collection_url, +) +from govoplan_addresses.backend.db.models import ( + AddressBook, + AddressList, + AddressListEntry, + AddressSyncConflict, + AddressSyncDiagnostic, + AddressSyncSource, + AddressSyncTombstone, + Contact, + ContactEmail, + ContactPhone, + ContactPostalAddress, +) +from govoplan_addresses.backend.schemas import ( + AddressBookCreateRequest, + AddressBookUpdateRequest, + AddressListCreateRequest, + AddressListEntryCreateRequest, + AddressListUpdateRequest, + AddressCardDavDiscoveryRequest, + AddressCardDavSourceCreateRequest, + AddressSyncPlanStats, + AddressSyncAttemptFinishRequest, + AddressSyncConflictCreateRequest, + AddressSyncConflictResolveRequest, + AddressSyncDiagnosticCreateRequest, + AddressSyncSourceCreateRequest, + AddressSyncSourceUpdateRequest, + AddressSyncTombstoneCreateRequest, + ContactCreateRequest, + ContactEmailPayload, + ContactPhonePayload, + ContactPostalAddressPayload, + ContactUpdateRequest, +) +from govoplan_addresses.backend.vcard import ParsedVCardIssue, contacts_to_vcard, parse_vcards_with_issues + + +class AddressBookError(ValueError): + pass + + +@dataclass(frozen=True, slots=True) +class VCardImportResult: + contacts: list[Contact] + issues: list[ParsedVCardIssue] + skipped: int + + +@dataclass(slots=True) +class AddressSyncPlanItem: + action: str + href: str | None = None + remote_uid: str | None = None + contact_id: str | None = None + display_name: str | None = None + etag: str | None = None + message: str | None = None + raw_vcard: str | None = None + parsed_payload: ContactCreateRequest | None = None + source_revision: str | None = None + + +@dataclass(slots=True) +class AddressSyncPlan: + sync_source: AddressSyncSource + stats: AddressSyncPlanStats + items: list[AddressSyncPlanItem] = field(default_factory=list) + + +READ_ONLY_SYNC_DIRECTIONS = {"read_only", "import"} +OUTBOUND_SYNC_DIRECTIONS = {"export", "two_way"} +INBOUND_SYNC_DIRECTIONS = {"read_only", "import", "two_way"} +REMOTE_SYNC_ACTIONS = {"remote_create", "remote_update", "remote_delete"} +ADDRESS_MODULE_ID = "addresses" +ADDRESS_CONTACTS_COLLECTION = "addresses.contacts" +ADDRESS_CONTACT_RESOURCE = "address_contact" +CARDDAV_SECRET_ENV_PREFIX = "env:" + + +def _trim(value: str | None) -> str | None: + if value is None: + return None + trimmed = value.strip() + return trimmed or None + + +def _account_id(principal: ApiPrincipal) -> str: + return principal.account_id + + +def _tenant_id_or_none(principal: ApiPrincipal) -> str | None: + try: + return principal.tenant_id + except RuntimeError: + return None + + +def _visible_book_predicate(principal: ApiPrincipal): + tenant_id = _tenant_id_or_none(principal) + account_id = _account_id(principal) + predicates = [AddressBook.scope_type == "system"] + if tenant_id: + predicates.extend( + [ + and_(AddressBook.tenant_id == tenant_id, AddressBook.scope_type == "tenant"), + and_(AddressBook.tenant_id == tenant_id, AddressBook.scope_type == "user", AddressBook.scope_id == account_id), + ] + ) + group_ids = tuple(principal.group_ids) + if group_ids: + predicates.append(and_(AddressBook.tenant_id == tenant_id, AddressBook.scope_type == "group", AddressBook.scope_id.in_(group_ids))) + return or_(*predicates) + + +def _visible_books_query(session: Session, principal: ApiPrincipal, *, include_deleted: bool = False): + query = session.query(AddressBook).filter(_visible_book_predicate(principal)) + if not include_deleted: + query = query.filter(AddressBook.deleted_at.is_(None)) + return query + + +def _require_mutable_book(book: AddressBook) -> None: + if book.read_only: + raise AddressBookError("Address book is read-only.") + if book.deleted_at is not None: + raise AddressBookError("Address book is deleted.") + + +def _require_mutable_address_list(address_list: AddressList) -> None: + _require_mutable_book(address_list.address_book) + if address_list.read_only: + raise AddressBookError("Address list is read-only.") + if address_list.deleted_at is not None: + raise AddressBookError("Address list is deleted.") + + +def _scope_id_for_create(principal: ApiPrincipal, payload: AddressBookCreateRequest, *, allow_system: bool = False) -> tuple[str | None, str | None]: + tenant_id = _tenant_id_or_none(principal) + if payload.scope_type == "system": + if not allow_system: + raise AddressBookError("Creating system address books requires address book administration permission.") + return None, None + if not tenant_id: + raise AddressBookError("A tenant context is required for tenant, group, and user address books.") + if payload.scope_type == "tenant": + return tenant_id, tenant_id + if payload.scope_type == "user": + return tenant_id, _account_id(principal) + if payload.scope_type == "group": + group_id = _trim(payload.group_id) + if not group_id: + raise AddressBookError("Group address books require a group id.") + if principal.group_ids and group_id not in principal.group_ids and not principal.has("addresses:address_book:admin"): + raise AddressBookError("The selected group is not visible to the current principal.") + return tenant_id, group_id + raise AddressBookError("Unsupported address book scope.") + + +def list_address_books(session: Session, principal: ApiPrincipal, *, include_deleted: bool = False) -> list[AddressBook]: + return _visible_books_query(session, principal, include_deleted=include_deleted).order_by(AddressBook.scope_type.asc(), AddressBook.name.asc(), AddressBook.id.asc()).all() + + +def address_book_contact_counts(session: Session, book_ids: Iterable[str], *, include_deleted: bool = False) -> dict[str, int]: + ids = tuple(book_ids) + if not ids: + return {} + query = ( + session.query(Contact.address_book_id, func.count(Contact.id)) + .filter(Contact.address_book_id.in_(ids)) + .group_by(Contact.address_book_id) + ) + if not include_deleted: + query = query.filter(Contact.deleted_at.is_(None)) + rows = query.all() + return {book_id: count for book_id, count in rows} + + +def address_list_entry_counts(session: Session, address_list_ids: Iterable[str]) -> dict[str, int]: + ids = tuple(address_list_ids) + if not ids: + return {} + rows = ( + session.query(AddressListEntry.address_list_id, func.count(AddressListEntry.id)) + .join(Contact, AddressListEntry.contact_id == Contact.id) + .filter(AddressListEntry.address_list_id.in_(ids), Contact.deleted_at.is_(None)) + .group_by(AddressListEntry.address_list_id) + .all() + ) + return {address_list_id: count for address_list_id, count in rows} + + +def get_visible_address_book(session: Session, principal: ApiPrincipal, book_id: str, *, include_deleted: bool = False) -> AddressBook: + book = _visible_books_query(session, principal, include_deleted=include_deleted).filter(AddressBook.id == book_id).one_or_none() + if book is None: + raise AddressBookError("Address book not found.") + return book + + +def _visible_sync_source_query(session: Session, principal: ApiPrincipal): + book_ids = [book.id for book in list_address_books(session, principal)] + if not book_ids: + return session.query(AddressSyncSource).filter(false()) + return session.query(AddressSyncSource).filter(AddressSyncSource.address_book_id.in_(book_ids)) + + +def list_sync_sources( + session: Session, + principal: ApiPrincipal, + *, + address_book_id: str | None = None, + include_disabled: bool = False, +) -> list[AddressSyncSource]: + query = _visible_sync_source_query(session, principal) + if address_book_id: + get_visible_address_book(session, principal, address_book_id) + query = query.filter(AddressSyncSource.address_book_id == address_book_id) + if not include_disabled: + query = query.filter(AddressSyncSource.enabled.is_(True)) + return query.order_by(AddressSyncSource.display_name.asc(), AddressSyncSource.id.asc()).all() + + +def get_visible_sync_source(session: Session, principal: ApiPrincipal, sync_source_id: str) -> AddressSyncSource: + sync_source = _visible_sync_source_query(session, principal).filter(AddressSyncSource.id == sync_source_id).one_or_none() + if sync_source is None: + raise AddressBookError("Address sync source not found.") + return sync_source + + +def create_sync_source( + session: Session, + principal: ApiPrincipal, + address_book_id: str, + payload: AddressSyncSourceCreateRequest, +) -> AddressSyncSource: + book = get_visible_address_book(session, principal, address_book_id) + if book.deleted_at is not None: + raise AddressBookError("Address book is deleted.") + connector_type = _trim(payload.connector_type) + display_name = _trim(payload.display_name) + if not connector_type: + raise AddressBookError("Sync connector type is required.") + if not display_name: + raise AddressBookError("Sync source display name is required.") + read_only = _read_only_from_sync_direction(payload.sync_direction, payload.read_only) + sync_source = AddressSyncSource( + tenant_id=book.tenant_id, + address_book_id=book.id, + connector_type=connector_type, + display_name=display_name, + external_account_ref=_trim(payload.external_account_ref), + external_address_book_ref=_trim(payload.external_address_book_ref), + sync_direction=payload.sync_direction, + read_only=read_only, + enabled=payload.enabled, + status="idle" if payload.enabled else "disabled", + sync_token=payload.sync_token, + etag=_trim(payload.etag), + remote_revision=_trim(payload.remote_revision), + created_by_account_id=_account_id(principal), + updated_by_account_id=_account_id(principal), + metadata_=payload.metadata or {}, + ) + session.add(sync_source) + session.flush() + _apply_sync_source_to_book(book, sync_source) + return sync_source + + +def update_sync_source( + session: Session, + principal: ApiPrincipal, + sync_source_id: str, + payload: AddressSyncSourceUpdateRequest, +) -> AddressSyncSource: + sync_source = get_visible_sync_source(session, principal, sync_source_id) + if "display_name" in payload.model_fields_set: + display_name = _trim(payload.display_name) + if not display_name: + raise AddressBookError("Sync source display name is required.") + sync_source.display_name = display_name + if "external_account_ref" in payload.model_fields_set: + sync_source.external_account_ref = _trim(payload.external_account_ref) + if "external_address_book_ref" in payload.model_fields_set: + sync_source.external_address_book_ref = _trim(payload.external_address_book_ref) + if "sync_direction" in payload.model_fields_set and payload.sync_direction is not None: + sync_source.sync_direction = payload.sync_direction + sync_source.read_only = _read_only_from_sync_direction(payload.sync_direction, payload.read_only) + elif "read_only" in payload.model_fields_set and payload.read_only is not None: + sync_source.read_only = _read_only_from_sync_direction(sync_source.sync_direction, payload.read_only) + if "enabled" in payload.model_fields_set and payload.enabled is not None: + sync_source.enabled = payload.enabled + if not payload.enabled: + sync_source.status = "disabled" + elif sync_source.status == "disabled": + sync_source.status = "idle" + if "sync_token" in payload.model_fields_set: + sync_source.sync_token = payload.sync_token + if "etag" in payload.model_fields_set: + sync_source.etag = _trim(payload.etag) + if "remote_revision" in payload.model_fields_set: + sync_source.remote_revision = _trim(payload.remote_revision) + if "metadata" in payload.model_fields_set: + sync_source.metadata_ = payload.metadata or {} + sync_source.updated_by_account_id = _account_id(principal) + _apply_sync_source_to_book(sync_source.address_book, sync_source) + return sync_source + + +def delete_sync_source(session: Session, principal: ApiPrincipal, sync_source_id: str) -> None: + sync_source = get_visible_sync_source(session, principal, sync_source_id) + book = sync_source.address_book + if book.source_ref == sync_source.id: + book.source_kind = "local" + book.source_ref = None + book.read_only = False + book.sync_status = None + book.sync_error = None + book.updated_by_account_id = _account_id(principal) + session.delete(sync_source) + + +def start_sync_attempt(session: Session, principal: ApiPrincipal, sync_source_id: str) -> AddressSyncSource: + sync_source = get_visible_sync_source(session, principal, sync_source_id) + if not sync_source.enabled: + raise AddressBookError("Address sync source is disabled.") + sync_source.status = "running" + sync_source.last_attempted_at = utcnow() + sync_source.last_error = None + sync_source.updated_by_account_id = _account_id(principal) + sync_source.address_book.sync_status = "running" + sync_source.address_book.sync_error = None + sync_source.address_book.updated_by_account_id = _account_id(principal) + return sync_source + + +def finish_sync_attempt( + session: Session, + principal: ApiPrincipal, + sync_source_id: str, + payload: AddressSyncAttemptFinishRequest, +) -> AddressSyncSource: + sync_source = get_visible_sync_source(session, principal, sync_source_id) + sync_source.status = payload.status + sync_source.last_attempted_at = sync_source.last_attempted_at or utcnow() + sync_source.last_error = _trim(payload.error) + sync_source.last_diagnostic = payload.diagnostic or None + if payload.status == "succeeded": + sync_source.last_success_at = utcnow() + sync_source.last_error = None + if "sync_token" in payload.model_fields_set: + sync_source.sync_token = payload.sync_token + if "etag" in payload.model_fields_set: + sync_source.etag = _trim(payload.etag) + if "remote_revision" in payload.model_fields_set: + sync_source.remote_revision = _trim(payload.remote_revision) + sync_source.updated_by_account_id = _account_id(principal) + sync_source.address_book.sync_status = payload.status + sync_source.address_book.sync_error = sync_source.last_error + sync_source.address_book.updated_by_account_id = _account_id(principal) + return sync_source + + +def list_sync_diagnostics( + session: Session, + principal: ApiPrincipal, + sync_source_id: str, + *, + limit: int = 100, +) -> list[AddressSyncDiagnostic]: + sync_source = get_visible_sync_source(session, principal, sync_source_id) + return ( + session.query(AddressSyncDiagnostic) + .filter(AddressSyncDiagnostic.sync_source_id == sync_source.id) + .order_by(AddressSyncDiagnostic.created_at.desc(), AddressSyncDiagnostic.id.desc()) + .limit(max(1, min(limit, 500))) + .all() + ) + + +def record_sync_diagnostic( + session: Session, + principal: ApiPrincipal, + sync_source_id: str, + payload: AddressSyncDiagnosticCreateRequest, +) -> AddressSyncDiagnostic: + sync_source = get_visible_sync_source(session, principal, sync_source_id) + diagnostic = AddressSyncDiagnostic( + tenant_id=sync_source.tenant_id, + sync_source_id=sync_source.id, + severity=payload.severity, + code=_trim(payload.code) or "sync", + message=_trim(payload.message) or "Sync diagnostic", + details=payload.details or {}, + ) + sync_source.last_diagnostic = { + "severity": diagnostic.severity, + "code": diagnostic.code, + "message": diagnostic.message, + } + if diagnostic.severity == "error": + sync_source.last_error = diagnostic.message + sync_source.status = "failed" + sync_source.address_book.sync_status = "failed" + sync_source.address_book.sync_error = diagnostic.message + sync_source.updated_by_account_id = _account_id(principal) + session.add(diagnostic) + return diagnostic + + +def list_sync_tombstones( + session: Session, + principal: ApiPrincipal, + sync_source_id: str, + *, + limit: int = 200, +) -> list[AddressSyncTombstone]: + sync_source = get_visible_sync_source(session, principal, sync_source_id) + return ( + session.query(AddressSyncTombstone) + .filter(AddressSyncTombstone.sync_source_id == sync_source.id) + .order_by(AddressSyncTombstone.created_at.desc(), AddressSyncTombstone.id.desc()) + .limit(max(1, min(limit, 1000))) + .all() + ) + + +def record_sync_tombstone( + session: Session, + principal: ApiPrincipal, + sync_source_id: str, + payload: AddressSyncTombstoneCreateRequest, +) -> AddressSyncTombstone: + sync_source = get_visible_sync_source(session, principal, sync_source_id) + contact = _sync_contact_or_none(session, principal, sync_source, payload.contact_id) + if not _trim(payload.remote_uid) and not _trim(payload.resource_href) and contact is None: + raise AddressBookError("Sync tombstone requires a contact id, remote uid, or resource href.") + tombstone = AddressSyncTombstone( + tenant_id=sync_source.tenant_id, + sync_source_id=sync_source.id, + address_book_id=sync_source.address_book_id, + contact_id=contact.id if contact is not None else None, + remote_uid=_trim(payload.remote_uid), + resource_href=_trim(payload.resource_href), + local_deleted_at=payload.local_deleted_at, + remote_deleted_at=payload.remote_deleted_at, + synced_at=payload.synced_at, + metadata_=payload.metadata or {}, + ) + session.add(tombstone) + return tombstone + + +def list_sync_conflicts( + session: Session, + principal: ApiPrincipal, + sync_source_id: str, + *, + status_filter: str | None = "open", + limit: int = 200, +) -> list[AddressSyncConflict]: + sync_source = get_visible_sync_source(session, principal, sync_source_id) + query = session.query(AddressSyncConflict).filter(AddressSyncConflict.sync_source_id == sync_source.id) + if status_filter: + query = query.filter(AddressSyncConflict.status == status_filter) + return query.order_by(AddressSyncConflict.created_at.desc(), AddressSyncConflict.id.desc()).limit(max(1, min(limit, 1000))).all() + + +def record_sync_conflict( + session: Session, + principal: ApiPrincipal, + sync_source_id: str, + payload: AddressSyncConflictCreateRequest, +) -> AddressSyncConflict: + sync_source = get_visible_sync_source(session, principal, sync_source_id) + contact = _sync_contact_or_none(session, principal, sync_source, payload.contact_id) + field_path = _trim(payload.field_path) + if not field_path: + raise AddressBookError("Sync conflict field path is required.") + conflict = AddressSyncConflict( + tenant_id=sync_source.tenant_id, + sync_source_id=sync_source.id, + address_book_id=sync_source.address_book_id, + contact_id=contact.id if contact is not None else None, + remote_uid=_trim(payload.remote_uid), + resource_href=_trim(payload.resource_href), + field_path=field_path, + local_value=payload.local_value, + remote_value=payload.remote_value, + local_updated_at=payload.local_updated_at, + remote_updated_at=payload.remote_updated_at, + status="open", + metadata_=payload.metadata or {}, + ) + sync_source.status = "conflict" + sync_source.address_book.sync_status = "conflict" + sync_source.updated_by_account_id = _account_id(principal) + session.add(conflict) + return conflict + + +def resolve_sync_conflict( + session: Session, + principal: ApiPrincipal, + conflict_id: str, + payload: AddressSyncConflictResolveRequest, +) -> AddressSyncConflict: + conflict = get_visible_sync_conflict(session, principal, conflict_id) + if payload.status == "resolved" and payload.resolution == "use_remote": + _apply_remote_sync_conflict(session, principal, conflict) + elif payload.status == "resolved" and payload.resolution == "merge": + if payload.merged_payload is None: + raise AddressBookError("Merge resolution requires a merged contact payload.") + _apply_payload_sync_conflict(session, principal, conflict, payload.merged_payload) + conflict.status = payload.status + conflict.resolution = payload.resolution + conflict.resolved_at = utcnow() + conflict.resolved_by_account_id = _account_id(principal) + if not list_sync_conflicts(session, principal, conflict.sync_source_id, status_filter="open", limit=1): + conflict.sync_source.status = "idle" + conflict.address_book.sync_status = "idle" + return conflict + + +def _apply_remote_sync_conflict(session: Session, principal: ApiPrincipal, conflict: AddressSyncConflict) -> None: + metadata = dict(conflict.metadata_ or {}) + remote_value = dict(conflict.remote_value or {}) + raw_payload = metadata.get("remote_payload") or remote_value.get("payload") + if not isinstance(raw_payload, dict): + raise AddressBookError("This conflict does not include a stored remote payload that can be applied automatically.") + payload = ContactCreateRequest.model_validate(raw_payload) + _apply_payload_sync_conflict(session, principal, conflict, payload) + + +def _apply_payload_sync_conflict(session: Session, principal: ApiPrincipal, conflict: AddressSyncConflict, payload: ContactCreateRequest) -> None: + metadata = dict(conflict.metadata_ or {}) + remote_value = dict(conflict.remote_value or {}) + item = AddressSyncPlanItem( + action="update", + href=conflict.resource_href, + remote_uid=conflict.remote_uid, + contact_id=conflict.contact_id, + display_name=payload.display_name, + etag=str(remote_value.get("etag") or "") or None, + raw_vcard=metadata.get("raw_vcard") if isinstance(metadata.get("raw_vcard"), str) else None, + parsed_payload=payload, + source_revision=str(metadata.get("source_revision") or remote_value.get("etag") or "") or None, + ) + _upsert_carddav_contact(session, principal, conflict.sync_source, item) + + +def get_visible_sync_conflict(session: Session, principal: ApiPrincipal, conflict_id: str) -> AddressSyncConflict: + sync_source_ids = [source.id for source in list_sync_sources(session, principal, include_disabled=True)] + if not sync_source_ids: + raise AddressBookError("Address sync conflict not found.") + conflict = session.query(AddressSyncConflict).filter(AddressSyncConflict.id == conflict_id, AddressSyncConflict.sync_source_id.in_(sync_source_ids)).one_or_none() + if conflict is None: + raise AddressBookError("Address sync conflict not found.") + return conflict + + +def discover_carddav_address_books( + session: Session, + principal: ApiPrincipal, + payload: AddressCardDavDiscoveryRequest, +) -> list[AddressCardDAVAddressBook]: + source = get_visible_sync_source(session, principal, payload.source_id) if payload.source_id else None + client = _carddav_client_from_payload(session, principal, payload, source=source) + return client.discover_addressbooks() + + +def create_carddav_sync_source( + session: Session, + principal: ApiPrincipal, + address_book_id: str, + payload: AddressCardDavSourceCreateRequest, +) -> AddressSyncSource: + collection_url = ensure_collection_url(payload.collection_url) + display_name = _trim(payload.display_name) or "CardDAV address book" + metadata = _carddav_metadata( + auth_type=payload.auth_type, + username=payload.username, + password=_secret_value(payload.password), + bearer_token=_secret_value(payload.bearer_token), + credential_ref=payload.credential_ref, + collection_url=collection_url, + ) + return create_sync_source( + session, + principal, + address_book_id, + AddressSyncSourceCreateRequest( + connector_type="carddav", + display_name=display_name, + external_address_book_ref=collection_url, + sync_direction=payload.sync_direction, + read_only=payload.read_only, + sync_token=payload.sync_token, + etag=payload.etag, + remote_revision=payload.remote_revision, + metadata=metadata, + ), + ) + + +def preview_sync_source( + session: Session, + principal: ApiPrincipal, + sync_source_id: str, + *, + force_full: bool = False, + password: str | None = None, + bearer_token: str | None = None, + client: AddressCardDAVClient | None = None, +) -> AddressSyncPlan: + sync_source = get_visible_sync_source(session, principal, sync_source_id) + if sync_source.connector_type != "carddav": + raise AddressBookError(f"Preview is not implemented for {sync_source.connector_type} sync sources.") + return _build_carddav_sync_plan( + session, + principal, + sync_source, + force_full=force_full, + password=password, + bearer_token=bearer_token, + client=client, + ) + + +def run_sync_source( + session: Session, + principal: ApiPrincipal, + sync_source_id: str, + *, + force_full: bool = False, + password: str | None = None, + bearer_token: str | None = None, + client: AddressCardDAVClient | None = None, +) -> AddressSyncPlan: + sync_source = start_sync_attempt(session, principal, sync_source_id) + write_client = client + if sync_source.connector_type == "carddav" and write_client is None: + write_client = _carddav_client_for_source(session, sync_source, password=password, bearer_token=bearer_token) + try: + plan = _build_carddav_sync_plan( + session, + principal, + sync_source, + force_full=force_full, + password=password, + bearer_token=bearer_token, + client=write_client, + ) + _apply_address_sync_plan(session, principal, plan, client=write_client) + status = "conflict" if plan.stats.conflicts else "succeeded" + if plan.stats.errors: + status = "failed" + finish_sync_attempt( + session, + principal, + sync_source.id, + AddressSyncAttemptFinishRequest( + status=status, + sync_token=plan.stats.sync_token, + etag=plan.stats.etag, + remote_revision=plan.stats.remote_revision, + error=f"{plan.stats.errors} sync item errors" if plan.stats.errors else None, + diagnostic=_sync_plan_diagnostic(plan), + ), + ) + return plan + except Exception as exc: + finish_sync_attempt( + session, + principal, + sync_source.id, + AddressSyncAttemptFinishRequest(status="failed", error=str(exc), diagnostic={"error": str(exc)}), + ) + raise + + +def _build_carddav_sync_plan( + session: Session, + principal: ApiPrincipal, + sync_source: AddressSyncSource, + *, + force_full: bool, + password: str | None, + bearer_token: str | None, + client: AddressCardDAVClient | None, +) -> AddressSyncPlan: + client = client or _carddav_client_for_source(session, sync_source, password=password, bearer_token=bearer_token) + stats = AddressSyncPlanStats() + plan = AddressSyncPlan(sync_source=sync_source, stats=stats) + collection_props = AddressCardDAVReportResult() + try: + try: + collection_props = client.propfind_collection() + except AddressCardDAVError as exc: + plan.items.append(AddressSyncPlanItem(action="unchanged", message=f"Collection PROPFIND failed; continuing with REPORT: {exc}")) + + if sync_source.sync_token and not force_full: + try: + report = client.sync_collection(sync_source.sync_token) + stats.used_sync_token = True + except AddressCardDAVSyncUnsupported as exc: + plan.items.append(AddressSyncPlanItem(action="unchanged", message=f"Sync token REPORT failed; falling back to full sync: {exc}")) + report = client.list_objects() + stats.full_sync = True + else: + report = client.list_objects() + stats.full_sync = True + + stats.sync_token = report.sync_token or collection_props.sync_token or sync_source.sync_token + stats.etag = report.ctag or collection_props.ctag or sync_source.etag + stats.remote_revision = report.ctag or collection_props.ctag or sync_source.remote_revision + _plan_carddav_report(session, sync_source=sync_source, client=client, report=report, plan=plan) + except AddressCardDAVError as exc: + plan.items.append(AddressSyncPlanItem(action="error", message=str(exc))) + stats.errors += 1 + return plan + + +def _plan_carddav_report( + session: Session, + *, + sync_source: AddressSyncSource, + client: AddressCardDAVClient, + report: AddressCardDAVReportResult, + plan: AddressSyncPlan, +) -> None: + local_by_href = _carddav_contacts_by_href(session, sync_source) + seen_hrefs: set[str] = set() + reads_remote = _sync_source_reads_remote(sync_source) + for item in report.objects: + href = item.href + seen_hrefs.add(href) + local = local_by_href.get(href) + if item.deleted: + if local is not None and local.deleted_at is None: + if _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(local, sync_source): + _add_sync_plan_item( + plan, + AddressSyncPlanItem( + action="conflict", + href=href, + contact_id=local.id, + display_name=local.display_name, + etag=item.etag, + message="Remote object was deleted while the local contact changed since the last successful sync.", + ), + ) + elif reads_remote: + _add_sync_plan_item(plan, AddressSyncPlanItem(action="delete", href=href, contact_id=local.id, display_name=local.display_name, etag=item.etag)) + else: + _add_sync_plan_item(plan, AddressSyncPlanItem(action="unchanged", href=href, etag=item.etag, message="Remote delete already reflected locally.")) + continue + + raw_vcard = item.address_data + if not raw_vcard: + try: + raw_vcard = client.fetch_object(href) + except AddressCardDAVError as exc: + _add_sync_plan_item(plan, AddressSyncPlanItem(action="error", href=href, etag=item.etag, message=str(exc))) + continue + parsed, error_message = _parse_single_remote_vcard(raw_vcard) + if parsed is None: + _add_sync_plan_item(plan, AddressSyncPlanItem(action="error", href=href, etag=item.etag, message=error_message or "Remote vCard could not be parsed.")) + continue + + if local is None: + if reads_remote: + _add_sync_plan_item( + plan, + AddressSyncPlanItem( + action="create", + href=href, + remote_uid=parsed.source_ref, + display_name=parsed.payload.display_name, + etag=item.etag, + raw_vcard=raw_vcard, + parsed_payload=parsed.payload, + source_revision=item.etag or parsed.source_revision, + ), + ) + else: + _add_sync_plan_item(plan, AddressSyncPlanItem(action="unchanged", href=href, etag=item.etag, message="Remote object ignored by export-only sync source.")) + continue + + remote_revision = item.etag or parsed.source_revision + if local.deleted_at is not None and _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(local, sync_source): + _add_sync_plan_item( + plan, + AddressSyncPlanItem( + action="remote_delete", + href=href, + remote_uid=parsed.source_ref, + contact_id=local.id, + display_name=local.display_name, + etag=item.etag or local.source_revision, + message="Local delete will be pushed to CardDAV.", + ), + ) + continue + + if local.deleted_at is None and local.source_revision == remote_revision: + if _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(local, sync_source): + _add_sync_plan_item( + plan, + AddressSyncPlanItem( + action="remote_update", + href=href, + remote_uid=parsed.source_ref, + contact_id=local.id, + display_name=local.display_name, + etag=item.etag or local.source_revision, + raw_vcard=_carddav_contact_vcard(local, href=href), + source_revision=item.etag or local.source_revision, + message="Local update will be pushed to CardDAV.", + ), + ) + continue + _add_sync_plan_item( + plan, + AddressSyncPlanItem(action="unchanged", href=href, remote_uid=parsed.source_ref, contact_id=local.id, display_name=local.display_name, etag=item.etag), + ) + continue + + if _local_contact_changed_after_last_sync(local, sync_source): + _add_sync_plan_item( + plan, + AddressSyncPlanItem( + action="conflict", + href=href, + remote_uid=parsed.source_ref, + contact_id=local.id, + display_name=local.display_name, + etag=item.etag, + raw_vcard=raw_vcard, + parsed_payload=parsed.payload, + source_revision=item.etag or parsed.source_revision, + message="Local contact changed since the last successful sync.", + ), + ) + continue + + if reads_remote: + _add_sync_plan_item( + plan, + AddressSyncPlanItem( + action="update", + href=href, + remote_uid=parsed.source_ref, + contact_id=local.id, + display_name=parsed.payload.display_name or local.display_name, + etag=item.etag, + raw_vcard=raw_vcard, + parsed_payload=parsed.payload, + source_revision=remote_revision, + ), + ) + else: + _add_sync_plan_item(plan, AddressSyncPlanItem(action="unchanged", href=href, contact_id=local.id, display_name=local.display_name, etag=item.etag, message="Remote update ignored by export-only sync source.")) + + if plan.stats.full_sync: + for href, contact in local_by_href.items(): + if href not in seen_hrefs and contact.deleted_at is None: + if _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(contact, sync_source): + _add_sync_plan_item( + plan, + AddressSyncPlanItem( + action="conflict", + href=href, + contact_id=contact.id, + display_name=contact.display_name, + etag=contact.source_revision, + message="Remote object is absent from full sync, but the local contact changed since the last successful sync.", + ), + ) + elif reads_remote: + _add_sync_plan_item(plan, AddressSyncPlanItem(action="delete", href=href, contact_id=contact.id, display_name=contact.display_name, message="Remote object is absent from full sync.")) + + _plan_carddav_outbound_local_changes(session, sync_source=sync_source, plan=plan) + + +def _plan_carddav_outbound_local_changes( + session: Session, + *, + sync_source: AddressSyncSource, + plan: AddressSyncPlan, +) -> None: + if not _sync_source_writes_remote(sync_source): + return + reserved_contact_ids = {item.contact_id for item in plan.items if item.contact_id and item.action != "unchanged"} + for contact in _carddav_contacts_for_source(session, sync_source): + if contact.id in reserved_contact_ids: + continue + if contact.source_ref: + if not _local_contact_changed_after_last_sync(contact, sync_source): + continue + if contact.deleted_at is not None: + if contact.source_revision: + _add_sync_plan_item( + plan, + AddressSyncPlanItem( + action="remote_delete", + href=contact.source_ref, + contact_id=contact.id, + display_name=contact.display_name, + etag=contact.source_revision, + message="Local delete will be pushed to CardDAV.", + ), + ) + else: + _add_sync_plan_item( + plan, + AddressSyncPlanItem( + action="conflict", + href=contact.source_ref, + contact_id=contact.id, + display_name=contact.display_name, + message="Local delete cannot be pushed because no remote ETag is known.", + ), + ) + continue + if contact.source_revision: + _add_sync_plan_item( + plan, + AddressSyncPlanItem( + action="remote_update", + href=contact.source_ref, + contact_id=contact.id, + display_name=contact.display_name, + etag=contact.source_revision, + raw_vcard=_carddav_contact_vcard(contact, href=contact.source_ref), + source_revision=contact.source_revision, + message="Local update will be pushed to CardDAV.", + ), + ) + else: + _add_sync_plan_item( + plan, + AddressSyncPlanItem( + action="conflict", + href=contact.source_ref, + contact_id=contact.id, + display_name=contact.display_name, + message="Local update cannot be pushed because no remote ETag is known.", + ), + ) + continue + + if contact.deleted_at is None: + href = _carddav_new_contact_href(sync_source, contact) + _add_sync_plan_item( + plan, + AddressSyncPlanItem( + action="remote_create", + href=href, + contact_id=contact.id, + display_name=contact.display_name, + raw_vcard=_carddav_contact_vcard(contact, href=href), + message="Local contact will be created in CardDAV.", + ), + ) + + +def _add_sync_plan_item(plan: AddressSyncPlan, item: AddressSyncPlanItem) -> None: + plan.items.append(item) + if item.action in {"create", "remote_create"}: + plan.stats.created += 1 + plan.stats.fetched += 1 + elif item.action in {"update", "remote_update"}: + plan.stats.updated += 1 + plan.stats.fetched += 1 + elif item.action in {"delete", "remote_delete"}: + plan.stats.deleted += 1 + elif item.action == "conflict": + plan.stats.conflicts += 1 + plan.stats.fetched += 1 + elif item.action == "unchanged": + plan.stats.unchanged += 1 + elif item.action == "error": + plan.stats.errors += 1 + + +def _apply_address_sync_plan(session: Session, principal: ApiPrincipal, plan: AddressSyncPlan, *, client: AddressCardDAVClient | None = None) -> None: + for item in plan.items: + if item.action == "create": + contact = _upsert_carddav_contact(session, principal, plan.sync_source, item) + item.contact_id = contact.id + elif item.action == "update": + contact = _upsert_carddav_contact(session, principal, plan.sync_source, item) + item.contact_id = contact.id + elif item.action == "delete" and item.contact_id: + contact = get_visible_contact(session, principal, item.contact_id, include_deleted=True) + previous = _contact_change_payload(contact, prefix="previous_") + contact.deleted_at = utcnow() + contact.updated_by_account_id = _account_id(principal) + record_sync_tombstone( + session, + principal, + plan.sync_source.id, + AddressSyncTombstoneCreateRequest( + contact_id=contact.id, + remote_uid=item.remote_uid, + resource_href=item.href, + local_deleted_at=contact.deleted_at, + remote_deleted_at=utcnow(), + synced_at=utcnow(), + ), + ) + _record_address_contact_change(session, principal, contact=contact, operation="deleted", previous=previous) + elif item.action in REMOTE_SYNC_ACTIONS: + if client is None: + raise AddressBookError("CardDAV write client is required for outbound sync.") + try: + _apply_carddav_remote_sync_item(session, principal, plan.sync_source, item, client=client) + except AddressCardDAVPreconditionFailed as exc: + _record_carddav_write_conflict(session, principal, plan, item, message=str(exc)) + elif item.action == "conflict": + local_value = {"contact_id": item.contact_id, "display_name": item.display_name} + if item.contact_id: + contact = get_visible_contact(session, principal, item.contact_id, include_deleted=True) + local_value["payload"] = _contact_payload_for_conflict(contact) + remote_payload = item.parsed_payload.model_dump(mode="json") if item.parsed_payload else None + record_sync_conflict( + session, + principal, + plan.sync_source.id, + AddressSyncConflictCreateRequest( + contact_id=item.contact_id, + remote_uid=item.remote_uid, + resource_href=item.href, + field_path="vcard", + local_value=local_value, + remote_value={"payload": remote_payload, "display_name": item.parsed_payload.display_name if item.parsed_payload else None, "etag": item.etag}, + metadata={"message": item.message, "raw_vcard": item.raw_vcard, "remote_payload": remote_payload, "source_revision": item.source_revision}, + ), + ) + session.flush() + + +def _apply_carddav_remote_sync_item( + session: Session, + principal: ApiPrincipal, + sync_source: AddressSyncSource, + item: AddressSyncPlanItem, + *, + client: AddressCardDAVClient, +) -> None: + if not item.contact_id: + raise AddressBookError("Outbound sync item is missing a contact id.") + contact = get_visible_contact(session, principal, item.contact_id, include_deleted=True) + if contact.address_book_id != sync_source.address_book_id: + raise AddressBookError("Outbound sync contact must belong to the sync source address book.") + if item.action == "remote_create": + href = item.href or _carddav_new_contact_href(sync_source, contact) + raw_vcard = item.raw_vcard or _carddav_contact_vcard(contact, href=href) + previous = _contact_change_payload(contact, prefix="previous_") + result = client.put_object(href, raw_vcard, create=True) + _mark_carddav_contact_synced(contact, sync_source=sync_source, href=href, raw_vcard=raw_vcard, etag=result.etag or item.source_revision or contact.source_revision) + contact.updated_by_account_id = _account_id(principal) + _record_address_contact_change(session, principal, contact=contact, operation="synced", previous=previous) + elif item.action == "remote_update": + if not item.href: + raise AddressBookError("Outbound update is missing a CardDAV href.") + etag = item.etag or contact.source_revision + raw_vcard = item.raw_vcard or _carddav_contact_vcard(contact, href=item.href) + previous = _contact_change_payload(contact, prefix="previous_") + result = client.put_object(item.href, raw_vcard, etag=etag) + _mark_carddav_contact_synced(contact, sync_source=sync_source, href=item.href, raw_vcard=raw_vcard, etag=result.etag or etag) + contact.updated_by_account_id = _account_id(principal) + _record_address_contact_change(session, principal, contact=contact, operation="synced", previous=previous) + elif item.action == "remote_delete": + if not item.href: + raise AddressBookError("Outbound delete is missing a CardDAV href.") + etag = item.etag or contact.source_revision + previous = _contact_change_payload(contact, prefix="previous_") + client.delete_object(item.href, etag=etag) + record_sync_tombstone( + session, + principal, + sync_source.id, + AddressSyncTombstoneCreateRequest( + contact_id=contact.id, + remote_uid=item.remote_uid, + resource_href=item.href, + local_deleted_at=contact.deleted_at, + remote_deleted_at=utcnow(), + synced_at=utcnow(), + ), + ) + contact.updated_by_account_id = _account_id(principal) + _record_address_contact_change(session, principal, contact=contact, operation="synced", previous=previous) + + +def _mark_carddav_contact_synced( + contact: Contact, + *, + sync_source: AddressSyncSource, + href: str, + raw_vcard: str, + etag: str | None, +) -> None: + contact.source_kind = sync_source.connector_type + contact.source_ref = href + contact.source_payload_kind = "vcard" + contact.source_payload_raw = raw_vcard + contact.source_revision = etag + provenance = dict(contact.provenance or {}) + provenance["carddav"] = { + "sync_source_id": sync_source.id, + "href": href, + "etag": etag, + "last_push_at": utcnow().isoformat(), + } + contact.provenance = provenance + + +def _record_carddav_write_conflict( + session: Session, + principal: ApiPrincipal, + plan: AddressSyncPlan, + item: AddressSyncPlanItem, + *, + message: str, +) -> None: + original_action = item.action + _decrement_sync_plan_stats(plan, original_action) + item.action = "conflict" + item.message = message + plan.stats.conflicts += 1 + local_value: dict[str, Any] = {"contact_id": item.contact_id, "display_name": item.display_name, "action": original_action} + if item.contact_id: + contact = get_visible_contact(session, principal, item.contact_id, include_deleted=True) + local_value["payload"] = _contact_payload_for_conflict(contact) + record_sync_conflict( + session, + principal, + plan.sync_source.id, + AddressSyncConflictCreateRequest( + contact_id=item.contact_id, + remote_uid=item.remote_uid, + resource_href=item.href, + field_path="vcard", + local_value=local_value, + remote_value={"etag": item.etag, "message": message}, + metadata={"message": message, "source": "carddav_write"}, + ), + ) + + +def _decrement_sync_plan_stats(plan: AddressSyncPlan, action: str) -> None: + if action == "remote_create": + plan.stats.created = max(0, plan.stats.created - 1) + plan.stats.fetched = max(0, plan.stats.fetched - 1) + elif action == "remote_update": + plan.stats.updated = max(0, plan.stats.updated - 1) + plan.stats.fetched = max(0, plan.stats.fetched - 1) + elif action == "remote_delete": + plan.stats.deleted = max(0, plan.stats.deleted - 1) + + +def _upsert_carddav_contact( + session: Session, + principal: ApiPrincipal, + sync_source: AddressSyncSource, + item: AddressSyncPlanItem, +) -> Contact: + if item.parsed_payload is None: + raise AddressBookError("Remote vCard payload is missing.") + contact = None + if item.contact_id: + contact = get_visible_contact(session, principal, item.contact_id, include_deleted=True) + if contact is None: + contact = Contact( + tenant_id=sync_source.tenant_id, + address_book_id=sync_source.address_book_id, + display_name=_display_name_from_payload(item.parsed_payload), + created_by_account_id=_account_id(principal), + ) + session.add(contact) + operation = "created" + previous = None + else: + previous = _contact_change_payload(contact, prefix="previous_") + operation = "created" if contact.deleted_at is not None else "updated" + contact.display_name = _display_name_from_payload(item.parsed_payload, fallback=contact.display_name) + contact.given_name = _trim(item.parsed_payload.given_name) + contact.family_name = _trim(item.parsed_payload.family_name) + contact.organization = _trim(item.parsed_payload.organization) + contact.role_title = _trim(item.parsed_payload.role_title) + contact.note = _trim(item.parsed_payload.note) + contact.tags = _normalize_tags(item.parsed_payload.tags) + contact.source_kind = sync_source.connector_type + contact.source_ref = item.href + contact.source_payload_kind = "vcard" + contact.source_payload_raw = item.raw_vcard + contact.source_revision = item.source_revision + contact.provenance = { + **(item.parsed_payload.provenance or {}), + "carddav": { + "sync_source_id": sync_source.id, + "href": item.href, + "remote_uid": item.remote_uid, + "etag": item.etag, + }, + } + contact.deleted_at = None + contact.updated_by_account_id = _account_id(principal) + _replace_emails(contact, item.parsed_payload.emails) + _replace_phones(contact, item.parsed_payload.phones) + _replace_postal_addresses(contact, item.parsed_payload.postal_addresses) + session.flush() + _record_address_contact_change(session, principal, contact=contact, operation=operation, previous=previous) + return contact + + +def _parse_single_remote_vcard(raw_vcard: str): + result = parse_vcards_with_issues(raw_vcard) + if not result.cards: + message = result.issues[0].message if result.issues else "No vCard entries found." + return None, message + return result.cards[0], "; ".join(issue.message for issue in result.issues if issue.severity == "error") or None + + +def _carddav_contacts_by_href(session: Session, sync_source: AddressSyncSource) -> dict[str, Contact]: + return {str(contact.source_ref): contact for contact in _carddav_contacts_for_source(session, sync_source) if contact.source_ref} + + +def _carddav_contacts_for_source(session: Session, sync_source: AddressSyncSource) -> list[Contact]: + rows = ( + session.query(Contact) + .filter( + Contact.address_book_id == sync_source.address_book_id, + ) + .all() + ) + return rows + + +def _sync_source_writes_remote(sync_source: AddressSyncSource) -> bool: + return sync_source.sync_direction in OUTBOUND_SYNC_DIRECTIONS and not sync_source.read_only + + +def _sync_source_reads_remote(sync_source: AddressSyncSource) -> bool: + return sync_source.sync_direction in INBOUND_SYNC_DIRECTIONS + + +def _carddav_new_contact_href(sync_source: AddressSyncSource, contact: Contact) -> str: + url = _trim(sync_source.external_address_book_ref) + if not url: + return f"{contact.id}.vcf" + try: + collection = ensure_collection_url(url) + except AddressCardDAVError: + return f"{contact.id}.vcf" + path = urllib.parse.urlparse(collection).path.rstrip("/") + if not path: + return f"{contact.id}.vcf" + return f"{path}/{contact.id}.vcf" + + +def _carddav_contact_vcard(contact: Contact, *, href: str | None = None) -> str: + content = contacts_to_vcard([contact]) + if href and "UID:" not in content: + content = content.replace("VERSION:4.0\r\n", f"VERSION:4.0\r\nUID:{href}\r\n", 1) + return content + + +def _local_contact_changed_after_last_sync(contact: Contact, sync_source: AddressSyncSource) -> bool: + if sync_source.last_success_at is None: + return False + if contact.deleted_at and contact.deleted_at > sync_source.last_success_at: + return True + return bool(contact.updated_at and contact.updated_at > sync_source.last_success_at) + + +def _carddav_client_from_payload( + session: Session, + principal: ApiPrincipal, + payload: AddressCardDavDiscoveryRequest, + *, + source: AddressSyncSource | None = None, +) -> AddressCardDAVClient: + url = payload.url or (source.external_address_book_ref if source else "") + metadata = dict(source.metadata_ or {}) if source else {} + auth = dict(metadata.get("carddav") or {}) + auth_type = payload.auth_type or str(auth.get("auth_type") or "none") + username = payload.username if payload.username is not None else auth.get("username") + password = _secret_value(payload.password) + bearer_token = _secret_value(payload.bearer_token) + credential_ref = payload.credential_ref if payload.credential_ref is not None else auth.get("credential_ref") + secret = _resolve_carddav_secret(auth_type=auth_type, password=password, bearer_token=bearer_token, credential_ref=credential_ref, encrypted=auth.get("secret_encrypted")) + return _new_carddav_client(url, auth_type=auth_type, username=username, secret=secret) + + +def _carddav_client_for_source( + session: Session, + sync_source: AddressSyncSource, + *, + password: str | None = None, + bearer_token: str | None = None, +) -> AddressCardDAVClient: + del session + metadata = dict(sync_source.metadata_ or {}) + auth = dict(metadata.get("carddav") or {}) + auth_type = str(auth.get("auth_type") or "none") + username = auth.get("username") + secret = _resolve_carddav_secret( + auth_type=auth_type, + password=password, + bearer_token=bearer_token, + credential_ref=auth.get("credential_ref"), + encrypted=auth.get("secret_encrypted"), + ) + return _new_carddav_client(sync_source.external_address_book_ref or "", auth_type=auth_type, username=username, secret=secret) + + +def _new_carddav_client(url: str, *, auth_type: str, username: str | None, secret: str | None) -> AddressCardDAVClient: + if auth_type == "basic": + if not username: + raise AddressBookError("CardDAV basic auth requires a username.") + if not secret: + raise AddressBookError("CardDAV basic auth requires a password or credential reference.") + return AddressCardDAVClient(collection_url=url, username=username, password=secret) + if auth_type == "bearer": + if not secret: + raise AddressBookError("CardDAV bearer auth requires a token or credential reference.") + return AddressCardDAVClient(collection_url=url, bearer_token=secret) + return AddressCardDAVClient(collection_url=url) + + +def _carddav_metadata( + *, + auth_type: str, + username: str | None, + password: str | None, + bearer_token: str | None, + credential_ref: str | None, + collection_url: str, +) -> dict[str, Any]: + secret = password if auth_type == "basic" else bearer_token if auth_type == "bearer" else None + auth: dict[str, Any] = { + "auth_type": auth_type, + "username": _trim(username), + "credential_ref": _trim(credential_ref), + } + if secret: + auth["secret_encrypted"] = encrypt_secret(secret) + return { + "carddav": auth, + "collection_url": collection_url, + } + + +def _resolve_carddav_secret( + *, + auth_type: str, + password: str | None, + bearer_token: str | None, + credential_ref: str | None, + encrypted: str | None, +) -> str | None: + if auth_type == "basic" and password: + return password + if auth_type == "bearer" and bearer_token: + return bearer_token + if credential_ref and credential_ref.startswith(CARDDAV_SECRET_ENV_PREFIX): + return os.environ.get(credential_ref.removeprefix(CARDDAV_SECRET_ENV_PREFIX)) + if encrypted: + return decrypt_secret(encrypted) + return None + + +def _secret_value(value: Any | None) -> str | None: + if value is None: + return None + if hasattr(value, "get_secret_value"): + return str(value.get_secret_value()) + return str(value) + + +def _sync_plan_diagnostic(plan: AddressSyncPlan) -> dict[str, Any]: + return { + "created": plan.stats.created, + "updated": plan.stats.updated, + "deleted": plan.stats.deleted, + "conflicts": plan.stats.conflicts, + "unchanged": plan.stats.unchanged, + "errors": plan.stats.errors, + "full_sync": plan.stats.full_sync, + "used_sync_token": plan.stats.used_sync_token, + } + + +def _contact_change_payload(contact: Contact, *, prefix: str = "") -> dict[str, Any]: + return { + f"{prefix}address_book_id": contact.address_book_id, + f"{prefix}display_name": contact.display_name, + f"{prefix}source_kind": contact.source_kind, + f"{prefix}source_ref": contact.source_ref, + f"{prefix}source_revision": contact.source_revision, + } + + +def _contact_payload_for_conflict(contact: Contact) -> dict[str, Any]: + return { + "display_name": contact.display_name, + "given_name": contact.given_name, + "family_name": contact.family_name, + "organization": contact.organization, + "role_title": contact.role_title, + "note": contact.note, + "tags": list(contact.tags or []), + "emails": [ + {"label": email.label, "email": email.email, "is_primary": email.is_primary} + for email in sorted(contact.emails, key=lambda item: (item.order_index, item.id)) + ], + "phones": [ + {"label": phone.label, "phone": phone.phone, "is_primary": phone.is_primary} + for phone in sorted(contact.phones, key=lambda item: (item.order_index, item.id)) + ], + "postal_addresses": [ + { + "label": address.label, + "street": address.street, + "postal_code": address.postal_code, + "locality": address.locality, + "region": address.region, + "country": address.country, + "is_primary": address.is_primary, + } + for address in sorted(contact.postal_addresses, key=lambda item: (item.order_index, item.id)) + ], + } + + +def _record_address_contact_change( + session: Session, + principal: ApiPrincipal, + *, + contact: Contact, + operation: str, + previous: dict[str, Any] | None = None, +) -> None: + payload = _contact_change_payload(contact) + if previous: + payload.update(previous) + record_change( + session, + module_id=ADDRESS_MODULE_ID, + collection=ADDRESS_CONTACTS_COLLECTION, + resource_type=ADDRESS_CONTACT_RESOURCE, + resource_id=contact.id, + operation=operation, + tenant_id=contact.tenant_id, + actor_type="user", + actor_id=_account_id(principal), + payload=payload, + ) + + +def _read_only_from_sync_direction(sync_direction: str, explicit_read_only: bool | None) -> bool: + if sync_direction in READ_ONLY_SYNC_DIRECTIONS: + return True + if explicit_read_only is not None: + return explicit_read_only + return False + + +def _apply_sync_source_to_book(book: AddressBook, sync_source: AddressSyncSource) -> None: + book.source_kind = sync_source.connector_type + book.source_ref = sync_source.id + book.read_only = sync_source.read_only or sync_source.sync_direction in READ_ONLY_SYNC_DIRECTIONS + book.sync_status = sync_source.status + book.sync_error = sync_source.last_error + book.updated_by_account_id = sync_source.updated_by_account_id + + +def _sync_contact_or_none(session: Session, principal: ApiPrincipal, sync_source: AddressSyncSource, contact_id: str | None) -> Contact | None: + if not contact_id: + return None + contact = get_visible_contact(session, principal, contact_id, include_deleted=True) + if contact.address_book_id != sync_source.address_book_id: + raise AddressBookError("Sync contact must belong to the sync source address book.") + return contact + + +def _visible_address_lists_query(session: Session, principal: ApiPrincipal, *, include_deleted: bool = False, include_deleted_books: bool = False): + book_ids = [book.id for book in list_address_books(session, principal, include_deleted=include_deleted_books)] + if not book_ids: + return session.query(AddressList).filter(false()) + query = session.query(AddressList).filter(AddressList.address_book_id.in_(book_ids)) + if not include_deleted: + query = query.filter(AddressList.deleted_at.is_(None)) + return query + + +def list_address_lists( + session: Session, + principal: ApiPrincipal, + *, + address_book_id: str | None = None, + include_deleted: bool = False, +) -> list[AddressList]: + query = _visible_address_lists_query(session, principal, include_deleted=include_deleted, include_deleted_books=include_deleted) + if address_book_id: + get_visible_address_book(session, principal, address_book_id, include_deleted=include_deleted) + query = query.filter(AddressList.address_book_id == address_book_id) + return query.order_by(AddressList.name.asc(), AddressList.id.asc()).all() + + +def get_visible_address_list(session: Session, principal: ApiPrincipal, address_list_id: str, *, include_deleted: bool = False) -> AddressList: + address_list = ( + _visible_address_lists_query(session, principal, include_deleted=include_deleted, include_deleted_books=include_deleted) + .filter(AddressList.id == address_list_id) + .one_or_none() + ) + if address_list is None: + raise AddressBookError("Address list not found.") + return address_list + + +def create_address_list(session: Session, principal: ApiPrincipal, address_book_id: str, payload: AddressListCreateRequest) -> AddressList: + book = get_visible_address_book(session, principal, address_book_id) + _require_mutable_book(book) + name = _trim(payload.name) + if not name: + raise AddressBookError("Address list name is required.") + address_list = AddressList( + tenant_id=book.tenant_id, + address_book_id=book.id, + name=name, + description=_trim(payload.description), + source_kind=book.source_kind, + read_only=book.read_only, + created_by_account_id=_account_id(principal), + updated_by_account_id=_account_id(principal), + metadata_={}, + ) + session.add(address_list) + return address_list + + +def update_address_list(session: Session, principal: ApiPrincipal, address_list_id: str, payload: AddressListUpdateRequest) -> AddressList: + address_list = get_visible_address_list(session, principal, address_list_id) + _require_mutable_address_list(address_list) + if "name" in payload.model_fields_set: + name = _trim(payload.name) + if not name: + raise AddressBookError("Address list name is required.") + address_list.name = name + if "description" in payload.model_fields_set: + address_list.description = _trim(payload.description) + address_list.updated_by_account_id = _account_id(principal) + return address_list + + +def delete_address_list(session: Session, principal: ApiPrincipal, address_list_id: str) -> None: + address_list = get_visible_address_list(session, principal, address_list_id) + _require_mutable_address_list(address_list) + address_list.deleted_at = utcnow() + address_list.updated_by_account_id = _account_id(principal) + + +def restore_address_list(session: Session, principal: ApiPrincipal, address_list_id: str) -> AddressList: + address_list = get_visible_address_list(session, principal, address_list_id, include_deleted=True) + _require_mutable_book(address_list.address_book) + if address_list.read_only: + raise AddressBookError("Address list is read-only.") + address_list.deleted_at = None + address_list.updated_by_account_id = _account_id(principal) + return address_list + + +def list_address_list_entries(session: Session, principal: ApiPrincipal, address_list_id: str) -> list[AddressListEntry]: + address_list = get_visible_address_list(session, principal, address_list_id) + return ( + session.query(AddressListEntry) + .join(Contact, AddressListEntry.contact_id == Contact.id) + .filter(AddressListEntry.address_list_id == address_list.id, Contact.deleted_at.is_(None)) + .order_by(AddressListEntry.order_index.asc(), AddressListEntry.id.asc()) + .all() + ) + + +def create_address_list_entry( + session: Session, + principal: ApiPrincipal, + address_list_id: str, + payload: AddressListEntryCreateRequest, +) -> AddressListEntry: + address_list = get_visible_address_list(session, principal, address_list_id) + _require_mutable_address_list(address_list) + contact = get_visible_contact(session, principal, payload.contact_id) + if contact.address_book_id != address_list.address_book_id: + raise AddressBookError("Address-list entries must reference contacts in the same address book.") + contact_email = _contact_email_by_id(contact, payload.contact_email_id) + postal_address = _contact_postal_address_by_id(contact, payload.contact_postal_address_id) + if contact_email is not None and postal_address is not None: + raise AddressBookError("Address-list entries may reference either an email address or a postal address, not both.") + order_index = _next_address_list_entry_order(session, address_list.id) + entry = AddressListEntry( + address_list_id=address_list.id, + contact_id=contact.id, + contact_email_id=contact_email.id if contact_email is not None else None, + contact_postal_address_id=postal_address.id if postal_address is not None else None, + target_kind=_address_list_entry_kind(contact_email, postal_address), + label=_trim(payload.label), + order_index=order_index, + metadata_={}, + ) + address_list.updated_by_account_id = _account_id(principal) + session.add(entry) + return entry + + +def delete_address_list_entry(session: Session, principal: ApiPrincipal, entry_id: str) -> None: + entry = get_visible_address_list_entry(session, principal, entry_id) + _require_mutable_address_list(entry.address_list) + entry.address_list.updated_by_account_id = _account_id(principal) + session.delete(entry) + + +def get_visible_address_list_entry(session: Session, principal: ApiPrincipal, entry_id: str) -> AddressListEntry: + list_ids = [address_list.id for address_list in list_address_lists(session, principal)] + if not list_ids: + raise AddressBookError("Address-list entry not found.") + entry = session.query(AddressListEntry).filter(AddressListEntry.id == entry_id, AddressListEntry.address_list_id.in_(list_ids)).one_or_none() + if entry is None: + raise AddressBookError("Address-list entry not found.") + return entry + + +def _contact_email_by_id(contact: Contact, email_id: str | None) -> ContactEmail | None: + if not email_id: + return None + email = next((item for item in contact.emails if item.id == email_id), None) + if email is None: + raise AddressBookError("Contact email not found.") + return email + + +def _contact_postal_address_by_id(contact: Contact, postal_address_id: str | None) -> ContactPostalAddress | None: + if not postal_address_id: + return None + postal_address = next((item for item in contact.postal_addresses if item.id == postal_address_id), None) + if postal_address is None: + raise AddressBookError("Contact postal address not found.") + return postal_address + + +def _address_list_entry_kind(contact_email: ContactEmail | None, postal_address: ContactPostalAddress | None) -> str: + if contact_email is not None: + return "email" + if postal_address is not None: + return "postal_address" + return "contact" + + +def _next_address_list_entry_order(session: Session, address_list_id: str) -> int: + current = session.query(func.max(AddressListEntry.order_index)).filter(AddressListEntry.address_list_id == address_list_id).scalar() + return int(current or 0) + 1 + + +def create_address_book( + session: Session, + principal: ApiPrincipal, + payload: AddressBookCreateRequest, + *, + allow_system: bool = False, +) -> AddressBook: + name = _trim(payload.name) + if not name: + raise AddressBookError("Address book name is required.") + tenant_id, scope_id = _scope_id_for_create(principal, payload, allow_system=allow_system) + book = AddressBook( + tenant_id=tenant_id, + scope_type=payload.scope_type, + scope_id=scope_id, + name=name, + description=_trim(payload.description), + source_kind="local", + read_only=False, + created_by_account_id=_account_id(principal), + updated_by_account_id=_account_id(principal), + metadata_={}, + ) + session.add(book) + return book + + +def update_address_book(session: Session, principal: ApiPrincipal, book_id: str, payload: AddressBookUpdateRequest) -> AddressBook: + book = get_visible_address_book(session, principal, book_id) + _require_mutable_book(book) + if "name" in payload.model_fields_set: + name = _trim(payload.name) + if not name: + raise AddressBookError("Address book name is required.") + book.name = name + if "description" in payload.model_fields_set: + book.description = _trim(payload.description) + book.updated_by_account_id = _account_id(principal) + return book + + +def delete_address_book(session: Session, principal: ApiPrincipal, book_id: str) -> None: + book = get_visible_address_book(session, principal, book_id) + _require_mutable_book(book) + deleted_at = utcnow() + book.deleted_at = deleted_at + book.updated_by_account_id = _account_id(principal) + for contact in book.contacts: + if contact.deleted_at is None: + contact.deleted_at = deleted_at + contact.updated_by_account_id = _account_id(principal) + + +def restore_address_book(session: Session, principal: ApiPrincipal, book_id: str, *, restore_contacts: bool = True) -> AddressBook: + book = get_visible_address_book(session, principal, book_id, include_deleted=True) + if book.read_only: + raise AddressBookError("Address book is read-only.") + book.deleted_at = None + book.updated_by_account_id = _account_id(principal) + if restore_contacts: + for contact in book.contacts: + if contact.deleted_at is not None: + contact.deleted_at = None + contact.updated_by_account_id = _account_id(principal) + return book + + +def _primary_email(payloads: list[ContactEmailPayload]) -> str | None: + if not payloads: + return None + primary = next((item.email for item in payloads if item.is_primary), None) + return _trim(primary) or _trim(payloads[0].email) + + +def _display_name_from_payload(payload: ContactCreateRequest | ContactUpdateRequest, *, fallback: str | None = None) -> str: + explicit = _trim(payload.display_name) + if explicit: + return explicit + parts = [_trim(payload.given_name), _trim(payload.family_name)] + joined = " ".join(part for part in parts if part) + if joined: + return joined + emails = getattr(payload, "emails", None) + if isinstance(emails, list): + email = _primary_email(emails) + if email: + return email + if fallback: + return fallback + raise AddressBookError("Contact name or email is required.") + + +def _normalize_tags(tags: list[str] | None) -> list[str]: + seen: set[str] = set() + normalized: list[str] = [] + for tag in tags or []: + value = tag.strip() + key = value.casefold() + if value and key not in seen: + seen.add(key) + normalized.append(value) + return normalized + + +def _replace_emails(contact: Contact, payloads: list[ContactEmailPayload]) -> None: + contact.emails.clear() + normalized = [_trim(item.email) for item in payloads] + normalized_payloads = [(item, email) for item, email in zip(payloads, normalized, strict=False) if email] + primary_index = next((index for index, (item, _email) in enumerate(normalized_payloads) if item.is_primary), 0) + for index, (item, email) in enumerate(normalized_payloads): + contact.emails.append( + ContactEmail( + label=_trim(item.label), + email=email or "", + is_primary=index == primary_index, + order_index=index, + ) + ) + + +def _replace_phones(contact: Contact, payloads: list[ContactPhonePayload]) -> None: + contact.phones.clear() + normalized = [_trim(item.phone) for item in payloads] + normalized_payloads = [(item, phone) for item, phone in zip(payloads, normalized, strict=False) if phone] + primary_index = next((index for index, (item, _phone) in enumerate(normalized_payloads) if item.is_primary), 0) + for index, (item, phone) in enumerate(normalized_payloads): + contact.phones.append( + ContactPhone( + label=_trim(item.label), + phone=phone or "", + is_primary=index == primary_index, + order_index=index, + ) + ) + + +def _postal_payload_has_content(payload: ContactPostalAddressPayload) -> bool: + return any(_trim(value) for value in (payload.street, payload.postal_code, payload.locality, payload.region, payload.country)) + + +def _replace_postal_addresses(contact: Contact, payloads: list[ContactPostalAddressPayload]) -> None: + contact.postal_addresses.clear() + normalized_payloads = [item for item in payloads if _postal_payload_has_content(item)] + primary_index = next((index for index, item in enumerate(normalized_payloads) if item.is_primary), 0) + for index, item in enumerate(normalized_payloads): + contact.postal_addresses.append( + ContactPostalAddress( + label=_trim(item.label), + street=_trim(item.street), + postal_code=_trim(item.postal_code), + locality=_trim(item.locality), + region=_trim(item.region), + country=_trim(item.country), + is_primary=index == primary_index, + order_index=index, + ) + ) + + +def create_contact(session: Session, principal: ApiPrincipal, address_book_id: str, payload: ContactCreateRequest) -> Contact: + book = get_visible_address_book(session, principal, address_book_id) + _require_mutable_book(book) + contact = Contact( + tenant_id=book.tenant_id, + address_book_id=book.id, + display_name=_display_name_from_payload(payload), + given_name=_trim(payload.given_name), + family_name=_trim(payload.family_name), + organization=_trim(payload.organization), + role_title=_trim(payload.role_title), + note=_trim(payload.note), + tags=_normalize_tags(payload.tags), + source_kind=book.source_kind, + provenance=payload.provenance or {}, + created_by_account_id=_account_id(principal), + updated_by_account_id=_account_id(principal), + metadata_={}, + ) + _replace_emails(contact, payload.emails) + _replace_phones(contact, payload.phones) + _replace_postal_addresses(contact, payload.postal_addresses) + session.add(contact) + return contact + + +def import_vcards(session: Session, principal: ApiPrincipal, address_book_id: str, content: str) -> VCardImportResult: + book = get_visible_address_book(session, principal, address_book_id) + _require_mutable_book(book) + contacts: list[Contact] = [] + result = parse_vcards_with_issues(content) + for parsed in result.cards: + contact = create_contact(session, principal, book.id, parsed.payload) + contact.source_kind = "vcard" + contact.source_ref = _trim(parsed.source_ref) + contact.source_payload_kind = "vcard" + contact.source_payload_raw = parsed.raw + contact.source_revision = _trim(parsed.source_revision) + contacts.append(contact) + return VCardImportResult(contacts=contacts, issues=result.issues, skipped=result.skipped) + + +def _visible_contact_query(session: Session, principal: ApiPrincipal, *, include_deleted: bool = False, include_deleted_books: bool = False): + book_ids = [book.id for book in list_address_books(session, principal, include_deleted=include_deleted_books)] + if not book_ids: + return session.query(Contact).filter(false()) + query = session.query(Contact).filter(Contact.address_book_id.in_(book_ids)) + if not include_deleted: + query = query.filter(Contact.deleted_at.is_(None)) + return query + + +def list_contacts( + session: Session, + principal: ApiPrincipal, + *, + address_book_id: str | None = None, + query: str | None = None, + limit: int = 200, + include_deleted: bool = False, +) -> list[Contact]: + contact_query = _visible_contact_query(session, principal, include_deleted=include_deleted, include_deleted_books=include_deleted) + if address_book_id: + get_visible_address_book(session, principal, address_book_id, include_deleted=include_deleted) + contact_query = contact_query.filter(Contact.address_book_id == address_book_id) + normalized_query = _trim(query) + if normalized_query: + pattern = f"%{normalized_query.lower()}%" + contact_query = contact_query.outerjoin(ContactEmail).filter( + or_( + func.lower(Contact.display_name).like(pattern), + func.lower(Contact.organization).like(pattern), + func.lower(ContactEmail.email).like(pattern), + ) + ) + return contact_query.order_by(Contact.display_name.asc(), Contact.id.asc()).limit(max(1, min(limit, 500))).all() + + +def get_visible_contact(session: Session, principal: ApiPrincipal, contact_id: str, *, include_deleted: bool = False) -> Contact: + contact = _visible_contact_query(session, principal, include_deleted=include_deleted, include_deleted_books=include_deleted).filter(Contact.id == contact_id).one_or_none() + if contact is None: + raise AddressBookError("Contact not found.") + return contact + + +def update_contact(session: Session, principal: ApiPrincipal, contact_id: str, payload: ContactUpdateRequest) -> Contact: + contact = get_visible_contact(session, principal, contact_id) + _require_mutable_book(contact.address_book) + fallback_name = contact.display_name + if any(field in payload.model_fields_set for field in ("display_name", "given_name", "family_name", "emails")): + contact.display_name = _display_name_from_payload(payload, fallback=fallback_name) + for field_name, attr_name in ( + ("given_name", "given_name"), + ("family_name", "family_name"), + ("organization", "organization"), + ("role_title", "role_title"), + ("note", "note"), + ): + if field_name in payload.model_fields_set: + setattr(contact, attr_name, _trim(getattr(payload, field_name))) + if "tags" in payload.model_fields_set: + contact.tags = _normalize_tags(payload.tags) + if "provenance" in payload.model_fields_set: + contact.provenance = payload.provenance or {} + if "emails" in payload.model_fields_set: + _replace_emails(contact, payload.emails or []) + if "phones" in payload.model_fields_set: + _replace_phones(contact, payload.phones or []) + if "postal_addresses" in payload.model_fields_set: + _replace_postal_addresses(contact, payload.postal_addresses or []) + contact.updated_by_account_id = _account_id(principal) + return contact + + +def delete_contact(session: Session, principal: ApiPrincipal, contact_id: str) -> None: + contact = get_visible_contact(session, principal, contact_id) + _require_mutable_book(contact.address_book) + contact.deleted_at = utcnow() + contact.updated_by_account_id = _account_id(principal) + + +def restore_contact(session: Session, principal: ApiPrincipal, contact_id: str) -> Contact: + contact = get_visible_contact(session, principal, contact_id, include_deleted=True) + _require_mutable_book(contact.address_book) + contact.deleted_at = None + contact.updated_by_account_id = _account_id(principal) + return contact + + +def export_address_book_vcard(session: Session, principal: ApiPrincipal, address_book_id: str) -> tuple[AddressBook, str]: + book = get_visible_address_book(session, principal, address_book_id) + contacts = ( + session.query(Contact) + .filter(Contact.address_book_id == book.id, Contact.deleted_at.is_(None)) + .order_by(Contact.display_name.asc(), Contact.id.asc()) + .all() + ) + return book, contacts_to_vcard(contacts) + + +def export_contact_vcard(session: Session, principal: ApiPrincipal, contact_id: str) -> tuple[Contact, str]: + contact = get_visible_contact(session, principal, contact_id) + return contact, contacts_to_vcard([contact]) diff --git a/src/govoplan_addresses/backend/vcard.py b/src/govoplan_addresses/backend/vcard.py new file mode 100644 index 0000000..7ed4ea1 --- /dev/null +++ b/src/govoplan_addresses/backend/vcard.py @@ -0,0 +1,500 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Literal + +from govoplan_addresses.backend.db.models import Contact +from govoplan_addresses.backend.schemas import ( + ContactCreateRequest, + ContactEmailPayload, + ContactPhonePayload, + ContactPostalAddressPayload, +) + + +class VCardError(ValueError): + pass + + +@dataclass(frozen=True, slots=True) +class ParsedVCard: + payload: ContactCreateRequest + raw: str + source_ref: str | None = None + source_revision: str | None = None + + +@dataclass(frozen=True, slots=True) +class ParsedVCardIssue: + index: int + message: str + severity: Literal["warning", "error"] = "error" + field: str | None = None + line: int | None = None + + +@dataclass(frozen=True, slots=True) +class VCardParseResult: + cards: list[ParsedVCard] + issues: list[ParsedVCardIssue] + skipped: int + + +@dataclass(slots=True) +class _VCardDraft: + fn: str | None = None + given_name: str | None = None + family_name: str | None = None + organization: str | None = None + role_title: str | None = None + note: str | None = None + version: str | None = None + uid: str | None = None + revision: str | None = None + tags: list[str] = field(default_factory=list) + emails: list[ContactEmailPayload] = field(default_factory=list) + phones: list[ContactPhonePayload] = field(default_factory=list) + addresses: list[ContactPostalAddressPayload] = field(default_factory=list) + urls: list[str] = field(default_factory=list) + + +def _normalize_lines(content: str) -> list[str]: + raw_lines = content.replace("\r\n", "\n").replace("\r", "\n").split("\n") + lines: list[str] = [] + for line in raw_lines: + if line.startswith((" ", "\t")) and lines: + lines[-1] += line[1:] + elif line: + lines.append(line) + return lines + + +def _split_unescaped(value: str, separator: str) -> list[str]: + parts: list[str] = [] + current: list[str] = [] + escaped = False + for char in value: + if escaped: + current.append(char) + escaped = False + elif char == "\\": + current.append(char) + escaped = True + elif char == separator: + parts.append("".join(current)) + current = [] + else: + current.append(char) + parts.append("".join(current)) + return parts + + +def _unescape_text(value: str) -> str: + return ( + value.replace("\\n", "\n") + .replace("\\N", "\n") + .replace("\\,", ",") + .replace("\\;", ";") + .replace("\\\\", "\\") + .strip() + ) + + +def _escape_text(value: str | None) -> str: + if value is None: + return "" + return ( + value.replace("\\", "\\\\") + .replace("\r\n", "\n") + .replace("\r", "\n") + .replace("\n", "\\n") + .replace(";", "\\;") + .replace(",", "\\,") + ) + + +def _parse_head(head: str) -> tuple[str, dict[str, list[str]]]: + parts = head.split(";") + name = parts[0].split(".")[-1].upper() + params: dict[str, list[str]] = {} + for part in parts[1:]: + if not part: + continue + if "=" in part: + key, raw_value = part.split("=", 1) + values = [item.strip().strip('"') for item in raw_value.split(",") if item.strip()] + else: + key = "TYPE" + values = [part.strip().strip('"')] + params.setdefault(key.upper(), []).extend(values) + return name, params + + +def _label_from_params(params: dict[str, list[str]]) -> str | None: + ignored = {"INTERNET", "VOICE", "PREF"} + for value in params.get("TYPE", []): + normalized = value.strip().lower() + if normalized and normalized.upper() not in ignored: + return normalized + return None + + +def _line_value(line: str, *, card_index: int) -> tuple[str, dict[str, list[str]], str]: + if ":" not in line: + raise VCardError(f"vCard {card_index}: line is missing ':' separator.") + head, value = line.split(":", 1) + name, params = _parse_head(head) + return name, params, value + + +def _is_pref(params: dict[str, list[str]]) -> bool: + values = [value.strip().upper() for value in params.get("TYPE", [])] + values.extend(value.strip().upper() for value in params.get("PREF", [])) + return "PREF" in values or "1" in values + + +def _card_blocks(content: str) -> list[list[str]]: + result = _card_blocks_with_issues(content) + if result.issues: + raise VCardError(result.issues[0].message) + return result.cards + + +@dataclass(frozen=True, slots=True) +class _CardBlockResult: + cards: list[list[str]] + issues: list[ParsedVCardIssue] + + +def _card_blocks_with_issues(content: str) -> _CardBlockResult: + lines = _normalize_lines(content) + blocks: list[list[str]] = [] + issues: list[ParsedVCardIssue] = [] + current: list[str] | None = None + for line in lines: + card_index = len(blocks) + 1 + try: + name, _params, value = _line_value(line, card_index=card_index) + except VCardError as exc: + issues.append(ParsedVCardIssue(index=card_index, message=str(exc), field="line")) + continue + if name == "BEGIN" and line.split(":", 1)[1].upper() == "VCARD": + if current is not None: + issues.append(ParsedVCardIssue(index=card_index, message="Nested vCard BEGIN is not supported.")) + current = None + continue + current = [line] + elif name == "END" and value.upper() == "VCARD": + if current is None: + issues.append(ParsedVCardIssue(index=card_index, message="vCard END appears before BEGIN.")) + continue + current.append(line) + blocks.append(current) + current = None + elif current is not None: + current.append(line) + if current is not None: + issues.append(ParsedVCardIssue(index=len(blocks) + 1, message="vCard BEGIN has no matching END.")) + if not blocks: + issues.append(ParsedVCardIssue(index=0, message="No vCard entries found.")) + return _CardBlockResult(cards=blocks, issues=issues) + + +def _parse_card(index: int, block: list[str]) -> tuple[ParsedVCard | None, list[ParsedVCardIssue]]: + draft = _VCardDraft() + issues: list[ParsedVCardIssue] = [] + + for line in block: + parsed = _parse_card_line(index, line, issues) + if parsed is not None: + name, params, value = parsed + _apply_card_property(index, draft, name, params, value, issues) + + if not _draft_has_identity(draft): + issues.append(ParsedVCardIssue(index=index, message=f"vCard {index}: contact has no name or email.")) + return None, issues + + raw = "\n".join(block) + payload = _draft_contact_payload(draft) + return ParsedVCard(payload=payload, raw=raw, source_ref=draft.uid, source_revision=draft.revision), issues + + +def _parse_card_line( + index: int, + line: str, + issues: list[ParsedVCardIssue], +) -> tuple[str, dict[str, list[str]], str] | None: + try: + return _line_value(line, card_index=index) + except VCardError as exc: + issues.append(ParsedVCardIssue(index=index, message=str(exc), field="line")) + return None + + +def _apply_card_property( + index: int, + draft: _VCardDraft, + name: str, + params: dict[str, list[str]], + value: str, + issues: list[ParsedVCardIssue], +) -> None: + if name in {"BEGIN", "END"}: + return + if _apply_card_metadata(index, draft, name, value, issues): + return + if _apply_card_identity(draft, name, value): + return + _apply_card_contact_detail(index, draft, name, params, value, issues) + + +def _apply_card_metadata( + index: int, + draft: _VCardDraft, + name: str, + value: str, + issues: list[ParsedVCardIssue], +) -> bool: + if name == "VERSION": + draft.version = value.strip() + if draft.version and draft.version not in {"3.0", "4.0"}: + issues.append(ParsedVCardIssue(index=index, severity="warning", field="VERSION", message=f"vCard version {draft.version} is not fully supported.")) + return True + if name == "UID": + draft.uid = _unescape_text(value) or draft.uid + return True + if name == "REV": + draft.revision = _unescape_text(value) or draft.revision + return True + return False + + +def _apply_card_identity(draft: _VCardDraft, name: str, value: str) -> bool: + if name == "FN": + draft.fn = _unescape_text(value) + return True + if name == "N": + parts = [_unescape_text(item) for item in _split_unescaped(value, ";")] + draft.family_name = parts[0] if len(parts) > 0 and parts[0] else draft.family_name + draft.given_name = parts[1] if len(parts) > 1 and parts[1] else draft.given_name + return True + if name == "ORG": + organization_parts = _unescaped_nonempty_values(value, ";") + draft.organization = " / ".join(organization_parts) or draft.organization + return True + if name in {"TITLE", "ROLE"}: + draft.role_title = _unescape_text(value) or draft.role_title + return True + if name == "NOTE": + draft.note = _unescape_text(value) or draft.note + return True + if name == "CATEGORIES": + draft.tags.extend(_unescaped_nonempty_values(value, ",")) + return True + return False + + +def _apply_card_contact_detail( + index: int, + draft: _VCardDraft, + name: str, + params: dict[str, list[str]], + value: str, + issues: list[ParsedVCardIssue], +) -> None: + if name == "EMAIL": + _append_card_email(index, draft, params, value, issues) + elif name == "TEL": + _append_card_phone(draft, params, value) + elif name == "ADR": + _append_card_address(draft, params, value) + elif name == "URL": + url = _unescape_text(value) + if url: + draft.urls.append(url) + + +def _append_card_email( + index: int, + draft: _VCardDraft, + params: dict[str, list[str]], + value: str, + issues: list[ParsedVCardIssue], +) -> None: + email = _unescape_text(value) + if not email: + return + if "@" not in email: + issues.append(ParsedVCardIssue(index=index, severity="warning", field="EMAIL", message=f"Skipped invalid email address: {email}")) + return + draft.emails.append(ContactEmailPayload(label=_label_from_params(params), email=email, is_primary=_is_pref(params) or not draft.emails)) + + +def _append_card_phone(draft: _VCardDraft, params: dict[str, list[str]], value: str) -> None: + phone = _unescape_text(value) + if phone: + draft.phones.append(ContactPhonePayload(label=_label_from_params(params), phone=phone, is_primary=_is_pref(params) or not draft.phones)) + + +def _append_card_address(draft: _VCardDraft, params: dict[str, list[str]], value: str) -> None: + parts = [_unescape_text(item) for item in _split_unescaped(value, ";")] + while len(parts) < 7: + parts.append("") + if not any(parts): + return + street = "\n".join(part for part in (parts[1], parts[2]) if part) + draft.addresses.append( + ContactPostalAddressPayload( + label=_label_from_params(params), + street=street or None, + locality=parts[3] or None, + region=parts[4] or None, + postal_code=parts[5] or None, + country=parts[6] or None, + is_primary=_is_pref(params) or not draft.addresses, + ) + ) + + +def _unescaped_nonempty_values(value: str, separator: str) -> list[str]: + return [text for text in (_unescape_text(item) for item in _split_unescaped(value, separator)) if text] + + +def _draft_has_identity(draft: _VCardDraft) -> bool: + return bool(draft.fn or draft.given_name or draft.family_name or draft.emails) + + +def _draft_contact_payload(draft: _VCardDraft) -> ContactCreateRequest: + payload = ContactCreateRequest( + display_name=draft.fn, + given_name=draft.given_name, + family_name=draft.family_name, + organization=draft.organization, + role_title=draft.role_title, + note=draft.note, + tags=draft.tags, + emails=draft.emails, + phones=draft.phones, + postal_addresses=draft.addresses, + provenance={ + "vcard": { + "version": draft.version, + "uid": draft.uid, + "revision": draft.revision, + "urls": draft.urls, + } + }, + ) + return payload + + +def parse_vcards_with_issues(content: str) -> VCardParseResult: + blocks = _card_blocks_with_issues(content) + parsed: list[ParsedVCard] = [] + issues = list(blocks.issues) + skipped = 0 + for index, block in enumerate(blocks.cards, start=1): + card, card_issues = _parse_card(index, block) + issues.extend(card_issues) + if card is None: + skipped += 1 + else: + parsed.append(card) + return VCardParseResult(cards=parsed, issues=issues, skipped=skipped) + + +def parse_vcards(content: str) -> list[ParsedVCard]: + result = parse_vcards_with_issues(content) + errors = [issue for issue in result.issues if issue.severity == "error"] + if errors: + raise VCardError(errors[0].message) + return result.cards + + +def contact_to_vcard(contact: Contact) -> str: + lines = _contact_identity_lines(contact) + lines.extend(_contact_email_lines(contact)) + lines.extend(_contact_phone_lines(contact)) + lines.extend(_contact_address_lines(contact)) + lines.extend(_contact_note_and_tag_lines(contact)) + lines.extend(_contact_url_lines(contact)) + lines.append("END:VCARD") + return "\r\n".join(lines) + "\r\n" + + +def _contact_identity_lines(contact: Contact) -> list[str]: + lines = [ + "BEGIN:VCARD", + "VERSION:4.0", + f"FN:{_escape_text(contact.display_name)}", + f"N:{_escape_text(contact.family_name)};{_escape_text(contact.given_name)};;;", + ] + if contact.source_ref: + lines.append(f"UID:{_escape_text(contact.source_ref)}") + if contact.source_revision: + lines.append(f"REV:{_escape_text(contact.source_revision)}") + if contact.organization: + lines.append(f"ORG:{_escape_text(contact.organization)}") + if contact.role_title: + lines.append(f"TITLE:{_escape_text(contact.role_title)}") + return lines + + +def _contact_email_lines(contact: Contact) -> list[str]: + lines: list[str] = [] + for email in contact.emails: + label = f";TYPE={_escape_text(email.label)}" if email.label else "" + lines.append(f"EMAIL{label}:{_escape_text(email.email)}") + return lines + + +def _contact_phone_lines(contact: Contact) -> list[str]: + lines: list[str] = [] + for phone in contact.phones: + label = f";TYPE={_escape_text(phone.label)}" if phone.label else "" + lines.append(f"TEL{label}:{_escape_text(phone.phone)}") + return lines + + +def _contact_address_lines(contact: Contact) -> list[str]: + lines: list[str] = [] + for address in contact.postal_addresses: + label = f";TYPE={_escape_text(address.label)}" if address.label else "" + lines.append( + "ADR" + f"{label}:;;{_escape_text(address.street)};{_escape_text(address.locality)};" + f"{_escape_text(address.region)};{_escape_text(address.postal_code)};{_escape_text(address.country)}" + ) + return lines + + +def _contact_note_and_tag_lines(contact: Contact) -> list[str]: + lines: list[str] = [] + if contact.note: + lines.append(f"NOTE:{_escape_text(contact.note)}") + if contact.tags: + lines.append(f"CATEGORIES:{','.join(_escape_text(tag) for tag in contact.tags)}") + return lines + + +def _contact_url_lines(contact: Contact) -> list[str]: + lines: list[str] = [] + urls = _contact_vcard_urls(contact) + if isinstance(urls, list): + for url in urls: + if isinstance(url, str) and url.strip(): + lines.append(f"URL:{_escape_text(url.strip())}") + return lines + + +def _contact_vcard_urls(contact: Contact) -> object: + if not isinstance(contact.provenance, dict): + return None + vcard = contact.provenance.get("vcard") + if not isinstance(vcard, dict): + return None + return vcard.get("urls") + + +def contacts_to_vcard(contacts: list[Contact]) -> str: + return "".join(contact_to_vcard(contact) for contact in contacts) diff --git a/src/govoplan_addresses/py.typed b/src/govoplan_addresses/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_addresses/py.typed @@ -0,0 +1 @@ + diff --git a/tests/test_addresses_service.py b/tests/test_addresses_service.py new file mode 100644 index 0000000..ad0dab3 --- /dev/null +++ b/tests/test_addresses_service.py @@ -0,0 +1,901 @@ +from __future__ import annotations + +import unittest + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from govoplan_core.core.change_sequence import ChangeSequenceEntry +from govoplan_core.db.base import Base +from govoplan_core.db.base import utcnow +from govoplan_addresses.backend.carddav import AddressCardDAVObject, AddressCardDAVReportResult, AddressCardDAVWriteResult +from govoplan_addresses.backend.capabilities import ( + CAPABILITY_ADDRESSES_CONTACT_WRITER, + CAPABILITY_ADDRESSES_LOOKUP, + CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, + AddressesContactWriterCapability, + AddressesLookupCapability, + AddressesRecipientSourceCapability, + AddressWriterError, +) +from govoplan_addresses.backend.db.models import ( + AddressBook, + AddressList, + AddressListEntry, + AddressSyncConflict, + AddressSyncDiagnostic, + AddressSyncSource, + AddressSyncTombstone, + Contact, + ContactEmail, + ContactPhone, + ContactPostalAddress, +) +from govoplan_addresses.backend.schemas import ( + AddressBookCreateRequest, + AddressListCreateRequest, + AddressListEntryCreateRequest, + AddressCardDavSourceCreateRequest, + AddressSyncAttemptFinishRequest, + AddressSyncConflictCreateRequest, + AddressSyncConflictResolveRequest, + AddressSyncDiagnosticCreateRequest, + AddressSyncSourceCreateRequest, + AddressSyncSourceUpdateRequest, + AddressSyncTombstoneCreateRequest, + ContactCreateRequest, + ContactEmailPayload, + ContactPostalAddressPayload, +) +from govoplan_addresses.backend.manifest import manifest +from govoplan_addresses.backend.service import ( + address_book_contact_counts, + address_list_entry_counts, + create_address_book, + create_address_list, + create_address_list_entry, + create_carddav_sync_source, + create_contact, + create_sync_source, + delete_address_list_entry, + delete_contact, + delete_sync_source, + export_address_book_vcard, + import_vcards, + list_address_list_entries, + list_address_lists, + list_address_books, + list_contacts, + list_sync_conflicts, + list_sync_diagnostics, + list_sync_sources, + list_sync_tombstones, + record_sync_conflict, + record_sync_diagnostic, + record_sync_tombstone, + run_sync_source, + preview_sync_source, + restore_contact, + resolve_sync_conflict, + start_sync_attempt, + finish_sync_attempt, + update_sync_source, +) + + +class FakeCardDavClient: + def __init__(self, objects: list[AddressCardDAVObject], *, sync_token: str = "sync-token-1", ctag: str = "ctag-1") -> None: + self.objects = objects + self.sync_token = sync_token + self.ctag = ctag + self.puts: list[dict[str, object]] = [] + self.deletes: list[dict[str, object]] = [] + + def propfind_collection(self) -> AddressCardDAVReportResult: + return AddressCardDAVReportResult(sync_token=self.sync_token, ctag=self.ctag) + + def list_objects(self) -> AddressCardDAVReportResult: + return AddressCardDAVReportResult(objects=self.objects, sync_token=self.sync_token, ctag=self.ctag) + + def sync_collection(self, _sync_token: str) -> AddressCardDAVReportResult: + return self.list_objects() + + def fetch_object(self, href: str) -> str: + for item in self.objects: + if item.href == href and item.address_data: + return item.address_data + raise RuntimeError(f"missing fake CardDAV object: {href}") + + def put_object(self, href: str, vcard: str, *, etag: str | None = None, create: bool = False, overwrite: bool = False) -> AddressCardDAVWriteResult: + self.puts.append({"href": href, "vcard": vcard, "etag": etag, "create": create, "overwrite": overwrite}) + return AddressCardDAVWriteResult(href=href, etag=f'"written-{len(self.puts)}"', status=201 if create else 204) + + def delete_object(self, href: str, *, etag: str | None = None, overwrite: bool = False) -> AddressCardDAVWriteResult: + self.deletes.append({"href": href, "etag": etag, "overwrite": overwrite}) + return AddressCardDAVWriteResult(href=href, etag=None, status=204) + + +class Principal: + account_id = "account-1" + group_ids = frozenset({"group-1"}) + + @property + def tenant_id(self) -> str: + return "tenant-1" + + def has(self, scope: str) -> bool: + return scope in { + "addresses:address_book:read", + "addresses:address_book:write", + "addresses:address_list:read", + "addresses:address_list:write", + "addresses:address_list:delete", + "addresses:contact:read", + "addresses:contact:write", + "addresses:contact:delete", + "addresses:sync:read", + "addresses:sync:write", + } + + +class ReadOnlyPrincipal(Principal): + def has(self, scope: str) -> bool: + return scope in { + "addresses:address_book:read", + "addresses:contact:read", + } + + +class AddressServiceTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all( + engine, + tables=[ + AddressBook.__table__, + AddressList.__table__, + Contact.__table__, + ContactEmail.__table__, + ContactPhone.__table__, + ContactPostalAddress.__table__, + AddressListEntry.__table__, + AddressSyncSource.__table__, + AddressSyncTombstone.__table__, + AddressSyncConflict.__table__, + AddressSyncDiagnostic.__table__, + ChangeSequenceEntry.__table__, + ], + ) + self.session = sessionmaker(bind=engine)() + self.principal = Principal() + + def test_scoped_book_contact_lookup_and_soft_delete(self) -> None: + book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Personal")) + self.session.commit() + self.session.refresh(book) + + contact = create_contact( + self.session, + self.principal, + book.id, + ContactCreateRequest(given_name="Ada", family_name="Lovelace", emails=[ContactEmailPayload(email="ada@example.local")]), + ) + self.session.commit() + self.session.refresh(contact) + + self.assertEqual([item.id for item in list_address_books(self.session, self.principal)], [book.id]) + self.assertEqual(address_book_contact_counts(self.session, [book.id])[book.id], 1) + self.assertEqual([item.id for item in list_contacts(self.session, self.principal, query="ada")], [contact.id]) + self.assertEqual(contact.emails[0].email, "ada@example.local") + self.assertTrue(contact.emails[0].is_primary) + + delete_contact(self.session, self.principal, contact.id) + self.session.commit() + + self.assertEqual(list_contacts(self.session, self.principal, address_book_id=book.id), []) + self.assertEqual(address_book_contact_counts(self.session, [book.id]).get(book.id, 0), 0) + + archived = list_contacts(self.session, self.principal, address_book_id=book.id, include_deleted=True) + self.assertEqual([item.id for item in archived], [contact.id]) + restore_contact(self.session, self.principal, contact.id) + self.session.commit() + self.assertEqual([item.id for item in list_contacts(self.session, self.principal, address_book_id=book.id)], [contact.id]) + + def test_vcard_import_and_export_preserves_common_fields(self) -> None: + book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Imported")) + self.session.commit() + self.session.refresh(book) + + result = import_vcards( + self.session, + self.principal, + book.id, + """BEGIN:VCARD +VERSION:4.0 +FN:Ada Lovelace +N:Lovelace;Ada;;; +ORG:Analytical Engine Office +TITLE:Mathematician +EMAIL;TYPE=work:ada@example.local +TEL;TYPE=work:+49 30 123 +ADR;TYPE=work:;;Main Street 1;Berlin;BE;10115;Germany +CATEGORIES:science,engineering +NOTE:First programmer +URL:https://example.local/ada +END:VCARD +""", + ) + self.session.commit() + self.session.refresh(result.contacts[0]) + + self.assertEqual(result.skipped, 0) + self.assertEqual(result.issues, []) + + contact = result.contacts[0] + self.assertEqual(contact.display_name, "Ada Lovelace") + self.assertEqual(contact.organization, "Analytical Engine Office") + self.assertEqual(contact.role_title, "Mathematician") + self.assertEqual(contact.tags, ["science", "engineering"]) + self.assertEqual(contact.emails[0].label, "work") + self.assertEqual(contact.phones[0].phone, "+49 30 123") + self.assertEqual(contact.postal_addresses[0].locality, "Berlin") + self.assertEqual(contact.source_kind, "vcard") + self.assertEqual(contact.source_payload_kind, "vcard") + self.assertIn("BEGIN:VCARD", contact.source_payload_raw) + self.assertEqual(contact.provenance["vcard"]["urls"], ["https://example.local/ada"]) + + _book, content = export_address_book_vcard(self.session, self.principal, book.id) + self.assertIn("BEGIN:VCARD", content) + self.assertIn("FN:Ada Lovelace", content) + self.assertIn("EMAIL;TYPE=work:ada@example.local", content) + self.assertIn("CATEGORIES:science,engineering", content) + self.assertIn("URL:https://example.local/ada", content) + + def test_vcard_import_reports_field_issues_without_blocking_valid_cards(self) -> None: + book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Imported")) + self.session.commit() + self.session.refresh(book) + + result = import_vcards( + self.session, + self.principal, + book.id, + """BEGIN:VCARD +VERSION:4.0 +FN:Valid Person +EMAIL:not-an-email +EMAIL;TYPE=work:valid@example.local +END:VCARD +BEGIN:VCARD +VERSION:4.0 +NOTE:No identity +END:VCARD +""", + ) + self.session.commit() + + self.assertEqual(len(result.contacts), 1) + self.assertEqual(result.skipped, 1) + self.assertEqual(result.contacts[0].emails[0].email, "valid@example.local") + self.assertTrue(any(issue.field == "EMAIL" and issue.severity == "warning" for issue in result.issues)) + self.assertTrue(any(issue.severity == "error" for issue in result.issues)) + + def test_manifest_and_address_capabilities(self) -> None: + provided = {item.name for item in manifest.provides_interfaces} + self.assertIn(CAPABILITY_ADDRESSES_LOOKUP, provided) + self.assertIn(CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, provided) + self.assertIn(CAPABILITY_ADDRESSES_CONTACT_WRITER, provided) + + book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Recipients")) + self.session.commit() + self.session.refresh(book) + contact = create_contact( + self.session, + self.principal, + book.id, + ContactCreateRequest(display_name="Ada Lovelace", emails=[ContactEmailPayload(email="ada@example.local")]), + ) + self.session.commit() + self.session.refresh(contact) + + lookup = AddressesLookupCapability().lookup(self.session, self.principal, query="ada") + self.assertEqual(len(lookup), 1) + self.assertEqual(lookup[0].email, "ada@example.local") + self.assertEqual(lookup[0].contact_id, contact.id) + warm_lookup = AddressesLookupCapability().lookup(self.session, self.principal, query="", limit=10) + self.assertEqual([item.email for item in warm_lookup], ["ada@example.local"]) + + recipient_sources = AddressesRecipientSourceCapability().list_sources(self.session, self.principal) + self.assertEqual(len(recipient_sources), 1) + self.assertEqual(recipient_sources[0].source_id, f"addresses:address_book:{book.id}") + self.assertEqual(recipient_sources[0].recipient_count, 1) + + snapshot = AddressesRecipientSourceCapability().snapshot_address_book(self.session, self.principal, address_book_id=book.id) + self.assertEqual(snapshot.source_id, f"addresses:address_book:{book.id}") + self.assertEqual(snapshot.recipients[0].email, "ada@example.local") + self.assertEqual(snapshot.recipients[0].provenance["contact_id"], contact.id) + + writer = AddressesContactWriterCapability() + targets = writer.list_write_targets(self.session, self.principal) + self.assertEqual(len(targets), 1) + self.assertTrue(targets[0].allowed) + self.assertEqual(targets[0].reason, "allowed") + self.assertEqual(targets[0].required_scopes, ("addresses:address_book:read", "addresses:contact:write")) + + created = writer.create_contact( + self.session, + self.principal, + address_book_id=book.id, + payload=ContactCreateRequest(display_name="Grace Hopper", emails=[ContactEmailPayload(email="grace@example.local")]), + provenance={"consumer_module": "test"}, + ) + self.assertEqual(created.email, "grace@example.local") + self.assertEqual(created.provenance["source_kind"], "local") + self.assertEqual(created.provenance["contact_provenance"]["consumer_module"], "test") + self.session.commit() + + missing_scope = writer.can_write_to_address_book(self.session, ReadOnlyPrincipal(), address_book_id=book.id) + self.assertFalse(missing_scope.allowed) + self.assertEqual(missing_scope.reason, "missing_scope") + self.assertEqual(missing_scope.required_scopes, ("addresses:address_book:read", "addresses:contact:write")) + + book.read_only = True + self.session.commit() + read_only = writer.can_write_to_address_book(self.session, self.principal, address_book_id=book.id) + self.assertFalse(read_only.allowed) + self.assertEqual(read_only.reason, "address_book_read_only") + self.assertTrue(read_only.read_only) + with self.assertRaises(AddressWriterError) as blocked: + writer.create_contact( + self.session, + self.principal, + address_book_id=book.id, + payload=ContactCreateRequest(display_name="Blocked Contact", emails=[ContactEmailPayload(email="blocked@example.local")]), + ) + self.assertEqual(blocked.exception.decision.reason, "address_book_read_only") + + def test_address_lists_group_contacts_and_expose_recipient_sources(self) -> None: + book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Personal")) + other_book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Other")) + self.session.commit() + + contact = create_contact( + self.session, + self.principal, + book.id, + ContactCreateRequest( + display_name="Ada Lovelace", + emails=[ + ContactEmailPayload(label="work", email="ada.work@example.local", is_primary=True), + ContactEmailPayload(label="private", email="ada.private@example.local"), + ], + postal_addresses=[ + ContactPostalAddressPayload(label="office", street="Main Street 1", postal_code="10115", locality="Berlin", country="Germany", is_primary=True), + ], + ), + ) + other_contact = create_contact( + self.session, + self.principal, + other_book.id, + ContactCreateRequest(display_name="Grace Hopper", emails=[ContactEmailPayload(email="grace@example.local")]), + ) + self.session.commit() + self.session.refresh(contact) + self.session.refresh(other_contact) + + address_list = create_address_list(self.session, self.principal, book.id, AddressListCreateRequest(name="Mathematicians")) + self.session.commit() + self.session.refresh(address_list) + + entry = create_address_list_entry( + self.session, + self.principal, + address_list.id, + AddressListEntryCreateRequest(contact_id=contact.id, contact_email_id=contact.emails[1].id, label="Private address"), + ) + postal_entry = create_address_list_entry( + self.session, + self.principal, + address_list.id, + AddressListEntryCreateRequest(contact_id=contact.id, contact_postal_address_id=contact.postal_addresses[0].id, label="Office address"), + ) + self.session.commit() + self.session.refresh(entry) + self.session.refresh(postal_entry) + + self.assertEqual([item.id for item in list_address_lists(self.session, self.principal, address_book_id=book.id)], [address_list.id]) + self.assertEqual(address_list_entry_counts(self.session, [address_list.id])[address_list.id], 2) + entries = list_address_list_entries(self.session, self.principal, address_list.id) + self.assertEqual(entries[0].contact_email.email, "ada.private@example.local") + self.assertEqual(entries[1].target_kind, "postal_address") + self.assertEqual(entries[1].contact_postal_address.locality, "Berlin") + + with self.assertRaisesRegex(ValueError, "same address book"): + create_address_list_entry( + self.session, + self.principal, + address_list.id, + AddressListEntryCreateRequest(contact_id=other_contact.id), + ) + + recipient_source = AddressesRecipientSourceCapability() + sources = recipient_source.list_sources(self.session, self.principal) + self.assertIn(f"addresses:address_list:{address_list.id}", {source.source_id for source in sources}) + + snapshot = recipient_source.snapshot(self.session, self.principal, source_id=f"addresses:address_list:{address_list.id}") + self.assertEqual(snapshot.source_kind, "address_list") + self.assertEqual(len(snapshot.recipients), 1) + self.assertEqual(snapshot.recipients[0].email, "ada.private@example.local") + self.assertEqual(snapshot.recipients[0].provenance["address_list_id"], address_list.id) + self.assertEqual(snapshot.recipients[0].provenance["address_list_entry_id"], entry.id) + + delete_address_list_entry(self.session, self.principal, entry.id) + delete_address_list_entry(self.session, self.principal, postal_entry.id) + self.session.commit() + self.assertEqual(list_address_list_entries(self.session, self.principal, address_list.id), []) + + def test_sync_source_marks_read_only_books_and_can_be_made_writable(self) -> None: + book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="CardDAV")) + self.session.commit() + self.session.refresh(book) + + sync_source = create_sync_source( + self.session, + self.principal, + book.id, + AddressSyncSourceCreateRequest( + connector_type="carddav", + display_name="CardDAV Personal", + external_account_ref="account://carddav/personal", + external_address_book_ref="/remote/addressbooks/personal/", + sync_direction="read_only", + metadata={"base_url": "https://carddav.example.local"}, + ), + ) + self.session.commit() + self.session.refresh(book) + self.session.refresh(sync_source) + + self.assertEqual(book.source_kind, "carddav") + self.assertEqual(book.source_ref, sync_source.id) + self.assertTrue(book.read_only) + self.assertEqual(book.sync_status, "idle") + self.assertEqual([item.id for item in list_sync_sources(self.session, self.principal)], [sync_source.id]) + + decision = AddressesContactWriterCapability().can_write_to_address_book(self.session, self.principal, address_book_id=book.id) + self.assertFalse(decision.allowed) + self.assertEqual(decision.reason, "address_book_read_only") + + with self.assertRaisesRegex(ValueError, "read-only"): + create_contact( + self.session, + self.principal, + book.id, + ContactCreateRequest(display_name="Read Only", emails=[ContactEmailPayload(email="readonly@example.local")]), + ) + + sync_source = update_sync_source( + self.session, + self.principal, + sync_source.id, + AddressSyncSourceUpdateRequest(sync_direction="two_way", read_only=False, metadata={"base_url": "https://carddav.example.local"}), + ) + self.session.commit() + self.session.refresh(book) + self.session.refresh(sync_source) + + self.assertFalse(sync_source.read_only) + self.assertFalse(book.read_only) + self.assertTrue(AddressesContactWriterCapability().can_write_to_address_book(self.session, self.principal, address_book_id=book.id).allowed) + + def test_sync_attempts_diagnostics_tombstones_and_conflicts_are_persisted(self) -> None: + book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Synced")) + self.session.commit() + self.session.refresh(book) + contact = create_contact( + self.session, + self.principal, + book.id, + ContactCreateRequest(display_name="Ada Lovelace", emails=[ContactEmailPayload(email="ada@example.local")]), + ) + sync_source = create_sync_source( + self.session, + self.principal, + book.id, + AddressSyncSourceCreateRequest(connector_type="carddav", display_name="Writable CardDAV", sync_direction="two_way", read_only=False), + ) + self.session.commit() + self.session.refresh(contact) + self.session.refresh(sync_source) + + start_sync_attempt(self.session, self.principal, sync_source.id) + self.session.commit() + self.session.refresh(sync_source) + self.assertEqual(sync_source.status, "running") + + finish_sync_attempt( + self.session, + self.principal, + sync_source.id, + AddressSyncAttemptFinishRequest(status="succeeded", sync_token="sync-token-1", etag="etag-1", remote_revision="rev-1"), + ) + diagnostic = record_sync_diagnostic( + self.session, + self.principal, + sync_source.id, + AddressSyncDiagnosticCreateRequest(severity="warning", code="carddav.rate_limit", message="Remote server asked us to slow down."), + ) + tombstone = record_sync_tombstone( + self.session, + self.principal, + sync_source.id, + AddressSyncTombstoneCreateRequest(contact_id=contact.id, remote_uid="ada-uid", resource_href="/contacts/ada.vcf"), + ) + conflict = record_sync_conflict( + self.session, + self.principal, + sync_source.id, + AddressSyncConflictCreateRequest( + contact_id=contact.id, + remote_uid="ada-uid", + resource_href="/contacts/ada.vcf", + field_path="emails[0].email", + local_value={"email": "ada@example.local"}, + remote_value={"email": "ada.remote@example.local"}, + ), + ) + self.session.commit() + self.session.refresh(sync_source) + self.session.refresh(diagnostic) + self.session.refresh(tombstone) + self.session.refresh(conflict) + + self.assertEqual(sync_source.sync_token, "sync-token-1") + self.assertEqual(sync_source.status, "conflict") + self.assertEqual(list_sync_diagnostics(self.session, self.principal, sync_source.id)[0].id, diagnostic.id) + self.assertEqual(list_sync_tombstones(self.session, self.principal, sync_source.id)[0].id, tombstone.id) + self.assertEqual(list_sync_conflicts(self.session, self.principal, sync_source.id)[0].id, conflict.id) + + resolved = resolve_sync_conflict( + self.session, + self.principal, + conflict.id, + AddressSyncConflictResolveRequest(resolution="manual", status="resolved"), + ) + self.session.commit() + self.session.refresh(sync_source) + self.session.refresh(resolved) + + self.assertEqual(resolved.status, "resolved") + self.assertEqual(resolved.resolution, "manual") + self.assertEqual(list_sync_conflicts(self.session, self.principal, sync_source.id), []) + self.assertEqual(sync_source.status, "idle") + + def test_sync_conflict_merge_resolution_applies_merged_payload(self) -> None: + book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Merged")) + self.session.commit() + self.session.refresh(book) + contact = create_contact( + self.session, + self.principal, + book.id, + ContactCreateRequest(display_name="Local Name", emails=[ContactEmailPayload(email="local@example.local")]), + ) + sync_source = create_sync_source( + self.session, + self.principal, + book.id, + AddressSyncSourceCreateRequest(connector_type="carddav", display_name="Writable CardDAV", sync_direction="two_way", read_only=False), + ) + self.session.commit() + self.session.refresh(contact) + self.session.refresh(sync_source) + + local_payload = { + "display_name": "Local Name", + "emails": [{"label": None, "email": "local@example.local", "is_primary": True}], + "phones": [], + "postal_addresses": [], + "tags": [], + } + remote_payload = { + "display_name": "Remote Name", + "emails": [{"label": None, "email": "remote@example.local", "is_primary": True}], + "phones": [], + "postal_addresses": [], + "tags": [], + } + conflict = record_sync_conflict( + self.session, + self.principal, + sync_source.id, + AddressSyncConflictCreateRequest( + contact_id=contact.id, + resource_href="/contacts/local.vcf", + field_path="vcard", + local_value={"payload": local_payload}, + remote_value={"payload": remote_payload, "etag": '"etag-remote"'}, + metadata={"remote_payload": remote_payload, "source_revision": '"etag-remote"'}, + ), + ) + self.session.commit() + self.session.refresh(conflict) + + resolve_sync_conflict( + self.session, + self.principal, + conflict.id, + AddressSyncConflictResolveRequest( + resolution="merge", + status="resolved", + merged_payload=ContactCreateRequest(display_name="Local Name", emails=[ContactEmailPayload(email="remote@example.local")]), + ), + ) + self.session.commit() + self.session.refresh(contact) + + self.assertEqual(contact.display_name, "Local Name") + self.assertEqual(contact.emails[0].email, "remote@example.local") + self.assertEqual(contact.source_revision, '"etag-remote"') + + def test_carddav_run_imports_contacts_and_preview_detects_conflicts(self) -> None: + book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Remote")) + self.session.commit() + self.session.refresh(book) + + sync_source = create_carddav_sync_source( + self.session, + self.principal, + book.id, + AddressCardDavSourceCreateRequest( + collection_url="https://carddav.example.local/addressbooks/personal/", + display_name="Remote CardDAV", + auth_type="none", + sync_direction="two_way", + read_only=False, + ), + ) + self.session.commit() + self.session.refresh(sync_source) + + first_client = FakeCardDavClient([ + AddressCardDAVObject( + href="/addressbooks/personal/ada.vcf", + etag='"etag-1"', + address_data="""BEGIN:VCARD +VERSION:4.0 +UID:ada-uid +FN:Ada Lovelace +EMAIL:ada@example.local +END:VCARD +""", + ) + ]) + plan = run_sync_source(self.session, self.principal, sync_source.id, client=first_client) + self.session.commit() + self.session.refresh(sync_source) + + self.assertEqual(plan.stats.created, 1) + self.assertEqual(sync_source.status, "succeeded") + contacts = list_contacts(self.session, self.principal, address_book_id=book.id) + self.assertEqual(len(contacts), 1) + self.assertEqual(contacts[0].display_name, "Ada Lovelace") + self.assertEqual(contacts[0].source_kind, "carddav") + self.assertEqual(contacts[0].source_ref, "/addressbooks/personal/ada.vcf") + self.assertEqual(contacts[0].source_revision, '"etag-1"') + + contacts[0].display_name = "Ada Local" + contacts[0].updated_at = utcnow() + self.session.commit() + + conflict_client = FakeCardDavClient( + [ + AddressCardDAVObject( + href="/addressbooks/personal/ada.vcf", + etag='"etag-2"', + address_data="""BEGIN:VCARD +VERSION:4.0 +UID:ada-uid +FN:Ada Remote +EMAIL:ada@example.local +END:VCARD +""", + ) + ], + sync_token="sync-token-2", + ctag="ctag-2", + ) + preview = preview_sync_source(self.session, self.principal, sync_source.id, client=conflict_client) + + self.assertEqual(preview.stats.conflicts, 1) + self.assertEqual(preview.items[0].action, "conflict") + self.assertEqual(preview.items[0].contact_id, contacts[0].id) + + run_conflict_client = FakeCardDavClient( + [ + AddressCardDAVObject( + href="/addressbooks/personal/ada.vcf", + etag='"etag-2"', + address_data="""BEGIN:VCARD +VERSION:4.0 +UID:ada-uid +FN:Ada Remote +EMAIL:ada.remote@example.local +END:VCARD +""", + ) + ], + sync_token="sync-token-2", + ctag="ctag-2", + ) + run_sync_source(self.session, self.principal, sync_source.id, client=run_conflict_client) + self.session.commit() + + conflicts = list_sync_conflicts(self.session, self.principal, sync_source.id) + self.assertEqual(len(conflicts), 1) + self.assertIn("payload", conflicts[0].remote_value) + resolved = resolve_sync_conflict( + self.session, + self.principal, + conflicts[0].id, + AddressSyncConflictResolveRequest(resolution="use_remote", status="resolved"), + ) + self.session.commit() + self.session.refresh(resolved) + self.session.refresh(contacts[0]) + + self.assertEqual(resolved.status, "resolved") + self.assertEqual(contacts[0].display_name, "Ada Remote") + self.assertEqual(contacts[0].emails[0].email, "ada.remote@example.local") + + def test_carddav_two_way_pushes_local_creates_updates_and_deletes(self) -> None: + book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Writable Remote")) + self.session.commit() + self.session.refresh(book) + + sync_source = create_carddav_sync_source( + self.session, + self.principal, + book.id, + AddressCardDavSourceCreateRequest( + collection_url="https://carddav.example.local/addressbooks/personal/", + display_name="Writable CardDAV", + auth_type="none", + sync_direction="two_way", + read_only=False, + ), + ) + self.session.commit() + self.session.refresh(book) + self.session.refresh(sync_source) + + contact = create_contact( + self.session, + self.principal, + book.id, + ContactCreateRequest(display_name="Grace Hopper", emails=[ContactEmailPayload(email="grace@example.local")]), + ) + self.session.commit() + self.session.refresh(contact) + + create_client = FakeCardDavClient([], sync_token="sync-token-create", ctag="ctag-create") + create_plan = run_sync_source(self.session, self.principal, sync_source.id, client=create_client) + self.session.commit() + self.session.refresh(contact) + self.session.refresh(sync_source) + + self.assertEqual(create_plan.stats.created, 1) + self.assertEqual(create_plan.items[-1].action, "remote_create") + self.assertEqual(len(create_client.puts), 1) + self.assertTrue(create_client.puts[0]["create"]) + self.assertEqual(contact.source_ref, f"/addressbooks/personal/{contact.id}.vcf") + self.assertEqual(contact.source_revision, '"written-1"') + + contact.display_name = "Grace Updated" + contact.updated_at = utcnow() + self.session.commit() + self.session.refresh(contact) + + update_client = FakeCardDavClient( + [ + AddressCardDAVObject( + href=contact.source_ref or "", + etag=contact.source_revision, + address_data="""BEGIN:VCARD +VERSION:4.0 +FN:Grace Hopper +EMAIL:grace@example.local +END:VCARD +""", + ) + ], + sync_token="sync-token-update", + ctag="ctag-update", + ) + update_plan = run_sync_source(self.session, self.principal, sync_source.id, client=update_client) + self.session.commit() + self.session.refresh(contact) + self.session.refresh(sync_source) + + self.assertEqual(update_plan.stats.updated, 1) + self.assertEqual(update_plan.items[-1].action, "remote_update") + self.assertEqual(len(update_client.puts), 1) + self.assertFalse(update_client.puts[0]["create"]) + self.assertEqual(update_client.puts[0]["etag"], '"written-1"') + self.assertIn("FN:Grace Updated", str(update_client.puts[0]["vcard"])) + + delete_contact(self.session, self.principal, contact.id) + self.session.commit() + self.session.refresh(contact) + + delete_client = FakeCardDavClient( + [ + AddressCardDAVObject( + href=contact.source_ref or "", + etag=contact.source_revision, + address_data="""BEGIN:VCARD +VERSION:4.0 +FN:Grace Updated +EMAIL:grace@example.local +END:VCARD +""", + ) + ], + sync_token="sync-token-delete", + ctag="ctag-delete", + ) + delete_plan = run_sync_source(self.session, self.principal, sync_source.id, client=delete_client) + self.session.commit() + + self.assertEqual(delete_plan.stats.deleted, 1) + self.assertEqual(delete_plan.items[-1].action, "remote_delete") + self.assertEqual(delete_client.deletes, [{"href": f"/addressbooks/personal/{contact.id}.vcf", "etag": contact.source_revision, "overwrite": False}]) + self.assertEqual(len(list_sync_tombstones(self.session, self.principal, sync_source.id)), 1) + + def test_delete_sync_source_disconnects_without_removing_contacts(self) -> None: + book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Disconnectable")) + self.session.commit() + self.session.refresh(book) + + sync_source = create_carddav_sync_source( + self.session, + self.principal, + book.id, + AddressCardDavSourceCreateRequest( + collection_url="https://carddav.example.local/addressbooks/personal/", + display_name="Disconnectable CardDAV", + auth_type="none", + sync_direction="two_way", + read_only=False, + ), + ) + contact = create_contact( + self.session, + self.principal, + book.id, + ContactCreateRequest(display_name="Local Contact", emails=[ContactEmailPayload(email="local@example.local")]), + ) + self.session.commit() + self.session.refresh(book) + self.session.refresh(sync_source) + self.session.refresh(contact) + + self.assertEqual(book.source_kind, "carddav") + self.assertEqual(book.source_ref, sync_source.id) + self.assertEqual(len(list_sync_sources(self.session, self.principal, include_disabled=True)), 1) + + delete_sync_source(self.session, self.principal, sync_source.id) + self.session.commit() + self.session.refresh(book) + self.session.refresh(contact) + + self.assertEqual(list_sync_sources(self.session, self.principal, include_disabled=True), []) + self.assertEqual(book.source_kind, "local") + self.assertIsNone(book.source_ref) + self.assertFalse(book.read_only) + self.assertEqual([item.id for item in list_contacts(self.session, self.principal, address_book_id=book.id)], [contact.id]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_vcard.py b/tests/test_vcard.py new file mode 100644 index 0000000..819d0e6 --- /dev/null +++ b/tests/test_vcard.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import unittest + +from govoplan_addresses.backend.db.models import Contact, ContactEmail, ContactPhone, ContactPostalAddress +from govoplan_addresses.backend.vcard import contact_to_vcard, parse_vcards_with_issues + + +class VCardTests(unittest.TestCase): + def test_parse_vcard_preserves_folded_and_escaped_text(self) -> None: + result = parse_vcards_with_issues( + "BEGIN:VCARD\r\n" + "VERSION:4.0\r\n" + "UID:contact-1\r\n" + "FN:Ada \r\n" + " Lovelace\r\n" + "N:Lovelace;Ada;;;\r\n" + "NOTE:Line one\\nLine two\r\n" + "CATEGORIES:science\\,history,engineering\r\n" + "EMAIL;TYPE=work;PREF=1:ada@example.local\r\n" + "ADR;TYPE=work:;;Main Street 1;Berlin;BE;10115;Germany\r\n" + "URL:https://example.local/ada\r\n" + "END:VCARD\r\n" + ) + + self.assertEqual([], result.issues) + self.assertEqual(1, len(result.cards)) + payload = result.cards[0].payload + self.assertEqual("Ada Lovelace", payload.display_name) + self.assertEqual("Line one\nLine two", payload.note) + self.assertEqual(["science,history", "engineering"], payload.tags) + self.assertEqual("work", payload.emails[0].label) + self.assertTrue(payload.emails[0].is_primary) + self.assertEqual("Berlin", payload.postal_addresses[0].locality) + self.assertEqual(["https://example.local/ada"], payload.provenance["vcard"]["urls"]) + + def test_contact_to_vcard_escapes_text_and_filters_urls(self) -> None: + contact = Contact( + address_book_id="book-1", + display_name="Ada Lovelace", + given_name="Ada", + family_name="Lovelace", + organization="Analytical Engine Office", + role_title="Mathematician", + note="Line one\nLine two", + tags=["science,history", "engineering"], + source_ref="contact-1", + source_revision="rev-1", + provenance={"vcard": {"urls": [" https://example.local/ada ", "", 42]}}, + ) + contact.emails = [ContactEmail(label="work", email="ada@example.local", is_primary=True, order_index=0)] + contact.phones = [ContactPhone(label="work", phone="+49 30 123", is_primary=True, order_index=0)] + contact.postal_addresses = [ + ContactPostalAddress( + label="work", + street="Main Street 1", + locality="Berlin", + region="BE", + postal_code="10115", + country="Germany", + is_primary=True, + order_index=0, + ) + ] + + content = contact_to_vcard(contact) + + self.assertIn("UID:contact-1\r\n", content) + self.assertIn("REV:rev-1\r\n", content) + self.assertIn("NOTE:Line one\\nLine two\r\n", content) + self.assertIn("CATEGORIES:science\\,history,engineering\r\n", content) + self.assertIn("EMAIL;TYPE=work:ada@example.local\r\n", content) + self.assertIn("URL:https://example.local/ada\r\n", content) + self.assertNotIn("URL:42", content) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..6652fac --- /dev/null +++ b/webui/package.json @@ -0,0 +1,28 @@ +{ + "name": "@govoplan/addresses-webui", + "version": "0.1.8", + "private": true, + "type": "module", + "main": "src/index.ts", + "module": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./styles/addresses.css": "./src/styles/addresses.css" + }, + "peerDependencies": { + "@govoplan/core-webui": "^0.1.8", + "lucide-react": "^1.23.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } +} diff --git a/webui/src/api/addresses.ts b/webui/src/api/addresses.ts new file mode 100644 index 0000000..7e8a8fb --- /dev/null +++ b/webui/src/api/addresses.ts @@ -0,0 +1,596 @@ +import { apiFetch, type ApiSettings } from "@govoplan/core-webui"; + +export type AddressBookScope = "user" | "group" | "tenant" | "system"; + +export type AddressBook = { + id: string; + tenant_id?: string | null; + scope_type: AddressBookScope; + scope_id?: string | null; + name: string; + description?: string | null; + source_kind: string; + source_ref?: string | null; + read_only: boolean; + sync_status?: string | null; + sync_error?: string | null; + contact_count: number; + deleted_at?: string | null; + created_at: string; + updated_at: string; +}; + +export type AddressList = { + id: string; + tenant_id?: string | null; + address_book_id: string; + name: string; + description?: string | null; + source_kind: string; + source_ref?: string | null; + read_only: boolean; + entry_count: number; + deleted_at?: string | null; + created_at: string; + updated_at: string; +}; + +export type ContactEmail = { + id?: string; + label?: string | null; + email: string; + is_primary: boolean; +}; + +export type ContactPhone = { + id?: string; + label?: string | null; + phone: string; + is_primary: boolean; +}; + +export type ContactPostalAddress = { + id?: string; + label?: string | null; + street?: string | null; + postal_code?: string | null; + locality?: string | null; + region?: string | null; + country?: string | null; + is_primary: boolean; +}; + +export type Contact = { + id: string; + tenant_id?: string | null; + address_book_id: string; + display_name: string; + given_name?: string | null; + family_name?: string | null; + organization?: string | null; + role_title?: string | null; + note?: string | null; + tags: string[]; + source_kind: string; + source_ref?: string | null; + source_payload_kind?: string | null; + source_revision?: string | null; + provenance: Record; + emails: ContactEmail[]; + phones: ContactPhone[]; + postal_addresses: ContactPostalAddress[]; + deleted_at?: string | null; + created_at: string; + updated_at: string; +}; + +export type AddressBookCreatePayload = { + scope_type: AddressBookScope; + group_id?: string | null; + name: string; + description?: string | null; +}; + +export type AddressBookUpdatePayload = { + name?: string | null; + description?: string | null; +}; + +export type AddressListPayload = { + name: string; + description?: string | null; +}; + +export type AddressListEntryPayload = { + contact_id: string; + contact_email_id?: string | null; + contact_postal_address_id?: string | null; + label?: string | null; +}; + +export type AddressListEntry = { + id: string; + address_list_id: string; + contact_id: string; + contact_email_id?: string | null; + contact_postal_address_id?: string | null; + target_kind: string; + label?: string | null; + order_index: number; + contact_display_name: string; + email?: string | null; + postal_address?: string | null; + created_at: string; + updated_at: string; +}; + +export type ContactPayload = { + display_name?: string | null; + given_name?: string | null; + family_name?: string | null; + organization?: string | null; + role_title?: string | null; + note?: string | null; + tags?: string[]; + emails?: Array>; + phones?: Array>; + postal_addresses?: Array>; + provenance?: Record; +}; + +export type AddressBookWriteOperation = "create_contact" | "update_contact" | "delete_contact"; + +export type AddressBookWriteDecision = { + address_book_id: string; + address_book_label?: string | null; + operation: string; + allowed: boolean; + reason: string; + message: string; + scope_type?: AddressBookScope | null; + scope_id?: string | null; + tenant_id?: string | null; + source_kind?: string | null; + read_only: boolean; + required_scopes: string[]; + provenance: Record; +}; + +type AddressBookListResponse = { + address_books: AddressBook[]; +}; + +type AddressListListResponse = { + address_lists: AddressList[]; +}; + +type AddressListEntryListResponse = { + entries: AddressListEntry[]; +}; + +type ContactListResponse = { + contacts: Contact[]; +}; + +type AddressBookWriteTargetsResponse = { + targets: AddressBookWriteDecision[]; +}; + +export type VCardImportResult = { + imported: number; + skipped: number; + contacts: Contact[]; + issues: Array<{ index: number; message: string; severity: "warning" | "error"; field?: string | null; line?: number | null }>; +}; + +export type AddressSyncSource = { + id: string; + tenant_id?: string | null; + address_book_id: string; + connector_type: string; + display_name: string; + external_account_ref?: string | null; + external_address_book_ref?: string | null; + sync_direction: string; + read_only: boolean; + enabled: boolean; + status: string; + sync_token?: string | null; + etag?: string | null; + remote_revision?: string | null; + last_attempted_at?: string | null; + last_success_at?: string | null; + last_error?: string | null; + last_diagnostic?: Record | null; + metadata: Record; + created_at: string; + updated_at: string; +}; + +export type AddressCardDavAddressBook = { + collection_url: string; + href: string; + display_name?: string | null; + description?: string | null; + ctag?: string | null; + sync_token?: string | null; +}; + +export type AddressSyncPlanStats = { + created: number; + updated: number; + deleted: number; + conflicts: number; + unchanged: number; + errors: number; + fetched: number; + full_sync: boolean; + used_sync_token: boolean; + sync_token?: string | null; + etag?: string | null; + remote_revision?: string | null; +}; + +export type AddressSyncPlanItem = { + action: "create" | "update" | "delete" | "remote_create" | "remote_update" | "remote_delete" | "conflict" | "unchanged" | "error"; + href?: string | null; + remote_uid?: string | null; + contact_id?: string | null; + display_name?: string | null; + etag?: string | null; + message?: string | null; +}; + +export type AddressSyncPlan = { + sync_source: AddressSyncSource; + stats: AddressSyncPlanStats; + items: AddressSyncPlanItem[]; +}; + +export type AddressSyncDiagnostic = { + id: string; + tenant_id?: string | null; + sync_source_id: string; + severity: string; + code: string; + message: string; + details: Record; + created_at: string; + updated_at: string; +}; + +export type AddressSyncTombstone = { + id: string; + tenant_id?: string | null; + sync_source_id: string; + address_book_id: string; + contact_id?: string | null; + remote_uid?: string | null; + resource_href?: string | null; + local_deleted_at?: string | null; + remote_deleted_at?: string | null; + synced_at?: string | null; + metadata: Record; + created_at: string; + updated_at: string; +}; + +export type AddressSyncConflict = { + id: string; + tenant_id?: string | null; + sync_source_id: string; + address_book_id: string; + contact_id?: string | null; + remote_uid?: string | null; + resource_href?: string | null; + field_path: string; + local_value?: Record | null; + remote_value?: Record | null; + local_updated_at?: string | null; + remote_updated_at?: string | null; + status: string; + resolution?: string | null; + resolved_at?: string | null; + resolved_by_account_id?: string | null; + metadata: Record; + created_at: string; + updated_at: string; +}; + +type AddressSyncSourceListResponse = { + sync_sources: AddressSyncSource[]; +}; + +type AddressCardDavDiscoveryResponse = { + address_books: AddressCardDavAddressBook[]; +}; + +type AddressSyncDiagnosticListResponse = { + diagnostics: AddressSyncDiagnostic[]; +}; + +type AddressSyncTombstoneListResponse = { + tombstones: AddressSyncTombstone[]; +}; + +type AddressSyncConflictListResponse = { + conflicts: AddressSyncConflict[]; +}; + +function queryString(params: Record): string { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== null && value !== undefined && value !== "") search.set(key, String(value)); + } + const serialized = search.toString(); + return serialized ? `?${serialized}` : ""; +} + +export async function listAddressBooks(settings: ApiSettings, options: {includeDeleted?: boolean;} = {}): Promise { + const response = await apiFetch( + settings, + `/api/v1/addresses/address-books${queryString({ include_deleted: options.includeDeleted ? "true" : null })}` + ); + return response.address_books; +} + +export function createAddressBook(settings: ApiSettings, payload: AddressBookCreatePayload): Promise { + return apiFetch(settings, "/api/v1/addresses/address-books", { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export function updateAddressBook(settings: ApiSettings, bookId: string, payload: AddressBookUpdatePayload): Promise { + return apiFetch(settings, `/api/v1/addresses/address-books/${bookId}`, { + method: "PATCH", + body: JSON.stringify(payload) + }); +} + +export function deleteAddressBook(settings: ApiSettings, bookId: string): Promise { + return apiFetch(settings, `/api/v1/addresses/address-books/${bookId}`, { method: "DELETE" }); +} + +export function restoreAddressBook(settings: ApiSettings, bookId: string): Promise { + return apiFetch(settings, `/api/v1/addresses/address-books/${bookId}/restore`, { method: "POST" }); +} + +export async function listAddressLists( + settings: ApiSettings, + options: { addressBookId?: string | null; includeDeleted?: boolean } = {} +): Promise { + const response = await apiFetch( + settings, + `/api/v1/addresses/address-lists${queryString({ + address_book_id: options.addressBookId, + include_deleted: options.includeDeleted ? "true" : null + })}` + ); + return response.address_lists; +} + +export function createAddressList(settings: ApiSettings, addressBookId: string, payload: AddressListPayload): Promise { + return apiFetch(settings, `/api/v1/addresses/address-books/${addressBookId}/address-lists`, { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export function updateAddressList(settings: ApiSettings, addressListId: string, payload: Partial): Promise { + return apiFetch(settings, `/api/v1/addresses/address-lists/${addressListId}`, { + method: "PATCH", + body: JSON.stringify(payload) + }); +} + +export function deleteAddressList(settings: ApiSettings, addressListId: string): Promise { + return apiFetch(settings, `/api/v1/addresses/address-lists/${addressListId}`, { method: "DELETE" }); +} + +export function restoreAddressList(settings: ApiSettings, addressListId: string): Promise { + return apiFetch(settings, `/api/v1/addresses/address-lists/${addressListId}/restore`, { method: "POST" }); +} + +export async function listAddressListEntries(settings: ApiSettings, addressListId: string): Promise { + const response = await apiFetch(settings, `/api/v1/addresses/address-lists/${addressListId}/entries`); + return response.entries; +} + +export function createAddressListEntry( + settings: ApiSettings, + addressListId: string, + payload: AddressListEntryPayload +): Promise { + return apiFetch(settings, `/api/v1/addresses/address-lists/${addressListId}/entries`, { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export function deleteAddressListEntry(settings: ApiSettings, entryId: string): Promise { + return apiFetch(settings, `/api/v1/addresses/address-list-entries/${entryId}`, { method: "DELETE" }); +} + +export async function listAddressBookWriteTargets( + settings: ApiSettings, + operation: AddressBookWriteOperation = "create_contact" +): Promise { + const response = await apiFetch( + settings, + `/api/v1/addresses/write-targets${queryString({ operation })}` + ); + return response.targets; +} + +export function getAddressBookWriteDecision( + settings: ApiSettings, + addressBookId: string, + operation: AddressBookWriteOperation = "create_contact" +): Promise { + return apiFetch( + settings, + `/api/v1/addresses/address-books/${addressBookId}/write-decision${queryString({ operation })}` + ); +} + +export async function listAddressSyncSources( + settings: ApiSettings, + options: { addressBookId?: string | null; includeDisabled?: boolean } = {} +): Promise { + const response = await apiFetch( + settings, + `/api/v1/addresses/sync-sources${queryString({ + address_book_id: options.addressBookId, + include_disabled: options.includeDisabled ? "true" : null + })}` + ); + return response.sync_sources; +} + +export function discoverCardDavAddressBooks( + settings: ApiSettings, + payload: { url: string; auth_type: "none" | "basic" | "bearer"; username?: string | null; password?: string | null; bearer_token?: string | null; credential_ref?: string | null } +): Promise { + return apiFetch(settings, "/api/v1/addresses/carddav/discover", { + method: "POST", + body: JSON.stringify(payload) + }).then((response) => response.address_books); +} + +export function createCardDavSyncSource( + settings: ApiSettings, + addressBookId: string, + payload: { + collection_url: string; + display_name?: string | null; + auth_type: "none" | "basic" | "bearer"; + username?: string | null; + password?: string | null; + bearer_token?: string | null; + credential_ref?: string | null; + sync_direction: "read_only" | "import" | "export" | "two_way"; + read_only?: boolean | null; + sync_token?: string | null; + etag?: string | null; + remote_revision?: string | null; + } +): Promise { + return apiFetch(settings, `/api/v1/addresses/address-books/${addressBookId}/carddav/sources`, { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export function updateAddressSyncSource( + settings: ApiSettings, + syncSourceId: string, + payload: Partial<{ + display_name: string | null; + external_account_ref: string | null; + external_address_book_ref: string | null; + sync_direction: "read_only" | "import" | "export" | "two_way"; + read_only: boolean | null; + enabled: boolean | null; + sync_token: string | null; + etag: string | null; + remote_revision: string | null; + metadata: Record | null; + }> +): Promise { + return apiFetch(settings, `/api/v1/addresses/sync-sources/${syncSourceId}`, { + method: "PATCH", + body: JSON.stringify(payload) + }); +} + +export function deleteAddressSyncSource(settings: ApiSettings, syncSourceId: string): Promise { + return apiFetch(settings, `/api/v1/addresses/sync-sources/${syncSourceId}`, { method: "DELETE" }); +} + +export function previewAddressSyncSource(settings: ApiSettings, syncSourceId: string, options: { forceFull?: boolean } = {}): Promise { + return apiFetch(settings, `/api/v1/addresses/sync-sources/${syncSourceId}/preview`, { + method: "POST", + body: JSON.stringify({ force_full: Boolean(options.forceFull) }) + }); +} + +export function runAddressSyncSource(settings: ApiSettings, syncSourceId: string, options: { forceFull?: boolean } = {}): Promise { + return apiFetch(settings, `/api/v1/addresses/sync-sources/${syncSourceId}/run`, { + method: "POST", + body: JSON.stringify({ force_full: Boolean(options.forceFull) }) + }); +} + +export async function listAddressSyncDiagnostics(settings: ApiSettings, syncSourceId: string): Promise { + const response = await apiFetch(settings, `/api/v1/addresses/sync-sources/${syncSourceId}/diagnostics`); + return response.diagnostics; +} + +export async function listAddressSyncTombstones(settings: ApiSettings, syncSourceId: string): Promise { + const response = await apiFetch(settings, `/api/v1/addresses/sync-sources/${syncSourceId}/tombstones`); + return response.tombstones; +} + +export async function listAddressSyncConflicts(settings: ApiSettings, syncSourceId: string, status = "open"): Promise { + const response = await apiFetch(settings, `/api/v1/addresses/sync-sources/${syncSourceId}/conflicts${queryString({ status })}`); + return response.conflicts; +} + +export function resolveAddressSyncConflict( + settings: ApiSettings, + conflictId: string, + resolution = "manual", + status = "resolved", + options: { mergedPayload?: Record | null } = {} +): Promise { + return apiFetch(settings, `/api/v1/addresses/sync-conflicts/${conflictId}/resolve`, { + method: "POST", + body: JSON.stringify({ resolution, status, merged_payload: options.mergedPayload ?? null }) + }); +} + +export async function listContacts(settings: ApiSettings, options: {addressBookId?: string | null;query?: string | null;limit?: number;includeDeleted?: boolean;} = {}): Promise { + const response = await apiFetch( + settings, + `/api/v1/addresses/contacts${queryString({ address_book_id: options.addressBookId, query: options.query, limit: options.limit, include_deleted: options.includeDeleted ? "true" : null })}` + ); + return response.contacts; +} + +export function createContact(settings: ApiSettings, addressBookId: string, payload: ContactPayload): Promise { + return apiFetch(settings, `/api/v1/addresses/address-books/${addressBookId}/contacts`, { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export function updateContact(settings: ApiSettings, contactId: string, payload: ContactPayload): Promise { + return apiFetch(settings, `/api/v1/addresses/contacts/${contactId}`, { + method: "PATCH", + body: JSON.stringify(payload) + }); +} + +export function deleteContact(settings: ApiSettings, contactId: string): Promise { + return apiFetch(settings, `/api/v1/addresses/contacts/${contactId}`, { method: "DELETE" }); +} + +export function restoreContact(settings: ApiSettings, contactId: string): Promise { + return apiFetch(settings, `/api/v1/addresses/contacts/${contactId}/restore`, { method: "POST" }); +} + +export function importAddressBookVcards(settings: ApiSettings, addressBookId: string, content: string): Promise { + return apiFetch(settings, `/api/v1/addresses/address-books/${addressBookId}/vcards/import`, { + method: "POST", + body: JSON.stringify({ content }) + }); +} + +export function exportAddressBookVcards(settings: ApiSettings, addressBookId: string): Promise { + return apiFetch(settings, `/api/v1/addresses/address-books/${addressBookId}/vcards/export`); +} + +export function exportContactVcard(settings: ApiSettings, contactId: string): Promise { + return apiFetch(settings, `/api/v1/addresses/contacts/${contactId}/vcard`); +} diff --git a/webui/src/features/addressbook/AddressBookPage.tsx b/webui/src/features/addressbook/AddressBookPage.tsx new file mode 100644 index 0000000..5e350f4 --- /dev/null +++ b/webui/src/features/addressbook/AddressBookPage.tsx @@ -0,0 +1,2505 @@ +import { Download, Edit3, Link2, Plus, RefreshCw, RotateCcw, Save, Search, Trash2, Upload, UserPlus, X } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState, type ButtonHTMLAttributes, type DragEvent as ReactDragEvent, type FormEvent } from "react"; +import { + ApiError, + Button, + ConfirmDialog, + Dialog, + DisabledActionTooltip, + ExplorerTree, + fetchAuthGroups, + FormField, + LoadingFrame, + StatusBadge, + ToggleSwitch, + hasScope, + type ApiSettings, + type AuthInfo, + type AuthUpdate +} from "@govoplan/core-webui"; +import { + createAddressBook, + createAddressList, + createAddressListEntry, + createCardDavSyncSource, + createContact, + deleteAddressBook, + deleteAddressList, + deleteAddressListEntry, + deleteContact, + deleteAddressSyncSource, + discoverCardDavAddressBooks, + exportAddressBookVcards, + exportContactVcard, + importAddressBookVcards, + listAddressBooks, + listAddressListEntries, + listAddressLists, + listAddressSyncConflicts, + listAddressSyncDiagnostics, + listAddressSyncSources, + listAddressSyncTombstones, + listContacts, + previewAddressSyncSource, + restoreAddressBook, + restoreAddressList, + restoreContact, + resolveAddressSyncConflict, + runAddressSyncSource, + updateAddressBook, + updateAddressList, + updateAddressSyncSource, + updateContact, + type AddressCardDavAddressBook, + type AddressBook, + type AddressBookScope, + type AddressList, + type AddressListEntry, + type AddressSyncConflict, + type AddressSyncDiagnostic, + type AddressSyncPlan, + type AddressSyncSource, + type AddressSyncTombstone, + type Contact +} from "../../api/addresses"; + +type Props = { + settings: ApiSettings; + auth: AuthInfo; + onAuthChange?: (auth: AuthUpdate | null, accessToken?: string) => void; +}; + +type BookDialogState = { + mode: "create" | "edit"; + book?: AddressBook; +}; + +type BookFormState = { + scope_type: AddressBookScope; + group_id: string; + name: string; + description: string; +}; + +type ContactDialogState = { + mode: "create" | "edit"; + contact?: Contact; +}; + +type ListDialogState = { + mode: "create" | "edit"; + list?: AddressList; +}; + +type ContactEmailRow = { + rowId: string; + label: string; + email: string; + is_primary: boolean; +}; + +type ContactPhoneRow = { + rowId: string; + label: string; + phone: string; + is_primary: boolean; +}; + +type ContactPostalAddressRow = { + rowId: string; + label: string; + street: string; + postal_code: string; + locality: string; + region: string; + country: string; + is_primary: boolean; +}; + +type ContactFormState = { + display_name: string; + given_name: string; + family_name: string; + organization: string; + role_title: string; + emails: ContactEmailRow[]; + phones: ContactPhoneRow[]; + postal_addresses: ContactPostalAddressRow[]; + tags: string; + note: string; +}; + +type ListFormState = { + name: string; + description: string; +}; + +type CardDavFormState = { + collection_url: string; + display_name: string; + auth_type: "none" | "basic" | "bearer"; + username: string; + password: string; + bearer_token: string; + credential_ref: string; + sync_direction: "read_only" | "import" | "export" | "two_way"; +}; + +type SyncInspectorState = { + source: AddressSyncSource; +} | null; + +type AddressListTargetOption = { + key: string; + label: string; + payload: { + contact_email_id?: string | null; + contact_postal_address_id?: string | null; + label?: string | null; + }; +}; + +type ConfirmState = + | { kind: "book"; book: AddressBook } + | { kind: "list"; list: AddressList } + | { kind: "contact"; contact: Contact } + | { kind: "sync-source"; source: AddressSyncSource } + | null; + +type AddressTreeNode = { + id: string; + kind: "source" | "scope" | "book" | "list"; + label: string; + sourceGroup?: "local" | "linked"; + scope?: AddressBookScope; + book?: AddressBook; + list?: AddressList; + children: AddressTreeNode[]; +}; + +type ConflictMergeChoice = "local" | "remote"; + +const EMPTY_BOOK_FORM: BookFormState = { + scope_type: "user", + group_id: "", + name: "", + description: "" +}; + +const EMPTY_LIST_FORM: ListFormState = { + name: "", + description: "" +}; + +const EMPTY_CARDDAV_FORM: CardDavFormState = { + collection_url: "", + display_name: "", + auth_type: "basic", + username: "", + password: "", + bearer_token: "", + credential_ref: "", + sync_direction: "read_only" +}; + +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; + +let contactRowSequence = 0; + +function nextContactRowId(prefix: string): string { + contactRowSequence += 1; + return `${prefix}-${contactRowSequence}`; +} + +function emptyEmailRow(isPrimary = true): ContactEmailRow { + return { rowId: nextContactRowId("email"), label: "work", email: "", is_primary: isPrimary }; +} + +function emptyPhoneRow(isPrimary = true): ContactPhoneRow { + return { rowId: nextContactRowId("phone"), label: "work", phone: "", is_primary: isPrimary }; +} + +function emptyPostalAddressRow(isPrimary = true): ContactPostalAddressRow { + return { + rowId: nextContactRowId("address"), + label: "work", + street: "", + postal_code: "", + locality: "", + region: "", + country: "", + is_primary: isPrimary + }; +} + +function emptyContactForm(): ContactFormState { + return { + display_name: "", + given_name: "", + family_name: "", + organization: "", + role_title: "", + emails: [emptyEmailRow()], + phones: [emptyPhoneRow()], + postal_addresses: [emptyPostalAddressRow()], + tags: "", + note: "" + }; +} + +const EMPTY_CONTACT_FORM: ContactFormState = emptyContactForm(); + +function errorMessage(error: unknown): string { + if (error instanceof ApiError) { + try { + const parsed = JSON.parse(error.body) as { detail?: unknown }; + if (typeof parsed.detail === "string") return parsed.detail; + } catch { + return error.message; + } + } + return error instanceof Error ? error.message : String(error); +} + +function scopeLabel(scope: AddressBookScope): string { + if (scope === "user") return "Personal"; + if (scope === "group") return "Group"; + if (scope === "tenant") return "Tenant"; + return "System"; +} + +function primaryEmail(contact: Contact): string { + return contact.emails.find((email) => email.is_primary)?.email ?? contact.emails[0]?.email ?? ""; +} + +function primaryPhone(contact: Contact): string { + return contact.phones.find((phone) => phone.is_primary)?.phone ?? contact.phones[0]?.phone ?? ""; +} + +function sourceGroupForBook(book: AddressBook): "local" | "linked" { + return book.source_kind === "local" ? "local" : "linked"; +} + +function sourceGroupLabel(sourceGroup: "local" | "linked"): string { + return sourceGroup === "local" ? "Local" : "Linked"; +} + +function syncSourceLabel(source: AddressSyncSource): string { + return source.display_name || source.external_address_book_ref || source.connector_type; +} + +function formatDateTime(value?: string | null): string { + if (!value) return "Never"; + try { + return new Intl.DateTimeFormat(undefined, { dateStyle: "short", timeStyle: "short" }).format(new Date(value)); + } catch { + return value; + } +} + +function planSummary(plan: AddressSyncPlan | null): string { + if (!plan) return "No preview loaded."; + const { stats } = plan; + return `${stats.created} create, ${stats.updated} update, ${stats.deleted} delete, ${stats.conflicts} conflict, ${stats.unchanged} unchanged, ${stats.errors} error`; +} + +function syncActionLabel(action: string): string { + if (action === "remote_create") return "Remote create"; + if (action === "remote_update") return "Remote update"; + if (action === "remote_delete") return "Remote delete"; + return action; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function conflictPayload(value: Record | null | undefined): Record | null { + if (!isRecord(value)) return null; + return isRecord(value.payload) ? value.payload : null; +} + +function conflictRemotePayload(conflict: AddressSyncConflict): Record | null { + const remoteValuePayload = conflictPayload(conflict.remote_value); + if (remoteValuePayload) return remoteValuePayload; + const metadataPayload = conflict.metadata?.remote_payload; + return isRecord(metadataPayload) ? metadataPayload : null; +} + +function canApplyRemoteConflict(conflict: AddressSyncConflict): boolean { + return Boolean(conflictRemotePayload(conflict)); +} + +function canMergeConflict(conflict: AddressSyncConflict): boolean { + return Boolean(conflictPayload(conflict.local_value) && conflictRemotePayload(conflict)); +} + +function defaultConflictMergeChoices(conflict: AddressSyncConflict): Record { + return Object.fromEntries(CONFLICT_PAYLOAD_FIELDS.map((field) => [field, "local" as ConflictMergeChoice])); +} + +function buildMergedConflictPayload(conflict: AddressSyncConflict, choices: Record): Record | null { + const localPayload = conflictPayload(conflict.local_value); + const remotePayload = conflictRemotePayload(conflict); + if (!localPayload || !remotePayload) return null; + const merged: Record = { ...localPayload }; + for (const field of CONFLICT_PAYLOAD_FIELDS) { + merged[field] = choices[field] === "remote" ? remotePayload[field] : localPayload[field]; + } + return merged; +} + +function shortJson(value: unknown): string { + if (value === null || value === undefined || value === "") return "—"; + if (Array.isArray(value)) { + if (value.length === 0) return "—"; + return value.map((item) => shortJson(item)).join("; "); + } + if (isRecord(value)) { + const entries = Object.entries(value) + .filter(([, entryValue]) => entryValue !== null && entryValue !== undefined && entryValue !== "") + .map(([key, entryValue]) => `${key}: ${shortJson(entryValue)}`); + return entries.length ? entries.join(", ") : "—"; + } + return String(value); +} + +function conflictFieldRows(conflict: AddressSyncConflict): Array<{ field: string; local: string; remote: string; differs: boolean }> { + const localPayload = conflictPayload(conflict.local_value); + const remotePayload = conflictRemotePayload(conflict); + if (!localPayload && !remotePayload) { + return [ + { + field: conflict.field_path, + local: shortJson(conflict.local_value), + remote: shortJson(conflict.remote_value), + differs: shortJson(conflict.local_value) !== shortJson(conflict.remote_value) + } + ]; + } + return CONFLICT_PAYLOAD_FIELDS.map((field) => { + const local = shortJson(localPayload?.[field]); + const remote = shortJson(remotePayload?.[field]); + return { field, local, remote, differs: local !== remote }; + }); +} + +function buildAddressTree(books: AddressBook[], addressLists: AddressList[]): AddressTreeNode[] { + const listsByBook = new Map(); + for (const list of addressLists) { + const current = listsByBook.get(list.address_book_id) ?? []; + current.push(list); + listsByBook.set(list.address_book_id, current); + } + + return (["local", "linked"] as const).map((sourceGroup) => { + const sourceBooks = books.filter((book) => sourceGroupForBook(book) === sourceGroup); + const scopeNodes = (["user", "group", "tenant", "system"] as const).map((scope) => { + const scopeBooks = sourceBooks.filter((book) => book.scope_type === scope); + return { + id: `scope:${sourceGroup}:${scope}`, + kind: "scope" as const, + label: scopeLabel(scope), + sourceGroup, + scope, + children: scopeBooks.map((book) => ({ + id: `book:${book.id}`, + kind: "book" as const, + label: book.name, + sourceGroup, + scope: book.scope_type, + book, + children: (listsByBook.get(book.id) ?? []).map((list) => ({ + id: `list:${list.id}`, + kind: "list" as const, + label: list.name, + sourceGroup, + scope: book.scope_type, + list, + children: [] + })) + })) + }; + }).filter((node) => node.children.length > 0); + + return { + id: `source:${sourceGroup}`, + kind: "source" as const, + label: sourceGroupLabel(sourceGroup), + sourceGroup, + children: scopeNodes + }; + }).filter((node) => node.children.length > 0); +} + +function expandedAddressTreeIds(nodes: AddressTreeNode[]): Set { + const expanded = new Set(); + function visit(node: AddressTreeNode) { + if (node.children.length > 0) expanded.add(node.id); + node.children.forEach(visit); + } + nodes.forEach(visit); + return expanded; +} + +function addressListEntryKey(entry: Pick): string { + if (entry.contact_email_id) return `${entry.contact_id}:email:${entry.contact_email_id}`; + if (entry.contact_postal_address_id) return `${entry.contact_id}:postal:${entry.contact_postal_address_id}`; + return `${entry.contact_id}:contact`; +} + +function listEntryContactIds(entries: AddressListEntry[]): Set { + return new Set(entries.map((entry) => entry.contact_id)); +} + +function contactListEntries(entries: AddressListEntry[], contactId: string): AddressListEntry[] { + return entries.filter((entry) => entry.contact_id === contactId); +} + +function listFormFromList(list?: AddressList): ListFormState { + if (!list) return EMPTY_LIST_FORM; + return { + name: list.name, + description: list.description ?? "" + }; +} + +function formatPostalAddress(address: Contact["postal_addresses"][number]): string { + return [address.street, [address.postal_code, address.locality].filter(Boolean).join(" "), address.region, address.country] + .filter((part) => part && part.trim()) + .join(", "); +} + +function addressListTargetOptions(contact: Contact): AddressListTargetOption[] { + const options: AddressListTargetOption[] = [{ + key: "contact", + label: "Whole contact", + payload: { label: null } + }]; + for (const email of contact.emails) { + if (!email.id) continue; + const label = `${email.label || "email"}${email.is_primary ? " primary" : ""}: ${email.email}`; + options.push({ + key: `email:${email.id}`, + label, + payload: { contact_email_id: email.id, label: email.label || null } + }); + } + for (const address of contact.postal_addresses) { + if (!address.id) continue; + const formatted = formatPostalAddress(address) || "Postal address"; + const label = `${address.label || "address"}${address.is_primary ? " primary" : ""}: ${formatted}`; + options.push({ + key: `postal:${address.id}`, + label, + payload: { contact_postal_address_id: address.id, label: address.label || null } + }); + } + return options; +} + +function entryTargetLabel(entry: AddressListEntry): string { + if (entry.target_kind === "email") return entry.email ? `Email: ${entry.email}` : "Email target"; + if (entry.target_kind === "postal_address") return entry.postal_address ? `Postal: ${entry.postal_address}` : "Postal address target"; + return "Whole contact"; +} + +function bookFormFromBook(book?: AddressBook): BookFormState { + if (!book) return EMPTY_BOOK_FORM; + return { + scope_type: book.scope_type, + group_id: book.scope_type === "group" ? book.scope_id ?? "" : "", + name: book.name, + description: book.description ?? "" + }; +} + +function contactFormFromContact(contact?: Contact): ContactFormState { + if (!contact) return emptyContactForm(); + return { + display_name: contact.display_name, + given_name: contact.given_name ?? "", + family_name: contact.family_name ?? "", + organization: contact.organization ?? "", + role_title: contact.role_title ?? "", + emails: normalizePrimaryRows(contact.emails.map((email) => ({ + rowId: nextContactRowId("email"), + label: email.label ?? "work", + email: email.email, + is_primary: email.is_primary + })), emptyEmailRow), + phones: normalizePrimaryRows(contact.phones.map((phone) => ({ + rowId: nextContactRowId("phone"), + label: phone.label ?? "work", + phone: phone.phone, + is_primary: phone.is_primary + })), emptyPhoneRow), + postal_addresses: normalizePrimaryRows(contact.postal_addresses.map((postal) => ({ + rowId: nextContactRowId("address"), + label: postal.label ?? "work", + street: postal.street ?? "", + postal_code: postal.postal_code ?? "", + locality: postal.locality ?? "", + region: postal.region ?? "", + country: postal.country ?? "", + is_primary: postal.is_primary + })), emptyPostalAddressRow), + tags: contact.tags.join(", "), + note: contact.note ?? "" + }; +} + +function normalizePrimaryFlags(rows: T[]): T[] { + if (!rows.length) return rows; + const primaryIndex = rows.findIndex((row) => row.is_primary); + return rows.map((row, index) => ({ ...row, is_primary: primaryIndex >= 0 ? index === primaryIndex : index === 0 })); +} + +function normalizePrimaryRows(rows: T[], fallback: () => T): T[] { + const nextRows = rows.length ? rows : [fallback()]; + return normalizePrimaryFlags(nextRows); +} + +function tagsFromForm(value: string): string[] { + const seen = new Set(); + return value.split(",").map((tag) => tag.trim()).filter((tag) => { + const key = tag.toLowerCase(); + if (!tag || seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function postalRowHasContent(row: ContactPostalAddressRow): boolean { + return [row.street, row.postal_code, row.locality, row.region, row.country].some((value) => value.trim()); +} + +function contactFormHasIdentity(form: ContactFormState): boolean { + return Boolean( + form.display_name.trim() || + form.given_name.trim() || + form.family_name.trim() || + form.emails.some((email) => email.email.trim()) + ); +} + +function safeFilename(value: string): string { + return (value.trim().replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || "address-book") + ".vcf"; +} + +function downloadText(filename: string, content: string, type = "text/vcard;charset=utf-8") { + const blob = new Blob([content], { type }); + const url = window.URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + window.URL.revokeObjectURL(url); +} + +function disabledReason(...conditions: Array<[boolean, string]>): string { + return conditions.find(([applies]) => applies)?.[1] ?? ""; +} + +type ReasonedButtonProps = ButtonHTMLAttributes & { + variant?: "primary" | "secondary" | "ghost" | "danger"; + disabledReason: string; +}; + +function ReasonedButton({ disabledReason, disabled, children, ...props }: ReasonedButtonProps) { + return ( + + + + ); +} + +export default function AddressBookPage({ settings, auth, onAuthChange }: Props) { + const [books, setBooks] = useState([]); + const [addressLists, setAddressLists] = useState([]); + const [addressListEntries, setAddressListEntries] = useState([]); + const [syncSources, setSyncSources] = useState([]); + const [contacts, setContacts] = useState([]); + const [selectedBookId, setSelectedBookId] = useState(""); + const [selectedListId, setSelectedListId] = useState(""); + const [selectedContactId, setSelectedContactId] = useState(""); + const [expandedTreeIds, setExpandedTreeIds] = useState>(() => new Set()); + const [query, setQuery] = useState(""); + const [showArchived, setShowArchived] = useState(false); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + const [bookDialog, setBookDialog] = useState(null); + const [bookForm, setBookForm] = useState(EMPTY_BOOK_FORM); + const [listDialog, setListDialog] = useState(null); + const [listForm, setListForm] = useState(EMPTY_LIST_FORM); + const [contactDialog, setContactDialog] = useState(null); + const [contactForm, setContactForm] = useState(EMPTY_CONTACT_FORM); + const [memberDialogOpen, setMemberDialogOpen] = useState(false); + const [memberCandidates, setMemberCandidates] = useState([]); + const [memberQuery, setMemberQuery] = useState(""); + const [memberTargetByContactId, setMemberTargetByContactId] = useState>({}); + const [dropTargetListId, setDropTargetListId] = useState(""); + const [importOpen, setImportOpen] = useState(false); + const [vcardContent, setVcardContent] = useState(""); + const [cardDavOpen, setCardDavOpen] = useState(false); + const [cardDavForm, setCardDavForm] = useState(EMPTY_CARDDAV_FORM); + const [cardDavDiscovery, setCardDavDiscovery] = useState([]); + const [syncInspector, setSyncInspector] = useState(null); + const [syncPlan, setSyncPlan] = useState(null); + const [syncDiagnostics, setSyncDiagnostics] = useState([]); + const [syncTombstones, setSyncTombstones] = useState([]); + const [syncConflicts, setSyncConflicts] = useState([]); + const [conflictDialog, setConflictDialog] = useState(null); + const [conflictMergeChoices, setConflictMergeChoices] = useState>({}); + const [confirmState, setConfirmState] = useState(null); + + const canWriteBooks = hasScope(auth, "addresses:address_book:write"); + const canDeleteBooks = hasScope(auth, "addresses:address_book:delete"); + const canAdminBooks = hasScope(auth, "addresses:address_book:admin"); + const canWriteLists = hasScope(auth, "addresses:address_list:write"); + const canDeleteLists = hasScope(auth, "addresses:address_list:delete"); + const canWriteContacts = hasScope(auth, "addresses:contact:write"); + const canDeleteContacts = hasScope(auth, "addresses:contact:delete"); + const canReadSync = hasScope(auth, "addresses:sync:read"); + const canWriteSync = hasScope(auth, "addresses:sync:write"); + + useEffect(() => { + if (auth.groups_loaded || !onAuthChange) return; + let active = true; + fetchAuthGroups(settings) + .then((next) => {if (active) onAuthChange(next);}) + .catch(() => undefined); + return () => {active = false;}; + }, [auth.groups_loaded, auth.user.id, auth.active_tenant?.id, auth.tenant.id, settings.accessToken, settings.apiBaseUrl, settings.apiKey]); + + const selectedBook = books.find((book) => book.id === selectedBookId) ?? books[0] ?? null; + const selectedList = addressLists.find((list) => list.id === selectedListId) ?? null; + const selectedBookSyncSources = useMemo( + () => selectedBook ? syncSources.filter((source) => source.address_book_id === selectedBook.id) : [], + [selectedBook, syncSources] + ); + const selectedSyncSource = selectedBookSyncSources[0] ?? null; + const addressTreeNodes = useMemo(() => buildAddressTree(books, addressLists), [addressLists, books]); + const selectedListContactIds = useMemo(() => listEntryContactIds(addressListEntries), [addressListEntries]); + const selectedListEntryKeys = useMemo(() => new Set(addressListEntries.map(addressListEntryKey)), [addressListEntries]); + const visibleContacts = useMemo( + () => selectedList ? contacts.filter((contact) => selectedListContactIds.has(contact.id)) : contacts, + [contacts, selectedList, selectedListContactIds] + ); + const memberCandidateContacts = useMemo(() => { + const normalizedQuery = memberQuery.trim().toLowerCase(); + return memberCandidates + .filter((contact) => !contact.deleted_at) + .filter((contact) => addressListTargetOptions(contact).some((option) => !selectedListEntryKeys.has(`${contact.id}:${option.key}`))) + .filter((contact) => { + if (!normalizedQuery) return true; + return [ + contact.display_name, + contact.given_name, + contact.family_name, + contact.organization, + contact.role_title, + primaryEmail(contact), + primaryPhone(contact), + contact.tags.join(" ") + ].some((value) => (value ?? "").toLowerCase().includes(normalizedQuery)); + }); + }, [memberCandidates, memberQuery, selectedListContactIds]); + const selectedContact = visibleContacts.find((contact) => contact.id === selectedContactId) ?? null; + const selectedContactListEntries = selectedContact && selectedList ? contactListEntries(addressListEntries, selectedContact.id) : []; + const activeTreeId = selectedList ? `list:${selectedList.id}` : selectedBook ? `book:${selectedBook.id}` : ""; + const loadingReason = loading ? "Address books are loading." : ""; + const savingReason = saving ? "An address-book action is already in progress." : ""; + const createBookReason = disabledReason( + [!canWriteBooks, "You need permission to manage address books."], + [saving, savingReason] + ); + const refreshReason = disabledReason( + [loading, loadingReason], + [saving, savingReason] + ); + const toggleArchiveReason = disabledReason( + [loading, loadingReason], + [saving, savingReason] + ); + const exportBookReason = disabledReason( + [!selectedBook, "Select an address book before exporting vCards."], + [saving, savingReason] + ); + const importBookReason = disabledReason( + [!selectedBook, "Select an address book before importing vCards."], + [!canWriteContacts, "You need permission to manage contacts."], + [Boolean(selectedBook?.read_only), "This address book is read-only."], + [Boolean(selectedBook?.deleted_at), "Restore this address book before importing contacts."], + [saving, savingReason] + ); + const connectCardDavReason = disabledReason( + [!selectedBook, "Select an address book before connecting CardDAV."], + [!canWriteSync, "You need permission to manage address sync."], + [Boolean(selectedBook?.deleted_at), "Restore this address book before connecting sync."], + [saving, savingReason] + ); + const inspectSyncReason = disabledReason( + [!selectedSyncSource, "This address book has no sync source."], + [!canReadSync, "You need permission to view address sync."], + [saving, savingReason] + ); + const previewSyncReason = disabledReason( + [!selectedSyncSource, "This address book has no sync source."], + [!canReadSync, "You need permission to view address sync."], + [saving, savingReason] + ); + const runSyncReason = disabledReason( + [!selectedSyncSource, "This address book has no sync source."], + [!canWriteSync, "You need permission to run address sync."], + [saving, savingReason] + ); + const createContactReason = disabledReason( + [!selectedBook, "Select an address book before adding a contact."], + [!canWriteContacts, "You need permission to manage contacts."], + [Boolean(selectedBook?.read_only), "This address book is read-only."], + [Boolean(selectedBook?.deleted_at), "Restore this address book before adding contacts."], + [saving, savingReason] + ); + const createListReason = disabledReason( + [!selectedBook, "Select an address book before adding a list."], + [!canWriteLists, "You need permission to manage address lists."], + [Boolean(selectedBook?.read_only), "This address book is read-only."], + [Boolean(selectedBook?.deleted_at), "Restore this address book before adding lists."], + [saving, savingReason] + ); + const listSaveReason = disabledReason( + [saving, savingReason], + [!listForm.name.trim(), "Enter an address-list name before saving."] + ); + const addMembersReason = disabledReason( + [!selectedList, "Select an address list before adding contacts."], + [!canWriteLists, "You need permission to manage address lists."], + [Boolean(selectedList?.read_only), "This address list is read-only."], + [Boolean(selectedList?.deleted_at), "Restore this address list before adding contacts."], + [Boolean(selectedBook?.read_only), "This address book is read-only."], + [Boolean(selectedBook?.deleted_at), "Restore this address book before adding contacts to lists."], + [saving, savingReason] + ); + const bookSaveReason = disabledReason( + [saving, savingReason], + [!bookForm.name.trim(), "Enter an address book name before saving."] + ); + const contactSaveReason = disabledReason( + [saving, savingReason], + [!contactFormHasIdentity(contactForm), "Enter a display name, name component, or email address before saving."] + ); + const dialogCancelReason = disabledReason([saving, savingReason]); + const vcardImportReason = disabledReason( + [saving, savingReason], + [!selectedBook, "Select an address book before importing vCards."], + [!vcardContent.trim(), "Paste vCard content before importing."] + ); + const addContactRowReason = disabledReason([saving, savingReason]); + const removeEmailRowReason = disabledReason( + [saving, savingReason], + [contactForm.emails.length === 1, "Keep at least one email row. Empty rows are ignored on save."] + ); + const removePhoneRowReason = disabledReason( + [saving, savingReason], + [contactForm.phones.length === 1, "Keep at least one phone row. Empty rows are ignored on save."] + ); + const removePostalAddressRowReason = disabledReason( + [saving, savingReason], + [contactForm.postal_addresses.length === 1, "Keep at least one postal address row. Empty rows are ignored on save."] + ); + + function restoreBookReason(_book: AddressBook): string { + return disabledReason( + [!canWriteBooks, "You need permission to manage address books."], + [saving, savingReason] + ); + } + + function editBookReason(book: AddressBook): string { + return disabledReason( + [!canWriteBooks, "You need permission to manage address books."], + [book.read_only, "This address book is read-only."], + [saving, savingReason] + ); + } + + function deleteBookReason(book: AddressBook): string { + return disabledReason( + [!canDeleteBooks, "You need permission to delete address books."], + [book.read_only, "This address book is read-only."], + [saving, savingReason] + ); + } + + function editListReason(list: AddressList): string { + return disabledReason( + [!canWriteLists, "You need permission to manage address lists."], + [list.read_only, "This address list is read-only."], + [Boolean(list.deleted_at), "Restore this address list before editing it."], + [saving, savingReason] + ); + } + + function deleteListReason(list: AddressList): string { + return disabledReason( + [!canDeleteLists, "You need permission to delete address lists."], + [list.read_only, "This address list is read-only."], + [Boolean(list.deleted_at), "This address list is already archived."], + [saving, savingReason] + ); + } + + function restoreListReason(list: AddressList): string { + return disabledReason( + [!canWriteLists, "You need permission to manage address lists."], + [list.read_only, "This address list is read-only."], + [Boolean(selectedBook?.read_only), "This address book is read-only."], + [saving, savingReason] + ); + } + + function addContactToListReason(list: AddressList | null, contact: Contact | null, targetKey = ""): string { + return disabledReason( + [!list, "Select an address list before adding contacts."], + [!contact, "Select a contact before adding it to a list."], + [!canWriteLists, "You need permission to manage address lists."], + [Boolean(list?.read_only), "This address list is read-only."], + [Boolean(list?.deleted_at), "Restore this address list before adding contacts."], + [Boolean(selectedBook?.read_only), "This address book is read-only."], + [Boolean(selectedBook?.deleted_at), "Restore this address book before adding contacts to lists."], + [Boolean(contact?.deleted_at), "Restore this contact before adding it to a list."], + [Boolean(list && contact && list.address_book_id !== contact.address_book_id), "Address-list entries must use contacts from the same address book."], + [Boolean(contact && targetKey && selectedListEntryKeys.has(`${contact.id}:${targetKey}`) && list?.id === selectedList?.id), "This target is already in the selected address list."], + [saving, savingReason] + ); + } + + function removeContactFromListReason(): string { + return disabledReason( + [!selectedList, "Select an address list before removing contacts."], + [selectedContactListEntries.length === 0, "This contact is not in the selected address list."], + [!canWriteLists, "You need permission to manage address lists."], + [Boolean(selectedList?.read_only), "This address list is read-only."], + [Boolean(selectedList?.deleted_at), "Restore this address list before removing contacts."], + [saving, savingReason] + ); + } + + function restoreContactReason(): string { + return disabledReason( + [!canWriteContacts, "You need permission to manage contacts."], + [Boolean(selectedBook?.read_only), "This address book is read-only."], + [Boolean(selectedBook?.deleted_at), "Restore this address book before restoring contacts."], + [saving, savingReason] + ); + } + + function exportContactReason(): string { + return disabledReason([saving, savingReason]); + } + + function editContactReason(): string { + return disabledReason( + [!canWriteContacts, "You need permission to manage contacts."], + [Boolean(selectedBook?.read_only), "This address book is read-only."], + [Boolean(selectedBook?.deleted_at), "Restore this address book before editing contacts."], + [saving, savingReason] + ); + } + + function deleteContactReason(): string { + return disabledReason( + [!canDeleteContacts, "You need permission to delete contacts."], + [Boolean(selectedBook?.read_only), "This address book is read-only."], + [Boolean(selectedBook?.deleted_at), "Restore this address book before deleting contacts."], + [saving, savingReason] + ); + } + + function availableTargetOptionsForContact(contact: Contact): AddressListTargetOption[] { + return addressListTargetOptions(contact).filter((option) => !selectedListEntryKeys.has(`${contact.id}:${option.key}`)); + } + + function selectedTargetOptionForContact(contact: Contact): AddressListTargetOption | null { + const options = availableTargetOptionsForContact(contact); + if (options.length === 0) return null; + const selectedKey = memberTargetByContactId[contact.id]; + return options.find((option) => option.key === selectedKey) ?? options[0]; + } + + function setMemberTarget(contactId: string, targetKey: string) { + setMemberTargetByContactId((current) => ({ ...current, [contactId]: targetKey })); + } + + const refreshBooks = useCallback(async () => { + const [nextBooks, nextAddressLists, nextSyncSources] = await Promise.all([ + listAddressBooks(settings, { includeDeleted: showArchived }), + listAddressLists(settings, { includeDeleted: showArchived }), + canReadSync ? listAddressSyncSources(settings, { includeDisabled: true }) : Promise.resolve([]) + ]); + setBooks(nextBooks); + setAddressLists(nextAddressLists); + setSyncSources(nextSyncSources); + setSelectedBookId((current) => nextBooks.some((book) => book.id === current) ? current : nextBooks[0]?.id ?? ""); + setSelectedListId((current) => nextAddressLists.some((list) => list.id === current) ? current : ""); + return nextBooks; + }, [canReadSync, settings, showArchived]); + + const refreshContacts = useCallback(async (bookId: string, search: string) => { + if (!bookId) { + setContacts([]); + return; + } + setContacts(await listContacts(settings, { addressBookId: bookId, query: search, limit: 500, includeDeleted: showArchived })); + }, [settings, showArchived]); + + const refreshListEntries = useCallback(async (listId: string) => { + if (!listId) { + setAddressListEntries([]); + return []; + } + const nextEntries = await listAddressListEntries(settings, listId); + setAddressListEntries(nextEntries); + return nextEntries; + }, [settings]); + + const refreshAll = useCallback(async () => { + setLoading(true); + setError(""); + try { + const nextBooks = await refreshBooks(); + const nextSelected = nextBooks.some((book) => book.id === selectedBookId) ? selectedBookId : nextBooks[0]?.id ?? ""; + await refreshContacts(nextSelected, query); + } catch (err) { + setError(errorMessage(err)); + } finally { + setLoading(false); + } + }, [query, refreshBooks, refreshContacts, selectedBookId]); + + useEffect(() => { + let active = true; + async function loadInitial() { + setLoading(true); + setError(""); + try { + await refreshBooks(); + } catch (err) { + if (active) setError(errorMessage(err)); + } finally { + if (active) setLoading(false); + } + } + void loadInitial(); + return () => {active = false;}; + }, [refreshBooks]); + + useEffect(() => { + void refreshContacts(selectedBookId, query).catch((err) => setError(errorMessage(err))); + }, [query, refreshContacts, selectedBookId]); + + useEffect(() => { + const defaultExpanded = expandedAddressTreeIds(addressTreeNodes); + if (defaultExpanded.size === 0) return; + setExpandedTreeIds((current) => { + const next = new Set(current); + for (const id of defaultExpanded) next.add(id); + return next; + }); + }, [addressTreeNodes]); + + useEffect(() => { + let active = true; + async function loadListEntries() { + if (!selectedListId || selectedList?.deleted_at) { + setAddressListEntries([]); + return; + } + try { + const nextEntries = await listAddressListEntries(settings, selectedListId); + if (active) setAddressListEntries(nextEntries); + } catch (err) { + if (active) { + setAddressListEntries([]); + setError(errorMessage(err)); + } + } + } + void loadListEntries(); + return () => {active = false;}; + }, [selectedList?.deleted_at, selectedListId, settings]); + + useEffect(() => { + if (!selectedContactId) return; + if (!visibleContacts.some((contact) => contact.id === selectedContactId)) setSelectedContactId(""); + }, [selectedContactId, visibleContacts]); + + function openCreateBookDialog() { + setBookForm({ + ...EMPTY_BOOK_FORM, + group_id: auth.groups[0]?.id ?? "" + }); + setBookDialog({ mode: "create" }); + } + + function openEditBookDialog(book: AddressBook) { + setBookForm(bookFormFromBook(book)); + setBookDialog({ mode: "edit", book }); + } + + function openCreateListDialog() { + setListForm(EMPTY_LIST_FORM); + setListDialog({ mode: "create" }); + } + + function openEditListDialog(list: AddressList) { + setListForm(listFormFromList(list)); + setListDialog({ mode: "edit", list }); + } + + function openCreateContactDialog() { + setContactForm(emptyContactForm()); + setContactDialog({ mode: "create" }); + } + + function openEditContactDialog(contact: Contact) { + setContactForm(contactFormFromContact(contact)); + setContactDialog({ mode: "edit", contact }); + } + + function updateEmailRow(rowId: string, patch: Partial>) { + setContactForm((current) => ({ + ...current, + emails: current.emails.map((row) => row.rowId === rowId ? { ...row, ...patch } : row) + })); + } + + function updatePhoneRow(rowId: string, patch: Partial>) { + setContactForm((current) => ({ + ...current, + phones: current.phones.map((row) => row.rowId === rowId ? { ...row, ...patch } : row) + })); + } + + function updatePostalAddressRow(rowId: string, patch: Partial>) { + setContactForm((current) => ({ + ...current, + postal_addresses: current.postal_addresses.map((row) => row.rowId === rowId ? { ...row, ...patch } : row) + })); + } + + function setPrimaryEmailRow(rowId: string) { + setContactForm((current) => ({ ...current, emails: current.emails.map((row) => ({ ...row, is_primary: row.rowId === rowId })) })); + } + + function setPrimaryPhoneRow(rowId: string) { + setContactForm((current) => ({ ...current, phones: current.phones.map((row) => ({ ...row, is_primary: row.rowId === rowId })) })); + } + + function setPrimaryPostalAddressRow(rowId: string) { + setContactForm((current) => ({ ...current, postal_addresses: current.postal_addresses.map((row) => ({ ...row, is_primary: row.rowId === rowId })) })); + } + + function addEmailRow() { + setContactForm((current) => ({ ...current, emails: [...current.emails, emptyEmailRow(false)] })); + } + + function addPhoneRow() { + setContactForm((current) => ({ ...current, phones: [...current.phones, emptyPhoneRow(false)] })); + } + + function addPostalAddressRow() { + setContactForm((current) => ({ ...current, postal_addresses: [...current.postal_addresses, emptyPostalAddressRow(false)] })); + } + + function removeEmailRow(rowId: string) { + setContactForm((current) => ({ ...current, emails: normalizePrimaryRows(current.emails.filter((row) => row.rowId !== rowId), emptyEmailRow) })); + } + + function removePhoneRow(rowId: string) { + setContactForm((current) => ({ ...current, phones: normalizePrimaryRows(current.phones.filter((row) => row.rowId !== rowId), emptyPhoneRow) })); + } + + function removePostalAddressRow(rowId: string) { + setContactForm((current) => ({ ...current, postal_addresses: normalizePrimaryRows(current.postal_addresses.filter((row) => row.rowId !== rowId), emptyPostalAddressRow) })); + } + + function openTreeNode(node: AddressTreeNode) { + if (node.kind === "book" && node.book) { + setSelectedBookId(node.book.id); + setSelectedListId(""); + setSelectedContactId(""); + return; + } + if (node.kind === "list" && node.list) { + setSelectedBookId(node.list.address_book_id); + setSelectedListId(node.list.id); + setSelectedContactId(""); + return; + } + toggleTreeNode(node); + } + + function toggleTreeNode(node: AddressTreeNode) { + if (node.children.length === 0) return; + setExpandedTreeIds((current) => { + const next = new Set(current); + if (next.has(node.id)) next.delete(node.id); + else next.add(node.id); + return next; + }); + } + + async function submitBook(event: FormEvent) { + event.preventDefault(); + setSaving(true); + setError(""); + setNotice(""); + try { + if (bookDialog?.mode === "edit" && bookDialog.book) { + await updateAddressBook(settings, bookDialog.book.id, { name: bookForm.name, description: bookForm.description || null }); + } else { + await createAddressBook(settings, { + scope_type: bookForm.scope_type, + group_id: bookForm.scope_type === "group" ? bookForm.group_id : null, + name: bookForm.name, + description: bookForm.description || null + }); + } + setBookDialog(null); + await refreshAll(); + } catch (err) { + setError(errorMessage(err)); + } finally { + setSaving(false); + } + } + + async function submitList(event: FormEvent) { + event.preventDefault(); + if (!selectedBook) return; + setSaving(true); + setError(""); + setNotice(""); + try { + let savedList: AddressList; + if (listDialog?.mode === "edit" && listDialog.list) { + savedList = await updateAddressList(settings, listDialog.list.id, { + name: listForm.name, + description: listForm.description || null + }); + } else { + savedList = await createAddressList(settings, selectedBook.id, { + name: listForm.name, + description: listForm.description || null + }); + } + setListDialog(null); + setSelectedBookId(savedList.address_book_id); + setSelectedListId(savedList.id); + setExpandedTreeIds((current) => { + const next = new Set(current); + next.add(`book:${savedList.address_book_id}`); + return next; + }); + await refreshBooks(); + await refreshListEntries(savedList.id); + } catch (err) { + setError(errorMessage(err)); + } finally { + setSaving(false); + } + } + + async function submitContact(event: FormEvent) { + event.preventDefault(); + if (!selectedBook) return; + setSaving(true); + setError(""); + setNotice(""); + const emails = normalizePrimaryFlags(contactForm.emails + .filter((email) => email.email.trim()) + .map((email) => ({ label: email.label || null, email: email.email.trim(), is_primary: email.is_primary }))); + const phones = normalizePrimaryFlags(contactForm.phones + .filter((phone) => phone.phone.trim()) + .map((phone) => ({ label: phone.label || null, phone: phone.phone.trim(), is_primary: phone.is_primary }))); + const postal_addresses = normalizePrimaryFlags(contactForm.postal_addresses + .filter(postalRowHasContent) + .map((address) => ({ + label: address.label || null, + street: address.street || null, + postal_code: address.postal_code || null, + locality: address.locality || null, + region: address.region || null, + country: address.country || null, + is_primary: address.is_primary + }))); + const payload = { + display_name: contactForm.display_name || null, + given_name: contactForm.given_name || null, + family_name: contactForm.family_name || null, + organization: contactForm.organization || null, + role_title: contactForm.role_title || null, + note: contactForm.note || null, + tags: tagsFromForm(contactForm.tags), + emails, + phones, + postal_addresses, + provenance: {} + }; + try { + let savedContact: Contact; + if (contactDialog?.mode === "edit" && contactDialog.contact) { + savedContact = await updateContact(settings, contactDialog.contact.id, payload); + } else { + savedContact = await createContact(settings, selectedBook.id, payload); + } + setContactDialog(null); + setSelectedContactId(savedContact.id); + await refreshBooks(); + await refreshContacts(selectedBook.id, query); + } catch (err) { + setError(errorMessage(err)); + } finally { + setSaving(false); + } + } + + async function confirmDelete() { + if (!confirmState) return; + setSaving(true); + setError(""); + setNotice(""); + try { + if (confirmState.kind === "book") { + await deleteAddressBook(settings, confirmState.book.id); + setConfirmState(null); + await refreshAll(); + } else if (confirmState.kind === "list") { + await deleteAddressList(settings, confirmState.list.id); + setConfirmState(null); + if (!showArchived && selectedListId === confirmState.list.id) { + setSelectedListId(""); + setAddressListEntries([]); + } + await refreshBooks(); + } else if (confirmState.kind === "contact") { + await deleteContact(settings, confirmState.contact.id); + if (selectedContactId === confirmState.contact.id) setSelectedContactId(""); + setConfirmState(null); + await refreshBooks(); + await refreshContacts(selectedBookId, query); + } else { + await deleteAddressSyncSource(settings, confirmState.source.id); + if (syncInspector?.source.id === confirmState.source.id) { + setSyncInspector(null); + setSyncPlan(null); + setSyncDiagnostics([]); + setSyncTombstones([]); + setSyncConflicts([]); + setConflictDialog(null); + } + setConfirmState(null); + setNotice("Sync source disconnected. Local contacts were kept."); + await refreshBooks(); + await refreshContacts(selectedBookId, query); + } + } catch (err) { + setError(errorMessage(err)); + } finally { + setSaving(false); + } + } + + async function restoreDeletedList(list: AddressList) { + setSaving(true); + setError(""); + setNotice(""); + try { + const restored = await restoreAddressList(settings, list.id); + setNotice(`Restored "${list.name}".`); + setSelectedBookId(restored.address_book_id); + setSelectedListId(restored.id); + await refreshBooks(); + await refreshListEntries(restored.id); + } catch (err) { + setError(errorMessage(err)); + } finally { + setSaving(false); + } + } + + async function restoreBook(book: AddressBook) { + setSaving(true); + setError(""); + setNotice(""); + try { + await restoreAddressBook(settings, book.id); + setNotice(`Restored "${book.name}".`); + await refreshAll(); + } catch (err) { + setError(errorMessage(err)); + } finally { + setSaving(false); + } + } + + async function restoreDeletedContact(contact: Contact) { + setSaving(true); + setError(""); + setNotice(""); + try { + await restoreContact(settings, contact.id); + setNotice(`Restored "${contact.display_name}".`); + await refreshBooks(); + await refreshContacts(selectedBookId, query); + } catch (err) { + setError(errorMessage(err)); + } finally { + setSaving(false); + } + } + + async function openAddMembersDialog() { + if (!selectedBook || !selectedList) return; + setMemberQuery(""); + setMemberTargetByContactId({}); + setMemberDialogOpen(true); + setError(""); + try { + setMemberCandidates(await listContacts(settings, { addressBookId: selectedBook.id, limit: 500, includeDeleted: showArchived })); + } catch (err) { + setError(errorMessage(err)); + } + } + + async function addContactToAddressList( + list: AddressList, + contact: Contact, + targetOption: AddressListTargetOption | null = null, + successMessage = `Added "${contact.display_name}" to "${list.name}".` + ) { + const option = targetOption ?? selectedTargetOptionForContact(contact); + const reason = addContactToListReason(list, contact, option?.key ?? ""); + if (reason) { + setError(reason); + return; + } + if (!option) { + setError("This contact has no available target left for the selected list."); + return; + } + setSaving(true); + setError(""); + setNotice(""); + try { + const currentEntries = list.id === selectedListId ? addressListEntries : await listAddressListEntries(settings, list.id); + if (currentEntries.some((entry) => addressListEntryKey(entry) === `${contact.id}:${option.key}`)) { + setNotice(`"${option.label}" is already in "${list.name}".`); + return; + } + await createAddressListEntry(settings, list.id, { contact_id: contact.id, ...option.payload }); + setNotice(successMessage); + await refreshBooks(); + if (list.id === selectedListId) await refreshListEntries(list.id); + } catch (err) { + setError(errorMessage(err)); + } finally { + setSaving(false); + } + } + + async function removeAddressListEntry(entry: AddressListEntry) { + if (!selectedList || !selectedContact) return; + setSaving(true); + setError(""); + setNotice(""); + try { + await deleteAddressListEntry(settings, entry.id); + setNotice(`Removed "${entryTargetLabel(entry)}" for "${selectedContact.display_name}" from "${selectedList.name}".`); + await refreshBooks(); + await refreshListEntries(selectedList.id); + } catch (err) { + setError(errorMessage(err)); + } finally { + setSaving(false); + } + } + + function handleContactDragStart(event: ReactDragEvent, contact: Contact) { + event.dataTransfer.effectAllowed = "copy"; + event.dataTransfer.setData(ADDRESS_CONTACT_DRAG_TYPE, contact.id); + event.dataTransfer.setData("text/plain", contact.display_name); + } + + function dragContactFromEvent(event: ReactDragEvent): Contact | null { + const contactId = event.dataTransfer.getData(ADDRESS_CONTACT_DRAG_TYPE); + if (!contactId) return null; + return contacts.find((contact) => contact.id === contactId) ?? memberCandidates.find((contact) => contact.id === contactId) ?? null; + } + + function handleTreeDragOver(event: ReactDragEvent, node: AddressTreeNode) { + if (node.kind !== "list" || !node.list) return; + const hasContact = Array.from(event.dataTransfer.types).includes(ADDRESS_CONTACT_DRAG_TYPE); + if (!hasContact) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + setDropTargetListId(node.list.id); + } + + function handleTreeDragLeave(_event: ReactDragEvent, node: AddressTreeNode) { + if (node.kind === "list" && node.list?.id === dropTargetListId) setDropTargetListId(""); + } + + async function handleTreeDrop(event: ReactDragEvent, node: AddressTreeNode) { + if (node.kind !== "list" || !node.list) return; + event.preventDefault(); + setDropTargetListId(""); + const contact = dragContactFromEvent(event); + if (!contact) return; + await addContactToAddressList(node.list, contact, null, `Added "${contact.display_name}" to "${node.list.name}" by drag and drop.`); + } + + async function exportSelectedBook() { + if (!selectedBook) return; + setSaving(true); + setError(""); + setNotice(""); + try { + const content = await exportAddressBookVcards(settings, selectedBook.id); + downloadText(safeFilename(selectedBook.name), content); + } catch (err) { + setError(errorMessage(err)); + } finally { + setSaving(false); + } + } + + async function exportOneContact(contact: Contact) { + setSaving(true); + setError(""); + setNotice(""); + try { + const content = await exportContactVcard(settings, contact.id); + downloadText(safeFilename(contact.display_name), content); + } catch (err) { + setError(errorMessage(err)); + } finally { + setSaving(false); + } + } + + async function submitVcardImport(event: FormEvent) { + event.preventDefault(); + if (!selectedBook) return; + setSaving(true); + setError(""); + setNotice(""); + try { + const result = await importAddressBookVcards(settings, selectedBook.id, vcardContent); + setImportOpen(false); + setVcardContent(""); + const issueCount = result.issues.length; + setNotice(`Imported ${result.imported} contact${result.imported === 1 ? "" : "s"}${result.skipped ? `, skipped ${result.skipped}` : ""}${issueCount ? ` (${issueCount} import issue${issueCount === 1 ? "" : "s"})` : ""}.`); + await refreshBooks(); + await refreshContacts(selectedBook.id, query); + } catch (err) { + setError(errorMessage(err)); + } finally { + setSaving(false); + } + } + + function openCardDavDialog() { + setCardDavForm(EMPTY_CARDDAV_FORM); + setCardDavDiscovery([]); + setCardDavOpen(true); + } + + function cardDavPayload() { + return { + url: cardDavForm.collection_url, + auth_type: cardDavForm.auth_type, + username: cardDavForm.username || null, + password: cardDavForm.password || null, + bearer_token: cardDavForm.bearer_token || null, + credential_ref: cardDavForm.credential_ref || null + }; + } + + async function discoverCardDavSources() { + setSaving(true); + setError(""); + setNotice(""); + try { + const discovered = await discoverCardDavAddressBooks(settings, cardDavPayload()); + setCardDavDiscovery(discovered); + setNotice(`Found ${discovered.length} CardDAV address book${discovered.length === 1 ? "" : "s"}.`); + } catch (err) { + setError(errorMessage(err)); + } finally { + setSaving(false); + } + } + + function useDiscoveredCardDavBook(item: AddressCardDavAddressBook) { + setCardDavForm((current) => ({ + ...current, + collection_url: item.collection_url, + display_name: current.display_name || item.display_name || selectedBook?.name || "CardDAV address book" + })); + } + + async function submitCardDavSource(event: FormEvent) { + event.preventDefault(); + if (!selectedBook) return; + setSaving(true); + setError(""); + setNotice(""); + try { + await createCardDavSyncSource(settings, selectedBook.id, { + collection_url: cardDavForm.collection_url, + display_name: cardDavForm.display_name || selectedBook.name, + auth_type: cardDavForm.auth_type, + username: cardDavForm.username || null, + password: cardDavForm.password || null, + bearer_token: cardDavForm.bearer_token || null, + credential_ref: cardDavForm.credential_ref || null, + sync_direction: cardDavForm.sync_direction, + read_only: cardDavForm.sync_direction === "read_only" || cardDavForm.sync_direction === "import" + }); + setCardDavOpen(false); + setCardDavDiscovery([]); + setNotice("CardDAV source connected."); + await refreshBooks(); + } catch (err) { + setError(errorMessage(err)); + } finally { + setSaving(false); + } + } + + async function loadSyncDetails(source: AddressSyncSource) { + const [diagnostics, tombstones, conflicts] = await Promise.all([ + listAddressSyncDiagnostics(settings, source.id), + listAddressSyncTombstones(settings, source.id), + listAddressSyncConflicts(settings, source.id, "open") + ]); + setSyncDiagnostics(diagnostics); + setSyncTombstones(tombstones); + setSyncConflicts(conflicts); + } + + async function openSyncInspector(source: AddressSyncSource) { + setSyncInspector({ source }); + setSyncPlan(null); + setError(""); + try { + await loadSyncDetails(source); + } catch (err) { + setError(errorMessage(err)); + } + } + + async function previewSelectedSync(source = selectedSyncSource) { + if (!source) return; + setSaving(true); + setError(""); + setNotice(""); + try { + const plan = await previewAddressSyncSource(settings, source.id); + setSyncPlan(plan); + setSyncInspector({ source: plan.sync_source }); + setNotice(`Sync preview: ${planSummary(plan)}.`); + await loadSyncDetails(plan.sync_source); + } catch (err) { + setError(errorMessage(err)); + } finally { + setSaving(false); + } + } + + async function runSelectedSync(source = selectedSyncSource) { + if (!source) return; + setSaving(true); + setError(""); + setNotice(""); + try { + const plan = await runAddressSyncSource(settings, source.id); + setSyncPlan(plan); + setSyncInspector({ source: plan.sync_source }); + setNotice(`Sync completed: ${planSummary(plan)}.`); + await refreshBooks(); + await refreshContacts(selectedBookId, query); + await loadSyncDetails(plan.sync_source); + } catch (err) { + setError(errorMessage(err)); + } finally { + setSaving(false); + } + } + + async function toggleSyncSourceEnabled(source: AddressSyncSource) { + setSaving(true); + setError(""); + setNotice(""); + try { + const next = await updateAddressSyncSource(settings, source.id, { enabled: !source.enabled }); + setSyncInspector({ source: next }); + setNotice(next.enabled ? "Sync source enabled." : "Sync source disabled."); + await refreshBooks(); + await loadSyncDetails(next); + } catch (err) { + setError(errorMessage(err)); + } finally { + setSaving(false); + } + } + + function openConflictReview(conflict: AddressSyncConflict) { + setConflictDialog(conflict); + setConflictMergeChoices(defaultConflictMergeChoices(conflict)); + } + + function setConflictMergeChoice(field: string, choice: ConflictMergeChoice) { + setConflictMergeChoices((current) => ({ ...current, [field]: choice })); + } + + async function resolveConflictWith( + conflict: AddressSyncConflict, + resolution: "keep_local" | "use_remote" | "merge" | "manual" | "ignored", + status: "resolved" | "ignored" = "resolved", + mergedPayload: Record | null = null + ) { + setSaving(true); + setError(""); + setNotice(""); + try { + await resolveAddressSyncConflict(settings, conflict.id, resolution, status, { mergedPayload }); + setConflictDialog(null); + setConflictMergeChoices({}); + setNotice(status === "ignored" ? "Sync conflict ignored." : "Sync conflict resolved."); + if (syncInspector) await loadSyncDetails(syncInspector.source); + await refreshBooks(); + await refreshContacts(selectedBookId, query); + } catch (err) { + setError(errorMessage(err)); + } finally { + setSaving(false); + } + } + + async function applyMergedConflict(conflict: AddressSyncConflict) { + const mergedPayload = buildMergedConflictPayload(conflict, conflictMergeChoices); + if (!mergedPayload) { + setError("This conflict does not contain local and remote field payloads that can be merged."); + return; + } + await resolveConflictWith(conflict, "merge", "resolved", mergedPayload); + } + + function renderTreeNodeContent(node: AddressTreeNode) { + if (node.kind === "source") { + const bookCount = books.filter((book) => sourceGroupForBook(book) === node.sourceGroup).length; + return {node.label}{bookCount} book{bookCount === 1 ? "" : "s"}; + } + if (node.kind === "scope" && node.scope) { + const contactCount = books.filter((book) => sourceGroupForBook(book) === node.sourceGroup && book.scope_type === node.scope).reduce((sum, book) => sum + book.contact_count, 0); + return {node.label}{contactCount} contact{contactCount === 1 ? "" : "s"}; + } + if (node.kind === "book" && node.book) { + return ( + + {node.book.name} + {node.book.contact_count} contact{node.book.contact_count === 1 ? "" : "s"} · {node.book.source_kind} + + ); + } + if (node.kind === "list" && node.list) { + return ( + + {node.list.name} + {node.list.entry_count} entr{node.list.entry_count === 1 ? "y" : "ies"} + + ); + } + return {node.label}; + } + + function renderSelectedBookPanel() { + if (!selectedBook) return

No address book selected.

; + return ( +
+
+ {selectedBook.name} +

{selectedBook.description || "No description."}

+
+
+ + + {selectedBook.sync_status && } + {selectedBook.read_only && } + {selectedBook.deleted_at && } +
+ {selectedBookSyncSources.length > 0 && +
+ Sync + {selectedBookSyncSources.map((source) => ( +

+ {syncSourceLabel(source)} · {source.connector_type} · {source.status} + {source.last_success_at ? ` · last success ${formatDateTime(source.last_success_at)}` : ""} + {source.last_error ? ` · ${source.last_error}` : ""} +

+ ))} +
+ } + {selectedList && +
+ {selectedList.name} +

{selectedList.description || "No list description."}

+
+ + {selectedList.read_only && } + {selectedList.deleted_at && } +
+
+ } +
+ ); + } + + function renderSelectedBookActions() { + return ( + <> + void refreshAll()} disabledReason={refreshReason}> + + + setImportOpen(true)} disabledReason={importBookReason}> + void exportSelectedBook()} disabledReason={exportBookReason}> + + selectedSyncSource && void openSyncInspector(selectedSyncSource)} disabledReason={inspectSyncReason}> + void previewSelectedSync()} disabledReason={previewSyncReason}>Preview + void runSelectedSync()} disabledReason={runSyncReason}>Sync + {selectedList ? + selectedList.deleted_at ? + void restoreDeletedList(selectedList)}> : + <> + openEditListDialog(selectedList)}> + setConfirmState({ kind: "list", list: selectedList })}> + : + selectedBook?.deleted_at ? + void restoreBook(selectedBook)}> : + <> + selectedBook && openEditBookDialog(selectedBook)}> + selectedBook && setConfirmState({ kind: "book", book: selectedBook })}> + + } + + ); + } + + function renderContactRow(contact: Contact) { + const selected = selectedContactId === contact.id; + return ( + + ); + } + + function renderContactDetail() { + if (!selectedContact) { + return ( +
+ No contact selected +

Select a contact from the middle list to view details here.

+
+ ); + } + return ( +
+
+
+

{selectedContact.display_name}

+

{[selectedContact.role_title, selectedContact.organization].filter(Boolean).join(" · ") || "Contact"}

+
+
+ {selectedContact.deleted_at ? + void restoreDeletedContact(selectedContact)}> Restore : + <> + void exportOneContact(selectedContact)}> vCard + openEditContactDialog(selectedContact)}> Edit + setConfirmState({ kind: "contact", contact: selectedContact })}> Delete + + } +
+
+
+ {selectedContact.deleted_at && } + {selectedContact.source_kind && } + {selectedContact.tags.map((tag) => {tag})} +
+ {selectedList && +
+

List membership

+
+ {selectedContactListEntries.map((entry) => ( +
+

{entryTargetLabel(entry)}

+ void removeAddressListEntry(entry)} + disabledReason={removeContactFromListReason()}> + Remove + +
+ ))} +
+
+ } +
+

Email

+ {selectedContact.emails.length === 0 ?

No email addresses.

: +
+ {selectedContact.emails.map((email, index) => ( +
+
{email.label || "email"}{email.is_primary ? " · primary" : ""}
+
{email.email}
+
+ ))} +
+ } +
+
+

Phone

+ {selectedContact.phones.length === 0 ?

No phone numbers.

: +
+ {selectedContact.phones.map((phone, index) => ( +
+
{phone.label || "phone"}{phone.is_primary ? " · primary" : ""}
+
{phone.phone}
+
+ ))} +
+ } +
+
+

Postal addresses

+ {selectedContact.postal_addresses.length === 0 ?

No postal addresses.

: +
+ {selectedContact.postal_addresses.map((address, index) => ( +
+
{address.label || "address"}{address.is_primary ? " · primary" : ""}
+
{formatPostalAddress(address) || "No formatted address."}
+
+ ))} +
+ } +
+ {selectedContact.note && +
+

Note

+

{selectedContact.note}

+
+ } +
+ ); + } + + return ( +
+ {error &&
{error}
} + {notice && !error &&
{notice}
} + + +
+ + +
+
+
+

{selectedList ? selectedList.name : selectedBook ? selectedBook.name : "Contacts"}

+

+ {visibleContacts.length} contact{visibleContacts.length === 1 ? "" : "s"} + {selectedList ? " in selected list" : selectedBook ? " in selected book" : ""} +

+
+
+ {selectedList && + void openAddMembersDialog()} disabledReason={addMembersReason}> Add to list + } + Contact +
+
+
+ setQuery(event.target.value)} placeholder="Search contacts" /> + {selectedBook?.read_only && } + {selectedBook?.deleted_at && } +
+
+ {visibleContacts.length === 0 ? +
{selectedBook ? "No contacts found." : "Select an address book."}
: + visibleContacts.map(renderContactRow) + } +
+
+ +
+ {renderContactDetail()} +
+
+
+ + setBookDialog(null)} + closeDisabled={saving} + footerClassName="button-row compact-actions" + footer={ + <> + setBookDialog(null)} disabledReason={dialogCancelReason}>Cancel + Save + + }> +
void submitBook(event)}> +
+ + + + {bookForm.scope_type === "group" && + + + + } +
+ setBookForm((current) => ({ ...current, name: event.target.value }))} autoFocus /> +