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

682 lines
27 KiB
Python

from __future__ import annotations
from datetime import UTC, datetime
from decimal import Decimal
from typing import Any
from uuid import uuid4
from sqlalchemy import (
JSON,
Boolean,
DateTime,
ForeignKey,
Index,
Integer,
Numeric,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
def new_id() -> str:
return str(uuid4())
def now_utc() -> datetime:
return datetime.now(UTC)
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=now_utc,
nullable=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=now_utc,
onupdate=now_utc,
nullable=False,
)
class Tenant(Base, TimestampMixin):
__tablename__ = "crm_tenants"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
slug: Mapped[str] = mapped_column(String(80), unique=True, nullable=False)
name: Mapped[str] = mapped_column(String(160), nullable=False)
mode: Mapped[str] = mapped_column(String(24), default="standalone", nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class User(Base, TimestampMixin):
__tablename__ = "crm_users"
__table_args__ = (
UniqueConstraint("tenant_id", "email", name="uq_crm_users_tenant_email"),
Index("ix_crm_users_tenant_role", "tenant_id", "role"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
email: Mapped[str] = mapped_column(String(255), nullable=False)
full_name: Mapped[str] = mapped_column(String(160), nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[str] = mapped_column(String(24), default="member", nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
tenant: Mapped[Tenant] = relationship()
class Organization(Base, TimestampMixin):
__tablename__ = "crm_organizations"
__table_args__ = (
Index("ix_crm_organizations_tenant_name", "tenant_id", "name"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name: Mapped[str] = mapped_column(String(200), nullable=False)
legal_name: Mapped[str | None] = mapped_column(String(240))
website: Mapped[str | None] = mapped_column(String(500))
primary_email: Mapped[str | None] = mapped_column(String(255))
phone: Mapped[str | None] = mapped_column(String(80))
industry: Mapped[str | None] = mapped_column(String(120))
address: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
attributes: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
owner_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_users.id", ondelete="SET NULL"),
)
owner: Mapped[User | None] = relationship()
contacts: Mapped[list[Contact]] = relationship(back_populates="organization")
class Contact(Base, TimestampMixin):
__tablename__ = "crm_contacts"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"primary_email",
name="uq_crm_contacts_tenant_primary_email",
),
Index("ix_crm_contacts_tenant_name", "tenant_id", "last_name", "first_name"),
Index("ix_crm_contacts_tenant_score", "tenant_id", "score"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
first_name: Mapped[str] = mapped_column(String(100), nullable=False)
last_name: Mapped[str] = mapped_column(String(100), default="", nullable=False)
job_title: Mapped[str | None] = mapped_column(String(160))
primary_email: Mapped[str | None] = mapped_column(String(255))
emails: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
phones: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
lifecycle_stage: Mapped[str] = mapped_column(String(40), default="lead", nullable=False)
lead_source: Mapped[str | None] = mapped_column(String(120))
score: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
organization_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_organizations.id", ondelete="SET NULL"),
index=True,
)
owner_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_users.id", ondelete="SET NULL"),
)
attributes: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
organization: Mapped[Organization | None] = relationship(back_populates="contacts")
owner: Mapped[User | None] = relationship()
@property
def name(self) -> str:
return f"{self.first_name} {self.last_name}".strip()
class Pipeline(Base, TimestampMixin):
__tablename__ = "crm_pipelines"
__table_args__ = (
UniqueConstraint("tenant_id", "name", name="uq_crm_pipelines_tenant_name"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name: Mapped[str] = mapped_column(String(140), nullable=False)
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
stages: Mapped[list[Stage]] = relationship(
back_populates="pipeline",
cascade="all, delete-orphan",
order_by="Stage.position",
)
class Stage(Base, TimestampMixin):
__tablename__ = "crm_stages"
__table_args__ = (
UniqueConstraint(
"pipeline_id",
"name",
name="uq_crm_stages_pipeline_name",
),
Index("ix_crm_stages_pipeline_position", "pipeline_id", "position"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
pipeline_id: Mapped[str] = mapped_column(
ForeignKey("crm_pipelines.id", ondelete="CASCADE"),
nullable=False,
)
name: Mapped[str] = mapped_column(String(140), nullable=False)
position: Mapped[int] = mapped_column(Integer, nullable=False)
probability: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
color: Mapped[str] = mapped_column(String(16), default="#64748b", nullable=False)
pipeline: Mapped[Pipeline] = relationship(back_populates="stages")
class LeadSource(Base, TimestampMixin):
__tablename__ = "crm_lead_sources"
__table_args__ = (
UniqueConstraint("tenant_id", "name", name="uq_crm_lead_sources_tenant_name"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name: Mapped[str] = mapped_column(String(120), nullable=False)
class LeadType(Base, TimestampMixin):
__tablename__ = "crm_lead_types"
__table_args__ = (
UniqueConstraint("tenant_id", "name", name="uq_crm_lead_types_tenant_name"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name: Mapped[str] = mapped_column(String(120), nullable=False)
class Lead(Base, TimestampMixin):
__tablename__ = "crm_leads"
__table_args__ = (
Index("ix_crm_leads_tenant_stage", "tenant_id", "stage_id", "position"),
Index("ix_crm_leads_tenant_status", "tenant_id", "status"),
Index("ix_crm_leads_tenant_owner", "tenant_id", "owner_id"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
title: Mapped[str] = mapped_column(String(240), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
value: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0, nullable=False)
currency: Mapped[str] = mapped_column(String(3), default="USD", nullable=False)
status: Mapped[str] = mapped_column(String(24), default="open", nullable=False)
score: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
lost_reason: Mapped[str | None] = mapped_column(Text)
expected_close_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
contact_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_contacts.id", ondelete="SET NULL"),
index=True,
)
organization_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_organizations.id", ondelete="SET NULL"),
index=True,
)
owner_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_users.id", ondelete="SET NULL"),
)
pipeline_id: Mapped[str] = mapped_column(
ForeignKey("crm_pipelines.id", ondelete="RESTRICT"),
nullable=False,
)
stage_id: Mapped[str] = mapped_column(
ForeignKey("crm_stages.id", ondelete="RESTRICT"),
nullable=False,
)
source_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_lead_sources.id", ondelete="SET NULL"),
)
type_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_lead_types.id", ondelete="SET NULL"),
)
# Ad attribution, promoted out of `attributes` into real columns.
# Meta stamps every lead-ad submission with the campaign, ad set and ad
# that produced it, so "spend and leads for this ad" is a join rather
# than a JSON scan — which also keeps it working on SQLite and
# PostgreSQL alike. Null for leads that did not come from an ad.
campaign_external_id: Mapped[str | None] = mapped_column(String(255), index=True)
adset_external_id: Mapped[str | None] = mapped_column(String(255))
ad_external_id: Mapped[str | None] = mapped_column(String(255))
attributes: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
contact: Mapped[Contact | None] = relationship()
organization: Mapped[Organization | None] = relationship()
owner: Mapped[User | None] = relationship()
pipeline: Mapped[Pipeline] = relationship()
stage: Mapped[Stage] = relationship()
source: Mapped[LeadSource | None] = relationship()
lead_type: Mapped[LeadType | None] = relationship()
class Activity(Base, TimestampMixin):
__tablename__ = "crm_activities"
__table_args__ = (
Index("ix_crm_activities_tenant_due", "tenant_id", "is_done", "due_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
activity_type: Mapped[str] = mapped_column(String(32), nullable=False)
title: Mapped[str] = mapped_column(String(240), nullable=False)
details: Mapped[str | None] = mapped_column(Text)
starts_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
ends_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
is_done: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
owner_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_users.id", ondelete="SET NULL"),
)
contact_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_contacts.id", ondelete="CASCADE"),
index=True,
)
organization_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_organizations.id", ondelete="CASCADE"),
index=True,
)
lead_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_leads.id", ondelete="CASCADE"),
index=True,
)
additional: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
owner: Mapped[User | None] = relationship()
contact: Mapped[Contact | None] = relationship()
organization: Mapped[Organization | None] = relationship()
lead: Mapped[Lead | None] = relationship()
class Product(Base, TimestampMixin):
__tablename__ = "crm_products"
__table_args__ = (
UniqueConstraint("tenant_id", "sku", name="uq_crm_products_tenant_sku"),
Index("ix_crm_products_tenant_name", "tenant_id", "name"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
sku: Mapped[str] = mapped_column(String(100), nullable=False)
name: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
quantity: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
price: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0, nullable=False)
currency: Mapped[str] = mapped_column(String(3), default="USD", nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
class Quote(Base, TimestampMixin):
__tablename__ = "crm_quotes"
__table_args__ = (
UniqueConstraint("tenant_id", "number", name="uq_crm_quotes_tenant_number"),
Index("ix_crm_quotes_tenant_status", "tenant_id", "status"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
number: Mapped[str] = mapped_column(String(80), nullable=False)
subject: Mapped[str] = mapped_column(String(240), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(32), default="draft", nullable=False)
currency: Mapped[str] = mapped_column(String(3), default="USD", nullable=False)
billing_address: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
shipping_address: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
discount_amount: Mapped[Decimal] = mapped_column(
Numeric(14, 2),
default=0,
nullable=False,
)
tax_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0, nullable=False)
adjustment_amount: Mapped[Decimal] = mapped_column(
Numeric(14, 2),
default=0,
nullable=False,
)
subtotal: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0, nullable=False)
grand_total: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0, nullable=False)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
contact_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_contacts.id", ondelete="SET NULL"),
)
organization_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_organizations.id", ondelete="SET NULL"),
)
lead_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_leads.id", ondelete="SET NULL"),
)
owner_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_users.id", ondelete="SET NULL"),
)
items: Mapped[list[QuoteItem]] = relationship(
back_populates="quote",
cascade="all, delete-orphan",
)
class QuoteItem(Base, TimestampMixin):
__tablename__ = "crm_quote_items"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
quote_id: Mapped[str] = mapped_column(
ForeignKey("crm_quotes.id", ondelete="CASCADE"),
nullable=False,
)
product_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_products.id", ondelete="SET NULL"),
)
name: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
quantity: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=1, nullable=False)
unit_price: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0, nullable=False)
tax_rate: Mapped[Decimal] = mapped_column(Numeric(6, 3), default=0, nullable=False)
line_total: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0, nullable=False)
quote: Mapped[Quote] = relationship(back_populates="items")
class AuditEvent(Base):
__tablename__ = "crm_audit_events"
__table_args__ = (
Index("ix_crm_audit_tenant_entity", "tenant_id", "entity_type", "entity_id"),
Index("ix_crm_audit_tenant_created", "tenant_id", "created_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
)
actor_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_users.id", ondelete="SET NULL"),
)
action: Mapped[str] = mapped_column(String(100), nullable=False)
entity_type: Mapped[str] = mapped_column(String(80), nullable=False)
entity_id: Mapped[str] = mapped_column(String(80), nullable=False)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=now_utc,
nullable=False,
)
class IntegrationEvent(Base):
__tablename__ = "crm_integration_events"
__table_args__ = (
Index("ix_crm_integration_events_delivery", "status", "available_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
)
topic: Mapped[str] = mapped_column(String(140), nullable=False)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
status: Mapped[str] = mapped_column(String(24), default="pending", nullable=False)
attempts: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
available_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=now_utc,
nullable=False,
)
delivered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=now_utc,
nullable=False,
)
class IntegrationCredential(Base, TimestampMixin):
__tablename__ = "crm_integration_credentials"
__table_args__ = (
UniqueConstraint("tenant_id", "name", name="uq_crm_integration_key_name"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name: Mapped[str] = mapped_column(String(120), nullable=False)
key_prefix: Mapped[str] = mapped_column(String(20), nullable=False)
key_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class ExternalLink(Base, TimestampMixin):
__tablename__ = "crm_external_links"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"provider",
"entity_type",
"external_id",
name="uq_crm_external_links_provider_entity",
),
Index("ix_crm_external_links_local", "tenant_id", "entity_type", "entity_id"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
)
provider: Mapped[str] = mapped_column(String(80), nullable=False)
entity_type: Mapped[str] = mapped_column(String(80), nullable=False)
entity_id: Mapped[str] = mapped_column(String(36), nullable=False)
external_id: Mapped[str] = mapped_column(String(255), nullable=False)
metadata_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class IdempotencyRecord(Base):
__tablename__ = "crm_idempotency_records"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"scope",
"idempotency_key",
name="uq_crm_idempotency_scope_key",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
)
scope: Mapped[str] = mapped_column(String(100), nullable=False)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
response_code: Mapped[int] = mapped_column(Integer, nullable=False)
response_body: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=now_utc,
nullable=False,
)
class Campaign(Base, TimestampMixin):
"""An advertising campaign mirrored from MaskanX.
The CRM does not run campaigns; it reports on them next to the leads
they produced. MaskanX pushes each campaign's figures here so that
"what did this campaign cost us per lead" is answerable without leaving
the CRM.
Money is stored in **minor** currency units (paise, cents), matching
what MaskanX sends and what Meta's own budget fields use. Storing a
decimal here would introduce a second convention and a rounding step
between two systems that currently agree exactly.
"""
__tablename__ = "crm_campaigns"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"provider",
"external_id",
name="uq_crm_campaigns_provider_external",
),
Index("ix_crm_campaigns_tenant_status", "tenant_id", "status"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
)
# "maskanx" for now; kept general so a second ad platform does not need
# a second table.
provider: Mapped[str] = mapped_column(String(80), nullable=False)
external_id: Mapped[str] = mapped_column(String(255), nullable=False)
name: Mapped[str] = mapped_column(String(240), nullable=False)
status: Mapped[str] = mapped_column(String(40), nullable=False, default="unknown")
objective: Mapped[str | None] = mapped_column(String(80), nullable=True)
channel: Mapped[str | None] = mapped_column(String(80), nullable=True)
currency: Mapped[str | None] = mapped_column(String(8), nullable=True)
daily_budget: Mapped[int | None] = mapped_column(Integer, nullable=True)
spend: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
impressions: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
clicks: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Leads as Meta counts them. The CRM's own lead count can differ — a
# lead may arrive here that Meta has not attributed yet, or vice versa —
# so both are kept rather than one being derived from the other.
leads: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
cost_per_lead: Mapped[int | None] = mapped_column(Integer, nullable=True)
metrics_from: Mapped[str | None] = mapped_column(String(20), nullable=True)
metrics_to: Mapped[str | None] = mapped_column(String(20), nullable=True)
synced_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=now_utc,
nullable=False,
)
metadata_json: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False,
)
class CustomProperty(Base, TimestampMixin):
"""A field an operator added, without a schema migration.
Modelled on how HubSpot treats properties: the definition lives in a
table, and the values live in the record's existing `attributes` JSON.
That is the whole point — adding a field is an INSERT, not a migration,
so an agent can create one mid-conversation and the next record can use
it immediately.
The cost of that choice is that values are not typed by the database,
so `data_type` is enforced in application code on write. Anything that
writes a custom property value has to go through that validation; a
direct UPDATE to `attributes` bypasses it.
`name` is the stable key stored inside `attributes`; `label` is what a
person reads. They are kept separate for the same reason HubSpot keeps
them separate: renaming a label must not orphan every value already
written under the old key.
"""
__tablename__ = "crm_custom_properties"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"object_type",
"name",
name="uq_crm_custom_properties_object_name",
),
Index("ix_crm_custom_properties_tenant_object", "tenant_id", "object_type"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
)
# Which kind of record carries it: contact, lead, organization, campaign.
object_type: Mapped[str] = mapped_column(String(40), nullable=False)
name: Mapped[str] = mapped_column(String(80), nullable=False)
label: Mapped[str] = mapped_column(String(160), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
data_type: Mapped[str] = mapped_column(String(24), nullable=False, default="string")
# Allowed values for an enumeration. Empty for every other type.
options: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
is_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Where it came from. An agent-created field is marked so a human can
# tell at a glance which fields they defined and which one a
# conversation produced.
created_by: Mapped[str | None] = mapped_column(String(120))