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.core.contact_points import ( ContactPointResolutionRequest, ContactPointSourceRequest, ) from govoplan_core.core.distribution_lists import DistributionSourceReference from govoplan_core.db.session import get_session from govoplan_addresses.backend.carddav import AddressCardDAVError from govoplan_addresses.backend.ldap import AddressLdapError from govoplan_addresses.backend.ldap_schemas import ( AddressLdapConnectionRequest, AddressLdapDiscoveryResponse, AddressLdapSourceCreateRequest, ) from govoplan_addresses.backend.db.models import ( AddressBook, AddressList, AddressListEntry, AddressSyncConflict, AddressSyncDiagnostic, AddressSyncSource, AddressSyncTombstone, Contact, ContactChannelRule, ContactMergeRecord, ContactPointQualityDecision, ContactPostalAddress, ) from govoplan_addresses.backend.import_schemas import ( AddressImportCommitRequest, AddressImportPreviewRequest, AddressImportProfileCreateRequest, AddressImportProfileListResponse, AddressImportProfileResponse, AddressImportProfileUpdateRequest, AddressImportRollbackRequest, AddressImportRunResponse, ) from govoplan_addresses.backend.imports import ( apply_address_import, create_import_profile, get_import_run, import_run_payload, list_import_profiles, preview_address_import, retire_import_profile, rollback_address_import, update_import_profile, ) from govoplan_addresses.backend.capabilities import ( AddressesContactPointResolutionCapability, 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, AddressCredentialEnvelopeListResponse, AddressCredentialEnvelopeResponse, AddressSyncAttemptFinishRequest, AddressSyncConflictCreateRequest, AddressSyncConflictListResponse, AddressSyncConflictResolveRequest, AddressSyncConflictResponse, AddressSyncDiagnosticCreateRequest, AddressSyncDiagnosticListResponse, AddressSyncDiagnosticResponse, AddressSyncSourceCreateRequest, AddressSyncSourceListResponse, AddressSyncSourceResponse, AddressSyncSourceUpdateRequest, AddressSyncRunRequest, AddressSyncPlanItemResponse, AddressSyncPlanResponse, AddressSyncTombstoneCreateRequest, AddressSyncTombstoneListResponse, AddressSyncTombstoneResponse, ContactCreateRequest, ContactChannelRuleCreateRequest, ContactChannelRuleListResponse, ContactChannelRuleResponse, ContactDuplicateFeatureResponse, ContactDuplicateSuggestionListResponse, ContactDuplicateSuggestionResponse, ContactFieldProvenanceResponse, ContactMergeRecordListResponse, ContactMergeRecordResponse, ContactMergeRecoveryRequest, ContactMergeRequest, ContactPointQualityDecisionCreateRequest, ContactPointQualityDecisionListResponse, ContactPointQualityDecisionResponse, ContactRedirectResponse, ContactPointResolveRequest, ContactPointResolutionResponse, ContactPointSnapshotResponse, ContactPointSourcePreviewResponse, ContactPointSourceRequestPayload, ContactListResponse, ContactResponse, ContactUpdateRequest, AddressQualityCorrectionResponse, AddressQualitySummaryResponse, VCardImportIssue, VCardImportRequest, VCardImportResponse, ) from govoplan_addresses.backend.service import ( AddressBookError, available_address_credentials, address_book_contact_counts, address_quality_summary, address_list_entry_counts, create_address_book, create_address_list, create_address_list_entry, create_carddav_sync_source, create_ldap_sync_source, create_contact, create_contact_channel_rule, create_contact_quality_decision, current_contact_quality, create_sync_source, count_contacts, delete_address_book, delete_address_list, delete_address_list_entry, delete_contact, delete_sync_source, discover_carddav_address_books, discover_ldap_base_dns, export_address_book_vcard, export_contact_vcard, end_contact_channel_rule, finish_sync_attempt, import_vcards, list_address_list_entries, list_address_lists, list_address_books, list_contacts, list_contact_channel_rules, list_contact_field_provenance, list_contact_merges, list_contact_quality_decisions, list_sync_conflicts, list_sync_diagnostics, list_sync_sources, list_sync_tombstones, record_sync_conflict, record_sync_diagnostic, record_sync_tombstone, merge_contacts, recover_contact_merge, restore_address_book, restore_address_list, restore_contact, resolve_contact_redirect, resolve_sync_conflict, preview_sync_source, public_address_sync_metadata, run_sync_source, start_sync_attempt, update_address_book, update_address_list, update_contact, suggest_duplicate_contacts, 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, *, field_provenance: list | None = None, ) -> ContactResponse: quality = current_contact_quality(contact) def quality_payload(channel: str, point_id: str) -> dict: decision = quality.get((channel, point_id)) or quality.get((channel, None)) return { "quality_state": decision.state if decision is not None else "valid", "quality_reason_code": ( decision.reason_code if decision is not None else None ), } return ContactResponse.model_validate( { "id": contact.id, "tenant_id": contact.tenant_id, "address_book_id": contact.address_book_id, "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 []), "source_kind": contact.source_kind, "source_ref": contact.source_ref, "source_payload_kind": contact.source_payload_kind, "source_revision": contact.source_revision, "provenance": dict(contact.provenance or {}), "emails": [ { "id": item.id, "label": item.label, "email": item.email, "original_email": item.original_email or item.email, "normalized_email": item.normalized_email or item.email.casefold(), "provenance": dict(item.provenance or {}), "is_primary": item.is_primary, **quality_payload("email", item.id), } for item in contact.emails ], "phones": [ { "id": item.id, "label": item.label, "phone": item.phone, "original_phone": item.original_phone or item.phone, "normalized_phone": item.normalized_phone or item.phone, "provenance": dict(item.provenance or {}), "is_primary": item.is_primary, **quality_payload("phone", item.id), } for item in contact.phones ], "postal_addresses": [ { "id": item.id, "label": item.label, "street": item.street, "postal_code": item.postal_code, "locality": item.locality, "region": item.region, "country": item.country, "original_value": dict(item.original_value or {}), "normalized_value": dict(item.normalized_value or {}), "provenance": dict(item.provenance or {}), "is_primary": item.is_primary, **quality_payload("postal", item.id), } for item in contact.postal_addresses ], "field_provenance": field_provenance or [], "deleted_at": contact.deleted_at, "created_at": contact.created_at, "updated_at": contact.updated_at, } ) def _contact_point_audit_details( contact: Contact, *, prefix: str = "", ) -> dict[str, object]: key_prefix = f"{prefix}_" if prefix else "" point_ids = { "email": [item.id for item in contact.emails], "phone": [item.id for item in contact.phones], "postal": [item.id for item in contact.postal_addresses], } return { f"{key_prefix}contact_point_counts": { channel: len(ids) for channel, ids in point_ids.items() }, f"{key_prefix}contact_point_ids": point_ids, } def _channel_rule_response(rule: ContactChannelRule) -> ContactChannelRuleResponse: return ContactChannelRuleResponse.model_validate(rule) def _quality_decision_response( decision: ContactPointQualityDecision, ) -> ContactPointQualityDecisionResponse: return ContactPointQualityDecisionResponse.model_validate(decision) def _merge_response(record: ContactMergeRecord) -> ContactMergeRecordResponse: return ContactMergeRecordResponse.model_validate(record) 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 _contact_point_resolution_request( principal: ApiPrincipal, payload: ContactPointResolveRequest, ) -> ContactPointResolutionRequest: return ContactPointResolutionRequest( tenant_id=principal.tenant_id, subject=DistributionSourceReference(**payload.subject.model_dump()), effective_at=payload.effective_at, purpose=payload.purpose, requested_channels=tuple(payload.requested_channels), address_purpose=payload.address_purpose, fallback_rule=payload.fallback_rule, locale=payload.locale, postal_format=payload.postal_format, context=payload.context, ) def _contact_point_source_request( principal: ApiPrincipal, payload: ContactPointSourceRequestPayload, ) -> ContactPointSourceRequest: return ContactPointSourceRequest( tenant_id=principal.tenant_id, source_id=payload.source_id, effective_at=payload.effective_at, purpose=payload.purpose, requested_channels=tuple(payload.requested_channels), address_purpose=payload.address_purpose, fallback_rule=payload.fallback_rule, locale=payload.locale, postal_format=payload.postal_format, max_items=payload.max_items, context=payload.context, ) 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": public_address_sync_metadata(sync_source.metadata_), "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), address_list_id: str | None = Query(default=None), query: str | None = Query(default=None), limit: int = Query(default=200, ge=1, le=500), offset: int = Query(default=0, ge=0), 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, address_list_id=address_list_id, query=query, limit=limit, offset=offset, include_deleted=include_deleted, ) total = count_contacts( session, principal, address_book_id=address_book_id, address_list_id=address_list_id, query=query, include_deleted=include_deleted, ) return ContactListResponse( contacts=[_contact_response(contact) for contact in contacts], total=total, offset=offset, limit=limit, has_more=offset + len(contacts) < total, ) 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-books/{book_id}/duplicate-suggestions", response_model=ContactDuplicateSuggestionListResponse, ) def api_suggest_duplicate_contacts( book_id: str, contact_id: str | None = Query(default=None), minimum_score: int = Query(default=40, ge=1, le=100), limit: int = Query(default=100, ge=1, le=100), scan_limit: int = Query(default=500, ge=2, le=500), principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "addresses:contact:read") try: scan = suggest_duplicate_contacts( session, principal, address_book_id=book_id, contact_id=contact_id, minimum_score=minimum_score, limit=limit, scan_limit=scan_limit, ) return ContactDuplicateSuggestionListResponse( suggestions=[ ContactDuplicateSuggestionResponse( left=_contact_response(item.left), right=_contact_response(item.right), score=item.score, confidence=item.confidence, features=[ ContactDuplicateFeatureResponse(**asdict(feature)) for feature in item.features ], ) for item in scan.suggestions ], scanned_contacts=scan.scanned_contacts, candidate_pairs=scan.candidate_pairs, truncated=scan.truncated, ) except AddressBookError as exc: raise _error(exc) from exc @router.get( "/address-books/{book_id}/quality-summary", response_model=AddressQualitySummaryResponse, ) def api_address_quality_summary( book_id: str, correction_limit: int = Query(default=100, ge=1, le=500), principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "addresses:contact:read") _require_scope(principal, "addresses:governance:read") try: summary = address_quality_summary( session, principal, address_book_id=book_id, correction_limit=correction_limit, ) return AddressQualitySummaryResponse( contact_count=summary.contact_count, contact_point_count=summary.contact_point_count, quality_counts=summary.quality_counts, duplicate_suggestion_count=summary.duplicate_suggestion_count, correction_count=summary.correction_count, corrections=[ AddressQualityCorrectionResponse(**asdict(item)) for item in summary.corrections ], truncated=summary.truncated, ) except AddressBookError as exc: raise _error(exc) from exc @router.get( "/contacts/{contact_id}/quality-decisions", response_model=ContactPointQualityDecisionListResponse, ) def api_list_contact_quality_decisions( contact_id: str, include_ended: bool = Query(default=True), principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "addresses:governance:read") try: return ContactPointQualityDecisionListResponse( decisions=[ _quality_decision_response(item) for item in list_contact_quality_decisions( session, principal, contact_id, include_ended=include_ended, ) ] ) except AddressBookError as exc: raise _error(exc) from exc @router.post( "/contacts/{contact_id}/quality-decisions", response_model=ContactPointQualityDecisionResponse, status_code=status.HTTP_201_CREATED, ) def api_create_contact_quality_decision( contact_id: str, payload: ContactPointQualityDecisionCreateRequest, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "addresses:governance:write") try: decision = create_contact_quality_decision( session, principal, contact_id, payload, ) audit_from_principal( session, principal, action="addresses.contact_quality_changed", object_type="address_contact_quality_decision", object_id=decision.id, details={ "contact_id": contact_id, "channel": decision.channel, "contact_point_id": decision.contact_point_id, "state": decision.state, "reason_code": decision.reason_code, "evidence_ref": decision.evidence_ref, }, ) session.commit() session.refresh(decision) return _quality_decision_response(decision) except AddressBookError as exc: session.rollback() raise _error(exc) from exc @router.get( "/contacts/{contact_id}/provenance", response_model=list[ContactFieldProvenanceResponse], ) def api_list_contact_provenance( contact_id: str, current_only: bool = Query(default=False), limit: int = Query(default=500, ge=1, le=2000), principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "addresses:contact:read") try: return [ ContactFieldProvenanceResponse.model_validate(item) for item in list_contact_field_provenance( session, principal, contact_id, current_only=current_only, limit=limit, ) ] except AddressBookError as exc: raise _error(exc) from exc @router.get( "/contacts/{contact_id}/redirect", response_model=ContactRedirectResponse, ) def api_resolve_contact_redirect( contact_id: str, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "addresses:contact:read") try: return ContactRedirectResponse.model_validate( asdict(resolve_contact_redirect(session, principal, contact_id)) ) except AddressBookError as exc: raise _error(exc) from exc @router.get("/contact-merges", response_model=ContactMergeRecordListResponse) def api_list_contact_merges( address_book_id: str | None = Query(default=None), contact_id: str | None = Query(default=None), limit: int = Query(default=100, ge=1, le=500), principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "addresses:contact:read") try: return ContactMergeRecordListResponse( merges=[ _merge_response(item) for item in list_contact_merges( session, principal, address_book_id=address_book_id, contact_id=contact_id, limit=limit, ) ] ) except AddressBookError as exc: raise _error(exc) from exc @router.post( "/contact-merges", response_model=ContactMergeRecordResponse, status_code=status.HTTP_201_CREATED, ) def api_merge_contacts( payload: ContactMergeRequest, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "addresses:contact:write") _require_scope(principal, "addresses:contact:delete") try: record = merge_contacts(session, principal, payload) audit_from_principal( session, principal, action="addresses.contacts_merged", object_type="address_contact_merge", object_id=record.id, details={ "winner_contact_id": record.winner_contact_id, "loser_contact_ids": list(record.loser_contact_ids), "before_hash": record.before_hash, "after_hash": record.after_hash, "reason": record.reason, }, ) session.commit() session.refresh(record) return _merge_response(record) except AddressBookError as exc: session.rollback() raise _error(exc) from exc def _recover_contact_merge_api( merge_id: str, payload: ContactMergeRecoveryRequest, principal: ApiPrincipal, session: Session, *, action: str, ) -> ContactMergeRecordResponse: _require_scope(principal, "addresses:contact:write") try: record = recover_contact_merge( session, principal, merge_id, payload, action=action, ) audit_from_principal( session, principal, action=f"addresses.contact_merge_{action}", object_type="address_contact_merge", object_id=record.id, details={ "winner_contact_id": record.winner_contact_id, "loser_contact_ids": list(record.loser_contact_ids), "expected_after_hash": payload.expected_after_hash, "reason": payload.reason, }, ) session.commit() session.refresh(record) return _merge_response(record) except AddressBookError as exc: session.rollback() raise _error(exc) from exc @router.post( "/contact-merges/{merge_id}/undo", response_model=ContactMergeRecordResponse, ) def api_undo_contact_merge( merge_id: str, payload: ContactMergeRecoveryRequest, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): return _recover_contact_merge_api( merge_id, payload, principal, session, action="undo", ) @router.post( "/contact-merges/{merge_id}/split", response_model=ContactMergeRecordResponse, ) def api_split_contact_merge( merge_id: str, payload: ContactMergeRecoveryRequest, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): return _recover_contact_merge_api( merge_id, payload, principal, session, action="split", ) @router.post("/contact-points/resolve", response_model=ContactPointResolutionResponse) def api_resolve_contact_points( payload: ContactPointResolveRequest, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "addresses:contact:read") _require_scope(principal, "addresses:governance:read") try: result = AddressesContactPointResolutionCapability().resolve_contact_points( session, principal, request=_contact_point_resolution_request(principal, payload), ) return ContactPointResolutionResponse.model_validate(asdict(result)) except (AddressBookError, ValueError) as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc), ) from exc @router.post( "/contact-point-sources/preview", response_model=ContactPointSourcePreviewResponse, ) def api_preview_contact_point_source( payload: ContactPointSourceRequestPayload, offset: int = Query(default=0, ge=0), limit: int = Query(default=100, ge=1, le=500), principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "addresses:contact:read") _require_scope(principal, "addresses:governance:read") try: result = AddressesContactPointResolutionCapability().preview_source( session, principal, request=_contact_point_source_request(principal, payload), offset=offset, limit=limit, ) return ContactPointSourcePreviewResponse.model_validate(asdict(result)) except (AddressBookError, ValueError) as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc), ) from exc @router.post( "/contact-point-snapshots", response_model=ContactPointSnapshotResponse, status_code=status.HTTP_201_CREATED, ) def api_freeze_contact_point_source( payload: ContactPointSourceRequestPayload, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "addresses:contact:read") _require_scope(principal, "addresses:governance:read") try: snapshot = AddressesContactPointResolutionCapability().freeze_source( session, principal, request=_contact_point_source_request(principal, payload), ) audit_from_principal( session, principal, action="addresses.contact_point_snapshot_created", object_type="address_contact_point_snapshot", object_id=snapshot.id, details={ "source_id": snapshot.request.source_id, "source_revision": snapshot.source_revision, "snapshot_hash": snapshot.snapshot_hash, "recipient_count": snapshot.recipient_count, "excluded_count": snapshot.excluded_count, "purpose": snapshot.request.purpose, }, ) session.commit() return ContactPointSnapshotResponse.model_validate(asdict(snapshot)) except (AddressBookError, ValueError) as exc: session.rollback() raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc), ) from exc @router.get( "/contact-point-snapshots/{snapshot_id}", response_model=ContactPointSnapshotResponse, ) def api_get_contact_point_snapshot( snapshot_id: str, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "addresses:contact:read") _require_scope(principal, "addresses:governance:read") snapshot = AddressesContactPointResolutionCapability().get_snapshot( session, principal, snapshot_id=snapshot_id, ) if snapshot is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Contact-point snapshot not found.", ) return ContactPointSnapshotResponse.model_validate(asdict(snapshot)) @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.flush() audit_from_principal( session, principal, action="addresses.address_list_entry_created", object_type="address_list_entry", object_id=entry.id, details={ "address_list_id": entry.address_list_id, "contact_id": entry.contact_id, "target_kind": entry.target_kind, }, ) 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: entry = session.get(AddressListEntry, entry_id) delete_address_list_entry(session, principal, entry_id) audit_from_principal( session, principal, action="addresses.address_list_entry_deleted", object_type="address_list_entry", object_id=entry_id, details={ "address_list_id": entry.address_list_id if entry is not None else None, "contact_id": entry.contact_id if entry is not None else None, "target_kind": entry.target_kind if entry is not None else None, }, ) 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:write") 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("/ldap/discover", response_model=AddressLdapDiscoveryResponse) def api_discover_ldap_base_dns( payload: AddressLdapConnectionRequest, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "addresses:sync:write") try: return AddressLdapDiscoveryResponse( base_dns=list(discover_ldap_base_dns(session, principal, payload)) ) except (AddressBookError, AddressLdapError) as exc: raise _error(AddressBookError(str(exc))) from exc @router.post( "/address-books/{book_id}/ldap/sources", response_model=AddressSyncSourceResponse, status_code=status.HTTP_201_CREATED, ) def api_create_ldap_sync_source( book_id: str, payload: AddressLdapSourceCreateRequest, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "addresses:sync:write") try: sync_source = create_ldap_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": "ldap", "sync_direction": "read_only", }, ) session.commit() session.refresh(sync_source) return _sync_source_response(sync_source) except (AddressBookError, AddressLdapError) as exc: session.rollback() raise _error(AddressBookError(str(exc))) from exc @router.get("/credentials", response_model=AddressCredentialEnvelopeListResponse) def api_list_address_credentials( source_id: str | None = Query(default=None), principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "addresses:sync:write") return AddressCredentialEnvelopeListResponse( credentials=[ AddressCredentialEnvelopeResponse.model_validate(item) for item in available_address_credentials( session, principal, source_id=source_id, ) ] ) @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, AddressLdapError) 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, AddressLdapError) 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, AddressLdapError) as exc: message = str(exc) session.rollback() try: finish_sync_attempt( session, principal, sync_source_id, AddressSyncAttemptFinishRequest( status="failed", error=message, diagnostic={ "severity": "error", "code": "connector_unavailable", "message": message, "retryable": True, "stage": "read", }, ), ) record_sync_diagnostic( session, principal, sync_source_id, AddressSyncDiagnosticCreateRequest( severity="error", code="connector_unavailable", message=message, details={"retryable": True, "stage": "read"}, ), ) session.commit() except Exception: session.rollback() raise _error(AddressBookError(message)) 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) audit_from_principal( session, principal, action="addresses.contact_created", object_type="address_contact", object_id=contact.id, details={ "address_book_id": contact.address_book_id, "source_kind": contact.source_kind, "field_names": sorted(payload.model_fields_set), **_contact_point_audit_details(contact), }, ) 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: previous_contact = session.get(Contact, contact_id) previous_point_details = ( _contact_point_audit_details(previous_contact, prefix="previous") if previous_contact is not None else {} ) contact = update_contact(session, principal, contact_id, payload) audit_from_principal( session, principal, action="addresses.contact_updated", object_type="address_contact", object_id=contact.id, details={ "address_book_id": contact.address_book_id, "source_kind": contact.source_kind, "field_names": sorted(payload.model_fields_set), **previous_point_details, **_contact_point_audit_details(contact), }, ) session.commit() session.refresh(contact) return _contact_response(contact) except AddressBookError as exc: session.rollback() raise _error(exc) from exc @router.get("/contacts/{contact_id}/channel-rules", response_model=ContactChannelRuleListResponse) def api_list_contact_channel_rules( contact_id: str, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "addresses:governance:read") try: return ContactChannelRuleListResponse( rules=[ _channel_rule_response(rule) for rule in list_contact_channel_rules(session, principal, contact_id) ] ) except AddressBookError as exc: raise _error(exc) from exc @router.post( "/contacts/{contact_id}/channel-rules", response_model=ContactChannelRuleResponse, status_code=status.HTTP_201_CREATED, ) def api_create_contact_channel_rule( contact_id: str, payload: ContactChannelRuleCreateRequest, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "addresses:governance:write") try: rule = create_contact_channel_rule(session, principal, contact_id, payload) _audit_address_sync( session, principal, action="addresses.channel_rule_created", object_type="address_contact_channel_rule", object_id=rule.id, details={ "contact_id": contact_id, "channel": rule.channel, "purpose": rule.purpose, "contact_point_id": rule.contact_point_id, "decision": rule.decision, "effective_from": rule.effective_from.isoformat() if rule.effective_from else None, "effective_until": rule.effective_until.isoformat() if rule.effective_until else None, "evidence_ref": rule.evidence_ref, }, ) session.commit() session.refresh(rule) return _channel_rule_response(rule) except AddressBookError as exc: session.rollback() raise _error(exc) from exc @router.delete( "/contact-channel-rules/{rule_id}", response_model=ContactChannelRuleResponse, ) def api_end_contact_channel_rule( rule_id: str, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "addresses:governance:write") try: rule = end_contact_channel_rule(session, principal, rule_id) _audit_address_sync( session, principal, action="addresses.channel_rule_ended", object_type="address_contact_channel_rule", object_id=rule.id, details={ "contact_id": rule.contact_id, "channel": rule.channel, "purpose": rule.purpose, "decision": rule.decision, "effective_until": rule.effective_until.isoformat() if rule.effective_until else None, }, ) session.commit() session.refresh(rule) return _channel_rule_response(rule) except AddressBookError as exc: session.rollback() raise _error(exc) from exc @router.delete("/contacts/{contact_id}", status_code=status.HTTP_204_NO_CONTENT) def api_delete_contact( contact_id: str, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "addresses:contact:delete") try: delete_contact(session, principal, contact_id) contact = session.get(Contact, contact_id) audit_from_principal( session, principal, action="addresses.contact_deleted", object_type="address_contact", object_id=contact_id, details={ "address_book_id": contact.address_book_id if contact is not None else None, "source_kind": contact.source_kind if contact is not None else None, }, ) 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) audit_from_principal( session, principal, action="addresses.contact_restored", object_type="address_contact", object_id=contact.id, details={ "address_book_id": contact.address_book_id, "source_kind": contact.source_kind, }, ) 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) for contact in result.contacts: audit_from_principal( session, principal, action="addresses.contact_imported", object_type="address_contact", object_id=contact.id, details={ "address_book_id": book_id, "source_kind": contact.source_kind, "source_revision": contact.source_revision, **_contact_point_audit_details(contact), }, ) 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("/import-profiles", response_model=AddressImportProfileListResponse) def api_list_address_import_profiles( include_history: bool = Query(default=False), principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "addresses:contact:read") return AddressImportProfileListResponse( profiles=[ AddressImportProfileResponse.model_validate(item) for item in list_import_profiles(session, principal, include_history=include_history) ] ) @router.post("/import-profiles", response_model=AddressImportProfileResponse, status_code=status.HTTP_201_CREATED) def api_create_address_import_profile( payload: AddressImportProfileCreateRequest, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "addresses:contact:write") try: profile = create_import_profile(session, principal, payload) session.flush() audit_from_principal( session, principal, action="addresses.import_profile_created", object_type="address_import_profile", object_id=profile.profile_key, details={"version": profile.version, "source_format": profile.source_format, "scope_type": profile.scope_type}, ) session.commit() session.refresh(profile) return AddressImportProfileResponse.model_validate(profile) except AddressBookError as exc: session.rollback() raise _error(exc) from exc @router.patch("/import-profiles/{profile_id}", response_model=AddressImportProfileResponse) def api_update_address_import_profile( profile_id: str, payload: AddressImportProfileUpdateRequest, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "addresses:contact:write") try: profile = update_import_profile(session, principal, profile_id, payload) session.flush() audit_from_principal( session, principal, action="addresses.import_profile_versioned", object_type="address_import_profile", object_id=profile.profile_key, details={"version": profile.version, "source_format": profile.source_format}, ) session.commit() session.refresh(profile) return AddressImportProfileResponse.model_validate(profile) except AddressBookError as exc: session.rollback() raise _error(exc) from exc @router.delete("/import-profiles/{profile_id}", status_code=status.HTTP_204_NO_CONTENT) def api_retire_address_import_profile( profile_id: str, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "addresses:contact:write") try: retire_import_profile(session, principal, profile_id) audit_from_principal( session, principal, action="addresses.import_profile_retired", object_type="address_import_profile", object_id=profile_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}/imports/preview", response_model=AddressImportRunResponse, status_code=status.HTTP_201_CREATED, ) def api_preview_address_import( book_id: str, payload: AddressImportPreviewRequest, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "addresses:contact:write") try: run = preview_address_import(session, principal, book_id, payload) audit_from_principal( session, principal, action="addresses.import_previewed", object_type="address_import_run", object_id=run.id, details={"input_hash": run.input_hash, "plan_hash": run.plan_hash, "statistics": run.statistics}, ) session.commit() session.refresh(run) return AddressImportRunResponse.model_validate(import_run_payload(run)) except AddressBookError as exc: session.rollback() raise _error(exc) from exc @router.get("/imports/{run_id}", response_model=AddressImportRunResponse) def api_get_address_import_run( run_id: str, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "addresses:contact:read") try: return AddressImportRunResponse.model_validate( import_run_payload(get_import_run(session, principal, run_id)) ) except AddressBookError as exc: raise _error(exc) from exc @router.post("/imports/{run_id}/apply", response_model=AddressImportRunResponse) def api_apply_address_import( run_id: str, payload: AddressImportCommitRequest, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "addresses:contact:write") try: run = apply_address_import(session, principal, run_id, expected_plan_hash=payload.expected_plan_hash) session.flush() audit_from_principal( session, principal, action="addresses.import_applied", object_type="address_import_run", object_id=run.id, details={"input_hash": run.input_hash, "plan_hash": run.plan_hash, "statistics": run.statistics}, ) session.commit() session.refresh(run) return AddressImportRunResponse.model_validate(import_run_payload(run)) except AddressBookError as exc: session.rollback() raise _error(exc) from exc @router.post("/imports/{run_id}/rollback", response_model=AddressImportRunResponse) def api_rollback_address_import( run_id: str, payload: AddressImportRollbackRequest, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "addresses:contact:write") try: run = rollback_address_import(session, principal, run_id, payload) session.flush() audit_from_principal( session, principal, action="addresses.import_rolled_back", object_type="address_import_run", object_id=run.id, details={"reason": payload.reason, "plan_hash": run.plan_hash}, ) session.commit() session.refresh(run) return AddressImportRunResponse.model_validate(import_run_payload(run)) except AddressBookError as exc: session.rollback() raise _error(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