"""Contacts CRUD endpoints.""" from fastapi import APIRouter, Depends, HTTPException, status, Query from sqlalchemy import select, func, or_ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import joinedload from app.api.deps import require_permission from app.db.session import get_async_session from app.models import User, Contact, Tag from app.schemas.contact import ( ContactCreateRequest, ContactUpdateRequest, ContactResponse, ContactListResponse, ) router = APIRouter(prefix="/contacts", tags=["contacts"]) @router.get("", response_model=ContactListResponse) async def list_contacts( page: int = Query(1, ge=1), size: int = Query(20, ge=1, le=100), search: str | None = Query(None, description="Search in name, email, phone"), type: str | None = Query( None, pattern="^(company|person)$", description="Filter by contact type" ), current_user: User = Depends(require_permission("contacts:read")), session: AsyncSession = Depends(get_async_session), ): """List contacts with search, filter by type, pagination.""" account_id = current_user.account_id base_q = select(Contact).where(Contact.account_id == account_id) # Search across name fields, email, phone if search: search_pattern = f"%{search}%" base_q = base_q.where( or_( Contact.company_name.ilike(search_pattern), Contact.first_name.ilike(search_pattern), Contact.last_name.ilike(search_pattern), Contact.email.ilike(search_pattern), Contact.phone.ilike(search_pattern), ) ) # Filter by type if type: base_q = base_q.where(Contact.type == type) # Count total count_q = select(func.count()).select_from(base_q.subquery()) total = (await session.execute(count_q)).scalar() or 0 # Fetch page with tags eager-loaded q = ( base_q.options(joinedload(Contact.tags)) .order_by(Contact.updated_at.desc()) .offset((page - 1) * size) .limit(size) ) result = await session.execute(q) contacts = result.unique().scalars().all() return ContactListResponse( items=[ContactResponse.model_validate(c) for c in contacts], total=total, page=page, size=size, ) @router.post("", response_model=ContactResponse, status_code=status.HTTP_201_CREATED) async def create_contact( body: ContactCreateRequest, current_user: User = Depends(require_permission("contacts:write")), session: AsyncSession = Depends(get_async_session), ): """Create a new contact.""" account_id = current_user.account_id contact = Contact( account_id=account_id, type=body.type, company_name=body.company_name, first_name=body.first_name, last_name=body.last_name, email=body.email, phone=body.phone, mobile=body.mobile, website=body.website, billing_street=body.billing_street, billing_number=body.billing_number, billing_postalcode=body.billing_postalcode, billing_city=body.billing_city, billing_country=body.billing_country, shipping_street=body.shipping_street, shipping_number=body.shipping_number, shipping_postalcode=body.shipping_postalcode, shipping_city=body.shipping_city, shipping_country=body.shipping_country, tax_number=body.tax_number, note=body.note, ) if body.tag_ids: # Fetch tags that belong to this tenant (or could be global? We'll enforce tenant scope) tag_result = await session.execute( select(Tag).where( Tag.id.in_(body.tag_ids), Tag.account_id == account_id, ) ) contact.tags = tag_result.scalars().all() session.add(contact) await session.commit() await session.refresh(contact) # Reload with relationships await session.execute( select(Contact) .options(joinedload(Contact.tags)) .where(Contact.id == contact.id) ) await session.refresh(contact) return ContactResponse.model_validate(contact) @router.get("/{contact_id}", response_model=ContactResponse) async def get_contact( contact_id: str, current_user: User = Depends(require_permission("contacts:read")), session: AsyncSession = Depends(get_async_session), ): """Get a specific contact with tags.""" account_id = current_user.account_id result = await session.execute( select(Contact) .options(joinedload(Contact.tags)) .where(Contact.id == contact_id, Contact.account_id == account_id) ) contact = result.unique().scalars().first() if not contact: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Contact not found", ) return ContactResponse.model_validate(contact) @router.put("/{contact_id}", response_model=ContactResponse) async def update_contact( contact_id: str, body: ContactUpdateRequest, current_user: User = Depends(require_permission("contacts:write")), session: AsyncSession = Depends(get_async_session), ): """Update an existing contact.""" account_id = current_user.account_id result = await session.execute( select(Contact) .options(joinedload(Contact.tags)) .where(Contact.id == contact_id, Contact.account_id == account_id) ) contact = result.unique().scalars().first() if not contact: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Contact not found", ) # Update fields if provided update_data = body.model_dump(exclude_unset=True, exclude={"tag_ids"}) for field, value in update_data.items(): setattr(contact, field, value) # Handle tag_ids separately: if provided, replace the tag associations if body.tag_ids is not None: tag_result = await session.execute( select(Tag).where( Tag.id.in_(body.tag_ids), Tag.account_id == account_id, ) ) contact.tags = tag_result.scalars().all() await session.commit() await session.refresh(contact) return ContactResponse.model_validate(contact) @router.delete("/{contact_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_contact( contact_id: str, current_user: User = Depends(require_permission("contacts:delete")), session: AsyncSession = Depends(get_async_session), ): """Hard-delete a contact.""" account_id = current_user.account_id result = await session.execute( select(Contact).where( Contact.id == contact_id, Contact.account_id == account_id ) ) contact = result.scalars().first() if not contact: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Contact not found", ) await session.delete(contact) await session.commit() return None