Files

682 lines
27 KiB
Python
Raw Permalink Normal View History

2026-08-01 10:28:41 +05:30
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))
2026-08-01 10:28:41 +05:30
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))