"""Unified contact routes — CRUD, contactpersons, FTS search, export, soft-delete.""" from __future__ import annotations import csv import io import uuid from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from fastapi.responses import StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db from app.deps import require_permission from app.schemas.contact import ( ContactCreate, ContactUpdate, ContactPersonCreate, ContactPersonUpdate, ) from app.services import contact_service from app.services import dedup_service router = APIRouter(prefix="/api/v1/contacts", tags=["contacts"]) # ── Deduplication / Merge (Task 5.23) ────────────────────────────────────────── from pydantic import BaseModel, Field 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)$"), db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("contacts:read")), ): """List contacts with pagination, FTS search, type/folder filter, sorting.""" tenant_id = uuid.UUID(current_user["tenant_id"]) 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, ) @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.""" tenant_id = uuid.UUID(current_user["tenant_id"]) csv_data = await contact_service.export_contacts_csv(db, tenant_id, contact_type=type, search=search) 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), current_user: dict = Depends(require_permission("contacts:write")), ): """Create a new contact (company or person).""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) data = body.model_dump(exclude_none=True) return await contact_service.create_contact(db, tenant_id, user_id, 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"]) try: return await contact_service.get_contact(db, tenant_id, contact_id) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) @router.put("/{contact_id}") async def update_contact( contact_id: str, body: ContactUpdate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("contacts:write")), ): """Update 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.update_contact(db, tenant_id, user_id, contact_id, data) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) @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), current_user: dict = Depends(require_permission("contacts:write")), ): """Soft-delete (or hard-delete with ?hard=true) a contact.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) try: if hard: await contact_service.hard_delete_contact(db, tenant_id, contact_id) else: await contact_service.delete_contact(db, tenant_id, contact_id, user_id) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) # ── 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"]) return await contact_service.list_contact_persons(db, tenant_id, contact_id) @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)) @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)) @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:write")), ): """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)) # ── 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), current_user: dict = Depends(require_permission("contacts:write")), ): """Merge two contacts (source → target).""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) try: return await dedup_service.merge_contacts( db, tenant_id, user_id, source_id=body.source_contact_id, target_id=body.target_contact_id, field_overrides=body.field_overrides, note=body.note, ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e))