from fastapi import APIRouter, HTTPException, Query, Response, status from sqlalchemy import func, or_, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import selectinload from app.core.security import Admin, CurrentUser, Database, Writer from app.models import Contact, Organization, User from app.schemas import ( ContactCreate, ContactOut, ContactUpdate, OrganizationCreate, OrganizationOut, OrganizationUpdate, Page, ) from app.services import ( add_audit, add_event, apply_updates, validated_attributes, contact_to_out, model_or_404, verify_optional_reference, ) router = APIRouter(tags=["Contacts"]) def _commit_or_conflict(db: Database, message: str) -> None: try: db.commit() except IntegrityError as exc: db.rollback() raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=message) from exc @router.get("/contacts", response_model=Page[ContactOut]) def list_contacts( user: CurrentUser, db: Database, search: str | None = Query(default=None, max_length=200), organization_id: str | None = None, page: int = Query(default=1, ge=1), page_size: int = Query(default=25, ge=1, le=100), ) -> Page[ContactOut]: filters = [Contact.tenant_id == user.tenant_id] if organization_id: filters.append(Contact.organization_id == organization_id) if search: pattern = f"%{search.strip().lower()}%" filters.append( or_( func.lower(Contact.first_name).like(pattern), func.lower(Contact.last_name).like(pattern), func.lower(Contact.primary_email).like(pattern), ), ) total = db.scalar(select(func.count(Contact.id)).where(*filters)) or 0 contacts = db.scalars( select(Contact) .where(*filters) .options(selectinload(Contact.organization), selectinload(Contact.owner)) .order_by(Contact.updated_at.desc()) .offset((page - 1) * page_size) .limit(page_size), ).all() return Page( items=[contact_to_out(contact) for contact in contacts], total=total, page=page, page_size=page_size, ) @router.post("/contacts", response_model=ContactOut, status_code=status.HTTP_201_CREATED) def create_contact(payload: ContactCreate, user: Writer, db: Database) -> ContactOut: verify_optional_reference( db, Organization, payload.organization_id, user.tenant_id, ) verify_optional_reference(db, User, payload.owner_id, user.tenant_id) values = payload.model_dump() values["attributes"] = validated_attributes( db, user.tenant_id, "contact", {}, values.get("attributes"), ) contact = Contact(tenant_id=user.tenant_id, **values) db.add(contact) try: db.flush() add_audit( db, actor=user, tenant_id=user.tenant_id, action="contact.created", entity_type="contact", entity_id=contact.id, ) add_event( db, tenant_id=user.tenant_id, topic="crm.contact.created", payload={"contact_id": contact.id}, ) db.commit() except IntegrityError as exc: db.rollback() raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="A contact with this primary email already exists.", ) from exc db.refresh(contact) contact = db.scalar( select(Contact) .where(Contact.id == contact.id) .options(selectinload(Contact.organization), selectinload(Contact.owner)), ) return contact_to_out(contact) @router.get("/contacts/{contact_id}", response_model=ContactOut) def get_contact(contact_id: str, user: CurrentUser, db: Database) -> ContactOut: contact = db.scalar( select(Contact) .where(Contact.id == contact_id, Contact.tenant_id == user.tenant_id) .options(selectinload(Contact.organization), selectinload(Contact.owner)), ) if contact is None: raise HTTPException(status_code=404, detail="The requested record was not found.") return contact_to_out(contact) @router.patch("/contacts/{contact_id}", response_model=ContactOut) def update_contact( contact_id: str, payload: ContactUpdate, user: Writer, db: Database, ) -> ContactOut: contact = model_or_404(db, Contact, contact_id, user.tenant_id) values = payload.model_dump(exclude_unset=True) verify_optional_reference( db, Organization, values.get("organization_id"), user.tenant_id, ) verify_optional_reference(db, User, values.get("owner_id"), user.tenant_id) if "attributes" in values: values["attributes"] = validated_attributes( db, user.tenant_id, "contact", contact.attributes, values["attributes"], ) apply_updates(contact, values) add_audit( db, actor=user, tenant_id=user.tenant_id, action="contact.updated", entity_type="contact", entity_id=contact.id, payload={"fields": sorted(values)}, ) add_event( db, tenant_id=user.tenant_id, topic="crm.contact.updated", payload={"contact_id": contact.id, "fields": sorted(values)}, ) _commit_or_conflict(db, "A contact with this primary email already exists.") contact = db.scalar( select(Contact) .where(Contact.id == contact_id) .options(selectinload(Contact.organization), selectinload(Contact.owner)), ) return contact_to_out(contact) @router.delete("/contacts/{contact_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_contact( contact_id: str, user: Admin, db: Database, ) -> Response: contact = model_or_404(db, Contact, contact_id, user.tenant_id) add_audit( db, actor=user, tenant_id=user.tenant_id, action="contact.deleted", entity_type="contact", entity_id=contact.id, ) db.delete(contact) db.commit() return Response(status_code=status.HTTP_204_NO_CONTENT) @router.get("/organizations", response_model=Page[OrganizationOut]) def list_organizations( user: CurrentUser, db: Database, search: str | None = Query(default=None, max_length=200), page: int = Query(default=1, ge=1), page_size: int = Query(default=25, ge=1, le=100), ) -> Page[OrganizationOut]: filters = [Organization.tenant_id == user.tenant_id] if search: filters.append( func.lower(Organization.name).like(f"%{search.strip().lower()}%"), ) total = db.scalar(select(func.count(Organization.id)).where(*filters)) or 0 records = db.scalars( select(Organization) .where(*filters) .order_by(Organization.updated_at.desc()) .offset((page - 1) * page_size) .limit(page_size), ).all() return Page( items=[OrganizationOut.model_validate(item) for item in records], total=total, page=page, page_size=page_size, ) @router.post( "/organizations", response_model=OrganizationOut, status_code=status.HTTP_201_CREATED, ) def create_organization( payload: OrganizationCreate, user: Writer, db: Database, ) -> OrganizationOut: verify_optional_reference(db, User, payload.owner_id, user.tenant_id) organization = Organization(tenant_id=user.tenant_id, **payload.model_dump()) db.add(organization) db.flush() add_audit( db, actor=user, tenant_id=user.tenant_id, action="organization.created", entity_type="organization", entity_id=organization.id, ) add_event( db, tenant_id=user.tenant_id, topic="crm.organization.created", payload={"organization_id": organization.id}, ) db.commit() db.refresh(organization) return OrganizationOut.model_validate(organization) @router.get("/organizations/{organization_id}", response_model=OrganizationOut) def get_organization( organization_id: str, user: CurrentUser, db: Database, ) -> OrganizationOut: organization = model_or_404( db, Organization, organization_id, user.tenant_id, ) return OrganizationOut.model_validate(organization) @router.patch("/organizations/{organization_id}", response_model=OrganizationOut) def update_organization( organization_id: str, payload: OrganizationUpdate, user: Writer, db: Database, ) -> OrganizationOut: organization = model_or_404( db, Organization, organization_id, user.tenant_id, ) values = payload.model_dump(exclude_unset=True) verify_optional_reference(db, User, values.get("owner_id"), user.tenant_id) apply_updates(organization, values) add_audit( db, actor=user, tenant_id=user.tenant_id, action="organization.updated", entity_type="organization", entity_id=organization.id, payload={"fields": sorted(values)}, ) db.commit() db.refresh(organization) return OrganizationOut.model_validate(organization)