Files
maskanx_crm_frontend/alembic/versions/b1c4d7e29a03_add_crm_campaigns.py
T
AFFAANhandClaude Opus 5 55d5af849b feat(campaigns): report advertising spend and cost per lead
MaskanX runs the campaigns; this stores what they cost and what they
produced so the question "what did this campaign cost us per lead" is
answerable next to the leads themselves.

POST /integrations/campaigns is an upsert keyed on (provider,
external_id), not an idempotent create like /leads. A lead is an event
that happened once; a campaign's figures change every time they are read,
and MaskanX re-pushes the same campaign as its spend grows. An
Idempotency-Key here would pin the CRM to the first numbers it ever saw.

Money is stored as integers in minor currency units, matching what
MaskanX sends and what Meta uses. A Numeric would add a second convention
and a rounding step between systems that currently agree exactly.

Ad attribution is promoted out of crm_leads.attributes into indexed
columns, so counting leads per campaign is a join rather than a JSON scan
— which also keeps it working on both SQLite and PostgreSQL.

Meta's lead count and the CRM's own are both kept. They routinely differ,
since Meta attributes late and leads can be entered by hand, and the gap
is worth seeing rather than hiding behind one number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 10:55:46 +05:30

95 lines
3.8 KiB
Python

"""add crm_campaigns
Mirrors advertising campaigns pushed from MaskanX so the CRM can report
spend and cost per lead alongside the leads those campaigns produced.
Money columns are integers in minor currency units (paise, cents), matching
what MaskanX sends and what Meta's budget fields use. A Numeric here would
add a second convention and a rounding step between two systems that
currently agree exactly.
Revision ID: b1c4d7e29a03
Revises: eca4681c9f66
Create Date: 2026-08-03
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'b1c4d7e29a03'
down_revision: Union[str, None] = 'eca4681c9f66'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'crm_campaigns',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('provider', sa.String(length=80), nullable=False),
sa.Column('external_id', sa.String(length=255), nullable=False),
sa.Column('name', sa.String(length=240), nullable=False),
sa.Column('status', sa.String(length=40), nullable=False),
sa.Column('objective', sa.String(length=80), nullable=True),
sa.Column('channel', sa.String(length=80), nullable=True),
sa.Column('currency', sa.String(length=8), nullable=True),
sa.Column('daily_budget', sa.Integer(), nullable=True),
sa.Column('spend', sa.Integer(), nullable=False),
sa.Column('impressions', sa.Integer(), nullable=False),
sa.Column('clicks', sa.Integer(), nullable=False),
sa.Column('leads', sa.Integer(), nullable=False),
sa.Column('cost_per_lead', sa.Integer(), nullable=True),
sa.Column('metrics_from', sa.String(length=20), nullable=True),
sa.Column('metrics_to', sa.String(length=20), nullable=True),
sa.Column('synced_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('metadata_json', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['crm_tenants.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
# MaskanX re-pushes the same campaign as its figures change, so the
# provider's own id is what identifies it, not our surrogate key.
sa.UniqueConstraint(
'tenant_id', 'provider', 'external_id',
name='uq_crm_campaigns_provider_external',
),
)
op.create_index(
'ix_crm_campaigns_tenant_status',
'crm_campaigns',
['tenant_id', 'status'],
)
# Ad attribution promoted out of crm_leads.attributes into real columns,
# so counting leads per campaign is an indexed join rather than a JSON
# scan. Nullable: most leads do not come from an ad.
op.add_column(
'crm_leads',
sa.Column('campaign_external_id', sa.String(length=255), nullable=True),
)
op.add_column(
'crm_leads',
sa.Column('adset_external_id', sa.String(length=255), nullable=True),
)
op.add_column(
'crm_leads',
sa.Column('ad_external_id', sa.String(length=255), nullable=True),
)
op.create_index(
'ix_crm_leads_campaign_external_id',
'crm_leads',
['campaign_external_id'],
)
def downgrade() -> None:
op.drop_index('ix_crm_leads_campaign_external_id', table_name='crm_leads')
op.drop_column('crm_leads', 'ad_external_id')
op.drop_column('crm_leads', 'adset_external_id')
op.drop_column('crm_leads', 'campaign_external_id')
op.drop_index('ix_crm_campaigns_tenant_status', table_name='crm_campaigns')
op.drop_table('crm_campaigns')