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

512 lines
14 KiB
Python

from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from typing import Any, Generic, TypeVar
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
T = TypeVar("T")
class ApiModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
class Page(ApiModel, Generic[T]):
items: list[T]
total: int
page: int
page_size: int
class LoginRequest(ApiModel):
workspace: str = Field(min_length=2, max_length=80)
email: EmailStr
password: str = Field(min_length=8, max_length=200)
class UserOut(ApiModel):
id: str
tenant_id: str
email: EmailStr
full_name: str
role: str
class TenantOut(ApiModel):
id: str
slug: str
name: str
mode: str
class TokenResponse(ApiModel):
access_token: str
token_type: str = "bearer"
expires_in: int
user: UserOut
tenant: TenantOut
class SessionResponse(ApiModel):
user: UserOut
tenant: TenantOut
class OrganizationBase(ApiModel):
name: str = Field(min_length=1, max_length=200)
legal_name: str | None = Field(default=None, max_length=240)
website: str | None = Field(default=None, max_length=500)
primary_email: EmailStr | None = None
phone: str | None = Field(default=None, max_length=80)
industry: str | None = Field(default=None, max_length=120)
address: dict[str, Any] = Field(default_factory=dict)
attributes: dict[str, Any] = Field(default_factory=dict)
owner_id: str | None = None
class OrganizationCreate(OrganizationBase):
pass
class OrganizationUpdate(ApiModel):
name: str | None = Field(default=None, min_length=1, max_length=200)
legal_name: str | None = Field(default=None, max_length=240)
website: str | None = Field(default=None, max_length=500)
primary_email: EmailStr | None = None
phone: str | None = Field(default=None, max_length=80)
industry: str | None = Field(default=None, max_length=120)
address: dict[str, Any] | None = None
attributes: dict[str, Any] | None = None
owner_id: str | None = None
class OrganizationOut(OrganizationBase):
id: str
tenant_id: str
created_at: datetime
updated_at: datetime
class ContactBase(ApiModel):
first_name: str = Field(min_length=1, max_length=100)
last_name: str = Field(default="", max_length=100)
job_title: str | None = Field(default=None, max_length=160)
primary_email: EmailStr | None = None
emails: list[dict[str, Any]] = Field(default_factory=list)
phones: list[dict[str, Any]] = Field(default_factory=list)
lifecycle_stage: str = Field(default="lead", max_length=40)
lead_source: str | None = Field(default=None, max_length=120)
score: int = Field(default=0, ge=0, le=100)
organization_id: str | None = None
owner_id: str | None = None
attributes: dict[str, Any] = Field(default_factory=dict)
class ContactCreate(ContactBase):
pass
class ContactUpdate(ApiModel):
first_name: str | None = Field(default=None, min_length=1, max_length=100)
last_name: str | None = Field(default=None, max_length=100)
job_title: str | None = Field(default=None, max_length=160)
primary_email: EmailStr | None = None
emails: list[dict[str, Any]] | None = None
phones: list[dict[str, Any]] | None = None
lifecycle_stage: str | None = Field(default=None, max_length=40)
lead_source: str | None = Field(default=None, max_length=120)
score: int | None = Field(default=None, ge=0, le=100)
organization_id: str | None = None
owner_id: str | None = None
attributes: dict[str, Any] | None = None
class ContactOut(ContactBase):
id: str
tenant_id: str
name: str
organization_name: str | None = None
owner_name: str | None = None
created_at: datetime
updated_at: datetime
class StageOut(ApiModel):
id: str
pipeline_id: str
name: str
position: int
probability: int
color: str
class PipelineOut(ApiModel):
id: str
name: str
is_default: bool
stages: list[StageOut]
class LeadBase(ApiModel):
title: str = Field(min_length=1, max_length=240)
description: str | None = None
value: Decimal = Field(default=Decimal("0"), ge=0)
currency: str = Field(default="USD", min_length=3, max_length=3)
score: int = Field(default=0, ge=0, le=100)
expected_close_at: datetime | None = None
contact_id: str | None = None
organization_id: str | None = None
owner_id: str | None = None
pipeline_id: str
stage_id: str
source_id: str | None = None
type_id: str | None = None
attributes: dict[str, Any] = Field(default_factory=dict)
@field_validator("currency")
@classmethod
def normalize_currency(cls, value: str) -> str:
return value.upper()
class LeadCreate(LeadBase):
pass
class LeadUpdate(ApiModel):
title: str | None = Field(default=None, min_length=1, max_length=240)
description: str | None = None
value: Decimal | None = Field(default=None, ge=0)
currency: str | None = Field(default=None, min_length=3, max_length=3)
status: str | None = Field(default=None, pattern="^(open|won|lost)$")
score: int | None = Field(default=None, ge=0, le=100)
lost_reason: str | None = None
expected_close_at: datetime | None = None
contact_id: str | None = None
organization_id: str | None = None
owner_id: str | None = None
pipeline_id: str | None = None
stage_id: str | None = None
source_id: str | None = None
type_id: str | None = None
attributes: dict[str, Any] | None = None
@field_validator("currency")
@classmethod
def normalize_currency(cls, value: str | None) -> str | None:
return value.upper() if value else value
class LeadMove(ApiModel):
stage_id: str
position: int = Field(default=0, ge=0)
class LeadOut(LeadBase):
id: str
status: str
position: int
lost_reason: str | None
closed_at: datetime | None
contact_name: str | None = None
organization_name: str | None = None
owner_name: str | None = None
pipeline_name: str
stage_name: str
stage_color: str
source_name: str | None = None
type_name: str | None = None
created_at: datetime
updated_at: datetime
class ActivityBase(ApiModel):
activity_type: str = Field(
pattern="^(task|call|meeting|note|email|lunch)$",
)
title: str = Field(min_length=1, max_length=240)
details: str | None = None
starts_at: datetime | None = None
ends_at: datetime | None = None
due_at: datetime | None = None
is_done: bool = False
owner_id: str | None = None
contact_id: str | None = None
organization_id: str | None = None
lead_id: str | None = None
additional: dict[str, Any] = Field(default_factory=dict)
class ActivityCreate(ActivityBase):
pass
class ActivityUpdate(ApiModel):
activity_type: str | None = Field(
default=None,
pattern="^(task|call|meeting|note|email|lunch)$",
)
title: str | None = Field(default=None, min_length=1, max_length=240)
details: str | None = None
starts_at: datetime | None = None
ends_at: datetime | None = None
due_at: datetime | None = None
is_done: bool | None = None
owner_id: str | None = None
contact_id: str | None = None
organization_id: str | None = None
lead_id: str | None = None
additional: dict[str, Any] | None = None
class ActivityOut(ActivityBase):
id: str
completed_at: datetime | None
owner_name: str | None = None
contact_name: str | None = None
organization_name: str | None = None
lead_title: str | None = None
created_at: datetime
updated_at: datetime
class ProductBase(ApiModel):
sku: str = Field(min_length=1, max_length=100)
name: str = Field(min_length=1, max_length=200)
description: str | None = None
quantity: int = Field(default=0, ge=0)
price: Decimal = Field(default=Decimal("0"), ge=0)
currency: str = Field(default="USD", min_length=3, max_length=3)
is_active: bool = True
class ProductCreate(ProductBase):
pass
class ProductOut(ProductBase):
id: str
created_at: datetime
updated_at: datetime
class QuoteOut(ApiModel):
id: str
number: str
subject: str
status: str
currency: str
subtotal: Decimal
grand_total: Decimal
expires_at: datetime | None
created_at: datetime
updated_at: datetime
class QuoteCreate(ApiModel):
number: str = Field(min_length=1, max_length=80)
subject: str = Field(min_length=1, max_length=240)
description: str | None = None
status: str = Field(default="draft", pattern="^(draft|sent|accepted|rejected|expired)$")
currency: str = Field(default="USD", min_length=3, max_length=3)
contact_id: str | None = None
organization_id: str | None = None
lead_id: str | None = None
owner_id: str | None = None
expires_at: datetime | None = None
@field_validator("currency")
@classmethod
def normalize_currency(cls, value: str) -> str:
return value.upper()
class ReferenceItem(ApiModel):
id: str
name: str
class DashboardStageMetric(ApiModel):
stage_id: str
stage_name: str
color: str
lead_count: int
total_value: Decimal
class DashboardSourceMetric(ApiModel):
source: str
lead_count: int
class DashboardResponse(ApiModel):
total_revenue: Decimal
open_pipeline_value: Decimal
total_leads: int
open_leads: int
won_leads: int
lost_leads: int
total_contacts: int
overdue_activities: int
stages: list[DashboardStageMetric]
sources: list[DashboardSourceMetric]
recent_activities: list[ActivityOut]
class IntegrationLeadRequest(ApiModel):
provider: str = Field(min_length=2, max_length=80)
external_id: str = Field(min_length=1, max_length=255)
first_name: str = Field(min_length=1, max_length=100)
last_name: str = Field(default="", max_length=100)
email: EmailStr | None = None
phone: str | None = Field(default=None, max_length=80)
company_name: str | None = Field(default=None, max_length=200)
job_title: str | None = Field(default=None, max_length=160)
lead_title: str | None = Field(default=None, max_length=240)
source: str = Field(default="MaskanX", max_length=120)
score: int = Field(default=0, ge=0, le=100)
campaign: dict[str, Any] = Field(default_factory=dict)
metadata: dict[str, Any] = Field(default_factory=dict)
class IntegrationLeadResponse(ApiModel):
contact_id: str
lead_id: str
created: bool
class IntegrationKeyCreate(ApiModel):
name: str = Field(min_length=2, max_length=120)
class IntegrationKeyResponse(ApiModel):
id: str
name: str
key: str
key_prefix: str
created_at: datetime
class IntegrationCredentialOut(ApiModel):
id: str
name: str
key_prefix: str
is_active: bool
last_used_at: datetime | None
created_at: datetime
updated_at: datetime
class IntegrationStatusResponse(ApiModel):
status: str
product: str
mode: str
tenant_id: str
tenant_name: str
workspace: str
credential_id: str
credential_name: str
key_prefix: str
class IntegrationCampaignRequest(ApiModel):
"""One advertising campaign's current figures, pushed from MaskanX.
Money is in **minor** currency units (paise, cents), matching what Meta
and MaskanX both use. Sending a decimal would introduce a second
convention and a rounding step between systems that agree exactly.
"""
provider: str = Field(default="maskanx", min_length=2, max_length=80)
external_id: str = Field(min_length=1, max_length=255)
name: str = Field(min_length=1, max_length=240)
status: str = Field(default="unknown", max_length=40)
objective: str | None = Field(default=None, max_length=80)
channel: str | None = Field(default=None, max_length=80)
currency: str | None = Field(default=None, max_length=8)
daily_budget: int | None = Field(default=None, ge=0)
spend: int = Field(default=0, ge=0)
impressions: int = Field(default=0, ge=0)
clicks: int = Field(default=0, ge=0)
leads: int = Field(default=0, ge=0)
# Sent rather than derived so the CRM shows exactly the figure MaskanX
# shows. Recomputing it here from spend and leads would drift whenever
# the two systems rounded differently.
cost_per_lead: int | None = Field(default=None, ge=0)
metrics_from: str | None = Field(default=None, max_length=20)
metrics_to: str | None = Field(default=None, max_length=20)
metadata: dict[str, Any] = Field(default_factory=dict)
class IntegrationCampaignResponse(ApiModel):
campaign_id: str
created: bool
class CampaignOut(ApiModel):
id: str
provider: str
external_id: str
name: str
status: str
objective: str | None
channel: str | None
currency: str | None
daily_budget: int | None
spend: int
impressions: int
clicks: int
leads: int
cost_per_lead: int | None
metrics_from: str | None
metrics_to: str | None
synced_at: datetime
# Leads the CRM itself holds for this campaign. Kept alongside `leads`
# (Meta's count) rather than replacing it: a lead can exist here that
# Meta has not attributed yet, and the gap between the two is worth
# seeing rather than hiding.
crm_leads: int = 0
class CustomPropertyCreate(ApiModel):
object_type: str = Field(min_length=2, max_length=40)
name: str = Field(min_length=2, max_length=80)
label: str = Field(min_length=1, max_length=160)
description: str | None = None
data_type: str = Field(default="string", max_length=24)
options: list[str] = Field(default_factory=list)
is_required: bool = False
class CustomPropertyUpdate(ApiModel):
"""A partial update.
`name`, `object_type` and `data_type` are absent on purpose. They are
the identity and meaning of the field: changing a name orphans every
value already stored under it, and changing a type leaves stored values
that no longer satisfy it. Delete and recreate instead — deliberately
more effort, because it loses data.
"""
label: str | None = Field(default=None, min_length=1, max_length=160)
description: str | None = None
options: list[str] | None = None
is_required: bool | None = None
class CustomPropertyOut(ApiModel):
id: str
object_type: str
name: str
label: str
description: str | None
data_type: str
options: list[str]
is_required: bool
created_by: str | None
created_at: datetime