Files
maskanx_crm_frontend/app/api/contacts.py
T
AFFAANhandClaude Opus 5 9d6aa64ad6 feat(properties): add custom fields without a migration
Modelled on HubSpot's custom properties: the definition lives in a table,
the values live in each record's existing JSON attributes. Adding a field
is an INSERT, so it can happen mid-conversation through MCP and the next
record can use it immediately.

The cost of that is that the database enforces nothing about the values,
so data_type is enforced in application code and every write path has to
come through it — otherwise the type on a definition is decoration.
Values are coerced rather than merely checked, because callers are agents
and HTTP clients and "42" is a number expressed loosely; what is rejected
is genuinely ambiguous, like "quite large" for a number.

A value whose property has no definition is rejected rather than stored.
A typo sitting in the database looks exactly like data, and a registry
whose set of fields is not actually the set of fields is worse than none.
Workspaces with nothing defined keep the old free-form behaviour, so this
does not break attributes already in use.

name and data_type are not updatable: renaming orphans every value stored
under the old key, and retyping leaves values that no longer satisfy the
type. Deleting a definition leaves existing values alone rather than
rewriting every record, so undoing a mistaken delete is just recreating
the property.

Two ways in, sharing one validator: a logged-in person, and an integration
key for MaskanX. An agent gets no shortcut around the rules a person is
held to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 11:07:50 +05:30

309 lines
9.1 KiB
Python

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)