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>
299 lines
8.7 KiB
Python
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
|