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>
206 lines
6.7 KiB
Python
206 lines
6.7 KiB
Python
"""Custom property definitions — fields added without a migration.
|
|
|
|
Two ways in, on purpose:
|
|
|
|
- `/properties` for a logged-in person, from the CRM's own settings screen.
|
|
- `/integrations/properties` for MaskanX, authenticated by an integration
|
|
key. That is what an agent reaches through MCP when a conversation asks
|
|
for a new field.
|
|
|
|
Both go through the same validation. An agent gets no shortcut around the
|
|
rules a person is held to — if anything it needs them more, since it will
|
|
happily invent a property name from a half-sentence.
|
|
"""
|
|
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Header, HTTPException, status
|
|
from sqlalchemy import select
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
from app.core.security import CurrentUser, Database
|
|
from app.models import CustomProperty
|
|
from app.properties import PropertyError, validate_definition
|
|
from app.schemas import (
|
|
CustomPropertyCreate,
|
|
CustomPropertyOut,
|
|
CustomPropertyUpdate,
|
|
)
|
|
|
|
router = APIRouter(prefix="/properties", tags=["Properties"])
|
|
integration_router = APIRouter(prefix="/integrations", tags=["Integrations"])
|
|
|
|
|
|
def _list_for(db, tenant_id: str, object_type: str | None) -> list[CustomProperty]:
|
|
query = select(CustomProperty).where(CustomProperty.tenant_id == tenant_id)
|
|
if object_type:
|
|
query = query.where(CustomProperty.object_type == object_type)
|
|
return list(db.scalars(query.order_by(CustomProperty.label)))
|
|
|
|
|
|
def _create(
|
|
db,
|
|
tenant_id: str,
|
|
payload: CustomPropertyCreate,
|
|
created_by: str,
|
|
) -> CustomProperty:
|
|
try:
|
|
validate_definition(
|
|
name=payload.name,
|
|
object_type=payload.object_type,
|
|
data_type=payload.data_type,
|
|
options=payload.options,
|
|
)
|
|
except PropertyError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=str(exc),
|
|
) from exc
|
|
|
|
prop = CustomProperty(
|
|
tenant_id=tenant_id,
|
|
object_type=payload.object_type,
|
|
name=payload.name,
|
|
label=payload.label,
|
|
description=payload.description,
|
|
data_type=payload.data_type,
|
|
options=payload.options,
|
|
is_required=payload.is_required,
|
|
created_by=created_by,
|
|
)
|
|
db.add(prop)
|
|
try:
|
|
db.commit()
|
|
except IntegrityError as exc:
|
|
db.rollback()
|
|
# Told plainly rather than as a constraint name: an agent that
|
|
# re-runs a request needs to know the field already exists, not
|
|
# that a unique index fired.
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=(
|
|
f"A property named {payload.name!r} already exists on "
|
|
f"{payload.object_type}."
|
|
),
|
|
) from exc
|
|
return prop
|
|
|
|
|
|
@router.get("", response_model=list[CustomPropertyOut])
|
|
def list_properties(
|
|
user: CurrentUser,
|
|
db: Database,
|
|
object_type: str | None = None,
|
|
) -> list[CustomProperty]:
|
|
return _list_for(db, user.tenant_id, object_type)
|
|
|
|
|
|
@router.post("", response_model=CustomPropertyOut, status_code=status.HTTP_201_CREATED)
|
|
def create_property(
|
|
payload: CustomPropertyCreate,
|
|
user: CurrentUser,
|
|
db: Database,
|
|
) -> CustomProperty:
|
|
return _create(db, user.tenant_id, payload, created_by=user.email)
|
|
|
|
|
|
@router.patch("/{property_id}", response_model=CustomPropertyOut)
|
|
def update_property(
|
|
property_id: str,
|
|
payload: CustomPropertyUpdate,
|
|
user: CurrentUser,
|
|
db: Database,
|
|
) -> CustomProperty:
|
|
"""Update a property's presentation, never its identity or type.
|
|
|
|
`name` and `data_type` are not updatable — see `CustomPropertyUpdate`.
|
|
Narrowing an enumeration is allowed even though records may already
|
|
hold a value that is no longer offered: those values stay readable and
|
|
simply cannot be chosen again, which is the behaviour an operator
|
|
retiring an option expects.
|
|
"""
|
|
prop = db.scalar(
|
|
select(CustomProperty).where(
|
|
CustomProperty.id == property_id,
|
|
CustomProperty.tenant_id == user.tenant_id,
|
|
),
|
|
)
|
|
if prop is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
|
|
if payload.options is not None and prop.data_type != "enumeration":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=f"Options only apply to an enumeration, not to a {prop.data_type}.",
|
|
)
|
|
if payload.options is not None and not payload.options:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="An enumeration needs at least one option.",
|
|
)
|
|
|
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
|
if value is not None:
|
|
setattr(prop, field, value)
|
|
db.commit()
|
|
return prop
|
|
|
|
|
|
@router.delete("/{property_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
def delete_property(property_id: str, user: CurrentUser, db: Database) -> None:
|
|
"""Remove a definition.
|
|
|
|
Values already written into records are left where they are. Rewriting
|
|
every record to strip a key would be a large, silent, irreversible
|
|
write in response to a small request; leaving them means undoing a
|
|
mistaken delete is just recreating the property.
|
|
"""
|
|
prop = db.scalar(
|
|
select(CustomProperty).where(
|
|
CustomProperty.id == property_id,
|
|
CustomProperty.tenant_id == user.tenant_id,
|
|
),
|
|
)
|
|
if prop is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
db.delete(prop)
|
|
db.commit()
|
|
|
|
|
|
# --- integration key access, for MaskanX and its MCP tools ---
|
|
|
|
|
|
@integration_router.get("/properties", response_model=list[CustomPropertyOut])
|
|
def list_properties_via_integration(
|
|
db: Database,
|
|
object_type: str | None = None,
|
|
integration_key: Annotated[str | None, Header(alias="X-Integration-Key")] = None,
|
|
) -> list[CustomProperty]:
|
|
from app.api.integrations import _authenticate_integration
|
|
|
|
credential = _authenticate_integration(db, integration_key)
|
|
properties = _list_for(db, credential.tenant_id, object_type)
|
|
db.commit()
|
|
return properties
|
|
|
|
|
|
@integration_router.post(
|
|
"/properties",
|
|
response_model=CustomPropertyOut,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def create_property_via_integration(
|
|
payload: CustomPropertyCreate,
|
|
db: Database,
|
|
integration_key: Annotated[str | None, Header(alias="X-Integration-Key")] = None,
|
|
) -> CustomProperty:
|
|
from app.api.integrations import _authenticate_integration
|
|
|
|
credential = _authenticate_integration(db, integration_key)
|
|
return _create(
|
|
db,
|
|
credential.tenant_id,
|
|
payload,
|
|
created_by=f"integration:{credential.name}",
|
|
)
|