HEX
Server: LiteSpeed
System: Linux s1049.use1.mysecurecloudhost.com 4.18.0-477.27.2.lve.el8.x86_64 #1 SMP Wed Oct 11 12:32:56 UTC 2023 x86_64
User: xedaptot (3356)
PHP: 8.3.31
Disabled: NONE
Upload Files
File: /home/xedaptot/ai.naniguide.com/database/seeders/Demo/Stage14_AutomationsSeeder.php
<?php

namespace Database\Seeders\Demo;

use App\Domain\Automation\Enum\Branch;
use App\Domain\Automation\Enum\NodeType;
use App\Domain\Automation\Enum\TriggerKey;
use App\Domain\Automation\Flow;
use App\Domain\Automation\FlowMutator;
use App\Domain\Automation\Node;
use App\Domain\Automation\Runtime\Phase;
use App\Model\Automation2;
use App\Model\AutomationEmail;
use App\Model\AutoTrigger;
use App\Model\MailList;
use App\Model\SendingServer;
use App\Model\Subscriber;
use App\Model\Template;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

/**
 * Stage 14 — "My Sales Pipeline" automation under Emma + 500 trigger rows
 * spread across every Phase / branch. Tuned for screenshot demos:
 *
 *   Trigger(welcome-new-subscriber)
 *     → Send #1 (template: Welcome customers)        ← welcome message
 *       → Wait(P3D)
 *         → Condition #1 (open Send #1, timeout P5D)
 *           ├─ Yes → Operation(tag: engaged-customer)        [leaf]
 *           └─ No  → Send #2 (template: Order Again)         ← reminder
 *                     → Condition #2 (open Send #2, timeout P5D)
 *                        ├─ Yes → Operation(tag: engaged-customer)  [leaf]
 *                        └─ No  →  (leaf — silent drop-off)
 *
 *   Bound to "Webinar Signups (Awesome Solutions)" (~2.3k subscribed) — a
 *   mid-sized list so the Subscribers page shows a healthy mix of involved /
 *   not-yet-involved members rather than the entire mailing list.
 *
 * Trigger rows: ~60% of the list's subscribed members get an auto_triggers
 * row (the rest are "not in workflow"). Distribution covers all branches +
 * all phases — see DIST_PCT for exact percentages:
 *   - 40% completed across 3 outcomes (Yes-fast / Yes-late / No-No drop-off)
 *   - 10% failed at random in-flow node
 *   - 50% in-flight at every wait / send / condition / operation node
 *
 * Tracking data: every subscriber past a Send node gets one tracking_logs
 * row + (~45%) open_logs + (~12%) click_logs, IPs picked from the fixed
 * 100-IP pool reused from Stage11 so geo charts stay coherent.
 *
 * Run order: AFTER Stage07 (lists), Stage08 (subscribers), Stage09 (templates),
 * Stage11 (so IP_POOL is loaded).
 */
class Stage14_AutomationsSeeder extends AbstractDemoSeeder
{
    protected int $fakerOffset = 14;

    protected const TARGET_LIST_NAME    = 'Webinar Signups (Awesome Solutions)';
    protected const AUTOMATION_NAME     = 'My Sales Pipeline';
    /** Fraction of the target list's subscribed members to involve in the automation. */
    protected const INVOLVE_FRACTION    = 0.60;
    protected const ENGAGED_TAG         = 'engaged-customer';
    protected const TRACKED_LINK_URL    = 'https://acelle-demo.com/sales-pipeline-cta';

    /** Tracking sim rates — match Stage11 distribution so charts read consistent. */
    protected const OPEN_RATE  = 0.45;
    protected const CLICK_RATE = 0.12;

    /**
     * Phase distribution as PERCENTAGES (must sum to 1.0). Applied to the
     * dynamic total computed from `INVOLVE_FRACTION × subscribed-count`.
     *
     * Buckets map to (phase, current_node) pairs and predict which Send nodes
     * each subscriber has been past (drives tracking-log generation downstream).
     */
    protected const DIST_PCT = [
        // Terminal — reached a leaf (40% total)
        'completed_yes_fast' => 0.20,   // Send1 → Wait → Cond1(Yes) → Op1(engaged) → done
        'completed_yes_late' => 0.10,   // Send1 → Wait → Cond1(No) → Send2 → Cond2(Yes) → Op2(engaged) → done
        'completed_no_no'    => 0.10,   // Send1 → Wait → Cond1(No) → Send2 → Cond2(No) → done (silent drop)
        'failed'             => 0.10,   // failed somewhere along the way
        // In-flight (50% total)
        'send1_queued'       => 0.08,   // queued at Send #1, hasn't sent yet
        'wait_after_welcome' => 0.12,   // past Send1, waiting at Wait(P3D)
        'cond1_waiting'      => 0.08,   // at Cond1, waiting for open timeout
        'op1_ready'          => 0.05,   // at Op1 (Yes branch, just landed there)
        'send2_queued'       => 0.06,   // at Send #2 (No branch from Cond1)
        'cond2_waiting'      => 0.06,   // at Cond2, waiting for open timeout
        'op2_ready'          => 0.05,   // at Op2 (Yes branch from Cond2)
    ];

    public function run(): void
    {
        $emma = Stage03_CustomersSeeder::$created['primary'] ?? null;
        if (!$emma) {
            $this->log('emma customer not found — skipping');
            return;
        }

        $list = MailList::where('customer_id', $emma->id)
            ->where('name', self::TARGET_LIST_NAME)
            ->first();
        if (!$list) {
            $this->log('target list "' . self::TARGET_LIST_NAME . '" not found — skipping');
            return;
        }

        $templates = $this->resolveTemplates();
        if (count($templates) < 2) {
            $this->log('need 2 named templates (Welcome customers / Order Again) — skipping');
            return;
        }

        $sendingServer = SendingServer::whereNull('customer_id')->first();
        if (!$sendingServer) {
            $this->log('no system sending server — skipping');
            return;
        }

        // ── 1. Build flow + persist Automation2 ─────────────────────────────
        [$flow, $nodes, $autoEmails] = $this->buildFlowAndEmails($emma->id, $templates);

        $automation = new Automation2();
        $automation->customer_id  = $emma->id;
        $automation->name         = self::AUTOMATION_NAME;
        $automation->mail_list_id = $list->id;
        $automation->status       = Automation2::STATUS_ACTIVE;
        $automation->data         = $flow->toJson();
        $automation->save();

        // Stitch each AutomationEmail row to its Send node id (so the runtime
        // can resolve emailUid → email when a Send tick fires). Order matches
        // resolveTemplates(): [0]=Welcome customers→send1, [1]=Order Again→send2.
        $sendNodeIds = [$nodes['send1'], $nodes['send2']];
        foreach ($autoEmails as $i => $autoEmail) {
            $autoEmail->automation2_id = $automation->id;
            $autoEmail->action_id      = $sendNodeIds[$i];
            $autoEmail->save();
        }

        // Re-write flow JSON now that AutomationEmail UIDs are known
        // (Send node `data.emailUid` placeholders → real UIDs).
        $automation->data = $this->bindEmailUids($flow, $autoEmails)->toJson();
        $automation->save();

        $this->log("created automation #{$automation->id} '{$automation->name}' on list '{$list->name}' (2 emails)");

        // ── 2. Pick 60% of the list's subscribed members ─────────────────────
        // The other 40% stay outside the automation so the Subscribers page
        // shows a realistic "in workflow vs. not-yet-involved" split.
        $subscribedTotal = (int) DB::table('subscribers')
            ->where('mail_list_id', $list->id)
            ->where('status', Subscriber::STATUS_SUBSCRIBED)
            ->count();
        $involveCount = (int) round($subscribedTotal * self::INVOLVE_FRACTION);

        $subscriberIds = DB::table('subscribers')
            ->where('mail_list_id', $list->id)
            ->where('status', Subscriber::STATUS_SUBSCRIBED)
            ->orderBy('id')
            ->limit($involveCount)
            ->pluck('id')
            ->all();

        $this->log("list has {$subscribedTotal} subscribed members; involving "
            . count($subscriberIds) . ' (~' . (int) (self::INVOLVE_FRACTION * 100) . '%)');

        // ── 3. Insert AutoTrigger rows + tracking data ──────────────────────
        $this->seedTriggerRowsAndTracking($automation, $autoEmails, $nodes, $subscriberIds, $sendingServer->id, $emma->id);
    }

    /**
     * Build the flow graph + AutomationEmail rows. Returns [Flow, nodeIdMap, [AutomationEmail × 3]].
     * The Send nodes get placeholder emailUids initially because AutomationEmail rows are inserted
     * AFTER Automation2 (FK). We re-bind in the caller via bindEmailUids().
     */
    protected function buildFlowAndEmails(int $customerId, array $templates): array
    {
        $flow = new Flow(
            [new Node(Flow::TRIGGER_ID, NodeType::Trigger, ['key' => TriggerKey::WelcomeNewSubscriber->value])],
            []
        );

        // Trigger → Send #1 (welcome) → Wait(P3D) → Cond #1
        [$flow, $send1] = FlowMutator::insertAfter($flow, Flow::TRIGGER_ID, null, NodeType::Send,
            ['emailUid' => '__send1__', 'emailSubject' => 'Welcome to the family']);
        [$flow, $wait]  = FlowMutator::insertAfter($flow, $send1->id, null, NodeType::Wait,
            ['duration' => 'P3D']);
        [$flow, $cond1] = FlowMutator::insertAfter($flow, $wait->id, null, NodeType::Condition,
            ['kind' => 'open', 'emailUid' => '__send1__', 'timeout' => 'P5D']);

        // Cond #1 Yes → Op #1 (engaged-customer tag)  [leaf]
        [$flow, $op1] = FlowMutator::insertAfter($flow, $cond1->id, Branch::Yes, NodeType::Operation,
            ['kind' => 'tag', 'tags' => [self::ENGAGED_TAG]]);

        // Cond #1 No → Send #2 (reminder) → Cond #2
        [$flow, $send2] = FlowMutator::insertAfter($flow, $cond1->id, Branch::No, NodeType::Send,
            ['emailUid' => '__send2__', 'emailSubject' => 'Just a friendly reminder']);
        [$flow, $cond2] = FlowMutator::insertAfter($flow, $send2->id, null, NodeType::Condition,
            ['kind' => 'open', 'emailUid' => '__send2__', 'timeout' => 'P5D']);

        // Cond #2 Yes → Op #2 (engaged-customer tag)  [leaf]
        // Cond #2 No → (no node — silent drop-off, leaf)
        [$flow, $op2] = FlowMutator::insertAfter($flow, $cond2->id, Branch::Yes, NodeType::Operation,
            ['kind' => 'tag', 'tags' => [self::ENGAGED_TAG]]);

        $nodes = [
            'trigger' => Flow::TRIGGER_ID,
            'send1'   => $send1->id,
            'wait'    => $wait->id,
            'cond1'   => $cond1->id,
            'op1'     => $op1->id,
            'send2'   => $send2->id,
            'cond2'   => $cond2->id,
            'op2'     => $op2->id,
        ];

        // 2 AutomationEmail rows (welcome + reminder)
        $autoEmails = [];
        $subjects   = ['Welcome to the family', 'Just a friendly reminder'];
        foreach ($templates as $i => $template) {
            $ae = AutomationEmail::newDefault();
            $ae->customer_id    = $customerId;
            $ae->subject        = $subjects[$i];
            $ae->from_email     = '[email protected]';
            $ae->from_name      = 'Awesome Solutions Sales';
            $ae->reply_to       = '[email protected]';
            $ae->action_id      = '';
            $ae->automation2_id = 0;
            $autoEmails[] = $ae;
        }
        return [$flow, $nodes, $autoEmails];
    }

    /**
     * Replace placeholder `__sendN__` emailUid strings in the flow (used in
     * BOTH Send and Condition nodes) with the just-saved AutomationEmail UIDs.
     * Returns a new Flow (immutable shape).
     *
     * Map: __send1__ → autoEmails[0]->uid, __send2__ → autoEmails[1]->uid.
     */
    protected function bindEmailUids(Flow $flow, array $autoEmails): Flow
    {
        $arr = json_decode($flow->toJson(), true);
        $map = ['__send1__' => $autoEmails[0]->uid, '__send2__' => $autoEmails[1]->uid];
        foreach ($arr['nodes'] as &$n) {
            if (isset($n['data']['emailUid']) && isset($map[$n['data']['emailUid']])) {
                $n['data']['emailUid'] = $map[$n['data']['emailUid']];
            }
        }
        return Flow::fromJson(json_encode($arr));
    }

    /**
     * Resolve the 2 named templates: Welcome customers (welcome message) +
     * Order Again (reminder). Pick the lowest-id master (system template,
     * before customer copies) for each.
     */
    protected function resolveTemplates(): array
    {
        $names = ['Welcome customers', 'Order Again'];
        $out = [];
        foreach ($names as $name) {
            $tpl = Template::where('name', $name)->orderBy('id')->first();
            if ($tpl) $out[] = $tpl;
        }
        return $out;
    }

    /**
     * Stitch each AutomationEmail's Email body to a real Template's HTML, so
     * preview / open / click pages render real content (not placeholder).
     * Called after AutomationEmail->save() because we need email_id populated.
     */
    protected function bindTemplatesToEmails(array $autoEmails, array $templates): void
    {
        foreach ($autoEmails as $i => $ae) {
            if (!$ae->email || empty($templates[$i])) continue;
            try {
                $ae->email->setTemplate($templates[$i], $templates[$i]->name);
                $ae->email->save();
            } catch (\Throwable $e) {
                $this->log("template bind failed for email #{$ae->id}: " . $e->getMessage());
            }
        }
    }

    /**
     * Insert 500 AutoTrigger rows distributed across phases per self::DIST,
     * plus tracking_logs / open_logs / click_logs for each subscriber that
     * progressed past a Send node.
     */
    protected function seedTriggerRowsAndTracking(
        Automation2 $automation,
        array $autoEmails,                    // [AutomationEmail × 3]
        array $nodes,                          // node id map
        array $subscriberIds,
        int $sendingServerId,
        int $customerId,
    ): void {
        $f = $this->faker();
        $available = count($subscriberIds);

        // Convert DIST_PCT → integer counts proportional to $available, with
        // the LAST bucket absorbing rounding error so the total still matches
        // exactly. Iteration order is preserved because DIST_PCT is an array literal.
        $bucketKeys  = array_keys(self::DIST_PCT);
        $lastKey     = end($bucketKeys);
        $counts      = [];
        $assigned    = 0;
        foreach (self::DIST_PCT as $bucket => $pct) {
            if ($bucket === $lastKey) {
                $counts[$bucket] = max(0, $available - $assigned);
            } else {
                $n = (int) round($available * $pct);
                $counts[$bucket] = $n;
                $assigned += $n;
            }
        }

        // Slice the subscriber array into per-bucket allocations — no overlap.
        $cursor = 0;
        $alloc  = [];
        foreach ($counts as $bucket => $count) {
            $take = min($count, max(0, $available - $cursor));
            $alloc[$bucket] = array_slice($subscriberIds, $cursor, $take);
            $cursor += $take;
        }

        // Bind templates AFTER autoEmails are saved (we need email_id). The
        // caller saved them via the Automation2 FK loop, so this is safe now.
        $this->bindTemplatesToEmails($autoEmails, $this->resolveTemplates());

        $triggerRows  = [];
        $now          = Carbon::now();

        // ── Build trigger rows per bucket ────────────────────────────────────
        foreach ($alloc as $bucket => $subs) {
            foreach ($subs as $subId) {
                // entryAt: spread last 30 days
                $entryAt = $now->copy()->subDays($f->numberBetween(1, 30))->subHours($f->numberBetween(0, 23));
                $row = $this->makeTriggerRow($bucket, $subId, $automation->id, $customerId, $nodes, $entryAt, $f);
                $triggerRows[] = $row;
            }
        }

        // ── Bulk insert ──────────────────────────────────────────────────────
        foreach (array_chunk($triggerRows, 500) as $chunk) {
            DB::table('auto_triggers')->insert($chunk);
        }

        // ── Tag subscribers whose run reached an Operation node ─────────────
        // Both Operation nodes apply the same `engaged-customer` tag. We
        // replicate the handler's side effect in DB so Subscribers-page tag
        // chips + the "engaged-customer" segment look coherent.
        //
        // Reached Op1 (Yes-fast path): completed_yes_fast + op1_ready
        // Reached Op2 (Yes-late path): completed_yes_late + op2_ready
        $taggedBuckets = ['completed_yes_fast', 'op1_ready', 'completed_yes_late', 'op2_ready'];
        $taggedIds = [];
        foreach ($taggedBuckets as $b) {
            foreach ($alloc[$b] ?? [] as $id) $taggedIds[] = $id;
        }
        if (!empty($taggedIds)) {
            $tagJson = json_encode([self::ENGAGED_TAG]);
            // JSON_MERGE_PRESERVE keeps any existing tags; coalesce NULL→'[]' first.
            DB::table('subscribers')->whereIn('id', $taggedIds)->update([
                'tags' => DB::raw("JSON_MERGE_PRESERVE(COALESCE(tags, JSON_ARRAY()), '{$tagJson}')"),
            ]);
        }

        // ── Tracking-log generation: which Sends has each bucket been past? ─
        //
        // Flow recap: Trigger → Send1 → Wait → Cond1 → (Yes: Op1) | (No: Send2 → Cond2 → Yes: Op2)
        //
        // 'completed_yes_fast' : Send1 delivered, Cond1 saw open → Op1 → done
        // 'completed_yes_late' : Send1 delivered, Cond1 No → Send2 delivered, Cond2 Yes → Op2 → done
        // 'completed_no_no'    : Send1 delivered, Cond1 No → Send2 delivered, Cond2 No → done
        // 'op1_ready'          : past Send1 (at Op1)
        // 'op2_ready'          : past Send1 + Send2 (at Op2)
        // 'cond1_waiting'      : past Send1
        // 'cond2_waiting'      : past Send1 + Send2
        // 'send2_queued'       : past Send1 (Send2 not delivered yet)
        // 'wait_after_welcome' : past Send1
        // 'send1_queued'       : Send1 not delivered yet
        // 'failed'             : assume past Send1 (most failures happen after the first send)
        $pastSends = [
            'completed_yes_fast' => ['send1'],
            'completed_yes_late' => ['send1', 'send2'],
            'completed_no_no'    => ['send1', 'send2'],
            'op1_ready'          => ['send1'],
            'op2_ready'          => ['send1', 'send2'],
            'cond1_waiting'      => ['send1'],
            'cond2_waiting'      => ['send1', 'send2'],
            'send2_queued'       => ['send1'],
            'wait_after_welcome' => ['send1'],
            'send1_queued'       => [],
            'failed'             => ['send1'],
        ];

        $totalTracking = $totalOpens = $totalClicks = 0;
        $ipPool = Stage11_TrackingLogsSeeder::IP_POOL;

        foreach ($alloc as $bucket => $subs) {
            $sends = $pastSends[$bucket] ?? [];
            if (empty($sends) || empty($subs)) continue;

            foreach ($sends as $sendKey) {
                $autoEmail = $autoEmails[(int) substr($sendKey, -1) - 1];
                $sentBase  = $now->copy()->subDays($f->numberBetween(1, 30));

                $trackingRows = [];
                $openRows     = [];
                $clickRows    = [];

                foreach ($subs as $subId) {
                    $sentAt    = $sentBase->copy()->addMinutes($f->numberBetween(0, 60 * 24));
                    if ($sentAt->isFuture()) $sentAt = $now;
                    $messageId = Str::uuid()->toString();

                    $trackingRows[] = [
                        'runtime_message_id'  => Str::random(20),
                        'message_id'          => $messageId,
                        'customer_id'         => $customerId,
                        'sending_server_id'   => $sendingServerId,
                        'campaign_id'         => null,
                        'automation_email_id' => $autoEmail->id,
                        'subscriber_id'       => $subId,
                        'status'              => 'sent',
                        'created_at'          => $sentAt,
                        'updated_at'          => $sentAt,
                    ];

                    if ($f->boolean(self::OPEN_RATE * 100)) {
                        $window   = max(1, $sentAt->diffInMinutes($now));
                        $openedAt = $sentAt->copy()->addMinutes($f->numberBetween(1, min(60 * 24 * 7, $window)));
                        if ($openedAt->isFuture()) $openedAt = $now;
                        $ip = $f->randomElement($ipPool);
                        $openRows[] = [
                            'message_id'      => $messageId,
                            'customer_id'     => $customerId,
                            'tracking_log_id' => null, // backfilled after tracking insert
                            'ip_address'      => $ip,
                            'user_agent'      => $f->userAgent,
                            'created_at'      => $openedAt,
                            'updated_at'      => $openedAt,
                        ];

                        if ($f->boolean(self::CLICK_RATE * 100)) {
                            $clickWindow = max(1, $openedAt->diffInMinutes($now));
                            $clickedAt = $openedAt->copy()->addMinutes($f->numberBetween(1, min(60, $clickWindow)));
                            if ($clickedAt->isFuture()) $clickedAt = $now;
                            $clickRows[] = [
                                'message_id'      => $messageId,
                                'customer_id'     => $customerId,
                                'tracking_log_id' => null, // backfilled after tracking insert
                                'url'             => self::TRACKED_LINK_URL,
                                'ip_address'      => $ip,
                                'user_agent'      => $f->userAgent,
                                'created_at'      => $clickedAt,
                                'updated_at'      => $clickedAt,
                            ];
                        }
                    }
                }

                foreach (array_chunk($trackingRows, 500) as $chunk) DB::table('tracking_logs')->insert($chunk);

                // Backfill tracking_log_id on open/click rows (production code
                // populates this FK; without it, queries that JOIN via
                // tracking_log_id return 0 — same bug as Stage11 for campaigns).
                if (!empty($openRows) || !empty($clickRows)) {
                    $messageIds = array_column($trackingRows, 'message_id');
                    $idMap = [];
                    foreach (array_chunk($messageIds, 1000) as $mChunk) {
                        foreach (DB::table('tracking_logs')->whereIn('message_id', $mChunk)->get(['id', 'message_id']) as $row) {
                            $idMap[$row->message_id] = $row->id;
                        }
                    }
                    foreach ($openRows as &$r)  { $r['tracking_log_id'] = $idMap[$r['message_id']] ?? null; }
                    unset($r);
                    foreach ($clickRows as &$r) { $r['tracking_log_id'] = $idMap[$r['message_id']] ?? null; }
                    unset($r);
                }

                foreach (array_chunk($openRows, 500)     as $chunk) DB::table('open_logs')->insert($chunk);
                foreach (array_chunk($clickRows, 500)    as $chunk) DB::table('click_logs')->insert($chunk);

                $totalTracking += count($trackingRows);
                $totalOpens    += count($openRows);
                $totalClicks   += count($clickRows);
            }
        }

        $this->log(sprintf(
            'seeded %d trigger rows (%s); tracking=%d opens=%d clicks=%d',
            array_sum(array_map('count', $alloc)),
            implode(', ', array_map(fn ($k) => "$k=" . count($alloc[$k]), array_keys($alloc))),
            $totalTracking,
            $totalOpens,
            $totalClicks,
        ));
    }

    /**
     * Compose one auto_triggers DB row matching the bucket's intended phase /
     * current_node / state shape. Bucket → (phase, current_node) mapping is
     * documented in self::DIST.
     */
    protected function makeTriggerRow(string $bucket, int $subId, int $autoId, int $customerId, array $nodes, Carbon $entryAt, \Faker\Generator $f): array
    {
        $base = [
            'uid'                => Str::uuid()->toString(),
            'subscriber_id'      => $subId,
            'automation2_id'     => $autoId,
            'customer_id'        => $customerId,
            'created_at'         => $entryAt,
            'updated_at'         => $entryAt->copy()->addMinutes($f->numberBetween(1, 60 * 24 * 7)),
            'state'              => json_encode([]),
            'history'            => json_encode([['outcome' => 'entry', 'at' => $entryAt->toIso8601String()]]),
            'scheduled_at'       => null,
            'error'              => null,
            'held_at'            => null,
            'trigger_session_id' => null,
        ];

        // Helper: standard "queued at Send" state (matches SendHandler 15-min safety net)
        $queuedSendState = fn () => json_encode([
            'queued'    => true,
            'queued_at' => Carbon::now()->subMinutes($f->numberBetween(1, 30))->toIso8601String(),
            'job_id'    => $f->numberBetween(1000, 9999),
        ]);

        return match ($bucket) {
            // ── Terminal ──────────────────────────────────────────────────────
            'completed_yes_fast' => array_merge($base, [
                'phase'           => Phase::Completed->value,
                'current_node_id' => $nodes['op1'],
                'updated_at'      => $entryAt->copy()->addDays($f->numberBetween(5, 12)),
                'state'           => json_encode(['sent_at' => $entryAt->copy()->addDays(1)->toIso8601String()]),
            ]),
            'completed_yes_late' => array_merge($base, [
                'phase'           => Phase::Completed->value,
                'current_node_id' => $nodes['op2'],
                'updated_at'      => $entryAt->copy()->addDays($f->numberBetween(10, 20)),
                'state'           => json_encode(['sent_at' => $entryAt->copy()->addDays(10)->toIso8601String()]),
            ]),
            'completed_no_no' => array_merge($base, [
                'phase'           => Phase::Completed->value,
                'current_node_id' => $nodes['cond2'],     // Cond2 No branch is a leaf
                'updated_at'      => $entryAt->copy()->addDays($f->numberBetween(13, 20)),
                'state'           => json_encode(['startedAt' => $entryAt->copy()->addDays(8)->toIso8601String()]),
            ]),
            'failed' => array_merge($base, [
                'phase'           => Phase::Failed->value,
                'current_node_id' => $f->randomElement([$nodes['send1'], $nodes['cond1'], $nodes['send2'], $nodes['cond2']]),
                'error'           => $f->randomElement([
                    'OutOfCredits: customer has 0 sending credits',
                    'SMTP error 550: mailbox does not exist',
                    'NodeOptionsValidator: invalid email template binding',
                    'Sending server timeout after 30s',
                ]),
            ]),

            // ── In-flight: at Send nodes (queued, 15-min safety-net wait) ────
            'send1_queued' => array_merge($base, [
                'phase'           => Phase::Waiting->value,
                'current_node_id' => $nodes['send1'],
                'scheduled_at'    => Carbon::now()->addMinutes($f->numberBetween(1, 15)),
                'state'           => $queuedSendState(),
            ]),
            'send2_queued' => array_merge($base, [
                'phase'           => Phase::Waiting->value,
                'current_node_id' => $nodes['send2'],
                'scheduled_at'    => Carbon::now()->addMinutes($f->numberBetween(1, 15)),
                'state'           => $queuedSendState(),
            ]),

            // ── In-flight: at Wait node ──────────────────────────────────────
            'wait_after_welcome' => array_merge($base, [
                'phase'           => Phase::Waiting->value,
                'current_node_id' => $nodes['wait'],
                'scheduled_at'    => Carbon::now()->addDays($f->numberBetween(1, 3)),
                'state'           => json_encode(['scheduled' => true]),
            ]),

            // ── In-flight: at Condition nodes (waiting on open timeout) ──────
            'cond1_waiting' => array_merge($base, [
                'phase'           => Phase::Waiting->value,
                'current_node_id' => $nodes['cond1'],
                'scheduled_at'    => Carbon::now()->addDays($f->numberBetween(1, 5)),
                'state'           => json_encode([
                    'startedAt' => Carbon::now()->subDays($f->numberBetween(0, 2))->toIso8601String(),
                ]),
            ]),
            'cond2_waiting' => array_merge($base, [
                'phase'           => Phase::Waiting->value,
                'current_node_id' => $nodes['cond2'],
                'scheduled_at'    => Carbon::now()->addDays($f->numberBetween(1, 5)),
                'state'           => json_encode([
                    'startedAt' => Carbon::now()->subDays($f->numberBetween(0, 2))->toIso8601String(),
                ]),
            ]),

            // ── In-flight: at Operation nodes (just landed, ready to apply) ──
            'op1_ready' => array_merge($base, [
                'phase'           => Phase::Running->value,
                'current_node_id' => $nodes['op1'],
                'state'           => json_encode([]),
            ]),
            'op2_ready' => array_merge($base, [
                'phase'           => Phase::Running->value,
                'current_node_id' => $nodes['op2'],
                'state'           => json_encode([]),
            ]),
        };
    }
}