File: /home/xedaptot/hi.naniguide.com/app/Jobs/ProcessAutomationRunJob.php
<?php
namespace App\Jobs;
use App\Models\AutomationRun;
use App\Models\Campaign;
use App\Models\Customer;
use App\Models\DeliveryServer;
use App\Models\EmailList;
use App\Models\ListSubscriber;
use App\Models\Template;
use App\Services\PersonalizationService;
use App\Services\CampaignService;
use App\Services\AutomationTriggerService;
use App\Services\DeliveryServerService;
use App\Services\EmailitApiService;
use App\Services\ZeptoMailApiService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use App\Models\AutomationRunLog;
class ProcessAutomationRunJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 30;
public int $timeout = 180;
public function __construct(
public int $runId
) {
}
public function handle(): void
{
$run = AutomationRun::query()->find($this->runId);
if (!$run || $run->status !== 'active') {
return;
}
$run->loadMissing('automation', 'subscriber.list');
$automation = $run->automation;
$subscriber = $run->subscriber;
if (!$automation || !$subscriber) {
$this->unlock($run);
return;
}
$this->logNodeEntry($run, $currentId);
$graph = (array) ($automation->graph ?? []);
$nodes = (array) ($graph['nodes'] ?? []);
$edges = (array) ($graph['edges'] ?? []);
$byId = [];
foreach ($nodes as $n) {
if (is_array($n) && isset($n['id'])) {
$byId[(string) $n['id']] = $n;
}
}
$currentId = (string) ($run->current_node_id ?? 'trigger_1');
$current = $byId[$currentId] ?? null;
if (!$current) {
$this->complete($run);
return;
}
$type = (string) ($current['type'] ?? '');
$settings = is_array($current['settings'] ?? null) ? $current['settings'] : [];
if ($type === 'trigger') {
$next = $this->firstOutgoing($edges, $currentId, null);
if (!$next) {
$this->complete($run);
return;
}
$this->advanceTo($run, $next, now());
return;
}
if ($type === 'delay') {
$value = (int) ($settings['delay_value'] ?? 0);
$unit = (string) ($settings['delay_unit'] ?? 'hours');
$next = $this->firstOutgoing($edges, $currentId, null);
if (!$next) {
$this->complete($run);
return;
}
$scheduledFor = $this->applyDelay(now(), $value, $unit);
$this->advanceTo($run, $next, $scheduledFor);
return;
}
if ($type === 'condition') {
$result = $this->evaluateCondition($subscriber, $settings, (array) ($run->trigger_context ?? []));
$branch = $result ? 'yes' : 'no';
$next = $this->firstOutgoing($edges, $currentId, $branch);
$next = $next ?: $this->firstOutgoing($edges, $currentId, null);
if (!$next) {
$this->complete($run);
return;
}
$this->advanceTo($run, $next, now());
return;
}
if ($type === 'email') {
$this->sendAutomationEmail($automation->customer_id, $subscriber, $settings);
$next = $this->firstOutgoing($edges, $currentId, null);
if (!$next) {
$this->complete($run);
return;
}
$this->advanceTo($run, $next, now());
return;
}
if ($type === 'webhook') {
$this->sendWebhook($subscriber, $settings, (array) ($run->trigger_context ?? []));
$next = $this->firstOutgoing($edges, $currentId, null);
if (!$next) {
$this->complete($run);
return;
}
$this->advanceTo($run, $next, now());
return;
}
if ($type === 'run_campaign') {
$this->sendCampaignToSubscriber($subscriber, $settings);
$next = $this->firstOutgoing($edges, $currentId, null);
if (!$next) {
$this->complete($run);
return;
}
$this->advanceTo($run, $next, now());
return;
}
if ($type === 'move_subscribers' || $type === 'copy_subscribers') {
$this->transferSubscriber($subscriber, $settings, $type === 'move_subscribers');
$next = $this->firstOutgoing($edges, $currentId, null);
if (!$next) {
$this->complete($run);
return;
}
$this->advanceTo($run, $next, now());
return;
}
if ($type === 'add_tag' || $type === 'remove_tag') {
$this->mutateTags($subscriber, $settings, $type === 'add_tag');
$next = $this->firstOutgoing($edges, $currentId, null);
if (!$next) {
$this->complete($run);
return;
}
$this->advanceTo($run, $next, now());
return;
}
if ($type === 'add_segment' || $type === 'remove_segment') {
$this->mutateSegment($subscriber, $settings, $type === 'add_segment');
$next = $this->firstOutgoing($edges, $currentId, null);
if (!$next) {
$this->complete($run);
return;
}
$this->advanceTo($run, $next, now());
return;
}
if ($type === 'update_field') {
$this->updateField($subscriber, $settings);
$next = $this->firstOutgoing($edges, $currentId, null);
if (!$next) {
$this->complete($run);
return;
}
$this->advanceTo($run, $next, now());
return;
}
if ($type === 'goal') {
$this->complete($run);
return;
}
if ($type === 'ab_split') {
$splitA = (int) ($settings['split_a_percent'] ?? 50);
$bucket = abs(crc32((string) $subscriber->email)) % 100;
$branch = $bucket < $splitA ? 'a' : 'b';
$next = $this->firstOutgoing($edges, $currentId, $branch);
if (!$next) {
$this->complete($run);
return;
}
$this->advanceTo($run, $next, now());
return;
}
if ($type === 'wait_until') {
$mode = (string) ($settings['mode'] ?? 'fixed');
$targetDate = null;
if ($mode === 'fixed') {
$dateStr = trim((string) ($settings['target_date'] ?? ''));
if ($dateStr !== '') {
try {
$targetDate = \Carbon\Carbon::parse($dateStr);
} catch (\Exception $e) {
$targetDate = null;
}
}
} elseif ($mode === 'field') {
$field = trim((string) ($settings['field'] ?? ''));
$time = trim((string) ($settings['time'] ?? '00:00'));
if ($field !== '') {
$value = $this->resolveSubscriberField($subscriber, $field);
if ($value && is_string($value)) {
try {
$targetDate = \Carbon\Carbon::parse($value . ' ' . $time);
} catch (\Exception $e) {
$targetDate = null;
}
}
}
}
$now = now();
// If target date is in the future, stay at this node and reschedule
if ($targetDate && $targetDate->greaterThan($now)) {
$run->update([
'next_scheduled_for' => $targetDate,
'locked_at' => null,
]);
return;
}
// Target date is now or past (or couldn't resolve) — continue
$next = $this->firstOutgoing($edges, $currentId, null);
if (!$next) {
$this->complete($run);
return;
}
$this->advanceTo($run, $next, $now);
return;
}
$next = $this->firstOutgoing($edges, $currentId, null);
if (!$next) {
$this->complete($run);
return;
}
$this->advanceTo($run, $next, now());
}
private function firstOutgoing(array $edges, string $from, ?string $branch): ?string
{
foreach ($edges as $e) {
if (!is_array($e)) continue;
if ((string) ($e['from'] ?? '') !== $from) continue;
$b = $e['branch'] ?? null;
$b = is_string($b) && $b !== '' ? $b : null;
if ($branch === null) {
if ($b === null) {
return (string) ($e['to'] ?? '');
}
continue;
}
if ($b === $branch) {
return (string) ($e['to'] ?? '');
}
}
return null;
}
private function advanceTo(AutomationRun $run, string $nodeId, $scheduledFor): void
{
$run->update([
'current_node_id' => $nodeId,
'next_scheduled_for' => $scheduledFor,
'locked_at' => null,
]);
$this->logNodeEntry($run, $nodeId);
}
private function logNodeEntry(AutomationRun $run, string $nodeId, ?string $event = null, ?array $meta = null): void
{
try {
$automation = $run->automation;
if (!$automation) {
return;
}
$graph = (array) ($automation->graph ?? []);
$nodes = (array) ($graph['nodes'] ?? []);
$nodeType = '';
foreach ($nodes as $n) {
if (is_array($n) && ($n['id'] ?? '') === $nodeId) {
$nodeType = (string) ($n['type'] ?? '');
break;
}
}
AutomationRunLog::query()->create([
'automation_run_id' => $run->id,
'automation_id' => $run->automation_id,
'node_id' => $nodeId,
'node_type' => $nodeType,
'event' => $event ?? 'entered',
'meta' => $meta,
'created_at' => now(),
]);
} catch (\Throwable $e) {
Log::warning('Failed to log automation run node entry', [
'run_id' => $run->id,
'node_id' => $nodeId,
'error' => $e->getMessage(),
]);
}
}
private function complete(AutomationRun $run): void
{
$run->update([
'status' => 'completed',
'next_scheduled_for' => null,
'locked_at' => null,
]);
}
private function unlock(AutomationRun $run): void
{
$run->update([
'locked_at' => null,
]);
}
private function applyDelay($base, int $value, string $unit)
{
if ($value <= 0) {
return $base;
}
return match ($unit) {
'minutes' => $base->copy()->addMinutes($value),
'hours' => $base->copy()->addHours($value),
'days' => $base->copy()->addDays($value),
'weeks' => $base->copy()->addWeeks($value),
default => $base,
};
}
private function evaluateCondition(ListSubscriber $subscriber, array $settings, array $context): bool
{
// New AND/OR group format
if (isset($settings['groups']) && is_array($settings['groups'])) {
$logic = strtolower(trim((string) ($settings['logic'] ?? 'and')));
$groups = array_values(array_filter($settings['groups'], 'is_array'));
if (empty($groups)) {
return false;
}
$results = array_map(
fn (array $group) => $this->evaluateSingleCondition($subscriber, $group, $context),
$groups
);
if ($logic === 'or') {
return in_array(true, $results, true);
}
return !in_array(false, $results, true);
}
// Legacy single-condition format
return $this->evaluateSingleCondition($subscriber, $settings, $context);
}
private function evaluateSingleCondition(ListSubscriber $subscriber, array $settings, array $context): bool
{
$field = trim((string) ($settings['field'] ?? ''));
$operator = trim((string) ($settings['operator'] ?? 'equals'));
$value = trim((string) ($settings['value'] ?? ''));
if ($field === '') {
return false;
}
$actual = null;
if (str_starts_with($field, 'custom_fields.')) {
$key = substr($field, strlen('custom_fields.'));
$custom = is_array($subscriber->custom_fields) ? $subscriber->custom_fields : [];
$actual = $custom[$key] ?? null;
} elseif (str_starts_with($field, 'payload.') || str_starts_with($field, 'context.payload.')) {
$path = str_starts_with($field, 'payload.')
? substr($field, strlen('payload.'))
: substr($field, strlen('context.payload.'));
$current = $context['payload'] ?? [];
foreach (explode('.', $path) as $segment) {
$segment = trim((string) $segment);
if ($segment === '' || !is_array($current) || !array_key_exists($segment, $current)) {
$current = null;
break;
}
$current = $current[$segment];
}
$actual = $current;
} else {
$actual = $subscriber->{$field} ?? null;
}
if (is_scalar($actual)) {
$actual = (string) $actual;
} elseif (is_array($actual)) {
$actual = json_encode($actual) ?: '';
} else {
$actual = '';
}
return match ($operator) {
'equals' => strcasecmp($actual, $value) === 0,
'not_equals' => strcasecmp($actual, $value) !== 0,
'contains' => stripos($actual, $value) !== false,
'not_contains' => stripos($actual, $value) === false,
'starts_with' => strcasecmp(substr($actual, 0, strlen($value)), $value) === 0,
'ends_with' => strcasecmp(substr($actual, -strlen($value)), $value) === 0,
'greater_than', '>' => is_numeric($actual) && is_numeric($value) && (float) $actual > (float) $value,
'less_than', '<' => is_numeric($actual) && is_numeric($value) && (float) $actual < (float) $value,
'greater_or_equal', '>=' => is_numeric($actual) && is_numeric($value) && (float) $actual >= (float) $value,
'less_or_equal', '<=' => is_numeric($actual) && is_numeric($value) && (float) $actual <= (float) $value,
'is_empty' => $actual === '' || $actual === null,
'is_not_empty' => $actual !== '' && $actual !== null,
'in_list' => in_array(strtolower($actual), array_map('strtolower', array_map('trim', explode(',', $value))), true),
default => false,
};
}
private function resolveSubscriberField(ListSubscriber $subscriber, string $field): mixed
{
if (str_starts_with($field, 'custom_fields.')) {
$key = substr($field, strlen('custom_fields.'));
$custom = is_array($subscriber->custom_fields) ? $subscriber->custom_fields : [];
return $custom[$key] ?? null;
}
return $subscriber->{$field} ?? null;
}
private function sendAutomationEmail(int $customerId, ListSubscriber $subscriber, array $settings): void
{
$subject = trim((string) ($settings['subject'] ?? ''));
$templateId = (int) ($settings['template_id'] ?? 0);
$deliveryServerId = (int) ($settings['delivery_server_id'] ?? 0);
$customer = Customer::query()->find($customerId);
$mustAddDelivery = $customer ? (bool) $customer->groupSetting('servers.permissions.must_add_delivery_server', false) : false;
$canUseSystem = $customer ? (bool) $customer->groupSetting('servers.permissions.can_use_system_servers', false) : false;
$template = $templateId > 0 ? Template::query()->find($templateId) : null;
if (!$template) {
throw new \RuntimeException('Template not found');
}
$html = (string) ($template->html_content ?? '');
$text = (string) ($template->plain_text_content ?? strip_tags($html));
$html = $this->personalize($html, $subscriber);
$text = $this->personalize($text, $subscriber);
$subject = $this->personalize($subject, $subscriber);
$unsubscribeUrl = $this->unsubscribeUrl($subscriber);
if ($html !== '') {
$footer = '<p style="font-size: 12px; color: #999; margin-top: 20px; text-align:center;"><a href="' . $unsubscribeUrl . '" style="color: #999;">Unsubscribe</a></p>';
if (stripos($html, '</body>') !== false) {
$html = str_ireplace('</body>', $footer . '</body>', $html);
} else {
$html .= $footer;
}
}
$text = rtrim($text) . "\n\n---\nUnsubscribe: {$unsubscribeUrl}";
$server = null;
if ($customer) {
$server = app(DeliveryServerService::class)->resolveDeliveryServerForCustomer(
$customer,
$deliveryServerId > 0 ? $deliveryServerId : null,
$mustAddDelivery,
$canUseSystem
);
} elseif ($deliveryServerId > 0) {
$server = DeliveryServer::query()->with('bounceServer')->find($deliveryServerId);
}
if (!$server && $mustAddDelivery) {
throw new \RuntimeException('Delivery server is required');
}
if ($server && $server->type === 'zeptomail-api') {
$message = [
'from_email' => (string) ($settings['from_email'] ?? ($server->from_email ?? config('mail.from.address'))),
'from_name' => (string) ($settings['from_name'] ?? ($server->from_name ?? config('mail.from.name'))),
'to_email' => (string) $subscriber->email,
'to_name' => trim((string) (($subscriber->first_name ?? '') . ' ' . ($subscriber->last_name ?? ''))),
'subject' => $subject,
'htmlbody' => $html,
'textbody' => $text,
'client_reference' => 'automation-email-' . $subscriber->id . '-' . now()->timestamp,
];
if (empty(($server->settings ?? [])['bounce_address']) && !empty($server->bounceServer?->username)) {
$message['bounce_address'] = (string) $server->bounceServer->username;
}
app(ZeptoMailApiService::class)->sendRaw($server, $message);
return;
}
if ($server && $server->type === 'emailit') {
$message = [
'from_email' => (string) ($settings['from_email'] ?? ($server->from_email ?? config('mail.from.address'))),
'from_name' => (string) ($settings['from_name'] ?? ($server->from_name ?? config('mail.from.name'))),
'subject' => $subject,
'htmlbody' => $html,
'textbody' => $text,
];
app(EmailitApiService::class)->send($server, $message);
return;
}
if ($server) {
app(DeliveryServerService::class)->configureMailFromServer($server);
}
Mail::raw($text, function ($message) use ($subscriber, $subject, $html, $settings, $server) {
$fromEmail = (string) ($settings['from_email'] ?? ($server?->from_email ?? config('mail.from.address')));
$fromName = (string) ($settings['from_name'] ?? ($server?->from_name ?? config('mail.from.name')));
$message->to($subscriber->email)
->subject($subject)
->from($fromEmail, $fromName);
$replyTo = trim((string) ($settings['reply_to'] ?? ''));
if ($replyTo !== '') {
$message->replyTo($replyTo);
}
if ($server && $server->bounceServer && $server->bounceServer->username) {
$message->returnPath($server->bounceServer->username);
}
if ($html !== '') {
$message->html($html);
}
});
}
private function sendWebhook(ListSubscriber $subscriber, array $settings, array $context): void
{
$url = trim((string) ($settings['url'] ?? ''));
$method = strtoupper(trim((string) ($settings['method'] ?? 'POST')));
if ($url === '') {
return;
}
$payload = [
'subscriber' => [
'id' => $subscriber->id,
'email' => $subscriber->email,
'first_name' => $subscriber->first_name,
'last_name' => $subscriber->last_name,
'list_id' => $subscriber->list_id,
'status' => $subscriber->status,
'custom_fields' => $subscriber->custom_fields,
'tags' => $subscriber->tags,
],
'context' => $context,
];
Http::timeout(10)->send($method, $url, [
'json' => $payload,
]);
}
private function sendCampaignToSubscriber(ListSubscriber $subscriber, array $settings): void
{
$campaignId = (int) ($settings['campaign_id'] ?? 0);
if ($campaignId <= 0) {
return;
}
$campaign = Campaign::query()->find($campaignId);
if (!$campaign) {
return;
}
$recipient = CampaignRecipient::query()->create([
'campaign_id' => $campaign->id,
'email' => $subscriber->email,
'first_name' => $subscriber->first_name,
'last_name' => $subscriber->last_name,
'status' => 'pending',
]);
$campaign->loadMissing('deliveryServer.bounceServer');
$server = $campaign->deliveryServer;
if ($server) {
app(DeliveryServerService::class)->configureMailFromServer($server);
}
$mailerName = config('mail.default', 'smtp');
$mailer = $mailerName ? Mail::mailer($mailerName) : Mail::mailer();
$mailer->to($recipient->email)->send(new \App\Mail\CampaignMailable($campaign, $recipient));
$recipient->markAsSent();
try {
app(AutomationTriggerService::class)->scheduleNegativeCampaignTriggersForRecipient($campaign, $recipient);
} catch (\Throwable $e) {
Log::warning('Failed scheduling negative campaign automation triggers (automation run_campaign node)', [
'campaign_id' => $campaign->id,
'recipient_id' => $recipient->id,
'error' => $e->getMessage(),
]);
}
}
private function transferSubscriber(ListSubscriber $subscriber, array $settings, bool $removeFromOriginal): void
{
$targetListId = (int) ($settings['target_list_id'] ?? 0);
if ($targetListId <= 0) {
return;
}
$targetList = EmailList::query()->find($targetListId);
if (!$targetList) {
return;
}
$service = app(\App\Services\ListSubscriberService::class);
$service->create($targetList, [
'email' => $subscriber->email,
'first_name' => $subscriber->first_name,
'last_name' => $subscriber->last_name,
'status' => 'confirmed',
'source' => 'automation',
'custom_fields' => $subscriber->custom_fields ?? [],
'tags' => $subscriber->tags ?? [],
]);
if ($removeFromOriginal) {
$service->unsubscribe($subscriber);
}
}
private function mutateTags(ListSubscriber $subscriber, array $settings, bool $add): void
{
$tags = isset($settings['tags']) && is_array($settings['tags']) ? $settings['tags'] : [];
$tags = array_values(array_filter($tags, fn ($t) => is_string($t) && trim($t) !== ''));
if (empty($tags)) {
return;
}
$current = is_array($subscriber->tags) ? $subscriber->tags : [];
if ($add) {
$subscriber->update(['tags' => array_values(array_unique(array_merge($current, $tags)))]);
} else {
$subscriber->update(['tags' => array_values(array_diff($current, $tags))]);
}
}
private function mutateSegment(ListSubscriber $subscriber, array $settings, bool $add): void
{
$segmentId = (int) ($settings['segment_id'] ?? 0);
if ($segmentId <= 0) {
return;
}
$exists = DB::table('list_segment_subscriber')
->where('segment_id', $segmentId)
->where('subscriber_id', $subscriber->id)
->exists();
if ($add) {
if (!$exists) {
DB::table('list_segment_subscriber')->insert([
'segment_id' => $segmentId,
'subscriber_id' => $subscriber->id,
'created_at' => now(),
'updated_at' => now(),
]);
}
} else {
if ($exists) {
DB::table('list_segment_subscriber')
->where('segment_id', $segmentId)
->where('subscriber_id', $subscriber->id)
->delete();
}
}
}
private function updateField(ListSubscriber $subscriber, array $settings): void
{
$field = trim((string) ($settings['field'] ?? ''));
$value = trim((string) ($settings['value'] ?? ''));
if ($field === '') {
return;
}
$allowedBuiltIn = ['first_name', 'last_name', 'status'];
if (in_array($field, $allowedBuiltIn, true)) {
$subscriber->update([$field => $value]);
return;
}
if (str_starts_with($field, 'custom_fields.')) {
$key = substr($field, strlen('custom_fields.'));
$custom = is_array($subscriber->custom_fields) ? $subscriber->custom_fields : [];
$custom[$key] = $value;
$subscriber->update(['custom_fields' => $custom]);
return;
}
if ($field === 'tags') {
$tags = array_map('trim', explode(',', $value));
$subscriber->update(['tags' => array_values(array_filter($tags))]);
}
}
private function personalize(string $content, ListSubscriber $subscriber): string
{
return app(PersonalizationService::class)->personalizeForSubscriber($content, $subscriber);
}
private function unsubscribeUrl(ListSubscriber $subscriber): string
{
$token = hash('sha256', $subscriber->email . $subscriber->list_id . config('app.key'));
return route('public.unsubscribe', [
'list' => $subscriber->list_id,
'email' => $subscriber->email,
'token' => $token,
]);
}
}