A multi-feature cluster turning previously skeletal modules into real workflows. Roughly half a day of new surfaces.
Custom KPIs — from formula editor to decision-driving widgets
Custom KPIs already existed as a formula builder at /analytics/custom-kpis but didn't show up where work happens. Three tiers of upgrades closed that gap.
Tier 1 — surface KPIs where decisions happen
Pre-seeded templates: ROAS, CPA, CTR%, CVR, eCPM. One-click clone via a modal picker (Use Template button on the index page, also accessible from the dashboard widget and the campaign edit form). Lower-is-better metrics like CPA/eCPM use inverted threshold bands so a green dot still means "good".
Auto-resolve metrics: new endpoint GET /analytics/custom-kpis/{id}/calculate-for-campaign?campaign_id=X&days=30 aggregates analytics_snapshots over the lookback window and runs the KPI formula automatically. No more manually plumbing metrics into /calculate.
Two new mount points: CampaignKPIsWidget on the campaign detail page (between stats and Spend chart), and a Subscribe KPIs collapsible section on the campaign edit form (parallel to Creator Applications).
Tier 2 — make it actually usable
Per-day history endpoint + inline SVG sparklines on every card. Auto-normalises against actual min/max so KPIs that hover near zero (CVR%) and high-range KPIs (ROAS) both render legibly. Stroke colour follows the threshold band.
Workspace dashboard widget — mounted on /dashboard (advertiser) and /agency/dashboard. Shows global KPIs aggregated workspace-wide (no campaign scope). Empty state has a "Pick a template" CTA so first-time users land on the picker without hunting.
Threshold breach alerts (lazy-on-load): when calculate-for-campaign returns red, a Notification is dropped for the workspace owner. Title links to the campaign, deduped by group_key = "kpi_breach.{kpi_id}.{campaign_id}" over a 24h window so a hot dashboard doesn't spam the bell. No new schema, no cron — every page render that surfaces a KPI also evaluates whether to alert.
Multi-campaign subscriptions: migration 4e6f8a1b2c3d adds campaign_ids JSONB NOT NULL DEFAULT '[]' to custom_kpis plus a GIN index. Backfill copies existing single campaign_id values into the array. The list endpoint OR-matches both columns so KPIs subscribed via either path surface correctly. UI dropped the "already attached to another campaign" lock — same KPI can pin to many campaigns now, badge shows fan-out (+ 2 campaigns).
Tier 3 (subset) — comparison vs previous period
Date range selector (7d / 30d / 90d) on every widget. Re-fetch on change.
Per-card delta arrow with previous-period comparison: ↑ +12.4% green = improved, ↓ -8.0% red = worsened. Direction inferred from the threshold band (higher_is_better helper) so a -8% CPA renders green (lower is better) while a -8% ROAS renders red. Hover the badge → tooltip shows the previous-period value. new if previous period had no data.
Demo seeding helper
New SA-only endpoint POST /analytics/admin/seed-campaign-snapshots?campaign_id=X&days=30 and a Seed demo data button on the campaign KPI widget. Inserts 30 days of synthetic analytics_snapshots with ±25% jitter so KPI cards light up without needing real platform telemetry. Idempotent. Per-campaign deterministic seed.
Cross-component sync fix
After unsubscribing a KPI on the edit page, the widget on the detail page wouldn't refresh — different components, different state. Now both widgets listen for a kpi:subscriptions-changed window event broadcast on every Subscribe / Unsubscribe / Global toggle, plus refetch on window focus. Also force-nulls the legacy campaign_id column on every PATCH so the JSONB array is the single source of truth (one-shot DB migration moved 3 leftover rows that were straddling both columns).
Creatives — from file bucket to workflow
The Creatives module was a flat list with binary approve/reject buttons. Two tiers of upgrades.
Tier 1 — linkage
Migration 5f7a9b3c4d6e adds line_item_id and brief_id FK columns to creatives (campaign_id was already there). All nullable so standalone library creatives keep working.
GET /creatives accepts new filters: campaign_id, line_item_id, brief_id, linkage=linked|standalone. POST /creatives and PUT /creatives/{id} accept the new IDs with FK existence + tenant ownership validation. null on PUT explicitly unlinks.
New "Creatives" tab on /campaigns/[id] showing only assets linked to that campaign, with Unlink per card (removes the FK without deleting the asset). The /creatives/upload form gets two new optional dropdowns (Link to campaign / Link to brief), pre-filled when arriving via ?campaign_id=. Library page gets a Linkage filter row (All / Linked / Standalone). The creative detail page shows a new Used in: row with clickable Campaign / Brief / Line Item pills.
After successful upload, the page now auto-redirects: campaign creatives tab if linked, else creative detail.
nginx fix: bumped client_max_body_size to 50m on the /advertise.network/api location so 5MB+ image / video uploads no longer 413 before reaching FastAPI.
Tier 2 — review threads
The binary approve/reject button became an iterative thread. Migration 6a8b0c3d4e5f adds a creative_comments table with self-FK for replies (one level deep), soft-delete, 15-min edit window, resolve/reopen audit fields.
Six new endpoints: list (GET /creatives/{id}/comments?include_resolved=true|false), summary count (GET /creatives/{id}/comments/summary), create (POST /creatives/{id}/comments, optional parent_comment_id for replies), edit (PATCH /comments/{id}, 15-min window), resolve / reopen (POST /comments/{id}/resolve|reopen), soft-delete (DELETE /comments/{id}).
Approve flow now refuses with HTTP 409 + {message, open_comments} while there are unresolved comments. The Validate button on the creative detail page changes label from Validate Creative → Validate (3 open) in orange when comments exist, then on click shows a confirm: "3 unresolved review comment(s). Approve anyway?". OK calls validate?force=true (logged as override).
Frontend: full-width <CreativeReviewThread> panel below the existing preview/specs/trafficking cards. Composer with Cmd+Enter shortcut, threaded replies indented in grey, resolve checkboxes per comment, "Show resolved" toggle in the header so old feedback collapses but stays auditable. Avatars from initials. "edited" italics indicator after edit. Soft-deleted rows render "comment deleted" placeholder.
Notifications: when a non-owner comments, the creative owner gets a New comment on <name> notification, deduped 5min/group via group_key = "creative_comment.{creative_id}" so a flurry of comments only pings once.
Briefs — workflow polish
Adds escape hatches and sender controls that were missing in the brief flow shipped on 2026-04-30.
Migration 3d5f7b9c2e4a adds BriefStatus.CANCELLED, RecipientStatus.WITHDRAWN, plus cancelled_at / cancellation_reason / withdrawn_at / withdrawal_reason columns.
New endpoints under /briefs:
POST /briefs/{id}/cancel— sender cancellation with reason ≥20 chars; closes open deliverables, notifies all recipientsPOST /briefs/{id}/withdraw— recipient withdrawal, blocked if any submission already existsPOST /briefs/{id}/recall— sender un-sends a SENT brief (reverts to DRAFT, deletes recipient rows) when no responses yetPOST /briefs/{id}/remind— sender pings PENDING recipients, optionalrecipient_user_idfor targetingPOST /briefs/{id}/extend-deadline— sender bumps brief deadline + per-deliverable deadlines, notifies recipients
Per-deliverable proof artefacts (extending the existing submit flow): content_description, delivery_proof_url, proof_screenshots[], content_attachments[]. Four new upload endpoints (proof / attachment, plain + direct variants) reusing LocalStorageService. New BriefDeliverableSubmitForm component with a collapsible expander for proof URLs / screenshots / raw assets.
Brief-scoped chat: deal_chat extended to support deal_kind='brief'. The same SSE notification channel and message endpoints work; brief workspace owner and recipients are authorised parties.
Influencer dropdown on /briefs/new replacing the old free-text Campaign ID input — driven by listCampaigns(), filtered to draft|pending_approval|approved|active, sorted by updated_at desc.
500 fix on influencer brief withdraw: stale reference BriefRecipient.influencer_id corrected to user_id in briefs.py:2039.
Migration chain
Heads applied today, in order: 0a1b2c3d4e5f → 1b2c3d4e5f6a (scheduling created_by) → 3d5f7b9c2e4a (brief cancel/withdraw) → 4e6f8a1b2c3d (KPI multi-campaign) → 5f7a9b3c4d6e (creative linkage) → 6a8b0c3d4e5f (creative comments).

