"""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')