"""Unified contact routes — CRUD, contactpersons, FTS search, export, soft-delete.""" from __future__ import annotations import csv import io import uuid 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 router = APIRouter(prefix="/api/v1/contacts", tags=["contacts"]) @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)$"), 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 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, 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("/{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"]) 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) 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))