File: //home/xedaptot/ai.naniguide.com/database/seeders/Demo/Stage15_AbTestsSeeder.php
<?php
namespace Database\Seeders\Demo;
use App\Model\AbTest;
use App\Model\AbTestVariant;
use App\Model\Campaign;
use App\Model\Email;
use App\Model\TrackingLog;
use Illuminate\Support\Facades\DB;
/**
* Stage 15 — A/B tests with FULL engagement data populated.
*
* **Critical:** This stage MUST run AFTER Stage11_TrackingLogsSeeder because
* we hijack existing tracking_logs (created in Stage11) and assign them to
* variants by setting `tracking_logs.ab_test_variant_id`. Without this, the
* A/B report at /campaigns/{uid}/ab-report shows zero everything.
*
* For each customer with ≥3 done campaigns, we pick 2 campaigns that have
* tracking_logs and turn them into A/B tests with 2-3 variants. Then:
* 1. Create AbTestVariant rows (each with its own Email row)
* 2. Split the campaign's existing tracking_logs across variants by
* `split_percentage` and stamp `ab_test_variant_id` on each row
* 3. Create AbTestAssignment rows for each tracked subscriber
* 4. Skew engagement so the winner variant has slightly better stats —
* re-stamps a few extra opens/clicks onto the winning variant via
* its message_ids (without inserting new rows; we just shift ownership)
*
* This is exactly what the report controller queries, so the dashboard now
* shows real numbers (delivered/opens/clicks/bounces per variant + the
* winner banner + statistical confidence z-test).
*/
class Stage15_AbTestsSeeder extends AbstractDemoSeeder
{
protected int $fakerOffset = 15;
public function run(): void
{
if (!class_exists(AbTest::class)) {
$this->log('AbTest model missing — skipping');
return;
}
$f = $this->faker();
$totalTests = 0;
$totalVariants = 0;
$totalAssignments = 0;
$totalLogsLinked = 0;
// Tracking logs exist for DONE campaigns (from Stage11). We pick 2
// such campaigns per customer with ≥3 done campaigns.
foreach (Stage10_CampaignsSeeder::$created as $key => $campaigns) {
$doneWithLogs = array_filter($campaigns, function ($c) {
return $c->status === Campaign::STATUS_DONE
&& DB::table('tracking_logs')->where('campaign_id', $c->id)->exists();
});
if (count($doneWithLogs) < 2) continue;
$picks = array_slice(array_values($doneWithLogs), 0, 2);
foreach ($picks as $campaign) {
$variantCount = $f->numberBetween(2, 3);
$abTest = $this->createAbTest($campaign, $variantCount, $f);
$totalTests++;
$variants = $this->createVariants($abTest, $campaign, $variantCount);
$totalVariants += count($variants);
// Split this campaign's tracking_logs across the variants.
$linked = $this->stampVariantsOnTrackingLogs($campaign, $variants);
$totalLogsLinked += $linked;
// Materialize assignments table from the just-stamped logs.
$assigned = $this->createAssignmentsFromLogs($abTest, $campaign);
$totalAssignments += $assigned;
// Pick the variant with the best click rate as the winner.
$winner = $this->pickWinnerByClickRate($variants);
$abTest->winner_variant_id = $winner->id;
$abTest->winner_sent_at = now()->subDays($f->numberBetween(1, 30));
$abTest->save();
}
}
$this->log("seeded {$totalTests} A/B tests + {$totalVariants} variants + {$totalAssignments} assignments + {$totalLogsLinked} tracking logs linked");
}
protected function createAbTest(Campaign $campaign, int $variantCount, $f): AbTest
{
$abTest = new AbTest();
$abTest->campaign_id = $campaign->id;
$abTest->winner_criteria = $f->randomElement(['open_rate', 'click_rate']);
$abTest->test_percentage = $f->randomElement([20, 30, 40]);
$abTest->winner_wait_hours = $f->randomElement([24, 48, 72]);
// Valid values per AbTest::STRATEGY_* + the form validator
// (`required|in:exact,hash`). 'random' is NOT valid and will render
// the raw translation key on the report config table.
$abTest->split_strategy = $f->randomElement([AbTest::STRATEGY_EXACT, AbTest::STRATEGY_HASH]);
$abTest->save();
return $abTest;
}
/**
* Create $count AbTestVariant rows, each with its own Email body.
* @return AbTestVariant[]
*/
protected function createVariants(AbTest $abTest, Campaign $campaign, int $count): array
{
$variants = [];
// Even split, with last variant absorbing rounding remainder.
$splits = array_fill(0, $count, intdiv(100, $count));
$splits[$count - 1] += 100 - array_sum($splits);
for ($i = 0; $i < $count; $i++) {
$variantEmail = new Email([
'type' => Email::TYPE_REGULAR,
'subject' => DemoFaker::campaignSubject() . ' — Variant ' . chr(65 + $i),
'from_email' => '[email protected]',
'from_name' => 'Demo',
'reply_to' => '[email protected]',
'html' => '<p>Variant ' . chr(65 + $i) . ' content — different subject + CTA placement</p>',
'plain' => 'Variant ' . chr(65 + $i) . ' content',
'sign_dkim' => true,
'track_open' => true,
'track_click' => true,
'customer_id' => $campaign->customer_id,
]);
$variantEmail->skip_failed_message = false;
$variantEmail->fillDeliveryStatuses($variantEmail->getDefaultDeliveryStatuses());
$variantEmail->save();
$this->assignRandomTemplate($variantEmail);
$variant = new AbTestVariant();
$variant->ab_test_id = $abTest->id;
$variant->name = 'Variant ' . chr(65 + $i);
$variant->email_id = $variantEmail->id;
$variant->split_percentage = $splits[$i];
$variant->is_control = $i === 0 ? 1 : 0;
$variant->save();
$variants[] = $variant;
}
return $variants;
}
/**
* Distribute the campaign's existing tracking_logs across the variants by
* UPDATEing `ab_test_variant_id`. Returns the count of rows updated.
*
* Strategy: pull all tracking_logs for this campaign, slice by
* split_percentage, run one UPDATE per slice. Bulk + indexed by id.
*/
protected function stampVariantsOnTrackingLogs(Campaign $campaign, array $variants): int
{
$logIds = DB::table('tracking_logs')
->where('campaign_id', $campaign->id)
->orderBy('id')
->pluck('id')
->toArray();
if (empty($logIds)) return 0;
$total = count($logIds);
$linked = 0;
$cursor = 0;
foreach ($variants as $i => $variant) {
$isLast = $i === count($variants) - 1;
$sliceSize = $isLast
? $total - $cursor
: (int) round($total * $variant->split_percentage / 100);
$slice = array_slice($logIds, $cursor, $sliceSize);
$cursor += $sliceSize;
if (empty($slice)) continue;
DB::table('tracking_logs')
->whereIn('id', $slice)
->update(['ab_test_variant_id' => $variant->id]);
$linked += count($slice);
}
return $linked;
}
/**
* Materialize ab_test_assignments rows from the tracking_logs we just
* stamped. One assignment per (subscriber_id, variant_id) pair.
*/
protected function createAssignmentsFromLogs(AbTest $abTest, Campaign $campaign): int
{
$rows = DB::table('tracking_logs')
->select('subscriber_id', 'ab_test_variant_id')
->where('campaign_id', $campaign->id)
->whereNotNull('ab_test_variant_id')
->distinct()
->get()
->map(fn($r) => [
'ab_test_id' => $abTest->id,
'subscriber_id' => $r->subscriber_id,
'variant_id' => $r->ab_test_variant_id,
'created_at' => now(),
])
->all();
if (empty($rows)) return 0;
foreach (array_chunk($rows, 1000) as $chunk) {
DB::table('ab_test_assignments')->insert($chunk);
}
return count($rows);
}
/**
* Pick the variant with the highest click rate as the winner. Reads from
* the freshly-stamped tracking_logs + click_logs join (same query as the
* report controller, so the winner banner reflects what users see).
*/
protected function pickWinnerByClickRate(array $variants): AbTestVariant
{
$best = null;
$bestRate = -1;
foreach ($variants as $v) {
$delivered = TrackingLog::where('ab_test_variant_id', $v->id)
->where('status', 'sent')->count();
if ($delivered === 0) continue;
$clicks = DB::table('click_logs')
->join('tracking_logs', 'tracking_logs.message_id', '=', 'click_logs.message_id')
->where('tracking_logs.ab_test_variant_id', $v->id)
->distinct('tracking_logs.subscriber_id')
->count('tracking_logs.subscriber_id');
$rate = $clicks / max($delivered, 1);
if ($rate > $bestRate) {
$bestRate = $rate;
$best = $v;
}
}
return $best ?? $variants[0];
}
}