File: /home/xedaptot/ai.naniguide.com/app/Http/Controllers/Refactor/AutomationController.php
<?php
namespace App\Http\Controllers\Refactor;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Model\Automation2;
use App\Model\MailList;
use App\Model\Segment;
use App\Services\TemplateService;
use Gate;
class AutomationController extends Controller
{
public function index(Request $request)
{
if (Gate::denies('list', Automation2::class)) {
return $this->notAuthorized();
}
$customer = $request->user()->customer;
$total = $customer->local()->automation2s()->count();
$active = $customer->local()->automation2s()->where('status', 'active')->count();
$inactive = $customer->local()->automation2s()->where('status', 'inactive')->count();
return view('refactor.pages.automations.index', [
'customer' => $customer,
'total' => $total,
'active' => $active,
'inactive' => $inactive,
]);
}
public function listing(Request $request)
{
if (Gate::denies('list', Automation2::class)) {
return $this->notAuthorized();
}
$query = $request->user()->customer->local()->automation2s()
->search($request->keyword);
// Status filter
if (!empty($request->status)) {
$query->where('status', $request->status);
}
$automations = $query
->orderBy($request->sort_order ?? 'created_at', $request->sort_direction ?? 'desc')
->paginate($request->per_page ?? 25);
return view('refactor.pages.automations._list', [
'automations' => $automations,
]);
}
/**
* Create automation wizard — single-page with step-by-step UX.
*/
public function create(Request $request)
{
if (Gate::denies('create', new Automation2())) {
return $this->noMoreItem();
}
$customer = $request->user()->customer;
$lists = $customer->local()->lists()->orderBy('name')->get();
// Available trigger types — sourced from the v2 TriggerKey enum
// (legacy Automation2::getTriggerTypes() was dropped in Phase 2 cleanup;
// the enum is the single source of truth for valid trigger keys now).
$triggers = collect(\App\Domain\Automation\Enum\TriggerKey::cases())->map(function ($case) {
$type = $case->value;
return [
'key' => $type,
'icon' => $this->triggerIcon($type),
'name' => trans('messages.automation.trigger.' . $type),
'desc' => trans('messages.automation.trigger.' . $type . '.desc'),
];
});
return view('refactor.pages.automations.create', [
'triggers' => $triggers,
'lists' => $lists,
]);
}
/**
* Store new automation (AJAX POST from wizard).
*/
public function store(Request $request)
{
if (Gate::denies('create', new Automation2())) {
return response()->json(['status' => 'error', 'message' => 'Unauthorized'], 403);
}
$customer = $request->user()->customer;
// Create automation
$automation = $customer->local()->newDefaultAutomation2();
$params = $request->all();
try {
$validator = $automation->createFromArray($params);
} catch (\Exception $e) {
return response()->json([
'status' => 'error',
'message' => $e->getMessage(),
], 500);
}
if ($validator->fails()) {
return response()->json([
'status' => 'error',
'errors' => $validator->errors()->toArray(),
], 422);
}
return response()->json([
'status' => 'success',
'message' => trans('refactor/automations.flash.created'),
'url' => route('refactor.automations.edit', $automation->uid),
]);
}
/**
* AJAX: Get trigger-specific option fields.
*/
public function triggerOptions(Request $request)
{
$triggerType = $request->trigger_type;
$customer = $request->user()->customer;
$lists = $customer->local()->lists()->orderBy('name')->get();
return view('refactor.pages.automations._trigger_options', [
'triggerType' => $triggerType,
'lists' => $lists,
]);
}
/**
* AJAX: Get segments for a mail list.
*/
public function segmentSelect(Request $request)
{
$list = MailList::findByUid($request->list_uid);
if (!$list) {
return response()->json([]);
}
$segments = $list->segments()->orderBy('name')->get()
->map(fn($s) => [
'uid' => $s->uid,
'name' => $s->name,
'subscribers_count' => $s->subscribersCount(true) ?? 0,
]);
return response()->json($segments->values());
}
/**
* AJAX: Get fields for a mail list.
*/
public function fieldSelect(Request $request)
{
$list = MailList::findByUid($request->list_uid);
if (!$list) {
return response()->json([]);
}
$fields = $list->fields()->orderBy('sort_order')->get()
->map(fn($f) => [
'uid' => $f->uid,
'label' => $f->label,
'tag' => $f->tag,
'type' => $f->type,
]);
return response()->json($fields->values());
}
private function triggerIcon(string $type): string
{
return match ($type) {
'welcome-new-subscriber' => 'waving_hand',
'say-happy-birthday' => 'cake',
'subscriber-added-date' => 'event',
'specific-date' => 'calendar_today',
'say-goodbye-subscriber' => 'waving_hand',
'api-3-0' => 'webhook',
'weekly-recurring' => 'date_range',
'monthly-recurring' => 'calendar_month',
'tag-based' => 'sell',
'remove-tag' => 'label_off',
'attribute-update' => 'edit_attributes',
'woo-abandoned-cart' => 'shopping_cart',
default => 'bolt',
};
}
/**
* Editor topbar "Settings" — render an McPopup partial with the automation's
* editable metadata. Mirrors the legacy "Settings" tab (which edited name +
* mail list); mail-list swap lives on the Subscribers screen because of its
* cleanup semantics, so this modal only renames the automation and links
* out to the swap UI.
*
* GET /automations/{uid}/settings
*/
public function settingsShow(Request $request, $uid)
{
$automation = Automation2::findByUid($uid);
if (!$automation || \Gate::denies('view', $automation)) {
return $this->notAuthorized();
}
// Each list ships with its total subscriber count so the switcher
// dropdown items show stats without extra round-trips. Counts come
// from AppCache so this stays cheap even for customers with many lists.
$lists = $request->user()->customer->local()->mailLists()->orderBy('name')->get(['id', 'uid', 'name']);
$mailLists = $lists->map(fn ($l) => [
'uid' => $l->uid,
'name' => $l->name,
'subscribers_count' => (int) (\App\Library\Cache\AppCache::for($l)->read('SubscriberCount', 0)),
])->values();
// Stats for the currently-bound list — feeds the mc-perf-card.
// Total / Active / Inactive (derived = total - active). All AppCache
// reads, no DB hit unless the cache is cold.
$currentListStats = null;
$currentListCreatedAt = null;
if ($current = $automation->mailList) {
$cache = \App\Library\Cache\AppCache::for($current);
$total = (int) $cache->read('SubscriberCount', 0);
$subscribed = (int) $cache->read('SubscribeCount', 0);
$currentListStats = [
'total' => $total,
'active' => $subscribed,
'inactive' => max(0, $total - $subscribed),
];
$currentListCreatedAt = $current->created_at?->format('M j, Y');
}
// Timezone dropdown source — same shape Tool::allTimeZones returns:
// [{zone, text, order}, ...] sorted by GMT offset. Fall back to the
// customer's timezone (or UTC) when the automation has none set.
$timezones = \App\Library\Tool::allTimeZones();
$currentTimezone = $automation->time_zone ?: ($automation->customer?->timezone ?: 'UTC');
return view('refactor.pages.automations._settings', [
'automation' => $automation,
'mailLists' => $mailLists,
'currentListStats' => $currentListStats,
'currentListCreatedAt' => $currentListCreatedAt,
'timezones' => $timezones,
'currentTimezone' => $currentTimezone,
]);
}
/**
* Persist the renamed automation. JSON response so the caller can update
* the topbar title + page title without a reload.
*
* PATCH /automations/{uid}/settings
*/
public function settingsUpdate(Request $request, $uid)
{
$automation = Automation2::findByUid($uid);
if (!$automation || \Gate::denies('update', $automation)) {
return $this->notAuthorized();
}
// Each field is an independent commit (inline-edit / dropdown click) —
// accept any subset on a single PATCH.
$validated = $request->validate([
'name' => 'sometimes|required|string|max:255',
'mail_list_uid' => 'sometimes|required|string|max:50',
'time_zone' => 'sometimes|required|string|max:64',
]);
// Validate timezone is a real PHP zone before persisting (prevents the
// model from blowing up later in `new DateTimeZone($zone)`).
if (array_key_exists('time_zone', $validated)) {
if (!in_array($validated['time_zone'], timezone_identifiers_list(), true)) {
return response()->json(['status' => 'error', 'message' => 'Unknown timezone'], 422);
}
}
$listChanged = false;
if (array_key_exists('mail_list_uid', $validated)) {
$newList = MailList::findByUid($validated['mail_list_uid']);
if (!$newList) {
return response()->json(['status' => 'error', 'message' => 'List not found'], 404);
}
if ($newList->customer_id !== $automation->customer_id) {
return response()->json(['status' => 'error', 'message' => 'List does not belong to this customer'], 403);
}
$listChanged = $newList->id !== $automation->mail_list_id;
}
$dirty = false;
if (array_key_exists('name', $validated)) {
$automation->name = $validated['name'];
$dirty = true;
}
if (array_key_exists('time_zone', $validated)) {
$automation->time_zone = $validated['time_zone'];
$dirty = true;
}
if ($dirty) {
$automation->save();
}
if ($listChanged) {
// updateMailList() wipes auto_triggers + trigger_sessions for safety.
$automation->updateMailList($newList);
}
return response()->json([
'status' => 'success',
'message' => $listChanged
? trans('refactor/automations.contacts.list_changed_success', ['name' => $newList->name])
: trans('refactor/automations.settings.saved'),
'name' => $automation->name,
'time_zone' => $automation->time_zone,
'list_changed' => $listChanged,
]);
}
/**
* Subscribers / contacts panel — show subscribers in the automation's mail list
* with their flow progress and a manual trigger control.
*/
public function contacts(Request $request, $uid)
{
$automation = Automation2::findByUid($uid);
if (!$automation || \Gate::denies('view', $automation)) {
return $this->notAuthorized();
}
// Per-automation subscriber-state breakdown — feeds the mc-pivot-bar
// chart in the contacts header. Each phase is a single COUNT(*) on
// auto_triggers (indexed on (automation2_id, phase)).
// "Not triggered" = subscribers in the mail list who never entered the
// flow = list_total - autoTriggers_total. Computed via two cheap
// COUNTs rather than a NOT-IN query (cheaper at scale).
$phaseEnum = \App\Domain\Automation\Runtime\Phase::class;
$base = $automation->autoTriggers();
$totalList = $automation->mailList ? $automation->mailList->subscribers()->count() : 0;
$running = (clone $base)->where('phase', $phaseEnum::Running->value)->count();
$waiting = (clone $base)->where('phase', $phaseEnum::Waiting->value)->count();
$completed = (clone $base)->where('phase', $phaseEnum::Completed->value)->count();
$failed = (clone $base)->where('phase', $phaseEnum::Failed->value)->count();
$triggered = $running + $waiting + $completed + $failed;
$stats = [
'list_total' => $totalList,
'not_triggered' => max(0, $totalList - $triggered),
'running' => $running,
'waiting' => $waiting,
'completed' => $completed,
'failed' => $failed,
// Kept for the existing hold filter dropdown — not shown in the bar.
'held' => (clone $base)->whereNotNull('held_at')->count(),
// Backwards-compat alias for any callers still referencing in_workflow.
'in_workflow' => $running + $waiting,
];
// Picker rows for the Node Performance section. Server-rendered into
// the page so the picker is paintable on first byte; the body itself
// hydrates via the contactsNodePerformance JSON endpoint.
$nodeOptions = [];
try {
$nodeOptions = \App\Domain\Automation\NodePerformance::pickerOptions($automation);
} catch (\App\Domain\Automation\FlowException $e) {
// Bad flow JSON — show the page without the section rather than 500.
$nodeOptions = [];
}
return view('refactor.pages.automations.contacts', [
'automation' => $automation,
'stats' => $stats,
'nodeOptions' => $nodeOptions,
]);
}
public function contactsListing(Request $request, $uid)
{
$automation = Automation2::findByUid($uid);
if (!$automation || \Gate::denies('view', $automation)) {
return $this->notAuthorized();
}
$list = $automation->mailList;
if (!$list) {
return view('refactor.pages.automations._contacts_list', [
'automation' => $automation,
'subscribers' => collect()->paginate(25),
'triggerMap' => [],
'nodeLabels' => [],
]);
}
$query = $list->subscribers();
if ($keyword = trim((string) $request->keyword)) {
$query->where('email', 'like', '%' . $keyword . '%');
}
// Two independent filter dimensions — phase is execution state (tier-1)
// and hold is admin override (tier-2). They compose via AND so admins
// can ask e.g. "rows at Waiting AND held".
//
// Phase values:
// '' → no filter (true All — in-workflow + not-in-workflow)
// 'never' → has NO trigger row
// 'in_workflow' → has ANY trigger row (synthetic — no Phase enum)
// 'running' / 'waiting' / 'completed' / 'failed' → exact phase match
$automationId = $automation->id;
$phase = $request->phase;
$hold = $request->hold; // null | 'held' | 'not_held'
if ($phase === 'never') {
// No-trigger-row filter — `hold` is meaningless here (no row to hold).
$query->whereDoesntHave('autoTriggers', function ($q) use ($automationId) {
$q->where('automation2_id', $automationId);
});
} elseif ($phase === 'in_workflow' || in_array($phase, ['running','waiting','completed','failed'], true) || $hold) {
$query->whereHas('autoTriggers', function ($q) use ($automationId, $phase, $hold) {
$q->where('automation2_id', $automationId);
if (in_array($phase, ['running','waiting','completed','failed'], true)) {
$q->where('phase', $phase);
}
// 'in_workflow' adds no extra phase predicate — just the
// whereHas existence check is the filter.
if ($hold === 'held') {
$q->whereNotNull('held_at');
} elseif ($hold === 'not_held') {
$q->whereNull('held_at');
}
});
}
$direction = $request->sort_direction === 'asc' ? 'asc' : 'desc';
$rawSort = $request->sort_order;
// Subscriber-level sort keys map straight to a column on `subscribers`.
// Trigger-level sort keys (`trigger_*`) map to the latest auto_trigger
// row for THIS automation per subscriber — implemented as a correlated
// subquery in orderBy so subscribers without a row still appear (NULL
// ordering follows the DB default; on MySQL: NULL last for DESC).
$triggerSortMap = [
'trigger_updated_at' => 'updated_at',
'trigger_created_at' => 'created_at',
];
if (isset($triggerSortMap[$rawSort])) {
$col = $triggerSortMap[$rawSort];
$subq = \App\Model\AutoTrigger::query()
->select($col)
->whereColumn('subscriber_id', 'subscribers.id')
->where('automation2_id', $automationId)
->orderBy('id', 'desc')
->limit(1);
$query->orderBy($subq, $direction);
} else {
$sort = in_array($rawSort, ['email', 'created_at', 'updated_at'], true) ? $rawSort : 'created_at';
$query->orderBy($sort, $direction);
}
$subscribers = $query->paginate($request->per_page ?? 25);
// Hydrate the AutoTrigger row per subscriber for THIS automation (single bulk query).
$subscriberIds = $subscribers->pluck('id')->all();
$triggerMap = [];
if (!empty($subscriberIds)) {
$rows = $automation->autoTriggers()
->whereIn('subscriber_id', $subscriberIds)
->orderBy('id', 'desc')
->get();
foreach ($rows as $row) {
$triggerMap[$row->subscriber_id] ??= $row; // first (=latest) row wins
}
}
// Map node id → human label so the list can show "Step: Send welcome email".
$nodeLabels = [];
if (!empty($automation->data)) {
$flow = \App\Domain\Automation\Flow::fromJson($automation->data);
foreach ($flow->nodes as $node) {
$nodeLabels[$node->id] = $this->nodeLabelFor($node);
}
}
return view('refactor.pages.automations._contacts_list', [
'automation' => $automation,
'subscribers' => $subscribers,
'triggerMap' => $triggerMap,
'nodeLabels' => $nodeLabels,
]);
}
public function contactsTrigger(Request $request, $uid, $subscriberUid)
{
$automation = Automation2::findByUid($uid);
if (!$automation || \Gate::denies('update', $automation)) {
return $this->notAuthorized();
}
$subscriber = \App\Model\Subscriber::findByUid($subscriberUid);
if (!$subscriber || $subscriber->mail_list_id !== $automation->mail_list_id) {
return response()->json(['status' => 'error', 'message' => 'Subscriber not found'], 404);
}
// Precondition checks run BEFORE dispatch so admins see exactly which
// gate stopped them — TriggerDispatcher::dispatch returns null on any
// failed gate without distinguishing them, which produced the
// ambiguous "may be paused, may be missing entry, may be already in
// workflow" message admins couldn't act on.
if (!$automation->isActive()) {
return response()->json([
'status' => 'error',
'message' => trans('refactor/automations.contacts.trigger_failed_paused'),
], 422);
}
try {
$flow = \App\Domain\Automation\Flow::fromJson($automation->data);
} catch (\Throwable $e) {
return response()->json([
'status' => 'error',
'message' => trans('refactor/automations.contacts.trigger_failed_invalid_flow'),
], 422);
}
$triggerKey = $flow->trigger()->data['key'] ?? null;
if (!$triggerKey) {
return response()->json([
'status' => 'error',
'message' => trans('refactor/automations.contacts.trigger_failed_no_key'),
], 422);
}
$entry = $flow->outgoing(\App\Domain\Automation\Flow::TRIGGER_ID)[0] ?? null;
if (!$entry) {
return response()->json([
'status' => 'error',
'message' => trans('refactor/automations.contacts.trigger_failed_no_entry'),
], 422);
}
$dispatcher = app(\App\Domain\Automation\Runtime\TriggerDispatcher::class);
$row = $dispatcher->dispatch($automation, $subscriber, $force = true);
if (!$row) {
// After the gates above, the only remaining causes are: existing
// trigger row (idempotent — dispatch should have returned it, so
// null here is unexpected) OR segment filter mismatch.
return response()->json([
'status' => 'error',
'message' => trans('refactor/automations.contacts.trigger_failed_segment'),
], 422);
}
return response()->json([
'status' => 'success',
'message' => trans('refactor/automations.contacts.triggered_success'),
]);
}
/**
* Hold a subscriber's run — tier-2 admin override that pauses the row
* WITHOUT touching its execution status (`phase` / `scheduled_at`).
* Cron skips held rows; AutomationSendLifecycle drops stale Send-job
* callbacks for held rows. Reverse with contactsUnhold().
*/
public function contactsHold(Request $request, $uid, $subscriberUid)
{
$automation = Automation2::findByUid($uid);
if (!$automation || \Gate::denies('update', $automation)) {
return $this->notAuthorized();
}
$subscriber = \App\Model\Subscriber::findByUid($subscriberUid);
if (!$subscriber) {
return response()->json(['status' => 'error', 'message' => 'Subscriber not found'], 404);
}
$row = $automation->autoTriggers()
->where('subscriber_id', $subscriber->id)
->orderBy('id', 'desc')
->first();
if (!$row) {
return response()->json(['status' => 'error', 'message' => 'No active run to hold'], 404);
}
if ($row->held_at !== null) {
return response()->json([
'status' => 'success',
'message' => trans('refactor/automations.contacts.hold_already'),
]);
}
$row->held_at = now();
// History records the override; phase + scheduled_at unchanged. The
// outcome value `held` is distinct from `fail` so reports can split
// admin actions from system failures cleanly.
$row->appendHistory([
'node' => $row->current_node_id,
'outcome' => 'held',
'phase_after' => $row->phase,
]);
$row->save();
return response()->json([
'status' => 'success',
'message' => trans('refactor/automations.contacts.hold_success'),
]);
}
/**
* Release a held row back to the cron / executor pipeline. Phase +
* scheduled_at are unchanged (they were preserved by Hold) so the row
* picks up on the next due tick.
*/
public function contactsUnhold(Request $request, $uid, $subscriberUid)
{
$automation = Automation2::findByUid($uid);
if (!$automation || \Gate::denies('update', $automation)) {
return $this->notAuthorized();
}
$subscriber = \App\Model\Subscriber::findByUid($subscriberUid);
if (!$subscriber) {
return response()->json(['status' => 'error', 'message' => 'Subscriber not found'], 404);
}
$row = $automation->autoTriggers()
->where('subscriber_id', $subscriber->id)
->orderBy('id', 'desc')
->first();
if (!$row) {
return response()->json(['status' => 'error', 'message' => 'No run found'], 404);
}
if ($row->held_at === null) {
return response()->json([
'status' => 'success',
'message' => trans('refactor/automations.contacts.unhold_already'),
]);
}
$row->held_at = null;
$row->appendHistory([
'node' => $row->current_node_id,
'outcome' => 'released',
'phase_after' => $row->phase,
]);
$row->save();
return response()->json([
'status' => 'success',
'message' => trans('refactor/automations.contacts.unhold_success'),
]);
}
/**
* Delete a subscriber's auto_trigger row. Resets their state to "Not
* triggered" — they become eligible to re-enter the workflow on the next
* matching event / cron tick. History is wiped along with the row; if
* an audit trail is needed, query before delete.
*
* Distinct from Hold (tier-2 admin override that preserves the row): this
* is the destructive sibling — no resume point. Useful for rolling back
* mistaken triggers or re-running a flow from scratch on a single subscriber.
*/
public function contactsDeleteTrigger(Request $request, $uid, $subscriberUid)
{
$automation = Automation2::findByUid($uid);
if (!$automation || \Gate::denies('update', $automation)) {
return $this->notAuthorized();
}
$subscriber = \App\Model\Subscriber::findByUid($subscriberUid);
if (!$subscriber) {
return response()->json(['status' => 'error', 'message' => 'Subscriber not found'], 404);
}
$rowsDeleted = $automation->autoTriggers()
->where('subscriber_id', $subscriber->id)
->delete();
if ($rowsDeleted === 0) {
return response()->json([
'status' => 'error',
'message' => trans('refactor/automations.contacts.delete_trigger_failed_no_row'),
], 404);
}
return response()->json([
'status' => 'success',
'message' => trans('refactor/automations.contacts.delete_trigger_success'),
]);
}
/**
* Render the "Progress" modal body for one subscriber's AutoTrigger row.
* Returns HTML (consumed by McPopup.open({ url })) so the caller doesn't
* have to render the diagnostic table client-side.
*
* GET /automations/{uid}/contacts/{subscriber_uid}/progress
*/
public function contactsProgress(Request $request, $uid, $subscriberUid)
{
$automation = Automation2::findByUid($uid);
if (!$automation || \Gate::denies('view', $automation)) {
return $this->notAuthorized();
}
$subscriber = \App\Model\Subscriber::findByUid($subscriberUid);
if (!$subscriber) {
abort(404);
}
$row = $automation->autoTriggers()
->where('subscriber_id', $subscriber->id)
->orderBy('id', 'desc')
->first();
// Build a node-id → human label map from the CURRENT flow so deleted
// nodes show as "(deleted)" — same convention as the History modal.
$labels = [];
if ($row && !empty($automation->data)) {
$flow = \App\Domain\Automation\Flow::fromJson($automation->data);
foreach ($flow->nodes as $n) {
$labels[$n->id] = $this->nodeLabelFor($n);
}
$labels[\App\Domain\Automation\Flow::TRIGGER_ID] = $labels[\App\Domain\Automation\Flow::TRIGGER_ID]
?? trans('refactor/automations.contacts.step_trigger');
}
$nodeLabel = $row && $row->current_node_id && isset($labels[$row->current_node_id])
? $labels[$row->current_node_id]
: null;
// Resolve history entries the same way contactsHistory() does so the
// timeline can show "(deleted)" for nodes the editor pruned mid-run.
$history = [];
if ($row && is_array($row->history)) {
$history = array_map(function ($e) use ($labels) {
$nodeId = $e['node'] ?? null;
$nodeLabel = $nodeId
? ($labels[$nodeId] ?? trans('refactor/automations.contacts.step_deleted'))
: '—';
return $e + ['nodeLabel' => $nodeLabel];
}, $row->history);
}
return view('refactor.pages.automations._contacts_progress', [
'automation' => $automation,
'subscriber' => $subscriber,
'row' => $row,
'nodeLabel' => $nodeLabel,
'history' => $history,
]);
}
/**
* Manually advance the subscriber's AutoTrigger by exactly one step
* (same primitive the cron uses). Returns the post-advance phase so the
* Progress modal can refresh itself.
*
* POST /automations/{uid}/contacts/{subscriber_uid}/advance
*/
/**
* Retry a Failed AutoTrigger row. Distinct from Advance:
* - Advance is a no-op on terminal phases (FlowExecutor::advance L63-65).
* - Retry resets `phase=Ready`, clears `error`, appends a 'retry' history
* marker, then drains so the handler at `current_node_id` runs again.
*
* Only meaningful on Failed rows; returns 422 otherwise so the UI surfaces
* a clear "nothing to retry" instead of pretending to do work.
*/
public function contactsRetry(Request $request, $uid, $subscriberUid)
{
$automation = Automation2::findByUid($uid);
if (!$automation || \Gate::denies('update', $automation)) {
return $this->notAuthorized();
}
$subscriber = \App\Model\Subscriber::findByUid($subscriberUid);
if (!$subscriber) {
return response()->json(['status' => 'error', 'message' => 'Subscriber not found'], 404);
}
$row = $automation->autoTriggers()
->where('subscriber_id', $subscriber->id)
->orderBy('id', 'desc')
->first();
if (!$row) {
return response()->json(['status' => 'error', 'message' => trans('refactor/automations.contacts.progress_no_trigger')], 404);
}
if ($row->phase !== \App\Domain\Automation\Runtime\Phase::Failed->value) {
return response()->json([
'status' => 'error',
'message' => trans('refactor/automations.contacts.retry_not_failed'),
], 422);
}
// Record what we're retrying (the row's last error) before we wipe it,
// so the timeline still shows what failed + that an admin retried.
$previousError = $row->error;
$row->appendHistory([
'node' => $row->current_node_id,
'outcome' => 'retry',
'error' => $previousError,
'phase_after' => \App\Domain\Automation\Runtime\Phase::Running->value,
]);
$row->phase = \App\Domain\Automation\Runtime\Phase::Running->value;
$row->error = null;
$row->scheduled_at = null;
$row->save();
// Drain — the same handler at current_node_id runs fresh; if it
// succeeds + advances, the row continues forward in the same call.
$phase = app(\App\Domain\Automation\Runtime\FlowExecutor::class)->drain($row);
return response()->json([
'status' => 'success',
'message' => trans('refactor/automations.contacts.retry_success', ['phase' => $phase->value]),
'phase' => $phase->value,
]);
}
/**
* Retry every Failed AutoTrigger row for this automation. Bulk version of
* contactsRetry — same per-row contract (reset phase to Running, clear
* error, drain) applied to all Failed rows in one HTTP round-trip.
*
* Drain is synchronous per row; for an automation with hundreds of failed
* rows this could be slow. We cap at 500 per call so the worst case is
* bounded (~10s if each drain is 20ms). If the count is higher, the admin
* can re-click — subsequent calls will pick up the next batch since
* successfully-drained rows are no longer Failed.
*
* Each row gets its own try/catch so one row's structural failure (deleted
* node, missing subscriber) doesn't abort the rest.
*/
public function contactsRetryAll(Request $request, $uid)
{
$automation = Automation2::findByUid($uid);
if (!$automation || \Gate::denies('update', $automation)) {
return $this->notAuthorized();
}
$failed = $automation->autoTriggers()
->where('phase', \App\Domain\Automation\Runtime\Phase::Failed->value)
->limit(500)
->get();
if ($failed->isEmpty()) {
return response()->json([
'status' => 'success',
'message' => trans('refactor/automations.contacts.retry_all_none'),
'retried' => 0, 'recovered' => 0, 'still_failed' => 0,
]);
}
$exec = app(\App\Domain\Automation\Runtime\FlowExecutor::class);
$recovered = 0;
$stillFailed = 0;
foreach ($failed as $row) {
try {
$row->appendHistory([
'node' => $row->current_node_id,
'outcome' => 'retry',
'error' => $row->error,
'phase_after' => \App\Domain\Automation\Runtime\Phase::Running->value,
]);
$row->phase = \App\Domain\Automation\Runtime\Phase::Running->value;
$row->error = null;
$row->scheduled_at = null;
$row->save();
$phase = $exec->drain($row);
if ($phase === \App\Domain\Automation\Runtime\Phase::Failed) {
$stillFailed++;
} else {
$recovered++;
}
} catch (\Throwable $e) {
// Don't let one bad row kill the loop; surface in count.
\Log::warning(sprintf(
'contactsRetryAll: row #%d threw — %s',
$row->id, $e->getMessage()
));
$stillFailed++;
}
}
return response()->json([
'status' => 'success',
'message' => trans('refactor/automations.contacts.retry_all_success', [
'retried' => $failed->count(),
'recovered' => $recovered,
'still_failed' => $stillFailed,
]),
'retried' => $failed->count(),
'recovered' => $recovered,
'still_failed' => $stillFailed,
]);
}
public function contactsAdvance(Request $request, $uid, $subscriberUid)
{
$automation = Automation2::findByUid($uid);
if (!$automation || \Gate::denies('update', $automation)) {
return $this->notAuthorized();
}
$subscriber = \App\Model\Subscriber::findByUid($subscriberUid);
if (!$subscriber) {
return response()->json(['status' => 'error', 'message' => 'Subscriber not found'], 404);
}
$row = $automation->autoTriggers()
->where('subscriber_id', $subscriber->id)
->orderBy('id', 'desc')
->first();
if (!$row) {
return response()->json(['status' => 'error', 'message' => trans('refactor/automations.contacts.progress_no_trigger')], 404);
}
$phase = app(\App\Domain\Automation\Runtime\FlowExecutor::class)->advance($row);
return response()->json([
'status' => 'success',
'message' => trans('refactor/automations.contacts.progress_advanced', ['phase' => $phase->value]),
'phase' => $phase->value,
]);
}
/**
* Swap the automation's mail list. Wipes all auto_triggers + trigger_sessions
* (existing rows reference subscribers from the old list and would orphan).
* Mirrors the legacy sidebar's list-change action — uses the same
* `Automation2::updateMailList()` helper which already calls `resetListRelatedData()`.
*
* POST /automations/{uid}/mail-list body: { list_uid }
*/
public function changeMailList(Request $request, $uid)
{
$automation = Automation2::findByUid($uid);
if (!$automation || \Gate::denies('update', $automation)) {
return $this->notAuthorized();
}
$validated = $request->validate(['list_uid' => 'required|string|max:50']);
$newList = MailList::findByUid($validated['list_uid']);
if (!$newList) {
return response()->json(['status' => 'error', 'message' => 'List not found'], 404);
}
// Ownership guard — never let a customer point an automation at another customer's list.
if ($newList->customer_id !== $automation->customer_id) {
return response()->json(['status' => 'error', 'message' => 'List does not belong to this customer'], 403);
}
if ($newList->id === $automation->mail_list_id) {
return response()->json([
'status' => 'success',
'message' => 'No change — already pointing at this list.',
]);
}
$automation->updateMailList($newList);
return response()->json([
'status' => 'success',
'message' => trans('refactor/automations.contacts.list_changed_success', ['name' => $newList->name]),
]);
}
/**
* Per-node performance JSON endpoint for the picker on the Subscribers
* screen. Returns the picked node's stats only (the picker itself ships in
* the page render to keep the JSON light).
*
* GET /automations/{uid}/contacts/node-performance?node={nodeId}
*/
public function contactsNodePerformance(Request $request, $uid)
{
$automation = Automation2::findByUid($uid);
if (!$automation || \Gate::denies('view', $automation)) {
return response()->json(['ok' => false, 'message' => 'Not authorized'], 403);
}
$nodeId = (string) $request->query('node', '');
if ($nodeId === '') {
return response()->json(['ok' => false, 'message' => 'node param required'], 422);
}
try {
$payload = \App\Domain\Automation\NodePerformance::forNode($automation, $nodeId);
} catch (\App\Domain\Automation\FlowException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], 404);
}
return response()->json(['ok' => true] + $payload);
}
/**
* Send-node deep-dive overview. Bound to nodeId so the URL stays stable
* even if admin replaces the bound email; controller resolves
* nodeId → AutomationEmail via the Flow JSON.
*/
public function emailReport(Request $request, $uid, $nodeId)
{
[$automation, $email] = $this->resolveSendNodeEmail($uid, $nodeId);
if ($automation instanceof \Symfony\Component\HttpFoundation\Response) return $automation;
$scope = \App\Library\Cache\AppCache::for($email);
$subscriberCount = (int) $scope->read('SubscriberCount', 0);
$deliveredCount = (int) $scope->read('DeliveredCount', 0);
$failedCount = (int) $scope->read('FailedDeliveredCount', 0);
$openCount = (int) $scope->read('UniqOpenCount', 0);
$openRate = (float) $scope->read('UniqOpenRate', 0);
$clickRate = (float) $scope->read('ClickedRate', 0);
$deliveredRate = (float) $scope->read('DeliveredRate', 0);
$notOpenRate = (float) $scope->read('NotOpenRate', 0);
$clickCount = (int) $scope->read('ClickCount', 0);
$uniqueClickCount = (int) $scope->read('UniqueClickCount', 0);
$bounceCount = (int) $scope->read('BounceCount', 0);
$unsubscribeCount = (int) $scope->read('UnsubscribeCount', 0);
$feedbackCount = (int) $scope->read('FeedbackCount', 0);
$bounceRate = (float) $scope->read('BounceRate', 0);
$unsubscribeRate = (float) $scope->read('UnsubscribeRate', 0);
$feedbackRate = (float) $scope->read('FeedbackRate', 0);
$top = $scope->read('OverviewTopBlocks', [
'top_links' => collect(), 'top_open_countries' => collect(), 'top_click_countries' => collect(),
'top_active_subscribers' => collect(), 'top_locations' => collect(),
'last_open_at' => null, 'last_click_at' => null, 'first_sent_at' => null, 'last_sent_at' => null,
]);
$reportComputedAt = $email->getOverviewComputedAt();
return view('refactor.pages.automations.email_report', [
'automation' => $automation,
'email' => $email,
'nodeId' => $nodeId,
'subscriberCount' => $subscriberCount,
'deliveredCount' => $deliveredCount,
'failedCount' => $failedCount,
'openCount' => $openCount,
'openRate' => $openRate,
'clickRate' => $clickRate,
'deliveredRate' => $deliveredRate,
'notOpenRate' => $notOpenRate,
'clickCount' => $clickCount,
'uniqueClickCount' => $uniqueClickCount,
'bounceCount' => $bounceCount,
'unsubscribeCount' => $unsubscribeCount,
'feedbackCount' => $feedbackCount,
'bounceRate' => $bounceRate,
'unsubscribeRate' => $unsubscribeRate,
'feedbackRate' => $feedbackRate,
'topLinks' => $top['top_links'] ?? collect(),
'topOpenCountries' => $top['top_open_countries'] ?? collect(),
'topClickCountries' => $top['top_click_countries'] ?? collect(),
'topActiveSubscribers' => $top['top_active_subscribers'] ?? collect(),
'topLocations' => $top['top_locations'] ?? collect(),
'lastOpenAt' => $top['last_open_at'] ?? null,
'lastClickAt' => $top['last_click_at'] ?? null,
'firstSentAt' => $top['first_sent_at'] ?? null,
'lastSentAt' => $top['last_sent_at'] ?? null,
'reportComputedAt' => $reportComputedAt,
]);
}
public function emailReportInsights(Request $request, $uid, $nodeId)
{
[$automation, $email] = $this->resolveSendNodeEmail($uid, $nodeId);
if ($automation instanceof \Symfony\Component\HttpFoundation\Response) return $automation;
$list = $email->defaultMailList;
$columns = $request->user()->getSetting('subscribers_columns') ?? [];
$fields = $list ? $list->getFields->where('tag', '!=', 'EMAIL') : collect();
$scope = \App\Library\Cache\AppCache::for($email);
$subscriberCount = (int) $scope->read('SubscriberCount', 0);
$deliveredCount = (int) $scope->read('DeliveredCount', 0);
$failedCount = (int) $scope->read('FailedDeliveredCount', 0);
return view('refactor.pages.automations.email_report_insights', [
'automation' => $automation,
'email' => $email,
'nodeId' => $nodeId,
'list' => $list,
'columns' => $columns,
'fields' => $fields,
'subscriberCount' => $subscriberCount,
'deliveredCount' => $deliveredCount,
'failedCount' => $failedCount,
]);
}
public function emailReportInsightsListing(Request $request, $uid, $nodeId)
{
[$automation, $email] = $this->resolveSendNodeEmail($uid, $nodeId);
if ($automation instanceof \Symfony\Component\HttpFoundation\Response) return $automation;
$filterMode = $request->tracking_status ?: 'all';
$sort = match ($request->sort_order) {
'recent_sent' => 'recent_sent',
'recent_activity', '', null => 'recent_activity',
default => throw new \LogicException("Unknown insights sort_order: {$request->sort_order}"),
};
// Subscribers who passed through this Send node (have a tracking_log row).
$subscribers = \App\Model\Subscriber::query()
->select('subscribers.*')
->addSelect('bounce_logs.raw AS bounced_message')
->addSelect('feedback_logs.feedback_type AS feedback_message')
->addSelect('tracking_logs.error AS failed_message')
->addSelect('tracking_logs.created_at AS sent_at')
->addSelect('tracking_logs.status AS tracking_status_val')
->join('tracking_logs', 'tracking_logs.subscriber_id', '=', 'subscribers.id')
->where('tracking_logs.automation_email_id', $email->id)
->leftJoin('bounce_logs', 'bounce_logs.tracking_log_id', '=', 'tracking_logs.id')
->leftJoin('feedback_logs', 'feedback_logs.tracking_log_id', '=', 'tracking_logs.id')
->leftJoin('unsubscribe_logs', 'unsubscribe_logs.message_id', '=', 'tracking_logs.message_id');
if ($request->open === 'yes') {
$subscribers->whereExists(function ($q) {
$q->select(\DB::raw(1))
->from('open_logs')
->whereColumn('open_logs.message_id', 'tracking_logs.message_id');
});
} elseif ($request->open === 'not_opened') {
$subscribers->whereNotExists(function ($q) {
$q->select(\DB::raw(1))
->from('open_logs')
->whereColumn('open_logs.message_id', 'tracking_logs.message_id');
});
}
if ($request->click === 'clicked') {
$subscribers->whereExists(function ($q) {
$q->select(\DB::raw(1))
->from('click_logs')
->whereColumn('click_logs.message_id', 'tracking_logs.message_id');
});
} elseif ($request->click === 'not_clicked') {
$subscribers->whereNotExists(function ($q) {
$q->select(\DB::raw(1))
->from('click_logs')
->whereColumn('click_logs.message_id', 'tracking_logs.message_id');
});
}
if ($filterMode !== 'all') {
$subscribers->where('tracking_logs.status', $filterMode);
}
if ($request->keyword) {
$subscribers->where('subscribers.email', 'like', '%' . $request->keyword . '%');
}
if ($sort === 'recent_activity') {
$clickAgg = \DB::table('click_logs')
->select('message_id', \DB::raw('MAX(created_at) AS last_click_at'))
->whereNotNull('message_id')
->groupBy('message_id');
$openAgg = \DB::table('open_logs')
->select('message_id', \DB::raw('MAX(created_at) AS last_open_at'))
->whereNotNull('message_id')
->groupBy('message_id');
$subscribers = $subscribers
->leftJoinSub($clickAgg, 'sub_last_click', fn($j) => $j->on('sub_last_click.message_id', '=', 'tracking_logs.message_id'))
->leftJoinSub($openAgg, 'sub_last_open', fn($j) => $j->on('sub_last_open.message_id', '=', 'tracking_logs.message_id'))
->orderByRaw('COALESCE(sub_last_click.last_click_at, sub_last_open.last_open_at, tracking_logs.created_at) DESC');
} else {
$subscribers->orderBy('tracking_logs.created_at', 'desc');
}
$subscribers = $subscribers->paginate($request->per_page ?: 25);
$list = $email->defaultMailList;
$columns = $request->user()->getSetting('subscribers_columns') ?? [];
// Same CSV-bridge as CampaignController::subscribersListing — McList
// collapses array filters to comma-joined strings, so the picker
// ships columns_csv and we explode here. Falls back to the saved
// setting if no explicit selection is present on the request.
$selectedColumns = is_array($request->columns)
? $request->columns
: ($request->columns_csv !== null ? array_filter(explode(',', (string) $request->columns_csv)) : $columns);
$fields = $list ? $list->getFields->whereIn('uid', $selectedColumns)->where('tag', '!=', 'EMAIL') : collect();
$firstNameCol = null;
$lastNameCol = null;
if ($list) {
$firstNameField = $list->getFields->firstWhere('tag', 'FIRST_NAME');
$lastNameField = $list->getFields->firstWhere('tag', 'LAST_NAME');
$firstNameCol = $firstNameField ? $firstNameField->custom_field_name : null;
$lastNameCol = $lastNameField ? $lastNameField->custom_field_name : null;
}
return view('refactor.pages.automations._email_report_insights_list', [
'automation' => $automation,
'email' => $email,
'nodeId' => $nodeId,
'list' => $list,
'subscribers' => $subscribers,
'fields' => $fields,
'columns' => $columns,
'filterMode' => $filterMode,
'firstNameCol' => $firstNameCol,
'lastNameCol' => $lastNameCol,
]);
}
public function emailReportOpenLog(Request $request, $uid, $nodeId)
{
return $this->emailReportLogPage($uid, $nodeId, 'open_log');
}
public function emailReportClickLog(Request $request, $uid, $nodeId)
{
return $this->emailReportLogPage($uid, $nodeId, 'click_log');
}
public function emailReportBounceLog(Request $request, $uid, $nodeId)
{
return $this->emailReportLogPage($uid, $nodeId, 'bounce_log');
}
public function emailReportOpenMap(Request $request, $uid, $nodeId)
{
[$automation, $email] = $this->resolveSendNodeEmail($uid, $nodeId);
if ($automation instanceof \Symfony\Component\HttpFoundation\Response) return $automation;
$locations = $email->openLocations()->get()->map(function ($loc) {
return [
'lat' => $loc->latitude,
'lng' => $loc->longitude,
'ip' => $loc->ip_address ?? '',
'email' => $loc->email ?? 'Unknown',
'sub_id' => $loc->subscriber_id,
'sub_uid' => $loc->subscriber_uid,
'list_uid' => $loc->list_uid,
'time' => $loc->open_at ? \Carbon\Carbon::parse($loc->open_at)->diffForHumans() : '',
'area' => $loc->name(),
];
})->filter(fn($l) => $l['lat'] && $l['lng'])->values();
$locations = $this->enrichLocationsWithNames($locations, $email);
return view('refactor.pages.automations.email_report_log', [
'automation' => $automation,
'email' => $email,
'nodeId' => $nodeId,
'activeTab' => 'open_map',
'locations' => $locations,
]);
}
public function emailReportClickMap(Request $request, $uid, $nodeId)
{
[$automation, $email] = $this->resolveSendNodeEmail($uid, $nodeId);
if ($automation instanceof \Symfony\Component\HttpFoundation\Response) return $automation;
$locations = $email->clickLocations()->get()->map(function ($loc) {
return [
'lat' => $loc->latitude,
'lng' => $loc->longitude,
'ip' => $loc->ip_address ?? '',
'email' => $loc->email ?? 'Unknown',
'sub_id' => $loc->subscriber_id,
'sub_uid' => $loc->subscriber_uid,
'list_uid' => $loc->list_uid,
'time' => $loc->click_at ? \Carbon\Carbon::parse($loc->click_at)->diffForHumans() : '',
'area' => $loc->name(),
'url' => $loc->url ?? '',
];
})->filter(fn($l) => $l['lat'] && $l['lng'])->values();
$locations = $this->enrichLocationsWithNames($locations, $email);
return view('refactor.pages.automations.email_report_log', [
'automation' => $automation,
'email' => $email,
'nodeId' => $nodeId,
'activeTab' => 'click_map',
'locations' => $locations,
]);
}
/**
* Mirror of CampaignController::enrichWithNames — single batch SQL to
* resolve FIRST_NAME / LAST_NAME columns for marker subscribers, plus a
* deterministic demo avatar URL when isSiteDemo() is on. Adds first_name,
* last_name, full_name, initials, avatar_url keys.
*/
private function enrichLocationsWithNames($locations, \App\Model\AutomationEmail $email)
{
$list = $email->defaultMailList;
$firstCol = $list ? optional($list->getFields->firstWhere('tag', 'FIRST_NAME'))->custom_field_name : null;
$lastCol = $list ? optional($list->getFields->firstWhere('tag', 'LAST_NAME'))->custom_field_name : null;
$subIds = $locations->pluck('sub_id')->filter()->unique()->values()->all();
$names = [];
if (!empty($subIds) && ($firstCol || $lastCol)) {
$cols = array_filter(['id', $firstCol, $lastCol]);
$names = \DB::table('subscribers')->whereIn('id', $subIds)->select($cols)->get()
->keyBy('id')->toArray();
}
$isDemo = isSiteDemo();
$pool = $isDemo ? \App\Model\Subscriber::demoAvatarPool() : [];
return $locations->map(function ($loc) use ($names, $firstCol, $lastCol, $isDemo, $pool) {
$row = $loc['sub_id'] && isset($names[$loc['sub_id']]) ? $names[$loc['sub_id']] : null;
$first = ($row && $firstCol) ? trim((string) ($row->{$firstCol} ?? '')) : '';
$last = ($row && $lastCol) ? trim((string) ($row->{$lastCol} ?? '')) : '';
$full = trim("{$first} {$last}");
$initials = ($first || $last)
? strtoupper(mb_substr($first, 0, 1) . mb_substr($last, 0, 1))
: strtoupper(mb_substr($loc['email'] ?: '?', 0, 1));
$loc['first_name'] = $first;
$loc['last_name'] = $last;
$loc['full_name'] = $full;
$loc['initials'] = $initials;
$loc['avatar_url'] = null;
if ($isDemo && !empty($pool) && $loc['sub_id']) {
$idx = abs(crc32((string) $loc['sub_id'])) % count($pool);
$loc['avatar_url'] = route('user.avatar', ['uid' => $pool[$idx]]);
}
return $loc;
});
}
/**
* Dispatch RefreshCacheableJob for the bound AutomationEmail. Mirrors
* CampaignController::updateStatistics — async, no page reload, freshness
* strip will reflect the new "computed at" once the worker finishes.
*/
public function emailReportRefresh(Request $request, $uid, $nodeId)
{
[$automation, $email] = $this->resolveSendNodeEmail($uid, $nodeId);
if ($automation instanceof \Symfony\Component\HttpFoundation\Response) return $automation;
\App\Jobs\RefreshCacheableJob::dispatchAll($email);
return response()->json([
'message' => trans('refactor/campaigns.cache.refresh_dispatched'),
], 202);
}
private function emailReportLogPage($uid, $nodeId, $activeTab)
{
[$automation, $email] = $this->resolveSendNodeEmail($uid, $nodeId);
if ($automation instanceof \Symfony\Component\HttpFoundation\Response) return $automation;
return view('refactor.pages.automations.email_report_log', [
'automation' => $automation,
'email' => $email,
'nodeId' => $nodeId,
'activeTab' => $activeTab,
]);
}
/**
* Resolve a {automation uid, nodeId} pair to its bound AutomationEmail.
* Returns either [$automation, $email] or a single Response on error so
* callers can short-circuit with one `instanceof Response` check.
*/
private function resolveSendNodeEmail($uid, $nodeId)
{
$automation = Automation2::findByUid($uid);
if (!$automation || \Gate::denies('view', $automation)) {
return [$this->notAuthorized(), null];
}
try {
$flow = \App\Domain\Automation\Flow::fromJson($automation->data);
$node = $flow->node($nodeId);
} catch (\App\Domain\Automation\FlowException $e) {
abort(404, $e->getMessage());
}
if ($node->type !== \App\Domain\Automation\Enum\NodeType::Send) {
abort(422, "Node {$nodeId} is not a Send node");
}
$emailUid = $node->data['emailUid'] ?? null;
if (!$emailUid) {
abort(422, "Send node {$nodeId} has no email bound yet");
}
$email = \App\Model\AutomationEmail::findByUid($emailUid);
if (!$email) {
abort(404, 'Email not found');
}
return [$automation, $email];
}
/**
* Build a short human label for a node — used in the contacts list to show
* which step a subscriber is currently at.
*/
private function nodeLabelFor(\App\Domain\Automation\Node $node): string
{
$data = $node->data;
return match ($node->type) {
\App\Domain\Automation\Enum\NodeType::Trigger =>
trans('refactor/automations.trigger.' . ($data['key'] ?? 'unknown'), [], null) ?: trans('refactor/automations.trigger.unknown'),
\App\Domain\Automation\Enum\NodeType::Send =>
($data['emailSubject'] ?? null) ?: trans('refactor/automations.contacts.step_send'),
\App\Domain\Automation\Enum\NodeType::Wait =>
trans('refactor/automations.contacts.step_wait'),
\App\Domain\Automation\Enum\NodeType::WaitUntil =>
trans('refactor/automations.contacts.step_wait_until'),
\App\Domain\Automation\Enum\NodeType::Condition =>
trans('refactor/automations.contacts.step_condition'),
\App\Domain\Automation\Enum\NodeType::Operation =>
trans('refactor/automations.contacts.step_operation'),
\App\Domain\Automation\Enum\NodeType::Webhook =>
trans('refactor/automations.contacts.step_webhook'),
};
}
public function delete(Request $request)
{
$automations = Automation2::whereIn(
'uid',
is_array($request->uids) ? $request->uids : explode(',', $request->uids)
);
$deleted = 0;
foreach ($automations->get() as $automation) {
if ($request->user()->customer->can('delete', $automation)) {
$automation->delete();
$deleted++;
}
}
return response()->json([
'message' => $deleted . ' ' . trans('refactor/automations.flash.deleted'),
]);
}
public function edit(Request $request, $uid)
{
$automation = \App\Model\Automation2::findByUid($uid);
if (!$automation || \Gate::denies('update', $automation)) {
return $this->notAuthorized();
}
$automation->updateCacheInBackground();
// Always use the new editor. Flow::fromJson tolerates null and legacy
// shape (returns empty) — legacy data gets overwritten on first save.
$flow = \App\Domain\Automation\Flow::fromJson($automation->data);
$listFieldOptions = collect($automation->mailList->getFields()->get())
->map(fn ($f) => ['value' => $f->uid, 'label' => $f->label, 'type' => $f->type])
->values()
->all();
$emailOptions = $automation->emails()
->orderBy('created_at', 'desc')
->get()
->map(fn ($e) => ['value' => $e->uid, 'label' => $e->subject ?: '(untitled)'])
->all();
$mailListOptions = $request->user()->customer->local()
->mailLists()
->where('id', '<>', $automation->mail_list_id)
->orderBy('name')
->get()
->map(fn ($l) => ['value' => $l->uid, 'label' => $l->name])
->all();
$httpConfigOptions = \App\Model\HttpConfig::where('customer_id', $automation->customer_id)
->orderBy('id', 'desc')
->get()
->map(fn ($c) => ['value' => $c->uid, 'label' => $c->request_url])
->all();
return view('refactor.automations.edit', [
'automation' => $automation,
// Use AutomationFlowController's wireFlow helper so the initial
// payload carries per-node `validation` decoration the same way
// every REST endpoint does — single source of truth for "is this
// node ready?" lives in NodeValidator.
'flow' => app(\App\Http\Controllers\Refactor\AutomationFlowController::class)
->wireFlow($flow, $automation),
'ctx' => [
'triggerKeys' => array_map(
fn ($c) => [
'value' => $c->value,
// Prefer the (longer, marketing-tone) legacy label for the picker — it
// describes the event to non-technical operators better than the short
// enum case name (e.g. "Welcome new subscribers" vs "WelcomeNewSubscriber").
'label' => trans('messages.automation.trigger.' . $c->value, [], null) ?: $c->name,
'desc' => trans('messages.automation.trigger.' . $c->value . '.desc', [], null) ?: '',
'icon' => $this->triggerIcon($c->value),
],
\App\Domain\Automation\Enum\TriggerKey::cases()
),
'listFields' => $listFieldOptions,
'emails' => $emailOptions,
'mailLists' => $mailListOptions,
'httpConfigs' => $httpConfigOptions,
// Defaults the inline "create email" wizard prefills with —
// mailList.from_* are admin-configured per-list defaults.
'emailDefaults' => [
'fromName' => $automation->mailList->from_name ?? '',
'fromEmail' => $automation->mailList->from_email ?? '',
],
// Per-trigger config the sidebar sub-form needs. Currently:
// - api-3-0 → POST endpoint URL the customer's app calls
// to push subscribers into the workflow. UID-bound so the
// worker can resolve back to this automation.
'triggerConfig' => [
'apiEndpoint' => route('automation_execute', ['uid' => $automation->uid]),
],
],
]);
}
public function templateEdit(Request $request, $uid, $email_uid)
{
$automation = Automation2::findByUid($uid);
$email = \App\Model\AutomationEmail::findByUid($email_uid);
if (Gate::denies('update', $automation)) {
return $this->notAuthorized();
}
if ($request->isMethod('post')) {
$validator = \Validator::make($request->all(), [
'content' => 'required',
'json' => 'required',
]);
if ($validator->fails()) {
return response()->json(['message' => $validator->errors()->first()], 400);
}
if (\App\Services\Plans\Gates\EntitlementGate::allows($request->user()->customer, \App\Services\Plans\Entitlements\EntitlementKey::HAS_UNSUBSCRIBE_URL_REQUIRED)
&& \App\Model\Setting::isYes('campaign.enforce_unsubscribe_url_check')
&& !\App\Library\StringHelper::containsUnsubscribeTag($request->content)) {
return response()->json(['message' => trans('messages.template.validation.unsubscribe_url_required')], 400);
}
$email->setTemplateContent($request->content, $request->json);
$email->updateLinks();
return response()->json(['status' => 'success']);
}
// Guard: builder view derefs $email->getEmailTemplate()->uid — bare
// AutomationEmail rows from Stage 1 have no template until the user
// completes Stage 2 (template picker). Bounce them back to the editor
// with a clear flash rather than crashing on the template view.
if (!$email->hasTemplate()) {
return redirect()->route('refactor.automations.edit', $automation->uid)
->with('alert-error', trans('refactor/automations.email.template_required_first'));
}
return view('refactor.pages.automations.template.edit', [
'automation' => $automation,
'list' => $automation->mailList,
'email' => $email,
'templates' => $request->user()->customer->getBuilderTemplates(),
'customer' => $automation->customer,
]);
}
/**
* Paste raw HTML as an automation email's template content. GET renders textarea; POST saves.
*/
public function customHtml(Request $request, $uid, $email_uid)
{
$automation = Automation2::findByUid($uid);
$email = \App\Model\AutomationEmail::findByUid($email_uid);
if (!$automation || !$email || Gate::denies('update', $automation)) {
return $this->notAuthorized();
}
if ($request->isMethod('post')) {
$this->validate($request, ['html' => 'required|string']);
if ($email->isCustomHtml()) {
$email->template->updateContent(\App\Model\Template::DEFAULT_BUILDER_JSON, $request->html);
} else {
TemplateService::for($email->email)->setCustomHtml($request->html, $email->name ?: $email->automation->name);
$email->email->updatePlainFromHtml();
$email->unsetRelation('email');
}
if ($request->ajax()) {
return response()->json([
'status' => 'success',
'message' => trans('refactor/automations.custom_html.saved'),
'redirect' => route('refactor.automations.edit', $automation->uid),
]);
}
return redirect()->route('refactor.automations.edit', $automation->uid);
}
return view('refactor.pages.automations.custom_html', [
'automation' => $automation,
'email' => $email,
'currentHtml' => $email->isCustomHtml() ? ($email->template->content ?? '') : '',
]);
}
public function copy(Request $request, $uid)
{
$automation = Automation2::findByUid($uid);
if (Gate::denies('create', $automation)) {
return $this->notAuthorized();
}
if ($request->isMethod('post')) {
[$validator, $newAutomation] = $automation->copy($request->name, $request->mail_list_uid);
if ($validator->fails()) {
return response()->view('refactor.pages.automations.copy', [
'automation' => $automation,
'newAutomation' => $newAutomation,
'errors' => $validator->errors(),
], 400);
}
return response()->json([
'status' => 'success',
'message' => trans('messages.automation.copied', ['name' => $newAutomation->name]),
]);
}
return view('refactor.pages.automations.copy', [
'automation' => $automation,
]);
}
public function enable(Request $request)
{
if (config('custom.japan') && !\App\Helpers\LicenseHelper::hasActiveLicense()) {
return response()->json([
'status' => 'error',
'message' => trans('messages.license.required'),
], 400);
}
$automations = Automation2::whereIn(
'uid',
is_array($request->uids) ? $request->uids : explode(',', $request->uids)
);
foreach ($automations->get() as $automation) {
if (Gate::allows('enable', $automation)) {
$automation->enable();
}
}
return response()->json([
'status' => 'success',
'message' => trans_choice('messages.automation.enabled', $automations->count()),
]);
}
public function disable(Request $request)
{
$automations = Automation2::whereIn(
'uid',
is_array($request->uids) ? $request->uids : explode(',', $request->uids)
);
foreach ($automations->get() as $automation) {
if (Gate::allows('disable', $automation)) {
$automation->disable();
}
}
return response()->json([
'status' => 'success',
'message' => trans_choice('messages.automation.disabled', $automations->count()),
]);
}
}