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>
73 lines
2.4 KiB
Python
73 lines
2.4 KiB
Python
"""Advertising campaigns, as mirrored from MaskanX.
|
|
|
|
Read-only. The CRM does not run campaigns — MaskanX does, and pushes their
|
|
figures here through `POST /integrations/campaigns`. This exists so that
|
|
"what did this campaign cost us per lead" can be answered next to the leads
|
|
themselves, without leaving the CRM.
|
|
|
|
Each campaign carries two lead counts: `leads` as Meta attributes them, and
|
|
`crm_leads` as this database actually holds them. They routinely differ —
|
|
Meta attributes late, and a lead can be entered here by hand — and the gap
|
|
is worth seeing rather than hiding behind one number.
|
|
"""
|
|
|
|
from fastapi import APIRouter
|
|
from sqlalchemy import func, select
|
|
|
|
from app.core.security import CurrentUser, Database
|
|
from app.models import Campaign, Lead
|
|
from app.schemas import CampaignOut
|
|
|
|
router = APIRouter(prefix="/campaigns", tags=["Campaigns"])
|
|
|
|
|
|
@router.get("", response_model=list[CampaignOut])
|
|
def list_campaigns(user: CurrentUser, db: Database) -> list[CampaignOut]:
|
|
campaigns = list(
|
|
db.scalars(
|
|
select(Campaign)
|
|
.where(Campaign.tenant_id == user.tenant_id)
|
|
.order_by(Campaign.spend.desc()),
|
|
),
|
|
)
|
|
|
|
# One grouped count rather than a query per campaign: a workspace with
|
|
# a hundred campaigns should still be one round trip.
|
|
counts = dict(
|
|
db.execute(
|
|
select(
|
|
Lead.campaign_external_id,
|
|
func.count(Lead.id),
|
|
)
|
|
.where(
|
|
Lead.tenant_id == user.tenant_id,
|
|
Lead.campaign_external_id.is_not(None),
|
|
)
|
|
.group_by(Lead.campaign_external_id),
|
|
).all(),
|
|
)
|
|
|
|
return [
|
|
CampaignOut(
|
|
id=campaign.id,
|
|
provider=campaign.provider,
|
|
external_id=campaign.external_id,
|
|
name=campaign.name,
|
|
status=campaign.status,
|
|
objective=campaign.objective,
|
|
channel=campaign.channel,
|
|
currency=campaign.currency,
|
|
daily_budget=campaign.daily_budget,
|
|
spend=campaign.spend,
|
|
impressions=campaign.impressions,
|
|
clicks=campaign.clicks,
|
|
leads=campaign.leads,
|
|
cost_per_lead=campaign.cost_per_lead,
|
|
metrics_from=campaign.metrics_from,
|
|
metrics_to=campaign.metrics_to,
|
|
synced_at=campaign.synced_at,
|
|
crm_leads=counts.get(campaign.external_id, 0),
|
|
)
|
|
for campaign in campaigns
|
|
]
|