"""Unified contact routes — CRUD, contactpersons, FTS search, export, soft-delete. Write operations (create, update, delete, merge) are delegated to Commands. Read operations (list, get, export, contact persons) use services directly. """ from __future__ import annotations import io import uuid from typing import Any import redis.asyncio as aioredis from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from fastapi.responses import StreamingResponse from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.commands.contact_commands import ( CreateContactCommand, DeleteContactCommand, MergeContactsCommand, UpdateContactCommand, ) from app.core.db import get_db from app.core.visibility import check_single_entity_access from app.deps import get_current_user, get_redis_dep, require_permission from app.models.contact import Contact from app.models.custom_field_definition import CustomFieldDefinition from app.plugins.registry import get_registry from app.schemas.contact import ( ContactCreate, ContactPersonCreate, ContactPersonUpdate, ContactUpdate, ) from app.services import contact_service, dedup_service router = APIRouter(prefix="/api/v1/contacts", tags=["contacts"]) # ── Deduplication / Merge (Task 5.23) ────────────────────────────────────────── from pydantic import BaseModel, Field # noqa: E402 class DuplicateCheckRequest(BaseModel): """Request body for duplicate detection.""" threshold: float = Field(default=0.7, ge=0.0, le=1.0) limit: int = Field(default=50, ge=1, le=200) class MergeRequest(BaseModel): """Request body for merging two contacts.""" source_contact_id: str target_contact_id: str field_overrides: dict[str, Any] | None = Field(default=None) note: str | None = Field(default=None) @router.get("") async def list_contacts( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), search: str | None = Query(None), type: str | None = Query(None, pattern="^(company|person)$"), folder_id: str | None = Query(None), sort_by: str = Query("displayname"), sort_order: str = Query("asc", pattern="^(asc|desc)$"), cursor: str | None = Query(None, description="Keyset pagination cursor (contact UUID)"), db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("contacts:read")), ): """List contacts with pagination, FTS search, type/folder filter, sorting. Supports keyset pagination via ``cursor`` parameter for large datasets. """ tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) is_admin = current_user.get("is_system_admin", False) return await contact_service.list_contacts( db, tenant_id, page=page, page_size=page_size, search=search, contact_type=type, folder_id=folder_id, sort_by=sort_by, sort_order=sort_order, resolved_perms=current_user, user_id=user_id, is_system_admin=is_admin, cursor=cursor, ) @router.get("/export") async def export_contacts( format: str = Query("csv", pattern="^(csv)$"), type: str | None = Query(None, pattern="^(company|person)$"), search: str | None = Query(None), db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("contacts:read")), ): """Stream contacts as CSV (W4c: via ContactsContract, export_service.py removed).""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) is_admin = current_user.get("is_system_admin", False) from app.plugins.builtins.contacts.contracts import ContactsContract headers, rows = await ContactsContract.ie_fetch_rows( db, tenant_id, "contacts", user_id=user_id, is_system_admin=is_admin, contact_type=type, search=search, ) from app.services.import_export_helpers import write_csv csv_data = write_csv(rows, headers).decode("utf-8") return StreamingResponse( io.StringIO(csv_data), media_type="text/csv", headers={"Content-Disposition": "attachment; filename=contacts.csv"}, ) @router.post("", status_code=status.HTTP_201_CREATED) async def create_contact( body: ContactCreate, db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis_dep), current_user: dict = Depends(require_permission("contacts:write")), ): """Create a new contact (company or person) via CreateContactCommand.""" data = body.model_dump(exclude_none=True) cmd = CreateContactCommand(data=data) result = await cmd.execute(db, redis, current_user) if not result.success: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=result.error) return result.data @router.get("/merge-history") async def get_contact_merge_history( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("contacts:read")), ): """Get paginated merge history for the tenant.""" tenant_id = uuid.UUID(current_user["tenant_id"]) return await dedup_service.get_merge_history(db, tenant_id, page=page, page_size=page_size) @router.get("/{contact_id}") async def get_contact( contact_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("contacts:read")), ): """Get a single contact with contact_persons.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) is_admin = current_user.get("is_system_admin", False) try: return await contact_service.get_contact(db, tenant_id, contact_id, user_id=user_id, is_system_admin=is_admin) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) from e except PermissionError as e: raise HTTPException(status_code=403, detail=str(e)) from e @router.put("/{contact_id}") async def update_contact( contact_id: str, body: ContactUpdate, db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis_dep), current_user: dict = Depends(require_permission("contacts:write")), ): """Update a contact via UpdateContactCommand.""" data = body.model_dump(exclude_none=True) cmd = UpdateContactCommand(contact_id=contact_id, data=data) result = await cmd.execute(db, redis, current_user) if not result.success: if "not found" in (result.error or "").lower(): raise HTTPException(status_code=404, detail=result.error) if "Invalid state transition" in (result.error or ""): raise HTTPException(status_code=422, detail=result.error) raise HTTPException(status_code=400, detail=result.error) return result.data @router.delete("/{contact_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_contact( contact_id: str, hard: bool = Query(False, description="GDPR hard-delete"), db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis_dep), current_user: dict = Depends(require_permission("contacts:delete")), ): """Soft-delete (or hard-delete with ?hard=true) a contact via DeleteContactCommand.""" cmd = DeleteContactCommand(contact_id=contact_id, hard=hard) result = await cmd.execute(db, redis, current_user) if not result.success: raise HTTPException(status_code=404, detail=result.error) return Response(status_code=status.HTTP_204_NO_CONTENT) # ── ContactPersons ── @router.get("/{contact_id}/persons") async def list_contact_persons( contact_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("contacts:read")), ): """List all contact persons for a contact.""" tenant_id = uuid.UUID(current_user["tenant_id"]) items = await contact_service.list_contact_persons(db, tenant_id, contact_id) return {"items": items, "total": len(items)} @router.post("/{contact_id}/persons", status_code=status.HTTP_201_CREATED) async def create_contact_person( contact_id: str, body: ContactPersonCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("contacts:write")), ): """Add a contact person to a contact.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) data = body.model_dump(exclude_none=True) try: return await contact_service.create_contact_person(db, tenant_id, user_id, contact_id, data) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) from e @router.put("/{contact_id}/persons/{person_id}") async def update_contact_person( contact_id: str, person_id: str, body: ContactPersonUpdate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("contacts:write")), ): """Update a contact person.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) data = body.model_dump(exclude_none=True) try: return await contact_service.update_contact_person(db, tenant_id, user_id, contact_id, person_id, data) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) from e @router.delete("/{contact_id}/persons/{person_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_contact_person( contact_id: str, person_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("contacts:delete")), ): """Delete a contact person.""" tenant_id = uuid.UUID(current_user["tenant_id"]) try: await contact_service.delete_contact_person(db, tenant_id, contact_id, person_id) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) from e # ── Deduplication / Merge endpoints (Task 5.23) ─────────────────────────────── @router.post("/duplicates") async def find_duplicate_contacts( body: DuplicateCheckRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("contacts:read")), ): """Find potential duplicate contacts within the tenant.""" tenant_id = uuid.UUID(current_user["tenant_id"]) return await dedup_service.find_duplicates( db, tenant_id, threshold=body.threshold, limit=body.limit ) @router.post("/merge") async def merge_duplicate_contacts( body: MergeRequest, db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis_dep), current_user: dict = Depends(require_permission("contacts:write")), ): """Merge two contacts (source → target) via MergeContactsCommand.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) is_admin = current_user.get("is_system_admin", False) # Check write access on both contacts try: source_uuid = uuid.UUID(body.source_contact_id) target_uuid = uuid.UUID(body.target_contact_id) except (ValueError, TypeError): raise HTTPException(status_code=400, detail="Invalid contact ID") from None source_access = await check_single_entity_access( db, "contact", source_uuid, user_id, tenant_id, required_level="write", is_system_admin=is_admin, ) if not source_access: raise HTTPException(status_code=403, detail="No write access to source contact") target_access = await check_single_entity_access( db, "contact", target_uuid, user_id, tenant_id, required_level="write", is_system_admin=is_admin, ) if not target_access: raise HTTPException(status_code=403, detail="No write access to target contact") cmd = MergeContactsCommand( source_contact_id=body.source_contact_id, target_contact_id=body.target_contact_id, field_overrides=body.field_overrides, note=body.note, ) result = await cmd.execute(db, redis, current_user) if not result.success: raise HTTPException(status_code=400, detail=result.error) return result.data # ─── Custom Fields (W4c: migrated from app/routes/custom_fields.py) ───────── class CustomFieldUpdateRequest(BaseModel): """Request body for updating custom field values.""" values: dict[str, Any] = {} async def _collect_custom_field_definitions( db: AsyncSession, tenant_id: uuid.UUID, entity: str = "contact", ) -> list[dict[str, Any]]: """Collect all custom field definitions from plugin manifests and DB. DB-stored definitions override plugin definitions with the same name. """ definitions: list[dict[str, Any]] = [] seen_names: set[str] = set() # 1. Collect from active plugin manifests registry = get_registry() for name in registry.list_discovered(): plugin = registry.get_plugin(name) if plugin is None: continue manifest = plugin.manifest for cf in manifest.custom_fields: if cf.entity != entity: continue if cf.name in seen_names: continue seen_names.add(cf.name) definitions.append( { "name": cf.name, "label": cf.label, "label_key": cf.label_key, "field_type": cf.field_type, "options": cf.options, "default_value": cf.default_value, "required": cf.required, "entity": cf.entity, "plugin": manifest.name, } ) # 2. Collect from DB (user-defined custom field definitions) stmt = select(CustomFieldDefinition).where( CustomFieldDefinition.tenant_id == tenant_id, CustomFieldDefinition.entity == entity, CustomFieldDefinition.is_active == True, # noqa: E712 ).order_by(CustomFieldDefinition.sort_order, CustomFieldDefinition.name) result = await db.execute(stmt) db_definitions = result.scalars().all() for d in db_definitions: if d.name in seen_names: # DB definition overrides plugin definition — replace it definitions = [x for x in definitions if x["name"] != d.name] else: seen_names.add(d.name) definitions.append( { "name": d.name, "label": d.label, "label_key": "", "field_type": d.field_type, "options": d.options or [], "default_value": d.default_value, "required": d.required, "entity": d.entity, "plugin": "user_defined", } ) return definitions async def _merge_definitions_with_values( definitions: list[dict[str, Any]], stored: dict[str, Any] | None ) -> list[dict[str, Any]]: """Merge field definitions with stored values, applying defaults.""" stored = stored or {} result: list[dict[str, Any]] = [] for d in definitions: name = d["name"] value = stored.get(name, d.get("default_value")) entry = {**d, "value": value} result.append(entry) return result @router.get("/{contact_id}/custom-fields", dependencies=[Depends(require_permission("contacts:read"))]) async def get_custom_fields( contact_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Get all custom fields for a contact (merged definitions + stored values).""" tenant_id = uuid.UUID(current_user["tenant_id"]) try: cid = uuid.UUID(contact_id) except (ValueError, TypeError): raise HTTPException(400, detail={"detail": "Invalid contact_id", "code": "invalid_id"}) from None result = await db.execute( select(Contact).where(Contact.id == cid, Contact.tenant_id == tenant_id) ) contact = result.scalar_one_or_none() if contact is None: raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"}) definitions = await _collect_custom_field_definitions(db, tenant_id, "contact") merged = await _merge_definitions_with_values(definitions, contact.custom) return {"fields": merged} @router.patch("/{contact_id}/custom-fields", dependencies=[Depends(require_permission("contacts:write"))]) async def update_custom_fields( contact_id: str, body: CustomFieldUpdateRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Update custom field values for a contact (stored in contacts.custom JSONB).""" tenant_id = uuid.UUID(current_user["tenant_id"]) try: cid = uuid.UUID(contact_id) except (ValueError, TypeError): raise HTTPException(400, detail={"detail": "Invalid contact_id", "code": "invalid_id"}) from None result = await db.execute( select(Contact).where(Contact.id == cid, Contact.tenant_id == tenant_id) ) contact = result.scalar_one_or_none() if contact is None: raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"}) # Validate against definitions definitions = await _collect_custom_field_definitions(db, tenant_id, "contact") def_map = {d["name"]: d for d in definitions} current_custom = dict(contact.custom or {}) for name, value in body.values.items(): if name not in def_map: raise HTTPException( 400, detail={"detail": f"Unknown custom field: {name}", "code": "unknown_field"}, ) field_def = def_map[name] # Validate required if field_def["required"] and (value is None or value == ""): raise HTTPException( 400, detail={"detail": f"Field '{name}' is required", "code": "required_field"}, ) # Validate select/multiselect options if field_def["field_type"] == "select" and value is not None: if value not in field_def["options"]: raise HTTPException( 400, detail={"detail": f"Invalid option for field '{name}'", "code": "invalid_option"}, ) if field_def["field_type"] == "multiselect" and value is not None: if not isinstance(value, list): raise HTTPException( 400, detail={"detail": f"Field '{name}' must be a list", "code": "invalid_type"}, ) for v in value: if v not in field_def["options"]: raise HTTPException( 400, detail={"detail": f"Invalid option '{v}' for field '{name}'", "code": "invalid_option"}, ) current_custom[name] = value contact.custom = current_custom await db.flush() merged = await _merge_definitions_with_values(definitions, contact.custom) return {"fields": merged}