Skip to main content

Ad Planning Module — Changelog (April 2026)

Systematic audit and fix of 30+ logical bugs across workflows, API routes, models, subscribers, and scheduled jobs. Organized into three phases: critical data integrity, wrong numbers, and performance.

Last updated: 2026-04-10


Phase 1: Critical Data Integrity

Cross-Customer Attribution Leakage (CRITICAL)

Bug: findAttributionStep in track-purchase-conversion.ts fetched ALL resolved attributions system-wide and assigned the most recent one to the current order — leaking any random customer's ad campaign into unrelated purchases.

Fix: Scoped by looking up the person's historical visitor_ids from their Conversion records first, then filtering attributions to only those visitor_ids.

File: src/workflows/ad-planning/conversions/track-purchase-conversion.ts

A/B Test p-Value Formula Broken (CRITICAL)

Bug: calculatePValue in statistical-utils.ts returned p > 1 for negative z-scores. Any A/B test where treatment outperformed control was never declared statistically significant because getSignificanceLevel fell through all thresholds.

Fix: Rewrote to use symmetric two-tailed formula p = 1 - erf(|z|/√2) with Math.max(0, Math.min(1, ...)) clamping.

File: src/modules/ad-planning/utils/statistical-utils.ts

CampaignAttribution Platform Enum Missing "direct"

Bug: CampaignAttribution model only allowed ["meta", "google", "generic"] with default "meta". Every direct-traffic session and every unresolved attribution was tagged as Meta.

Fix: Added "direct" to enum, changed default from "meta" to "direct". Updated all Zod validators and workflow step types to match.

Files: campaign-attribution.ts, attribution/route.ts, track-conversion.ts, track-purchase-conversion.ts, track-lead-conversion.ts Migration: Migration20260410033050.ts

Experiment Filter on Nonexistent Column

Bug: GET /admin/ad-planning/experiments filtered on ad_campaign_id which doesn't exist on the ABExperiment model. MikroORM would throw a DB error on every filtered request.

Fix: Replaced with experiment_type filter (a real column). Also switched to listAndCountABExperiments so count returns the total, not the page size.

File: src/api/admin/ad-planning/experiments/route.ts

Recalculate Scores Job OOM Risk

Bug: Weekly job loaded ALL conversions and ALL customer journeys into memory with no pagination.

Fix: Added 30-day activity window filter, paginated fetch (500 rows/page), and concurrent processing (5 at a time with parallel score workflows per person).

File: src/jobs/ad-planning/recalculate-scores-job.ts


Phase 2: Wrong Numbers

Conversion Currency Default "INR"

Bug: Conversion.currency model field defaulted to "INR" regardless of store locale. Every EUR/USD/GBP conversion was silently tagged INR.

Fix: Changed to nullable(). Removed hardcoded "INR" fallbacks in track-conversion.ts, track-purchase-conversion.ts, and track-lead-conversion.ts. Callers now pass the real currency from the order or null.

Migration: Included in Migration20260410035444.ts

Lead Conversion Platform Detection

Bug: track-lead-conversion.ts read utm_campaign (the campaign name) instead of utm_source (the traffic source) to detect platform. Since campaign names rarely contain "facebook" or "google", all leads were tagged "generic".

Fix: Now reads utm_source first, falls back to utm_campaign. Added common aliases: fb, ig, adwords.

File: src/workflows/ad-planning/conversions/track-lead-conversion.ts

Forecast Confidence Interval Formula Inverted

Bug: forecasts/route.ts used (1 - (1 - confidenceLevel)) which simplifies to just confidenceLevel. Lower confidence produced tighter intervals (backwards).

Fix: margin = 1 - confidenceLevel, applied as (1 ± margin).

File: src/api/admin/ad-planning/forecasts/route.ts

Churn Risk Ignores "very_negative" Sentiment

Bug: calculate-churn-risk.ts only counted sentiment_label === "negative". A customer with exclusively "very_negative" feedback had a negative sentiment ratio of 0.

Fix: Both "negative" (weight 1.0) and "very_negative" (weight 1.5) now contribute. Applied to both the standalone workflow and the inline copy in track-purchase-conversion.ts.

NPS Scale-5 Rounding Bug

Bug: normalizeRatingStep used Math.round((rating - 1) * 2.5) which maps rating 4 to 8 instead of 7.5, flipping the NPS category from where it should be.

Fix: Classify from the raw 5-point rating directly (5 = promoter, 4 = passive, 1-3 = detractor) instead of rounding through the 0-10 scale. The stored nps_value still uses the linear mapping for cross-scale aggregation.

File: src/workflows/ad-planning/scoring/calculate-nps.ts

Feedback Subscriber NPS Scale Boundary

Bug: rating <= 5 ? "5" : "10" — a 10-point scale rating of exactly 5 was misrouted to the 5-point scale calculation.

Fix: Changed to < 6 with support for an explicit scale field on the event payload. Added top-level ID guard and try/catch.

File: src/subscribers/ad-planning/feedback-created.ts

Analytics-Event Subscriber Try/Catch Scoping

Bug: Attribution resolution ran outside the main try/catch block, firing even when the conversion tracking above had thrown an error. The catch was also completely silent (no logging).

Fix: Moved inside the outer try/catch. Added intelligent error logging that suppresses duplicate-key errors (expected for unique index) but logs real failures.

File: src/subscribers/ad-planning/analytics-event-created.ts

Bulk Attribution Exclusion Set Truncation

Bug: listCampaignAttributions({}) with no pagination hit Medusa's default list limit. Beyond that limit, session IDs weren't in the exclusion set, causing duplicate attribution writes.

Fix: Scoped the query to only the candidate session IDs from the current batch, with explicit take matching the batch size.

File: src/workflows/ad-planning/attribution/bulk-resolve-attributions.ts

Budget Forecast Duplicate Accumulation

Bug: (ad_campaign_id, forecast_date) index was not unique. Re-running the forecast job for the same campaign/date produced duplicate rows that inflated MAPE calculations.

Fix: Made the index unique. Migration: Migration20260410035444.ts


Phase 3: Performance & Pagination

Dashboard Unbounded List Calls

All four listX calls in the dashboard route now have explicit take limits (10,000 for conversions/attributions, 200 for segments, 100 for experiments). Previously they loaded entire tables into memory per request.

Stats Routes Capped

conversions/stats, attribution/stats, and journeys/funnel routes now cap at 50,000 rows per aggregation request to prevent OOM on high-traffic stores.

Count-vs-Page-Size Fixed

Three list endpoints returned page size instead of total count, making client-side pagination impossible:

RouteFix
GET /admin/ad-planning/experimentslistAndCountABExperiments
GET /admin/ad-planning/forecastslistAndCountBudgetForecasts
GET /admin/ad-planning/journeyslistAndCountCustomerJourneys

Segment Member Count

GET /admin/ad-planning/segments/:id no longer loads all member rows to compute member_count. Uses the stored customer_count on the segment (maintained by buildSegmentWorkflow), with listAndCount fallback.


Segment Enrichment Fixes

Missing UI Field Aliases

The segment UI field picker exposed fields (total_orders, total_spent, avg_order_value, days_since_last_order, clv) that didn't exist in the enriched customer data object. All segment rules using these fields silently matched 0 members.

Fixed by adding computed fields to build-segment.ts:

UI FieldBackend Computation
total_ordersMedusa customer order count, fallback to ad-planning purchase conversion count
total_spentSum of conversion_value from purchase conversions
avg_order_valuetotal_spent / paidPurchases.length (excludes €0 drafts)
days_since_last_orderDays since latest purchase conversion
clvCLV score from CustomerScore

Numeric Comparison Operators

evaluateSegmentCriteria compared values using JavaScript's default coercion. Since the UI stores numeric inputs as strings (e.g., "300"), comparisons like 5 >= "300" used lexicographic ordering (always false).

Fix: Added toNumber() coercion helper for >=, <=, >, <, between operators.


Customer Scores Enrichment

Person Name Display

The scores page showed raw person_id truncated to 12 characters. Now joins Person + Medusa Customer (by email) and returns display_name, person, and customer objects.

Percentile Calculation

The percentile field was in the UI interface but never existed on the CustomerScore model. The cell's null guard missed undefined, producing NaN.

Fix: Server-side percentile calculation using standard percentile rank formula. Returns null when fewer than 2 data points exist per score type.

Tier Inference

Added server-side tier computation:

  • CLV: platinum (≥50k), gold (≥20k), silver (≥5k), bronze
  • Engagement/NPS: high (≥75), medium (≥40), low
  • Churn risk: high (≥70), medium (≥40), low

Translations Integration

Model-Level Translatable Fields

Added .translatable() to partner-visible text fields across 9 models:

ModelTranslatable Fields
designname, description, designer_notes, revision_notes
design_specificationstitle, details, special_instructions, reviewer_notes
design_colorsname, usage_notes
design_componentrole, notes
tasktitle, description, message
task_template / task_categoryname, description, message_template
production_runscancelled_reason, finish_notes, completion_notes, rejection_reason, rejection_notes
raw_materialsname, description, composition, usage_guidelines, storage_requirements
material_typesname, description

Locale Middleware

Added applyLocale middleware to all partner GET routes. Route handlers pass { locale: req.locale } to query.graph() / query.index() calls.

Partner-UI Integration

I18nProvider syncs the current i18n language to the SDK's x-medusa-locale global header.


Test Results

All existing + new tests pass:

SuiteTestsStatus
ad-planning-bug-fixes.spec.ts11✅ New
ad-planning-attribution.spec.ts15✅ Pass
ad-planning-conversions.spec.ts22✅ Pass
ad-planning-experiments.spec.ts22✅ Pass
design-translations.spec.ts6✅ New
Total76All passing

Migrations

MigrationChanges
Migration20260410033050.ts (ad_planning)CampaignAttribution.platform adds "direct" + default changed to "direct"; SegmentMember unique index on (segment_id, person_id)
Migration20260410035444.ts (ad_planning)BudgetForecast unique index on (ad_campaign_id, forecast_date); Conversion.currency changed to nullable
Migration20260410031716.ts (analytics)AnalyticsSession adds UTM columns (utm_source, utm_medium, utm_campaign, utm_term, utm_content) + index