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>
309 lines
9.1 KiB
Python
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)
|