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);
}
}