Files
maskanx_crm_frontend/app/api/properties.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

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}",
)