"""Contact routes — CRUD, N:M, soft-delete, GDPR hard-delete, streaming CSV export.""" 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 import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db from app.deps import require_permission from app.models.contact import Contact from app.schemas.contact import ContactCreate, ContactUpdate 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), sort_by: str = Query("last_name"), 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 and optional search. page_size is capped at 100 — values >100 return 422. """ tenant_id = uuid.UUID(current_user["tenant_id"]) result = await contact_service.list_contacts( db, tenant_id, page=page, page_size=page_size, search=search, sort_by=sort_by, sort_order=sort_order, resolved_perms=current_user, ) return result @router.get("/export") async def export_contacts( format: str = Query("csv", pattern="^(csv)$"), search: str | None = Query(None), db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("contacts:read")), ): """Stream contacts as CSV (not buffered — uses StreamingResponse). For large exports (>1000 records), an ARQ background job should be started with a notification on completion. This endpoint streams directly for immediate download. """ tenant_id = uuid.UUID(current_user["tenant_id"]) from app.core.db import get_session_factory async def generate_csv(): """Async generator yielding CSV rows one at a time (streaming). Creates its own DB session to avoid using the request-scoped session which gets closed after the endpoint function returns. """ output = io.StringIO() writer = csv.writer(output) # Write header row header = [ "id", "first_name", "last_name", "email", "phone", "mobile", "position", "department", "linkedin_url", "notes", "created_at", "updated_at", ] writer.writerow(header) yield output.getvalue() output.seek(0) output.truncate(0) # Stream rows in batches to avoid loading all into memory batch_size = 500 offset = 0 factory = get_session_factory() async with factory() as session: while True: base = select(Contact).where( Contact.tenant_id == tenant_id, Contact.deleted_at.is_(None), ) if search: pattern = f"%{search}%" base = base.where( (Contact.first_name.ilike(pattern)) | (Contact.last_name.ilike(pattern)) | (Contact.email.ilike(pattern)) ) base = base.order_by(Contact.last_name.asc()).offset(offset).limit(batch_size) result = await session.execute(base) contacts = result.scalars().all() if not contacts: break for c in contacts: writer.writerow( [ str(c.id), c.first_name, c.last_name, c.email or "", c.phone or "", c.mobile or "", c.position or "", c.department or "", c.linkedin_url or "", c.notes or "", c.created_at.isoformat() if c.created_at else "", c.updated_at.isoformat() if c.updated_at else "", ] ) yield output.getvalue() output.seek(0) output.truncate(0) offset += batch_size return StreamingResponse( generate_csv(), 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 contact. Optionally link to companies via company_ids array.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) data = body.model_dump() 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 companies array. Cross-tenant returns 404.""" tenant_id = uuid.UUID(current_user["tenant_id"]) try: cid = uuid.UUID(contact_id) except ValueError: raise HTTPException( 400, detail={"detail": "Invalid contact_id", "code": "invalid_id"} ) from None data = await contact_service.get_contact_detail(db, tenant_id, cid, resolved_perms=current_user) if data is None: raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"}) return data @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"]) try: cid = uuid.UUID(contact_id) except ValueError: raise HTTPException( 400, detail={"detail": "Invalid contact_id", "code": "invalid_id"} ) from None data = body.model_dump(exclude_unset=True) result = await contact_service.update_contact(db, tenant_id, user_id, cid, data) if result is None: raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"}) return result @router.delete("/{contact_id}") async def delete_contact( contact_id: str, gdpr: bool = Query(False), db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("contacts:write")), ): """Delete a contact. Default: soft-delete. Use gdpr=true for hard-delete with deletion_log.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) try: cid = uuid.UUID(contact_id) except ValueError: raise HTTPException( 400, detail={"detail": "Invalid contact_id", "code": "invalid_id"} ) from None if gdpr: deleted = await contact_service.gdpr_hard_delete_contact(db, tenant_id, user_id, cid) else: deleted = await contact_service.soft_delete_contact(db, tenant_id, user_id, cid) if not deleted: raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"}) return Response(status_code=status.HTTP_204_NO_CONTENT)