Files
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

299 lines
8.7 KiB
Python

from __future__ import annotations
from datetime import UTC, datetime
from decimal import Decimal
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.orm import Session, selectinload
from app.models import (
Activity,
AuditEvent,
Contact,
IntegrationEvent,
Lead,
Organization,
Pipeline,
Stage,
User,
)
from app.schemas import ActivityOut, ContactOut, LeadOut
def model_or_404(
db: Session,
model: type[Any],
entity_id: str,
tenant_id: str,
) -> Any:
entity = db.scalar(
select(model).where(
model.id == entity_id,
model.tenant_id == tenant_id,
),
)
if entity is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="The requested record was not found.",
)
return entity
def verify_optional_reference(
db: Session,
model: type[Any],
entity_id: str | None,
tenant_id: str,
) -> None:
if entity_id is not None:
model_or_404(db, model, entity_id, tenant_id)
def add_audit(
db: Session,
*,
actor: User | None,
tenant_id: str,
action: str,
entity_type: str,
entity_id: str,
payload: dict[str, Any] | None = None,
) -> None:
db.add(
AuditEvent(
tenant_id=tenant_id,
actor_id=actor.id if actor else None,
action=action,
entity_type=entity_type,
entity_id=entity_id,
payload=payload or {},
),
)
def add_event(
db: Session,
*,
tenant_id: str,
topic: str,
payload: dict[str, Any],
) -> None:
db.add(
IntegrationEvent(
tenant_id=tenant_id,
topic=topic,
payload=payload,
),
)
def apply_updates(entity: Any, values: dict[str, Any]) -> None:
for field, value in values.items():
setattr(entity, field, value)
def contact_to_out(contact: Contact) -> ContactOut:
return ContactOut(
id=contact.id,
tenant_id=contact.tenant_id,
first_name=contact.first_name,
last_name=contact.last_name,
name=contact.name,
job_title=contact.job_title,
primary_email=contact.primary_email,
emails=contact.emails,
phones=contact.phones,
lifecycle_stage=contact.lifecycle_stage,
lead_source=contact.lead_source,
score=contact.score,
organization_id=contact.organization_id,
organization_name=contact.organization.name if contact.organization else None,
owner_id=contact.owner_id,
owner_name=contact.owner.full_name if contact.owner else None,
attributes=contact.attributes,
created_at=contact.created_at,
updated_at=contact.updated_at,
)
def lead_to_out(lead: Lead) -> LeadOut:
return LeadOut(
id=lead.id,
title=lead.title,
description=lead.description,
value=lead.value,
currency=lead.currency,
status=lead.status,
score=lead.score,
position=lead.position,
lost_reason=lead.lost_reason,
expected_close_at=lead.expected_close_at,
closed_at=lead.closed_at,
contact_id=lead.contact_id,
contact_name=lead.contact.name if lead.contact else None,
organization_id=lead.organization_id,
organization_name=lead.organization.name if lead.organization else None,
owner_id=lead.owner_id,
owner_name=lead.owner.full_name if lead.owner else None,
pipeline_id=lead.pipeline_id,
pipeline_name=lead.pipeline.name,
stage_id=lead.stage_id,
stage_name=lead.stage.name,
stage_color=lead.stage.color,
source_id=lead.source_id,
source_name=lead.source.name if lead.source else None,
type_id=lead.type_id,
type_name=lead.lead_type.name if lead.lead_type else None,
attributes=lead.attributes,
created_at=lead.created_at,
updated_at=lead.updated_at,
)
def activity_to_out(activity: Activity) -> ActivityOut:
return ActivityOut(
id=activity.id,
activity_type=activity.activity_type,
title=activity.title,
details=activity.details,
starts_at=activity.starts_at,
ends_at=activity.ends_at,
due_at=activity.due_at,
is_done=activity.is_done,
completed_at=activity.completed_at,
owner_id=activity.owner_id,
owner_name=activity.owner.full_name if activity.owner else None,
contact_id=activity.contact_id,
contact_name=activity.contact.name if activity.contact else None,
organization_id=activity.organization_id,
organization_name=(
activity.organization.name if activity.organization else None
),
lead_id=activity.lead_id,
lead_title=activity.lead.title if activity.lead else None,
additional=activity.additional,
created_at=activity.created_at,
updated_at=activity.updated_at,
)
def lead_load_options() -> tuple[Any, ...]:
return (
selectinload(Lead.contact),
selectinload(Lead.organization),
selectinload(Lead.owner),
selectinload(Lead.pipeline),
selectinload(Lead.stage),
selectinload(Lead.source),
selectinload(Lead.lead_type),
)
def activity_load_options() -> tuple[Any, ...]:
return (
selectinload(Activity.owner),
selectinload(Activity.contact),
selectinload(Activity.organization),
selectinload(Activity.lead),
)
def validate_lead_references(
db: Session,
*,
tenant_id: str,
pipeline_id: str,
stage_id: str,
contact_id: str | None = None,
organization_id: str | None = None,
owner_id: str | None = None,
) -> None:
pipeline = model_or_404(db, Pipeline, pipeline_id, tenant_id)
stage = model_or_404(db, Stage, stage_id, tenant_id)
if stage.pipeline_id != pipeline.id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="The selected stage does not belong to the selected pipeline.",
)
verify_optional_reference(db, Contact, contact_id, tenant_id)
verify_optional_reference(db, Organization, organization_id, tenant_id)
verify_optional_reference(db, User, owner_id, tenant_id)
def set_lead_status(lead: Lead, status_value: str, lost_reason: str | None) -> None:
if status_value == "lost" and not lost_reason:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="A lost reason is required when a lead is marked lost.",
)
lead.status = status_value
lead.lost_reason = lost_reason if status_value == "lost" else None
lead.closed_at = datetime.now(UTC) if status_value in {"won", "lost"} else None
def next_lead_position(db: Session, tenant_id: str, stage_id: str) -> int:
maximum = db.scalar(
select(func.max(Lead.position)).where(
Lead.tenant_id == tenant_id,
Lead.stage_id == stage_id,
),
)
return int(maximum or 0) + 1
def decimal_or_zero(value: Decimal | None) -> Decimal:
return value or Decimal("0")
def validated_attributes(
db: Session,
tenant_id: str,
object_type: str,
current: dict[str, Any],
incoming: dict[str, Any] | None,
) -> dict[str, Any]:
"""Merge incoming custom property values after validating them.
Custom property values live in a JSON column, so the database enforces
nothing about them — every write path has to come through here or the
`data_type` on a definition is decoration.
Values whose property has no definition are rejected rather than
stored. Silently accepting one would let a typo sit in the database
looking like data, and a registry whose set of fields is not actually
the set of fields is worse than none.
"""
from sqlalchemy import select
from app.models import CustomProperty
from app.properties import PropertyError, apply_values
if incoming is None:
return current
definitions = list(
db.scalars(
select(CustomProperty).where(
CustomProperty.tenant_id == tenant_id,
CustomProperty.object_type == object_type,
),
),
)
# Nothing defined yet: keep the previous behaviour of storing whatever
# arrives, so adding this validation does not break workspaces that
# already use `attributes` as a free-form bag.
if not definitions:
return incoming
try:
return apply_values(definitions, current, incoming)
except PropertyError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc