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>
682 lines
27 KiB
Python
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))
|