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/be.naniguide.com/app/Http/Controllers/Refactor/AutomationFlowController.php
<?php

namespace App\Http\Controllers\Refactor;

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\FlowException;
use App\Domain\Automation\FlowMutator;
use App\Domain\Automation\NodeOptionsValidator;
use App\Http\Controllers\Controller;
use App\Model\Automation2;
use Gate;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

/**
 * REST endpoints for the new graph-normalized flow editor.
 *
 *   GET    /automations/{uid}/flow
 *   POST   /automations/{uid}/nodes/new/{type}
 *   PATCH  /automations/{uid}/nodes/{nodeId}
 *   DELETE /automations/{uid}/nodes/{nodeId}?mode=shift|cascade
 *   PATCH  /automations/{uid}/trigger
 *
 * Storage: writes graph-normalized JSON straight to automation2s.data.
 * Runtime compatibility: legacy `Automation2::check()` / `Action` subclasses
 * fail-fast on this shape (see Automation2::isV2FlowShape()).
 */
class AutomationFlowController extends Controller
{
    public function show(Request $request, string $uid): JsonResponse
    {
        $automation = $this->resolveAutomation($uid, 'update');
        if ($automation instanceof JsonResponse) return $automation;

        return $this->ok(Flow::fromJson($automation->data));
    }

    public function createNode(Request $request, string $uid, string $type): JsonResponse
    {
        $automation = $this->resolveAutomation($uid, 'update');
        if ($automation instanceof JsonResponse) return $automation;

        $nodeType = NodeType::tryFrom($type);
        if ($nodeType === null || $nodeType === NodeType::Trigger) {
            return $this->fail("Unsupported node type: {$type}", 422);
        }

        $validated = $request->validate([
            'data'               => 'sometimes|array',
            'insertAfter'        => 'sometimes|nullable|array',
            'insertAfter.nodeId' => 'sometimes|required|string',
            'insertAfter.branch' => 'sometimes|nullable|in:yes,no',
            'insertBefore'       => 'sometimes|nullable|string',
            'insertOnEdge'       => 'sometimes|nullable|string',
        ]);

        // Per-type schema enforcement (rejects unknown kinds, malformed durations, …)
        try {
            $data = NodeOptionsValidator::validateForType($nodeType, $validated['data'] ?? []);
        } catch (FlowException $e) {
            return $this->fail($e->getMessage(), 422);
        }

        // Position fields are mutually exclusive
        $positions = array_filter([
            'insertAfter'  => $validated['insertAfter']  ?? null,
            'insertBefore' => $validated['insertBefore'] ?? null,
            'insertOnEdge' => $validated['insertOnEdge'] ?? null,
        ]);
        if (count($positions) !== 1) {
            return $this->fail('Provide exactly one of insertAfter / insertBefore / insertOnEdge', 422);
        }

        try {
            $flow = Flow::fromJson($automation->data);

            if (isset($positions['insertAfter'])) {
                $branch = isset($positions['insertAfter']['branch'])
                    ? Branch::tryFrom($positions['insertAfter']['branch'])
                    : null;
                [$flow, $node] = FlowMutator::insertAfter(
                    $flow,
                    $positions['insertAfter']['nodeId'],
                    $branch,
                    $nodeType,
                    $data
                );
            } elseif (isset($positions['insertBefore'])) {
                [$flow, $node] = FlowMutator::insertBefore(
                    $flow, $positions['insertBefore'], $nodeType, $data
                );
            } else {
                [$flow, $node] = FlowMutator::insertOnEdge(
                    $flow, $positions['insertOnEdge'], $nodeType, $data
                );
            }
        } catch (FlowException $e) {
            return $this->fail($e->getMessage(), 422);
        }

        $this->persist($automation, $flow);
        return $this->ok($flow, $node->toArray());
    }

    public function updateNode(Request $request, string $uid, string $nodeId): JsonResponse
    {
        $automation = $this->resolveAutomation($uid, 'update');
        if ($automation instanceof JsonResponse) return $automation;

        $validated = $request->validate([
            'data' => 'required|array',
            // Optional: when present and different from current node type, the
            // node's TYPE is swapped (id + edges preserved). Today only used by
            // the unified Wait sidebar (mode toggle wait <-> wait_until). Narrow
            // allowlist keeps the swap surface tight to that feature.
            'type' => 'sometimes|string|in:wait,wait_until',
        ]);

        try {
            $flow = Flow::fromJson($automation->data);

            // Per-type validation needs the node's type — fetch first
            $existing = $flow->node($nodeId);
            if ($existing->type === NodeType::Trigger) {
                return $this->fail('Use PATCH /trigger to update the trigger', 422);
            }

            $targetType = isset($validated['type'])
                ? NodeType::from($validated['type'])
                : $existing->type;

            $data = NodeOptionsValidator::validateForType($targetType, $validated['data']);

            [$flow, $node] = $targetType === $existing->type
                ? FlowMutator::replaceNode($flow, $nodeId, $data)
                : FlowMutator::replaceNodeWithType($flow, $nodeId, $targetType, $data);
        } catch (FlowException $e) {
            return $this->fail($e->getMessage(), 422);
        }

        $this->persist($automation, $flow);
        return $this->ok($flow, $node->toArray());
    }

    public function deleteNode(Request $request, string $uid, string $nodeId): JsonResponse
    {
        $automation = $this->resolveAutomation($uid, 'update');
        if ($automation instanceof JsonResponse) return $automation;

        $mode = $request->query('mode', 'shift');

        try {
            $flow = Flow::fromJson($automation->data);
            $flow = match ($mode) {
                'shift'   => FlowMutator::deleteShift($flow, $nodeId),
                'cascade' => FlowMutator::deleteCascade($flow, $nodeId),
                default   => throw new FlowException("Unknown delete mode: {$mode}"),
            };
        } catch (FlowException $e) {
            return $this->fail($e->getMessage(), 422);
        }

        $this->persist($automation, $flow);
        $this->cleanupOrphanEmails($automation, $flow);
        return $this->ok($flow);
    }

    /**
     * Update sender / tracking settings on the AutomationEmail bound to a Send
     * node. Content (HTML / template) is edited via the builder route — this
     * endpoint covers the metadata that wraps it: subject / from / reply / track.
     *
     *   PATCH /automations/{uid}/nodes/{nodeId}/email/settings
     */
    public function updateEmailSettings(Request $request, string $uid, string $nodeId): JsonResponse
    {
        $automation = $this->resolveAutomation($uid, 'update');
        if ($automation instanceof JsonResponse) return $automation;

        try {
            $flow = Flow::fromJson($automation->data);
            $node = $flow->node($nodeId);
            if ($node->type !== NodeType::Send) {
                return $this->fail("Node {$nodeId} is not a Send node", 422);
            }
        } catch (FlowException $e) {
            return $this->fail($e->getMessage(), 404);
        }

        $emailUid = $node->data['emailUid'] ?? null;
        if (!$emailUid) {
            return $this->fail('No email bound to this node', 422);
        }
        $email = \App\Model\AutomationEmail::findByUid($emailUid);
        if (!$email || $email->automation2_id !== $automation->id) {
            return $this->fail('Email not found', 404);
        }

        $validated = $request->validate([
            'subject'     => 'required|string|max:200',
            'fromName'    => 'required|string|max:100',
            'fromEmail'   => 'required|email|max:191',
            'replyTo'     => 'sometimes|nullable|email|max:191',
            'trackOpen'   => 'sometimes|boolean',
            'trackClick'  => 'sometimes|boolean',
            'signDkim'    => 'sometimes|boolean',
            'skipFailed'  => 'sometimes|boolean',
        ]);

        $email->subject    = $validated['subject'];
        $email->from_name  = $validated['fromName'];
        $email->from_email = $validated['fromEmail'];
        $email->reply_to   = $validated['replyTo'] ?? $validated['fromEmail'];
        if (array_key_exists('trackOpen', $validated))  $email->track_open  = (bool) $validated['trackOpen'];
        if (array_key_exists('trackClick', $validated)) $email->track_click = (bool) $validated['trackClick'];
        if (array_key_exists('signDkim', $validated))   $email->sign_dkim   = (bool) $validated['signDkim'];
        if (array_key_exists('skipFailed', $validated)) $email->skip_failed_message = (bool) $validated['skipFailed'];
        $email->save();

        // Mirror new subject onto node.data so the chip subtitle updates without
        // a full reload — sidebar shows the same value immediately.
        try {
            $newData = array_merge($node->data, ['emailSubject' => $email->subject]);
            [$flow, $node] = FlowMutator::replaceNode($flow, $nodeId, $newData);
        } catch (FlowException $e) {
            return $this->fail($e->getMessage(), 422);
        }
        $this->persist($automation, $flow);

        return response()->json([
            'ok'       => true,
            'flow'     => $this->wireFlow($flow, $automation),
            'node'     => $node->toArray(),
            'settings' => $this->emailSettingsPayload($email),
        ]);
    }

    /**
     * Read sender / tracking settings for a Send node's email — used to
     * populate the "Edit settings" form when the sidebar opens.
     *
     *   GET /automations/{uid}/nodes/{nodeId}/email/settings
     */
    public function showEmailSettings(Request $request, string $uid, string $nodeId): JsonResponse
    {
        $automation = $this->resolveAutomation($uid, 'view');
        if ($automation instanceof JsonResponse) return $automation;

        try {
            $flow = Flow::fromJson($automation->data);
            $node = $flow->node($nodeId);
            if ($node->type !== NodeType::Send) {
                return $this->fail("Node {$nodeId} is not a Send node", 422);
            }
        } catch (FlowException $e) {
            return $this->fail($e->getMessage(), 404);
        }
        $emailUid = $node->data['emailUid'] ?? null;
        if (!$emailUid) return $this->fail('No email bound to this node', 422);

        $email = \App\Model\AutomationEmail::findByUid($emailUid);
        if (!$email || $email->automation2_id !== $automation->id) {
            return $this->fail('Email not found', 404);
        }

        return response()->json(['ok' => true, 'settings' => $this->emailSettingsPayload($email)]);
    }

    private function emailSettingsPayload(\App\Model\AutomationEmail $email): array
    {
        $lastEditedAt = null;
        // Legacy semantics: "last edited" = the underlying Email row's updated_at,
        // since template/content edits propagate there. Falls back to the
        // AutomationEmail row when the inner email has no template yet.
        if ($email->hasTemplate()) {
            $stamp = $email->email?->updated_at ?? $email->updated_at;
            if ($stamp) $lastEditedAt = $stamp->diffForHumans();
        }

        return [
            'subject'        => $email->subject,
            'fromName'       => $email->from_name,
            'fromEmail'      => $email->from_email,
            'replyTo'        => $email->reply_to,
            'trackOpen'      => (bool) $email->track_open,
            'trackClick'     => (bool) $email->track_click,
            'signDkim'       => (bool) $email->sign_dkim,
            'skipFailed'     => (bool) $email->skip_failed_message,
            // Summary-only fields (read-only from UI perspective). These do NOT
            // come back through PATCH — they just decorate the sidebar row list.
            'hasContent'     => $email->hasTemplate(),
            'lastEditedAt'   => $lastEditedAt,
            'webhooksCount'  => method_exists($email, 'emailWebhooks') ? $email->emailWebhooks()->count() : 0,
            'thumbUrl'       => $email->hasTemplate() ? $email->getThumbUrl() : null,
            'isCustomHtml'   => $email->isCustomHtml(),
        ];
    }

    /**
     * Template picker modal — paginated gallery of system + customer templates.
     *
     *   GET /automations/{uid}/nodes/{nodeId}/email/template-gallery?from=mine|system&page=N
     */
    public function templateGallery(Request $request, string $uid, string $nodeId): JsonResponse
    {
        $automation = $this->resolveAutomation($uid, 'view');
        if ($automation instanceof JsonResponse) return $automation;

        try {
            $flow = Flow::fromJson($automation->data);
            $node = $flow->node($nodeId);
            if ($node->type !== NodeType::Send) {
                return $this->fail("Node {$nodeId} is not a Send node", 422);
            }
        } catch (FlowException $e) {
            return $this->fail($e->getMessage(), 404);
        }

        $from = $request->query('from') === 'mine' ? 'mine' : 'system';
        $page = max(1, (int) ($request->query('page') ?? 1));
        $per  = 12;

        // Both `CustomerEmailTemplate` (mine) and `SystemEmailTemplate` are
        // pivot-shaped: the actual renderable Template (with name, thumb,
        // categories) lives on `->template`. Eager-load it to avoid N+1, then
        // emit the underlying Template's uid since `templateChoose` resolves
        // pickable uids against `Template::findByUid` first.
        if ($from === 'mine') {
            $query = $automation->customer->customerEmailTemplates()->with('template');
        } else {
            $query = \App\Model\SystemEmailTemplate::query()->with('template');
        }

        $items = $query->orderBy('created_at', 'desc')
            ->paginate($per, ['*'], 'page', $page);

        $payload = $items->getCollection()
            ->map(function ($t) {
                $template = $t->template;
                if (!$template) return null; // orphaned pivot row — skip rather than 500
                return [
                    'uid'      => $template->uid,
                    'name'     => $t->name ?? $template->name ?? '(untitled)',
                    'thumbUrl' => $template->getThumbUrl(),
                ];
            })
            ->filter()
            ->values()
            ->all();

        return response()->json([
            'ok'         => true,
            'items'      => $payload,
            'total'      => $items->total(),
            'page'       => $items->currentPage(),
            'lastPage'   => $items->lastPage(),
            'from'       => $from,
        ]);
    }

    /**
     * Apply a chosen Template to the bound email.
     *
     *   POST /automations/{uid}/nodes/{nodeId}/email/template-choose
     *   body: { template_uid }
     */
    public function templateChoose(Request $request, string $uid, string $nodeId): JsonResponse
    {
        $automation = $this->resolveAutomation($uid, 'update');
        if ($automation instanceof JsonResponse) return $automation;

        $validated = $request->validate(['template_uid' => 'required|string|max:50']);

        try {
            $flow = Flow::fromJson($automation->data);
            $node = $flow->node($nodeId);
            if ($node->type !== NodeType::Send) {
                return $this->fail("Node {$nodeId} is not a Send node", 422);
            }
        } catch (FlowException $e) {
            return $this->fail($e->getMessage(), 404);
        }

        $emailUid = $node->data['emailUid'] ?? null;
        if (!$emailUid) return $this->fail('No email bound to this node', 422);
        $email = \App\Model\AutomationEmail::findByUid($emailUid);
        if (!$email || $email->automation2_id !== $automation->id) return $this->fail('Email not found', 404);

        // Try Template (customer-private) first, then SystemEmailTemplate.
        $template = \App\Model\Template::findByUid($validated['template_uid']);
        if (!$template) {
            $sys = \App\Model\SystemEmailTemplate::findByUid($validated['template_uid']);
            if ($sys) $template = $sys->template;
        }
        if (!$template) return $this->fail('Template not found', 404);

        \App\Services\TemplateService::for($email->email)->setTemplate($template, $email->subject ?: $automation->name);
        $email->email->updatePlainFromHtml();
        $email->unsetRelation('email');

        return response()->json([
            'ok'       => true,
            'message'  => 'Template applied',
            'settings' => $this->emailSettingsPayload($email->fresh()),
        ]);
    }

    /**
     * Replace the email body with raw HTML (legacy "Paste HTML" tab).
     *
     *   POST /automations/{uid}/nodes/{nodeId}/email/custom-html
     *   body: { html }
     */
    public function templateCustomHtml(Request $request, string $uid, string $nodeId): JsonResponse
    {
        $automation = $this->resolveAutomation($uid, 'update');
        if ($automation instanceof JsonResponse) return $automation;

        $validated = $request->validate(['html' => 'required|string']);

        try {
            $flow = Flow::fromJson($automation->data);
            $node = $flow->node($nodeId);
            if ($node->type !== NodeType::Send) {
                return $this->fail("Node {$nodeId} is not a Send node", 422);
            }
        } catch (FlowException $e) {
            return $this->fail($e->getMessage(), 404);
        }

        $emailUid = $node->data['emailUid'] ?? null;
        if (!$emailUid) return $this->fail('No email bound to this node', 422);
        $email = \App\Model\AutomationEmail::findByUid($emailUid);
        if (!$email || $email->automation2_id !== $automation->id) return $this->fail('Email not found', 404);

        if ($email->isCustomHtml()) {
            $email->template->updateContent(\App\Model\Template::DEFAULT_BUILDER_JSON, $validated['html']);
        } else {
            \App\Services\TemplateService::for($email->email)->setCustomHtml($validated['html'], $email->subject ?: $automation->name);
            $email->email->updatePlainFromHtml();
            $email->unsetRelation('email');
        }

        return response()->json([
            'ok'       => true,
            'message'  => 'Custom HTML applied',
            'settings' => $this->emailSettingsPayload($email->fresh()),
        ]);
    }

    /**
     * Send a test message of the email bound to a Send node to a given address.
     *
     *   POST /automations/{uid}/nodes/{nodeId}/email/test
     *   body: { to: "[email protected]" }
     */
    public function sendTestEmail(Request $request, string $uid, string $nodeId): JsonResponse
    {
        $automation = $this->resolveAutomation($uid, 'update');
        if ($automation instanceof JsonResponse) return $automation;

        $validated = $request->validate(['to' => 'required|email']);

        try {
            $flow = Flow::fromJson($automation->data);
            $node = $flow->node($nodeId);
            if ($node->type !== NodeType::Send) {
                return $this->fail("Node {$nodeId} is not a Send node", 422);
            }
        } catch (FlowException $e) {
            return $this->fail($e->getMessage(), 404);
        }

        $emailUid = $node->data['emailUid'] ?? null;
        if (!$emailUid) return $this->fail('No email bound to this node', 422);

        $email = \App\Model\AutomationEmail::findByUid($emailUid);
        if (!$email || $email->automation2_id !== $automation->id) {
            return $this->fail('Email not found', 404);
        }

        try {
            $email->sendTestEmail($validated['to']);
        } catch (\Throwable $e) {
            return $this->fail('Send test failed: ' . $e->getMessage(), 422);
        }

        return response()->json(['ok' => true, 'message' => 'Test email sent to ' . $validated['to']]);
    }

    // deleteEmailForNode removed in 2026-05-02 cleanup — "Replace email" feature
    // dropped in favor of "delete the Send node, add a new one". Email lifecycle
    // is now driven exclusively by the Send-node lifecycle (cascade cleanup
    // below) so there's no orphan-detach surface area.

    /**
     * Render the email body as raw HTML for in-sidebar preview (loaded in an
     * iframe when the user clicks the Content-row thumbnail). Subscriber-bound
     * tags use a mock subscriber so the preview shows realistic placeholders
     * without needing a real recipient.
     *
     *   GET /automations/{uid}/nodes/{nodeId}/email/preview
     */
    public function previewEmail(Request $request, string $uid, string $nodeId)
    {
        $automation = $this->resolveAutomation($uid, 'view');
        if ($automation instanceof JsonResponse) return $automation;

        try {
            $flow = Flow::fromJson($automation->data);
            $node = $flow->node($nodeId);
            if ($node->type !== NodeType::Send) {
                return response('Not a Send node', 422);
            }
        } catch (FlowException $e) {
            return response($e->getMessage(), 404);
        }

        $emailUid = $node->data['emailUid'] ?? null;
        if (!$emailUid) return response('No email bound to this node', 422);

        $email = \App\Model\AutomationEmail::findByUid($emailUid);
        if (!$email || $email->automation2_id !== $automation->id) {
            return response('Email not found', 404);
        }
        if (!$email->hasTemplate()) {
            return response('<!doctype html><html><body style="font-family:sans-serif;color:#6B7280;padding:48px;text-align:center">No content yet — design your email first.</body></html>', 200)
                ->header('Content-Type', 'text/html; charset=utf-8');
        }

        try {
            $mock = $email->mockSubscriber('[email protected]');
            $html = $email->getHtmlContent($mock, null, null, false);
        } catch (\Throwable $e) {
            $html = '<!doctype html><html><body style="font-family:sans-serif;color:#B91C1C;padding:48px">Preview failed: '
                . htmlspecialchars($e->getMessage(), ENT_QUOTES) . '</body></html>';
        }

        return response($html)
            ->header('Content-Type', 'text/html; charset=utf-8')
            // No-cache so edits in the builder show up immediately on next preview.
            ->header('Cache-Control', 'no-store, max-age=0');
    }

    /**
     * Drop AutomationEmail rows whose `action_id` no longer matches a Send node
     * in the flow. Invariant: every AutomationEmail belongs to exactly one Send
     * node; once that node is gone (deleted directly or as part of a cascade),
     * the email row is orphan and should be removed along with its underlying
     * Email + Template records.
     */
    private function cleanupOrphanEmails(Automation2 $automation, Flow $flow): void
    {
        $liveSendIds = [];
        foreach ($flow->nodes as $n) {
            if ($n->type === NodeType::Send) $liveSendIds[$n->id] = true;
        }

        $orphans = $automation->emails()
            ->whereNotIn('action_id', array_keys($liveSendIds) ?: [''])
            ->get();
        foreach ($orphans as $orphan) {
            $orphan->deleteAndCleanup();
        }
    }

    public function updateTrigger(Request $request, string $uid): JsonResponse
    {
        $automation = $this->resolveAutomation($uid, 'update');
        if ($automation instanceof JsonResponse) return $automation;

        $validated = $request->validate([
            'triggerKey' => 'required|string',
            'data'       => 'sometimes|array',
        ]);

        $key = TriggerKey::tryFrom($validated['triggerKey']);
        if ($key === null) {
            return $this->fail("Unknown trigger key: {$validated['triggerKey']}", 422);
        }

        // Per-key schema enforcement (rejects malformed `at`, daysOfWeek, …)
        try {
            $data = NodeOptionsValidator::validateTrigger($key, $validated['data'] ?? []);
        } catch (FlowException $e) {
            return $this->fail($e->getMessage(), 422);
        }
        // `key` from URL is the single source of truth.
        $data['key'] = $key->value;
        unset($data['init']);

        try {
            $flow = Flow::fromJson($automation->data);
            [$flow, $node] = FlowMutator::replaceTrigger($flow, $data);
        } catch (FlowException $e) {
            return $this->fail($e->getMessage(), 422);
        }

        $this->persist($automation, $flow);
        return $this->ok($flow, $node->toArray());
    }

    /**
     * Create a new AutomationEmail and link it to the given Send node's `emailUid`.
     * Returns the post-update flow + the refreshed emails picker list so the
     * sidebar can switch from "wizard mode" to "picker mode" without a full reload.
     *
     *   POST /automations/{uid}/nodes/{nodeId}/email
     *   body: { subject, fromName, fromEmail, replyTo? }
     */
    public function createEmailForNode(Request $request, string $uid, string $nodeId): JsonResponse
    {
        $automation = $this->resolveAutomation($uid, 'update');
        if ($automation instanceof JsonResponse) return $automation;

        $validated = $request->validate([
            'subject'   => 'required|string|max:200',
            'fromName'   => 'required|string|max:100',
            'fromEmail'  => 'required|email|max:191',
            'replyTo'    => 'sometimes|nullable|email|max:191',
            // Stage 1 wizard expanded to full sender setup — these were
            // previously edited via the separate "Edit settings" modal but
            // collecting them up-front keeps the wizard linear (Setup →
            // Template → Done).
            'trackOpen'  => 'sometimes|boolean',
            'trackClick' => 'sometimes|boolean',
            'signDkim'   => 'sometimes|boolean',
            'skipFailed' => 'sometimes|boolean',
        ]);

        try {
            $flow = Flow::fromJson($automation->data);
            $node = $flow->node($nodeId);
            if ($node->type !== NodeType::Send) {
                return $this->fail("Node {$nodeId} is not a Send node", 422);
            }
        } catch (FlowException $e) {
            return $this->fail($e->getMessage(), 404);
        }

        // Idempotent: drop any prior AutomationEmail rows bound to this node
        // (orphans from a failed wizard attempt) before creating a fresh one.
        // Invariant: 1 Send node ↔ 1 AutomationEmail.
        foreach ($automation->emails()->where('action_id', $nodeId)->get() as $stale) {
            $stale->deleteAndCleanup();
        }

        $email = \App\Model\AutomationEmail::newDefault();
        $email->automation2_id = $automation->id;
        $email->customer_id    = $automation->customer_id;
        $email->action_id      = $nodeId;
        $email->subject        = $validated['subject'];
        $email->from_name      = $validated['fromName'];
        $email->from_email     = $validated['fromEmail'];
        $email->reply_to       = $validated['replyTo'] ?? $validated['fromEmail'];
        if (array_key_exists('trackOpen',  $validated)) $email->track_open  = (bool) $validated['trackOpen'];
        if (array_key_exists('trackClick', $validated)) $email->track_click = (bool) $validated['trackClick'];
        if (array_key_exists('signDkim',   $validated)) $email->sign_dkim   = (bool) $validated['signDkim'];
        if (array_key_exists('skipFailed', $validated)) $email->skip_failed_message = (bool) $validated['skipFailed'];
        $email->save();

        // NOTE (2026-05-02): no longer auto-assign a blank builder template.
        // The Send sidebar wizard's Stage 2 ("Setup template") explicitly
        // asks the admin to pick a template or paste custom HTML — auto-
        // assigning a blank template would skip stage 2 by making
        // hasContent=true on the very first save. Stage 3 (summary) only
        // shows up after templateChoose / templateCustomHtml has run.
        //
        // The legacy `templateEdit` builder route is now guarded against
        // missing-template by templateEdit() in AutomationController.
        $email->unsetRelation('email');

        try {
            $newData = array_merge($node->data, [
                'emailUid'     => $email->uid,
                'emailSubject' => $email->subject,
            ]);
            [$flow, $node] = FlowMutator::replaceNode($flow, $nodeId, $newData);
        } catch (FlowException $e) {
            return $this->fail($e->getMessage(), 422);
        }
        $this->persist($automation, $flow);

        // Refreshed emails list — newest first, matches AutomationController@edit ordering.
        $emailOptions = $automation->emails()
            ->orderBy('created_at', 'desc')
            ->get()
            ->map(fn ($e) => ['value' => $e->uid, 'label' => $e->subject ?: '(untitled)'])
            ->all();

        return response()->json([
            'ok'        => true,
            'flow'      => $this->wireFlow($flow, $automation),
            'node'      => $node->toArray(),
            'emails'    => $emailOptions,
            'editUrl'   => route('refactor.automations.template_edit', [$automation->uid, $email->uid]),
        ]);
    }

    // ---------- helpers ----------

    /** @return Automation2|JsonResponse */
    private function resolveAutomation(string $uid, string $ability)
    {
        $automation = Automation2::findByUid($uid);
        if (!$automation) {
            return $this->fail('Automation not found', 404);
        }
        if (Gate::denies($ability, $automation)) {
            return $this->fail('Not authorized', 403);
        }
        return $automation;
    }

    private function persist(Automation2 $automation, Flow $flow): void
    {
        $automation->data = $flow->toJson();
        $automation->save();
    }

    private function ok(Flow $flow, ?array $node = null): JsonResponse
    {
        $automation = $this->resolveAutomationFromUrl();
        $body = ['ok' => true, 'flow' => $this->wireFlow($flow, $automation)];
        if ($node !== null) $body['node'] = $node;
        return response()->json($body);
    }

    /**
     * Decorate the persisted Flow shape for the wire — same {nodes, edges}
     * structure plus per-node `validation: {valid, message}` for any node
     * that fails the NodeValidator's "ready to run?" check. Persisted JSON
     * in `automation2s.data` stays clean (Rule 1).
     */
    public function wireFlow(Flow $flow, ?Automation2 $automation): array
    {
        $arr = $flow->toArray();
        if (!$automation) return $arr;
        foreach ($arr['nodes'] as $i => $rawNode) {
            $node = $flow->node($rawNode['id']);
            $v = \App\Domain\Automation\NodeValidator::validate($node, $automation);
            if ($v !== null) {
                $arr['nodes'][$i]['validation'] = $v;
            }
        }
        return $arr;
    }

    /** Best-effort lookup of the Automation from the current route's `uid` param. */
    private function resolveAutomationFromUrl(): ?Automation2
    {
        $uid = request()->route('uid');
        if (!is_string($uid)) return null;
        return Automation2::findByUid($uid);
    }

    private function fail(string $error, int $status): JsonResponse
    {
        return response()->json(['ok' => false, 'error' => $error], $status);
    }
}