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>
512 lines
14 KiB
Python
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
|