feat: add marketing automations / playbooks engine (Phase 6)

Adds the Marketing Automations system that allows sellers to create
automated marketing workflows triggered by CannaiQ intelligence data.

Features:
- Automation configuration with triggers (scheduled, manual)
- Condition evaluators (competitor OOS, slow mover, new store)
- Action executors (create promo, create campaign)
- Scheduled command and queued job execution
- Full CRUD UI with quick-start presets
- Run history tracking with detailed logs

Components:
- MarketingAutomation and MarketingAutomationRun models
- AutomationRunner service with extensible condition/action handlers
- RunDueMarketingAutomations command for cron scheduling
- RunMarketingAutomationJob for Horizon-backed execution
- Seller UI at /s/{business}/marketing/automations
This commit is contained in:
kelly
2025-12-09 16:41:32 -07:00
parent c7fdc67060
commit 50bb3fce77
14 changed files with 3361 additions and 0 deletions

View File

@@ -0,0 +1,108 @@
<?php
namespace App\Console\Commands;
use App\Jobs\RunMarketingAutomationJob;
use App\Models\Marketing\MarketingAutomation;
use Illuminate\Console\Command;
class RunDueMarketingAutomations extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'marketing:run-due-automations
{--business= : Only process automations for a specific business ID}
{--dry-run : Show which automations would run without executing them}
{--sync : Run synchronously instead of dispatching to queue}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Check and run all due marketing automations';
/**
* Execute the console command.
*/
public function handle(): int
{
$businessId = $this->option('business');
$dryRun = $this->option('dry-run');
$sync = $this->option('sync');
$this->info('Checking for due marketing automations...');
// Query active automations
$query = MarketingAutomation::where('is_active', true)
->whereIn('trigger_type', [
MarketingAutomation::TRIGGER_SCHEDULED_CANNAIQ_CHECK,
MarketingAutomation::TRIGGER_SCHEDULED_STORE_CHECK,
]);
if ($businessId) {
$query->where('business_id', $businessId);
}
$automations = $query->get();
if ($automations->isEmpty()) {
$this->info('No active automations found.');
return Command::SUCCESS;
}
$this->info("Found {$automations->count()} active automation(s).");
$dueCount = 0;
foreach ($automations as $automation) {
if (! $automation->isDue()) {
$this->line(" - <comment>{$automation->name}</comment>: Not due yet");
continue;
}
$dueCount++;
if ($dryRun) {
$this->line(" - <info>{$automation->name}</info>: Would run (dry-run mode)");
$this->line(" Trigger: {$automation->trigger_type_label}");
$this->line(" Frequency: {$automation->frequency_label}");
$this->line(' Last run: '.($automation->last_run_at?->diffForHumans() ?? 'Never'));
continue;
}
$this->line(" - <info>{$automation->name}</info>: Dispatching...");
if ($sync) {
// Run synchronously
try {
$job = new RunMarketingAutomationJob($automation->id);
$job->handle(app(\App\Services\Marketing\AutomationRunner::class));
$this->line(' <info>Completed</info>');
} catch (\Exception $e) {
$this->error(" Failed: {$e->getMessage()}");
}
} else {
// Dispatch to queue
RunMarketingAutomationJob::dispatch($automation->id);
$this->line(' <info>Dispatched to queue</info>');
}
}
if ($dryRun) {
$this->newLine();
$this->info("Dry run complete. {$dueCount} automation(s) would have been executed.");
} else {
$this->newLine();
$this->info("Done. {$dueCount} automation(s) ".($sync ? 'executed' : 'dispatched').'.');
}
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,312 @@
<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Jobs\RunMarketingAutomationJob;
use App\Models\Business;
use App\Models\Marketing\MarketingAutomation;
use App\Models\Marketing\MarketingList;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class MarketingAutomationController extends Controller
{
public function index(Request $request, Business $business)
{
$this->authorizeForBusiness($business);
$automations = MarketingAutomation::where('business_id', $business->id)
->with('latestRun')
->when($request->status === 'active', fn ($q) => $q->where('is_active', true))
->when($request->status === 'inactive', fn ($q) => $q->where('is_active', false))
->when($request->trigger_type, fn ($q, $type) => $q->where('trigger_type', $type))
->latest()
->paginate(15);
return view('seller.marketing.automations.index', compact('business', 'automations'));
}
public function create(Request $request, Business $business)
{
$this->authorizeForBusiness($business);
$presets = MarketingAutomation::getTypePresets();
$selectedPreset = $request->query('preset');
$lists = MarketingList::where('business_id', $business->id)
->withCount('contacts')
->orderBy('name')
->get();
return view('seller.marketing.automations.create', compact(
'business',
'presets',
'selectedPreset',
'lists'
));
}
public function store(Request $request, Business $business)
{
$this->authorizeForBusiness($business);
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string|max:1000',
'scope' => 'required|in:internal,portal',
'trigger_type' => 'required|in:'.implode(',', array_keys(MarketingAutomation::TRIGGER_TYPES)),
'frequency' => 'required|in:'.implode(',', array_keys(MarketingAutomation::FREQUENCIES)),
'time_of_day' => 'nullable|date_format:H:i',
'condition_type' => 'required|in:'.implode(',', array_keys(MarketingAutomation::CONDITION_TYPES)),
// Condition-specific fields
'category' => 'nullable|string|max:100',
'min_inventory_units' => 'nullable|integer|min:1',
'min_price_advantage' => 'nullable|numeric|min:0|max:1',
'velocity_threshold' => 'nullable|integer|min:0',
'min_days_in_stock' => 'nullable|integer|min:1',
// Action fields
'promo_type' => 'required|string',
'duration_hours' => 'required|integer|min:1|max:720',
'discount_percent' => 'nullable|integer|min:1|max:100',
'channels' => 'required|array|min:1',
'channels.*' => 'in:email,sms',
'list_id' => 'nullable|exists:marketing_lists,id',
'send_mode' => 'required|in:immediate,schedule,draft',
'subject_template' => 'nullable|string|max:255',
'sms_body_template' => 'nullable|string|max:320',
'email_body_template' => 'nullable|string',
]);
// Build configs
$triggerConfig = [
'frequency' => $validated['frequency'],
'time_of_day' => $validated['time_of_day'] ?? '09:00',
'store_scope' => 'all',
];
$conditionConfig = [
'type' => $validated['condition_type'],
];
// Add condition-specific fields
if ($validated['condition_type'] === MarketingAutomation::CONDITION_COMPETITOR_OUT_OF_STOCK) {
$conditionConfig['category'] = $validated['category'] ?? null;
$conditionConfig['min_inventory_units'] = $validated['min_inventory_units'] ?? 30;
$conditionConfig['min_price_advantage'] = $validated['min_price_advantage'] ?? 0.1;
} elseif ($validated['condition_type'] === MarketingAutomation::CONDITION_SLOW_MOVER_CLEARANCE) {
$conditionConfig['velocity_30d_threshold'] = $validated['velocity_threshold'] ?? 5;
$conditionConfig['min_inventory_units'] = $validated['min_inventory_units'] ?? 50;
$conditionConfig['min_days_in_stock'] = $validated['min_days_in_stock'] ?? 30;
} elseif ($validated['condition_type'] === MarketingAutomation::CONDITION_NEW_STORE_LAUNCH) {
$conditionConfig['first_appearance_window_days'] = 7;
}
$actionConfig = [
'create_promo' => [
'promo_type' => $validated['promo_type'],
'duration_hours' => $validated['duration_hours'],
],
'create_campaign' => [
'channels' => $validated['channels'],
'list_id' => $validated['list_id'] ?? null,
'send_mode' => $validated['send_mode'],
'subject_template' => $validated['subject_template'] ?? 'Special Offer',
'sms_body_template' => $validated['sms_body_template'] ?? '🔥 Special deal today!',
'email_body_template' => $validated['email_body_template'] ?? '<p>Check out our special offer!</p>',
],
];
if (! empty($validated['discount_percent'])) {
$actionConfig['create_promo']['discount_percent'] = $validated['discount_percent'];
}
$automation = MarketingAutomation::create([
'business_id' => $business->id,
'name' => $validated['name'],
'description' => $validated['description'],
'is_active' => true,
'scope' => $validated['scope'],
'trigger_type' => $validated['trigger_type'],
'trigger_config' => $triggerConfig,
'condition_config' => $conditionConfig,
'action_config' => $actionConfig,
]);
return redirect()
->route('seller.marketing.automations.index', $business)
->with('success', "Automation \"{$automation->name}\" created successfully.");
}
public function edit(Request $request, Business $business, MarketingAutomation $automation)
{
$this->authorizeForBusiness($business);
$this->ensureAutomationBelongsToBusiness($automation, $business);
$presets = MarketingAutomation::getTypePresets();
$lists = MarketingList::where('business_id', $business->id)
->withCount('contacts')
->orderBy('name')
->get();
return view('seller.marketing.automations.edit', compact(
'business',
'automation',
'presets',
'lists'
));
}
public function update(Request $request, Business $business, MarketingAutomation $automation)
{
$this->authorizeForBusiness($business);
$this->ensureAutomationBelongsToBusiness($automation, $business);
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string|max:1000',
'scope' => 'required|in:internal,portal',
'trigger_type' => 'required|in:'.implode(',', array_keys(MarketingAutomation::TRIGGER_TYPES)),
'frequency' => 'required|in:'.implode(',', array_keys(MarketingAutomation::FREQUENCIES)),
'time_of_day' => 'nullable|date_format:H:i',
'condition_type' => 'required|in:'.implode(',', array_keys(MarketingAutomation::CONDITION_TYPES)),
'category' => 'nullable|string|max:100',
'min_inventory_units' => 'nullable|integer|min:1',
'min_price_advantage' => 'nullable|numeric|min:0|max:1',
'velocity_threshold' => 'nullable|integer|min:0',
'min_days_in_stock' => 'nullable|integer|min:1',
'promo_type' => 'required|string',
'duration_hours' => 'required|integer|min:1|max:720',
'discount_percent' => 'nullable|integer|min:1|max:100',
'channels' => 'required|array|min:1',
'channels.*' => 'in:email,sms',
'list_id' => 'nullable|exists:marketing_lists,id',
'send_mode' => 'required|in:immediate,schedule,draft',
'subject_template' => 'nullable|string|max:255',
'sms_body_template' => 'nullable|string|max:320',
'email_body_template' => 'nullable|string',
]);
// Build configs (same as store)
$triggerConfig = [
'frequency' => $validated['frequency'],
'time_of_day' => $validated['time_of_day'] ?? '09:00',
'store_scope' => 'all',
];
$conditionConfig = [
'type' => $validated['condition_type'],
];
if ($validated['condition_type'] === MarketingAutomation::CONDITION_COMPETITOR_OUT_OF_STOCK) {
$conditionConfig['category'] = $validated['category'] ?? null;
$conditionConfig['min_inventory_units'] = $validated['min_inventory_units'] ?? 30;
$conditionConfig['min_price_advantage'] = $validated['min_price_advantage'] ?? 0.1;
} elseif ($validated['condition_type'] === MarketingAutomation::CONDITION_SLOW_MOVER_CLEARANCE) {
$conditionConfig['velocity_30d_threshold'] = $validated['velocity_threshold'] ?? 5;
$conditionConfig['min_inventory_units'] = $validated['min_inventory_units'] ?? 50;
$conditionConfig['min_days_in_stock'] = $validated['min_days_in_stock'] ?? 30;
} elseif ($validated['condition_type'] === MarketingAutomation::CONDITION_NEW_STORE_LAUNCH) {
$conditionConfig['first_appearance_window_days'] = 7;
}
$actionConfig = [
'create_promo' => [
'promo_type' => $validated['promo_type'],
'duration_hours' => $validated['duration_hours'],
],
'create_campaign' => [
'channels' => $validated['channels'],
'list_id' => $validated['list_id'] ?? null,
'send_mode' => $validated['send_mode'],
'subject_template' => $validated['subject_template'] ?? 'Special Offer',
'sms_body_template' => $validated['sms_body_template'] ?? '🔥 Special deal today!',
'email_body_template' => $validated['email_body_template'] ?? '<p>Check out our special offer!</p>',
],
];
if (! empty($validated['discount_percent'])) {
$actionConfig['create_promo']['discount_percent'] = $validated['discount_percent'];
}
$automation->update([
'name' => $validated['name'],
'description' => $validated['description'],
'scope' => $validated['scope'],
'trigger_type' => $validated['trigger_type'],
'trigger_config' => $triggerConfig,
'condition_config' => $conditionConfig,
'action_config' => $actionConfig,
]);
return redirect()
->route('seller.marketing.automations.index', $business)
->with('success', "Automation \"{$automation->name}\" updated successfully.");
}
public function toggle(Request $request, Business $business, MarketingAutomation $automation)
{
$this->authorizeForBusiness($business);
$this->ensureAutomationBelongsToBusiness($automation, $business);
$automation->update([
'is_active' => ! $automation->is_active,
]);
$status = $automation->is_active ? 'enabled' : 'disabled';
return redirect()
->back()
->with('success', "Automation \"{$automation->name}\" has been {$status}.");
}
public function runNow(Request $request, Business $business, MarketingAutomation $automation)
{
$this->authorizeForBusiness($business);
$this->ensureAutomationBelongsToBusiness($automation, $business);
if (! $automation->is_active) {
return redirect()
->back()
->with('error', 'Cannot run an inactive automation. Enable it first.');
}
// Dispatch the job
RunMarketingAutomationJob::dispatch($automation->id);
return redirect()
->route('seller.marketing.automations.runs.index', [$business, $automation])
->with('success', "Automation \"{$automation->name}\" has been queued to run.");
}
public function destroy(Request $request, Business $business, MarketingAutomation $automation)
{
$this->authorizeForBusiness($business);
$this->ensureAutomationBelongsToBusiness($automation, $business);
$name = $automation->name;
$automation->delete();
return redirect()
->route('seller.marketing.automations.index', $business)
->with('success', "Automation \"{$name}\" has been deleted.");
}
protected function authorizeForBusiness(Business $business): void
{
$user = Auth::user();
// Check user has access to this business
if (! $user->businesses->contains($business->id) && ! $user->hasRole('Super Admin')) {
abort(403, 'Unauthorized access to this business.');
}
}
protected function ensureAutomationBelongsToBusiness(MarketingAutomation $automation, Business $business): void
{
if ($automation->business_id !== $business->id) {
abort(404);
}
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Marketing\MarketingAutomation;
use App\Models\Marketing\MarketingAutomationRun;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class MarketingAutomationRunController extends Controller
{
public function index(Request $request, Business $business, MarketingAutomation $automation)
{
$this->authorizeForBusiness($business);
$this->ensureAutomationBelongsToBusiness($automation, $business);
$runs = MarketingAutomationRun::where('marketing_automation_id', $automation->id)
->when($request->status, fn ($q, $status) => $q->where('status', $status))
->orderBy('started_at', 'desc')
->paginate(25);
return view('seller.marketing.automations.runs.index', compact(
'business',
'automation',
'runs'
));
}
public function show(Request $request, Business $business, MarketingAutomation $automation, MarketingAutomationRun $run)
{
$this->authorizeForBusiness($business);
$this->ensureAutomationBelongsToBusiness($automation, $business);
$this->ensureRunBelongsToAutomation($run, $automation);
return view('seller.marketing.automations.runs.show', compact(
'business',
'automation',
'run'
));
}
protected function authorizeForBusiness(Business $business): void
{
$user = Auth::user();
if (! $user->businesses->contains($business->id) && ! $user->hasRole('Super Admin')) {
abort(403, 'Unauthorized access to this business.');
}
}
protected function ensureAutomationBelongsToBusiness(MarketingAutomation $automation, Business $business): void
{
if ($automation->business_id !== $business->id) {
abort(404);
}
}
protected function ensureRunBelongsToAutomation(MarketingAutomationRun $run, MarketingAutomation $automation): void
{
if ($run->marketing_automation_id !== $automation->id) {
abort(404);
}
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace App\Jobs;
use App\Models\Marketing\MarketingAutomation;
use App\Services\Marketing\AutomationRunner;
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\Log;
class RunMarketingAutomationJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*/
public int $tries = 3;
/**
* The number of seconds to wait before retrying the job.
*/
public int $backoff = 60;
/**
* The automation ID to run.
*/
public int $automationId;
/**
* Create a new job instance.
*/
public function __construct(int $automationId)
{
$this->automationId = $automationId;
$this->onQueue('marketing');
}
/**
* Execute the job.
*/
public function handle(AutomationRunner $runner): void
{
$automation = MarketingAutomation::find($this->automationId);
if (! $automation) {
Log::warning('RunMarketingAutomationJob: Automation not found', [
'automation_id' => $this->automationId,
]);
return;
}
if (! $automation->is_active) {
Log::info('RunMarketingAutomationJob: Automation is inactive, skipping', [
'automation_id' => $this->automationId,
'automation_name' => $automation->name,
]);
return;
}
Log::info('RunMarketingAutomationJob: Starting automation run', [
'automation_id' => $automation->id,
'automation_name' => $automation->name,
'business_id' => $automation->business_id,
]);
$run = $runner->runAutomation($automation);
Log::info('RunMarketingAutomationJob: Automation run completed', [
'automation_id' => $automation->id,
'run_id' => $run->id,
'status' => $run->status,
'summary' => $run->summary,
]);
}
/**
* Handle a job failure.
*/
public function failed(\Throwable $exception): void
{
Log::error('RunMarketingAutomationJob: Job failed', [
'automation_id' => $this->automationId,
'error' => $exception->getMessage(),
'trace' => $exception->getTraceAsString(),
]);
// Update automation status to error
$automation = MarketingAutomation::find($this->automationId);
if ($automation) {
$automation->update([
'last_run_at' => now(),
'last_status' => 'error',
]);
}
}
/**
* Get the tags that should be assigned to the job.
*/
public function tags(): array
{
$automation = MarketingAutomation::find($this->automationId);
return [
'marketing',
'automation',
'automation:'.$this->automationId,
'business:'.($automation?->business_id ?? 'unknown'),
];
}
}

View File

@@ -0,0 +1,304 @@
<?php
namespace App\Models\Marketing;
use App\Models\Business;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class MarketingAutomation extends Model
{
// Trigger types
public const TRIGGER_SCHEDULED_CANNAIQ_CHECK = 'scheduled_cannaiq_check';
public const TRIGGER_SCHEDULED_STORE_CHECK = 'scheduled_store_check';
public const TRIGGER_MANUAL_TEST = 'manual_test';
public const TRIGGER_TYPES = [
self::TRIGGER_SCHEDULED_CANNAIQ_CHECK => 'Scheduled CannaiQ Check',
self::TRIGGER_SCHEDULED_STORE_CHECK => 'Scheduled Store Check',
self::TRIGGER_MANUAL_TEST => 'Manual Test',
];
// Condition types
public const CONDITION_COMPETITOR_OUT_OF_STOCK = 'competitor_out_of_stock_and_we_have_inventory';
public const CONDITION_SLOW_MOVER_CLEARANCE = 'slow_mover_clearance';
public const CONDITION_NEW_STORE_LAUNCH = 'new_store_launch';
public const CONDITION_TYPES = [
self::CONDITION_COMPETITOR_OUT_OF_STOCK => 'Competitor Out of Stock (We Have Inventory)',
self::CONDITION_SLOW_MOVER_CLEARANCE => 'Slow Mover Clearance',
self::CONDITION_NEW_STORE_LAUNCH => 'New Store Launch',
];
// Scope types
public const SCOPE_INTERNAL = 'internal';
public const SCOPE_PORTAL = 'portal';
public const SCOPES = [
self::SCOPE_INTERNAL => 'Internal Only',
self::SCOPE_PORTAL => 'Portal Visible',
];
// Frequency options
public const FREQUENCY_HOURLY = 'hourly';
public const FREQUENCY_DAILY = 'daily';
public const FREQUENCY_WEEKLY = 'weekly';
public const FREQUENCIES = [
self::FREQUENCY_HOURLY => 'Hourly',
self::FREQUENCY_DAILY => 'Daily',
self::FREQUENCY_WEEKLY => 'Weekly',
];
// Promo types for actions
public const PROMO_TYPES = [
'flash_bogo' => 'Flash BOGO',
'percent_off' => 'Percent Off',
'dollar_off' => 'Dollar Off',
'clearance' => 'Clearance',
'launch_special' => 'Launch Special',
];
protected $fillable = [
'business_id',
'name',
'description',
'is_active',
'scope',
'trigger_type',
'trigger_config',
'condition_config',
'action_config',
'last_run_at',
'last_status',
'meta',
];
protected $casts = [
'is_active' => 'boolean',
'trigger_config' => 'array',
'condition_config' => 'array',
'action_config' => 'array',
'meta' => 'array',
'last_run_at' => 'datetime',
];
// Relationships
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function runs(): HasMany
{
return $this->hasMany(MarketingAutomationRun::class);
}
public function latestRun(): HasMany
{
return $this->runs()->latest('started_at')->limit(1);
}
// Accessors
public function getTriggerTypeLabelAttribute(): string
{
return self::TRIGGER_TYPES[$this->trigger_type] ?? $this->trigger_type;
}
public function getConditionTypeLabelAttribute(): string
{
$type = $this->condition_config['type'] ?? 'unknown';
return self::CONDITION_TYPES[$type] ?? $type;
}
public function getScopeLabelAttribute(): string
{
return self::SCOPES[$this->scope] ?? $this->scope;
}
public function getFrequencyAttribute(): string
{
return $this->trigger_config['frequency'] ?? self::FREQUENCY_DAILY;
}
public function getFrequencyLabelAttribute(): string
{
$frequency = $this->frequency;
return self::FREQUENCIES[$frequency] ?? $frequency;
}
public function getStatusColorAttribute(): string
{
return match ($this->last_status) {
'success' => 'success',
'partial' => 'warning',
'error', 'failed' => 'error',
'skipped' => 'ghost',
default => 'ghost',
};
}
// Methods
/**
* Check if the automation is due to run based on its schedule.
*/
public function isDue(): bool
{
if (! $this->is_active) {
return false;
}
if ($this->trigger_type === self::TRIGGER_MANUAL_TEST) {
return false; // Manual automations are never auto-triggered
}
$frequency = $this->trigger_config['frequency'] ?? self::FREQUENCY_DAILY;
$timeOfDay = $this->trigger_config['time_of_day'] ?? '09:00';
$minuteOffset = $this->trigger_config['minute_offset'] ?? 0;
$now = Carbon::now();
// If never run, it's due (assuming time matches for daily/weekly)
if (! $this->last_run_at) {
return $this->timeMatchesSchedule($now, $frequency, $timeOfDay, $minuteOffset);
}
return match ($frequency) {
self::FREQUENCY_HOURLY => $this->last_run_at->diffInMinutes($now) >= 60 - 5, // 5-min buffer
self::FREQUENCY_DAILY => $this->last_run_at->diffInHours($now) >= 23 && $this->timeMatchesSchedule($now, $frequency, $timeOfDay, $minuteOffset),
self::FREQUENCY_WEEKLY => $this->last_run_at->diffInDays($now) >= 6 && $this->timeMatchesSchedule($now, $frequency, $timeOfDay, $minuteOffset),
default => false,
};
}
/**
* Check if current time matches the scheduled time.
*/
protected function timeMatchesSchedule(Carbon $now, string $frequency, string $timeOfDay, int $minuteOffset): bool
{
if ($frequency === self::FREQUENCY_HOURLY) {
return $now->minute >= $minuteOffset && $now->minute < $minuteOffset + 10;
}
// Parse time of day (HH:MM format)
[$hour, $minute] = array_map('intval', explode(':', $timeOfDay));
// Allow a 30-minute window after scheduled time
$scheduledMinutes = $hour * 60 + $minute;
$currentMinutes = $now->hour * 60 + $now->minute;
return $currentMinutes >= $scheduledMinutes && $currentMinutes < $scheduledMinutes + 30;
}
/**
* Get automation type preset info for the UI.
*/
public static function getTypePresets(): array
{
return [
'competitor_flash_sale' => [
'name' => 'Competitor Out of Stock → Flash Sale',
'description' => 'When competitors are out of stock in your category and you have inventory, automatically create a flash promo and SMS campaign.',
'trigger_type' => self::TRIGGER_SCHEDULED_CANNAIQ_CHECK,
'condition_type' => self::CONDITION_COMPETITOR_OUT_OF_STOCK,
'default_trigger_config' => [
'frequency' => self::FREQUENCY_HOURLY,
'minute_offset' => 5,
'store_scope' => 'all',
],
'default_condition_config' => [
'type' => self::CONDITION_COMPETITOR_OUT_OF_STOCK,
'category' => null,
'min_inventory_units' => 30,
'min_price_advantage' => 0.1,
],
'default_action_config' => [
'create_promo' => [
'promo_type' => 'flash_bogo',
'duration_hours' => 24,
],
'create_campaign' => [
'channels' => ['sms'],
'list_type' => 'consumers',
'send_mode' => 'immediate',
'subject_template' => 'Flash Deal: {product_name} Today Only',
'sms_body_template' => '🔥 Flash deal today at {store_name}: {promo_text}',
],
],
],
'slow_mover_clearance' => [
'name' => 'Slow Movers → Clearance Email',
'description' => 'For slow-moving inventory, automatically create clearance promos and email campaigns to deal-seekers.',
'trigger_type' => self::TRIGGER_SCHEDULED_CANNAIQ_CHECK,
'condition_type' => self::CONDITION_SLOW_MOVER_CLEARANCE,
'default_trigger_config' => [
'frequency' => self::FREQUENCY_DAILY,
'time_of_day' => '09:00',
],
'default_condition_config' => [
'type' => self::CONDITION_SLOW_MOVER_CLEARANCE,
'velocity_30d_threshold' => 5,
'min_inventory_units' => 50,
'min_days_in_stock' => 30,
],
'default_action_config' => [
'create_promo' => [
'promo_type' => 'clearance',
'duration_hours' => 168, // 7 days
'discount_percent' => 20,
],
'create_campaign' => [
'channels' => ['email'],
'list_type' => 'consumers',
'list_tags' => ['deal_seeker', 'loyal'],
'send_mode' => 'immediate',
'subject_template' => 'Clearance Alert: Save on {product_name}',
'email_body_template' => 'Great deals on premium products. {promo_text}',
],
],
],
'new_store_launch' => [
'name' => 'New Store Launch → Welcome Blast',
'description' => 'When your brand appears at a new store in CannaiQ, automatically send a welcome campaign.',
'trigger_type' => self::TRIGGER_SCHEDULED_STORE_CHECK,
'condition_type' => self::CONDITION_NEW_STORE_LAUNCH,
'default_trigger_config' => [
'frequency' => self::FREQUENCY_DAILY,
'time_of_day' => '10:00',
],
'default_condition_config' => [
'type' => self::CONDITION_NEW_STORE_LAUNCH,
'first_appearance_window_days' => 7,
],
'default_action_config' => [
'create_promo' => [
'promo_type' => 'launch_special',
'duration_hours' => 168, // 7 days
],
'create_campaign' => [
'channels' => ['email', 'sms'],
'list_type' => 'consumers',
'send_mode' => 'immediate',
'subject_template' => 'Now Available: {brand_name} at {store_name}',
'sms_body_template' => '🎉 {brand_name} is now at {store_name}! {promo_text}',
'email_body_template' => 'Exciting news! {brand_name} products are now available at {store_name}.',
],
],
],
];
}
}

View File

@@ -0,0 +1,169 @@
<?php
namespace App\Models\Marketing;
use App\Models\Business;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MarketingAutomationRun extends Model
{
public const STATUS_RUNNING = 'running';
public const STATUS_SUCCESS = 'success';
public const STATUS_PARTIAL = 'partial';
public const STATUS_FAILED = 'failed';
public const STATUS_SKIPPED = 'skipped';
public const STATUSES = [
self::STATUS_RUNNING => 'Running',
self::STATUS_SUCCESS => 'Success',
self::STATUS_PARTIAL => 'Partial',
self::STATUS_FAILED => 'Failed',
self::STATUS_SKIPPED => 'Skipped',
];
protected $fillable = [
'business_id',
'marketing_automation_id',
'started_at',
'finished_at',
'status',
'summary',
'details',
];
protected $casts = [
'started_at' => 'datetime',
'finished_at' => 'datetime',
'details' => 'array',
];
// Relationships
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function automation(): BelongsTo
{
return $this->belongsTo(MarketingAutomation::class, 'marketing_automation_id');
}
// Accessors
public function getStatusLabelAttribute(): string
{
return self::STATUSES[$this->status] ?? $this->status;
}
public function getStatusColorAttribute(): string
{
return match ($this->status) {
self::STATUS_RUNNING => 'info',
self::STATUS_SUCCESS => 'success',
self::STATUS_PARTIAL => 'warning',
self::STATUS_FAILED => 'error',
self::STATUS_SKIPPED => 'ghost',
default => 'ghost',
};
}
public function getDurationAttribute(): ?string
{
if (! $this->started_at) {
return null;
}
$end = $this->finished_at ?? now();
$seconds = $this->started_at->diffInSeconds($end);
if ($seconds < 60) {
return "{$seconds}s";
}
$minutes = floor($seconds / 60);
$remainingSeconds = $seconds % 60;
return "{$minutes}m {$remainingSeconds}s";
}
// Methods
/**
* Mark the run as started.
*/
public static function start(MarketingAutomation $automation): self
{
return self::create([
'business_id' => $automation->business_id,
'marketing_automation_id' => $automation->id,
'started_at' => now(),
'status' => self::STATUS_RUNNING,
'details' => [],
]);
}
/**
* Mark the run as finished with success.
*/
public function succeed(string $summary, array $details = []): self
{
return $this->finish(self::STATUS_SUCCESS, $summary, $details);
}
/**
* Mark the run as finished with partial success.
*/
public function partial(string $summary, array $details = []): self
{
return $this->finish(self::STATUS_PARTIAL, $summary, $details);
}
/**
* Mark the run as failed.
*/
public function fail(string $summary, array $details = []): self
{
return $this->finish(self::STATUS_FAILED, $summary, $details);
}
/**
* Mark the run as skipped (conditions not met).
*/
public function skip(string $summary, array $details = []): self
{
return $this->finish(self::STATUS_SKIPPED, $summary, $details);
}
/**
* Finish the run with the given status.
*/
protected function finish(string $status, string $summary, array $details = []): self
{
$this->update([
'finished_at' => now(),
'status' => $status,
'summary' => $summary,
'details' => array_merge($this->details ?? [], $details),
]);
return $this;
}
/**
* Add details to the run.
*/
public function addDetails(array $details): self
{
$this->update([
'details' => array_merge($this->details ?? [], $details),
]);
return $this;
}
}

View File

@@ -0,0 +1,705 @@
<?php
namespace App\Services\Marketing;
use App\Jobs\SendMarketingCampaignJob;
use App\Models\Marketing\MarketingAutomation;
use App\Models\Marketing\MarketingAutomationRun;
use App\Models\Marketing\MarketingCampaign;
use App\Models\Marketing\MarketingList;
use App\Models\Marketing\MarketingPromo;
use App\Services\Cannaiq\CannaiqClient;
use Illuminate\Support\Facades\Log;
/**
* AutomationRunner - Evaluates and executes marketing automations.
*
* Handles condition evaluation based on CannaiQ data and local inventory,
* then executes actions like creating promos and launching campaigns.
*/
class AutomationRunner
{
protected CannaiqClient $cannaiqClient;
protected array $runDetails = [];
public function __construct(CannaiqClient $cannaiqClient)
{
$this->cannaiqClient = $cannaiqClient;
}
/**
* Run an automation and return the run record.
*/
public function runAutomation(MarketingAutomation $automation): MarketingAutomationRun
{
$this->runDetails = [
'automation_id' => $automation->id,
'automation_name' => $automation->name,
'trigger_type' => $automation->trigger_type,
'condition_type' => $automation->condition_config['type'] ?? 'unknown',
'started_at' => now()->toIso8601String(),
];
// Start the run record
$run = MarketingAutomationRun::start($automation);
try {
// Evaluate conditions
$evaluationResult = $this->evaluateConditions($automation);
if (! $evaluationResult['conditions_met']) {
// Conditions not met - skip
$run->skip(
$evaluationResult['reason'] ?? 'Conditions not met',
array_merge($this->runDetails, [
'evaluation' => $evaluationResult,
])
);
$this->updateAutomationStatus($automation, 'skipped');
return $run;
}
// Execute actions
$actionResults = $this->executeActions($automation, $evaluationResult['context'] ?? []);
// Determine final status
$hasErrors = collect($actionResults)->contains('success', false);
$allFailed = collect($actionResults)->every('success', false);
if ($allFailed) {
$run->fail(
'All actions failed',
array_merge($this->runDetails, [
'evaluation' => $evaluationResult,
'actions' => $actionResults,
])
);
$this->updateAutomationStatus($automation, 'error');
} elseif ($hasErrors) {
$run->partial(
$this->buildSummary($actionResults),
array_merge($this->runDetails, [
'evaluation' => $evaluationResult,
'actions' => $actionResults,
])
);
$this->updateAutomationStatus($automation, 'partial');
} else {
$run->succeed(
$this->buildSummary($actionResults),
array_merge($this->runDetails, [
'evaluation' => $evaluationResult,
'actions' => $actionResults,
])
);
$this->updateAutomationStatus($automation, 'success');
}
return $run;
} catch (\Exception $e) {
Log::error('AutomationRunner: Exception during automation run', [
'automation_id' => $automation->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
$run->fail(
'Exception: '.$e->getMessage(),
array_merge($this->runDetails, [
'error' => $e->getMessage(),
'exception_class' => get_class($e),
])
);
$this->updateAutomationStatus($automation, 'error');
return $run;
}
}
/**
* Evaluate conditions based on condition_config type.
*/
protected function evaluateConditions(MarketingAutomation $automation): array
{
$conditionConfig = $automation->condition_config;
$conditionType = $conditionConfig['type'] ?? 'unknown';
return match ($conditionType) {
MarketingAutomation::CONDITION_COMPETITOR_OUT_OF_STOCK => $this->evaluateCompetitorOutOfStock($automation),
MarketingAutomation::CONDITION_SLOW_MOVER_CLEARANCE => $this->evaluateSlowMoverClearance($automation),
MarketingAutomation::CONDITION_NEW_STORE_LAUNCH => $this->evaluateNewStoreLaunch($automation),
default => [
'conditions_met' => false,
'reason' => "Unknown condition type: {$conditionType}",
],
};
}
/**
* Evaluate: Competitor is out of stock and we have inventory.
*/
protected function evaluateCompetitorOutOfStock(MarketingAutomation $automation): array
{
$config = $automation->condition_config;
$triggerConfig = $automation->trigger_config;
$category = $config['category'] ?? null;
$minInventory = $config['min_inventory_units'] ?? 30;
$minPriceAdvantage = $config['min_price_advantage'] ?? 0.1;
$storeScope = $triggerConfig['store_scope'] ?? 'all';
$brandIds = $triggerConfig['brand_ids'] ?? [];
// Get store external IDs to check
$storeIds = $this->getStoreExternalIds($automation->business_id, $storeScope);
if (empty($storeIds)) {
return [
'conditions_met' => false,
'reason' => 'No stores found for this business',
];
}
$opportunities = [];
foreach ($storeIds as $storeId) {
try {
// Get competitor snapshot from CannaiQ
$competitorData = $this->cannaiqClient->getStoreCompetitorSnapshot($storeId);
if (! empty($competitorData['error'])) {
continue;
}
// Check for out-of-stock competitors in our category
$outOfStockCompetitors = $this->findOutOfStockCompetitors(
$competitorData,
$category,
$minInventory,
$minPriceAdvantage
);
if (! empty($outOfStockCompetitors)) {
$opportunities[] = [
'store_id' => $storeId,
'competitors' => $outOfStockCompetitors,
'category' => $category,
];
}
} catch (\Exception $e) {
Log::warning('AutomationRunner: Error checking store', [
'store_id' => $storeId,
'error' => $e->getMessage(),
]);
}
}
if (empty($opportunities)) {
return [
'conditions_met' => false,
'reason' => 'No competitor out-of-stock opportunities found',
'stores_checked' => count($storeIds),
];
}
return [
'conditions_met' => true,
'reason' => count($opportunities).' store(s) have competitor out-of-stock opportunities',
'context' => [
'opportunities' => $opportunities,
'category' => $category,
],
];
}
/**
* Evaluate: Slow-moving inventory that needs clearance.
*/
protected function evaluateSlowMoverClearance(MarketingAutomation $automation): array
{
$config = $automation->condition_config;
$velocityThreshold = $config['velocity_30d_threshold'] ?? 5;
$minInventory = $config['min_inventory_units'] ?? 50;
$minDaysInStock = $config['min_days_in_stock'] ?? 30;
// Get slow movers from CannaiQ or local inventory
// For now, we'll stub this with CannaiQ product metrics
$storeIds = $this->getStoreExternalIds($automation->business_id, 'all');
$slowMovers = [];
foreach ($storeIds as $storeId) {
try {
$productMetrics = $this->cannaiqClient->getStoreProductMetrics($storeId, 100, 0);
if (! empty($productMetrics['error'])) {
continue;
}
$products = $productMetrics['products'] ?? [];
foreach ($products as $product) {
$velocity = $product['velocity_30d'] ?? 0;
$inventory = $product['inventory_units'] ?? 0;
$daysInStock = $product['days_in_stock'] ?? 0;
if ($velocity < $velocityThreshold
&& $inventory >= $minInventory
&& $daysInStock >= $minDaysInStock) {
$slowMovers[] = [
'store_id' => $storeId,
'product_id' => $product['id'] ?? null,
'product_name' => $product['name'] ?? 'Unknown',
'velocity_30d' => $velocity,
'inventory' => $inventory,
'days_in_stock' => $daysInStock,
];
}
}
} catch (\Exception $e) {
Log::warning('AutomationRunner: Error checking slow movers', [
'store_id' => $storeId,
'error' => $e->getMessage(),
]);
}
}
if (empty($slowMovers)) {
return [
'conditions_met' => false,
'reason' => 'No slow-moving products found matching criteria',
];
}
return [
'conditions_met' => true,
'reason' => count($slowMovers).' slow-moving product(s) found',
'context' => [
'slow_movers' => $slowMovers,
],
];
}
/**
* Evaluate: New store launch (first appearance of brand at a store).
*/
protected function evaluateNewStoreLaunch(MarketingAutomation $automation): array
{
$config = $automation->condition_config;
$windowDays = $config['first_appearance_window_days'] ?? 7;
// Check for new store appearances in CannaiQ
// This would typically involve comparing current store list vs. cached/stored list
// For now, stub this with a simplified check
$storeIds = $this->getStoreExternalIds($automation->business_id, 'all');
// Get previously known stores from meta or a tracking table
$knownStores = $automation->meta['known_stores'] ?? [];
$newStores = array_diff($storeIds, $knownStores);
if (empty($newStores)) {
return [
'conditions_met' => false,
'reason' => 'No new store appearances found',
];
}
// Update known stores in meta
$automation->update([
'meta' => array_merge($automation->meta ?? [], [
'known_stores' => $storeIds,
'last_store_check' => now()->toIso8601String(),
]),
]);
return [
'conditions_met' => true,
'reason' => count($newStores).' new store(s) detected',
'context' => [
'new_stores' => array_values($newStores),
],
];
}
/**
* Execute actions based on action_config.
*/
protected function executeActions(MarketingAutomation $automation, array $context): array
{
$actionConfig = $automation->action_config;
$results = [];
// Create promo if configured
if (! empty($actionConfig['create_promo'])) {
$results['promo'] = $this->createPromo($automation, $actionConfig['create_promo'], $context);
}
// Create campaign if configured
if (! empty($actionConfig['create_campaign'])) {
$promoId = $results['promo']['promo_id'] ?? null;
$results['campaign'] = $this->createCampaign(
$automation,
$actionConfig['create_campaign'],
$context,
$promoId
);
}
return $results;
}
/**
* Create a promo based on action config.
*/
protected function createPromo(MarketingAutomation $automation, array $promoConfig, array $context): array
{
try {
$promoType = $promoConfig['promo_type'] ?? 'flash_bogo';
$durationHours = $promoConfig['duration_hours'] ?? 24;
$discountPercent = $promoConfig['discount_percent'] ?? null;
// Map automation promo types to MarketingPromo types
$typeMapping = [
'flash_bogo' => MarketingPromo::TYPE_BOGO,
'percent_off' => MarketingPromo::TYPE_PERCENT_OFF,
'clearance' => MarketingPromo::TYPE_PERCENT_OFF,
'launch_special' => MarketingPromo::TYPE_BOGO,
];
$promoTypeDb = $typeMapping[$promoType] ?? MarketingPromo::TYPE_PERCENT_OFF;
// Build promo name from context
$promoName = $this->buildPromoName($automation, $context);
// Build config
$config = [];
if ($discountPercent) {
$config['discount_value'] = $discountPercent;
}
// Determine store scope from context
$storeExternalId = null;
if (! empty($context['opportunities'][0]['store_id'])) {
$storeExternalId = $context['opportunities'][0]['store_id'];
}
$promo = MarketingPromo::create([
'business_id' => $automation->business_id,
'store_external_id' => $storeExternalId,
'name' => $promoName,
'type' => $promoTypeDb,
'config' => $config,
'status' => MarketingPromo::STATUS_ACTIVE,
'starts_at' => now(),
'ends_at' => now()->addHours($durationHours),
'description' => "Auto-generated by automation: {$automation->name}",
'sms_copy' => $this->buildSmsCopy($automation, $context),
'email_copy' => $this->buildEmailCopy($automation, $context),
]);
return [
'success' => true,
'promo_id' => $promo->id,
'promo_name' => $promo->name,
];
} catch (\Exception $e) {
Log::error('AutomationRunner: Failed to create promo', [
'automation_id' => $automation->id,
'error' => $e->getMessage(),
]);
return [
'success' => false,
'error' => $e->getMessage(),
];
}
}
/**
* Create a campaign based on action config.
*/
protected function createCampaign(
MarketingAutomation $automation,
array $campaignConfig,
array $context,
?int $promoId = null
): array {
try {
$channels = $campaignConfig['channels'] ?? ['email'];
$listType = $campaignConfig['list_type'] ?? 'consumers';
$sendMode = $campaignConfig['send_mode'] ?? 'immediate';
$subjectTemplate = $campaignConfig['subject_template'] ?? 'Special Offer';
$smsBodyTemplate = $campaignConfig['sms_body_template'] ?? '';
$emailBodyTemplate = $campaignConfig['email_body_template'] ?? '';
// Get or create a marketing list for this business
$list = $this->getOrCreateMarketingList($automation->business_id, $listType);
if (! $list) {
return [
'success' => false,
'error' => 'No marketing list found for this business',
];
}
$results = [];
foreach ($channels as $channel) {
$campaignName = "{$automation->name} - ".ucfirst($channel).' - '.now()->format('M j, Y');
$campaignData = [
'business_id' => $automation->business_id,
'name' => $campaignName,
'channel' => $channel,
'status' => MarketingCampaign::STATUS_DRAFT,
'marketing_list_id' => $list->id,
'source_type' => MarketingCampaign::SOURCE_AUTOMATION,
'source_id' => $automation->id,
];
// Fill content based on channel
if ($channel === 'email') {
$campaignData['subject'] = $this->replacePlaceholders($subjectTemplate, $context);
$campaignData['email_body_html'] = $this->replacePlaceholders($emailBodyTemplate, $context);
} elseif ($channel === 'sms') {
$campaignData['sms_body'] = $this->replacePlaceholders($smsBodyTemplate, $context);
}
// Link to promo if available
if ($promoId) {
$campaignData['source_type'] = MarketingCampaign::SOURCE_PROMO;
$campaignData['source_id'] = $promoId;
}
$campaign = MarketingCampaign::create($campaignData);
// Handle send mode
if ($sendMode === 'immediate') {
$campaign->update(['status' => MarketingCampaign::STATUS_SENDING]);
SendMarketingCampaignJob::dispatch($campaign);
$results[$channel] = [
'success' => true,
'campaign_id' => $campaign->id,
'status' => 'sending',
];
} elseif ($sendMode === 'schedule') {
$scheduleOffset = $campaignConfig['schedule_offset_hours'] ?? 1;
$campaign->schedule(now()->addHours($scheduleOffset));
$results[$channel] = [
'success' => true,
'campaign_id' => $campaign->id,
'status' => 'scheduled',
'send_at' => $campaign->send_at->toIso8601String(),
];
} else {
$results[$channel] = [
'success' => true,
'campaign_id' => $campaign->id,
'status' => 'draft',
];
}
}
return [
'success' => true,
'campaigns' => $results,
];
} catch (\Exception $e) {
Log::error('AutomationRunner: Failed to create campaign', [
'automation_id' => $automation->id,
'error' => $e->getMessage(),
]);
return [
'success' => false,
'error' => $e->getMessage(),
];
}
}
/**
* Get store external IDs for a business.
*/
protected function getStoreExternalIds(int $businessId, string $scope): array
{
// Get stores from cannaiq_store_metrics table or configured store list
// For now, we'll get from the CannaiQ cached data
$stores = \DB::table('cannaiq_store_metrics')
->where('business_id', $businessId)
->pluck('store_external_id')
->unique()
->toArray();
return $stores;
}
/**
* Find out-of-stock competitors from CannaiQ data.
*/
protected function findOutOfStockCompetitors(
array $competitorData,
?string $category,
int $minInventory,
float $minPriceAdvantage
): array {
$outOfStock = [];
// Parse competitor snapshot data
$competitors = $competitorData['competitors'] ?? [];
foreach ($competitors as $competitor) {
$competitorProducts = $competitor['products'] ?? [];
foreach ($competitorProducts as $product) {
$productCategory = $product['category'] ?? null;
// Skip if category filter specified and doesn't match
if ($category && $productCategory !== $category) {
continue;
}
$competitorStock = $product['competitor_inventory'] ?? 0;
$ourStock = $product['our_inventory'] ?? 0;
$priceAdvantage = $product['price_advantage'] ?? 0;
// Check if competitor is out of stock and we have inventory
if ($competitorStock === 0 && $ourStock >= $minInventory && $priceAdvantage >= $minPriceAdvantage) {
$outOfStock[] = [
'competitor_name' => $competitor['name'] ?? 'Unknown',
'product_name' => $product['name'] ?? 'Unknown',
'our_inventory' => $ourStock,
'price_advantage' => $priceAdvantage,
];
}
}
}
return $outOfStock;
}
/**
* Get or create a marketing list for campaigns.
*/
protected function getOrCreateMarketingList(int $businessId, string $listType): ?MarketingList
{
// Try to find existing list
$list = MarketingList::where('business_id', $businessId)
->where('type', 'static')
->first();
if ($list) {
return $list;
}
// Create a default list if none exists
return MarketingList::create([
'business_id' => $businessId,
'name' => 'All Contacts',
'type' => 'static',
'description' => 'Auto-created list for automation campaigns',
]);
}
/**
* Build promo name from automation and context.
*/
protected function buildPromoName(MarketingAutomation $automation, array $context): string
{
$conditionType = $automation->condition_config['type'] ?? 'unknown';
return match ($conditionType) {
MarketingAutomation::CONDITION_COMPETITOR_OUT_OF_STOCK => 'Flash Sale - '.now()->format('M j'),
MarketingAutomation::CONDITION_SLOW_MOVER_CLEARANCE => 'Clearance - '.now()->format('M j'),
MarketingAutomation::CONDITION_NEW_STORE_LAUNCH => 'Welcome Special - '.now()->format('M j'),
default => $automation->name.' - '.now()->format('M j'),
};
}
/**
* Build SMS copy from context.
*/
protected function buildSmsCopy(MarketingAutomation $automation, array $context): string
{
$template = $automation->action_config['create_campaign']['sms_body_template']
?? '🔥 Special deal today! Visit us for exclusive savings.';
return $this->replacePlaceholders($template, $context);
}
/**
* Build email copy from context.
*/
protected function buildEmailCopy(MarketingAutomation $automation, array $context): string
{
$template = $automation->action_config['create_campaign']['email_body_template']
?? '<p>Check out our latest special offers!</p>';
return $this->replacePlaceholders($template, $context);
}
/**
* Replace placeholders in templates.
*/
protected function replacePlaceholders(string $template, array $context): string
{
$replacements = [
'{store_name}' => $context['opportunities'][0]['store_id'] ?? 'our store',
'{product_name}' => $context['opportunities'][0]['competitors'][0]['product_name']
?? $context['slow_movers'][0]['product_name']
?? 'featured products',
'{promo_text}' => 'Limited time offer!',
'{brand_name}' => 'Our Brand',
'{category}' => $context['category'] ?? 'cannabis',
];
return str_replace(array_keys($replacements), array_values($replacements), $template);
}
/**
* Build a human-readable summary of action results.
*/
protected function buildSummary(array $actionResults): string
{
$parts = [];
if (! empty($actionResults['promo']['success'])) {
$parts[] = "Created promo: {$actionResults['promo']['promo_name']}";
}
if (! empty($actionResults['campaign']['success'])) {
$campaigns = $actionResults['campaign']['campaigns'] ?? [];
$campaignCount = count($campaigns);
$parts[] = "Created {$campaignCount} campaign(s)";
foreach ($campaigns as $channel => $result) {
if ($result['success']) {
$parts[] = ucfirst($channel).": {$result['status']}";
}
}
}
return implode('. ', $parts) ?: 'No actions executed';
}
/**
* Update automation status after run.
*/
protected function updateAutomationStatus(MarketingAutomation $automation, string $status): void
{
$automation->update([
'last_run_at' => now(),
'last_status' => $status,
]);
}
}

View File

@@ -0,0 +1,58 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('marketing_automations', function (Blueprint $table) {
$table->id();
$table->foreignId('business_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->text('description')->nullable();
$table->boolean('is_active')->default(true);
$table->string('scope')->default('internal'); // internal, portal
$table->string('trigger_type'); // scheduled_cannaiq_check, scheduled_store_check, manual_test
$table->json('trigger_config'); // frequency, time_of_day, store_scope, etc.
$table->json('condition_config'); // type, category, thresholds, etc.
$table->json('action_config'); // create_promo, create_campaign, etc.
$table->timestamp('last_run_at')->nullable();
$table->string('last_status')->nullable(); // success, error, skipped
$table->json('meta')->nullable();
$table->timestamps();
$table->index(['business_id', 'is_active']);
$table->index(['trigger_type', 'is_active']);
});
Schema::create('marketing_automation_runs', function (Blueprint $table) {
$table->id();
$table->foreignId('business_id')->constrained()->cascadeOnDelete();
$table->foreignId('marketing_automation_id')->constrained()->cascadeOnDelete();
$table->timestamp('started_at');
$table->timestamp('finished_at')->nullable();
$table->string('status'); // running, success, partial, failed, skipped
$table->text('summary')->nullable();
$table->json('details')->nullable();
$table->timestamps();
$table->index(['marketing_automation_id', 'started_at']);
$table->index(['business_id', 'status']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('marketing_automation_runs');
Schema::dropIfExists('marketing_automations');
}
};

View File

@@ -0,0 +1,287 @@
# Marketing Automations (Playbooks Engine)
The Automations system allows sellers to create automated marketing actions triggered by CannaiQ intelligence data. When conditions are met (e.g., competitor out of stock, slow-moving inventory), the system automatically creates promos and/or campaigns.
## Architecture
### Database Schema
```
marketing_automations
├── id
├── business_id # Owning business
├── name # User-friendly automation name
├── description # Optional description
├── is_active # Whether automation is enabled
├── scope # 'internal' (seller) or 'portal' (dispensary)
├── trigger_type # When to run (scheduled_cannaiq_check, manual_test, etc.)
├── trigger_config # JSON: frequency, time_of_day, day_of_week
├── condition_config # JSON: condition_type, thresholds, filters
├── action_config # JSON: what to do when conditions are met
├── last_run_at # Timestamp of last execution
├── last_status # Result of last run
├── meta # Additional metadata
└── timestamps
marketing_automation_runs
├── id
├── marketing_automation_id
├── status # running, success, partial, failed, skipped
├── started_at
├── completed_at
├── summary # Human-readable result summary
├── details # JSON: full execution log
└── timestamps
```
### Key Components
| Component | Path | Purpose |
|-----------|------|---------|
| Model | `app/Models/Marketing/MarketingAutomation.php` | Automation configuration and presets |
| Model | `app/Models/Marketing/MarketingAutomationRun.php` | Run execution records |
| Service | `app/Services/Marketing/AutomationRunner.php` | Evaluates conditions, executes actions |
| Command | `app/Console/Commands/RunDueMarketingAutomations.php` | Scheduled execution entry point |
| Job | `app/Jobs/RunMarketingAutomationJob.php` | Queued automation execution |
| Controller | `app/Http/Controllers/Seller/MarketingAutomationController.php` | CRUD and manual triggers |
| Controller | `app/Http/Controllers/Seller/MarketingAutomationRunController.php` | Run history viewing |
## Trigger Types
| Type | Description |
|------|-------------|
| `scheduled_cannaiq_check` | Run on schedule, query CannaiQ for market data |
| `scheduled_store_check` | Run on schedule, check specific store data |
| `manual_test` | Only run when manually triggered |
### Schedule Configuration
```json
{
"frequency": "daily", // daily, weekly, hourly
"time_of_day": "09:00", // When to run (for daily/weekly)
"day_of_week": "monday" // Which day (for weekly)
}
```
## Condition Types
### 1. Competitor Out of Stock (`competitor_out_of_stock_and_we_have_inventory`)
Finds opportunities where competitor products are out of stock but you have inventory.
**Config:**
```json
{
"condition_type": "competitor_out_of_stock_and_we_have_inventory",
"brands": ["competitor-brand-1", "competitor-brand-2"],
"min_inventory_threshold": 10
}
```
**CannaiQ Query:** Fetches market availability data, identifies gaps in competitor inventory.
### 2. Slow Mover Clearance (`slow_mover_clearance`)
Identifies products with low velocity that should be promoted to move inventory.
**Config:**
```json
{
"condition_type": "slow_mover_clearance",
"days_threshold": 30,
"velocity_threshold": 0.5
}
```
**Logic:** Uses sales velocity calculations to identify stagnant inventory.
### 3. New Store Launch (`new_store_launch`)
Triggered when new stores appear in the market that don't carry your products.
**Config:**
```json
{
"condition_type": "new_store_launch",
"regions": ["region-1", "region-2"]
}
```
**CannaiQ Query:** Monitors for new store registrations in target regions.
## Action Types
### Create Promo (`create_promo`)
Creates a `MarketingPromo` record with the specified configuration.
**Config:**
```json
{
"action_type": "create_promo",
"promo_type": "percentage_off",
"discount_value": 15,
"duration_days": 7
}
```
### Create Campaign (`create_campaign`)
Creates a `MarketingCampaign` and optionally sends immediately.
**Config:**
```json
{
"action_type": "create_campaign",
"channel": "email",
"template_id": 123,
"send_immediately": true
}
```
## Scheduled Execution
### Cron Setup
Add to `app/Console/Kernel.php`:
```php
// Run every 15 minutes to check for due automations
$schedule->command('marketing:run-due-automations')
->everyFifteenMinutes()
->withoutOverlapping();
```
### Command Options
```bash
# Run all due automations (queued)
php artisan marketing:run-due-automations
# Run for specific business
php artisan marketing:run-due-automations --business=123
# Dry run (show what would run)
php artisan marketing:run-due-automations --dry-run
# Run synchronously (not queued)
php artisan marketing:run-due-automations --sync
```
### Queue Configuration
Jobs are dispatched to the `marketing` queue. Configure Horizon:
```php
'marketing' => [
'connection' => 'redis',
'queue' => ['marketing'],
'balance' => 'simple',
'processes' => 2,
'tries' => 3,
],
```
## UI Routes
| Route | Method | Purpose |
|-------|--------|---------|
| `/s/{business}/marketing/automations` | GET | List automations |
| `/s/{business}/marketing/automations/create` | GET | Create form |
| `/s/{business}/marketing/automations` | POST | Store new automation |
| `/s/{business}/marketing/automations/{id}/edit` | GET | Edit form |
| `/s/{business}/marketing/automations/{id}` | PATCH | Update automation |
| `/s/{business}/marketing/automations/{id}` | DELETE | Delete automation |
| `/s/{business}/marketing/automations/{id}/toggle` | POST | Enable/disable |
| `/s/{business}/marketing/automations/{id}/run-now` | POST | Manual trigger |
| `/s/{business}/marketing/automations/{id}/runs` | GET | Run history |
| `/s/{business}/marketing/automations/{id}/runs/{run}` | GET | Run details |
## Quick Start Presets
The system includes preset templates for common automation scenarios:
1. **Competitor Gap Finder** - Monitor competitor out-of-stock events
2. **Slow Mover Clearance** - Auto-promote stagnant inventory
3. **New Store Welcome** - Outreach when new stores appear
Access presets via:
- UI: Create automation page shows presets when no automations exist
- Code: `MarketingAutomation::getTypePresets()`
## Status Tracking
### Automation Run Statuses
| Status | Description |
|--------|-------------|
| `running` | Currently executing |
| `success` | Completed with all actions successful |
| `partial` | Some actions succeeded, some failed |
| `failed` | Execution failed |
| `skipped` | Conditions not met, no actions taken |
### Status Colors (UI)
```php
'running' => 'info',
'success' => 'success',
'partial' => 'warning',
'failed' => 'error',
'skipped' => 'ghost',
```
## Extending the System
### Adding a New Condition Type
1. Add constant to `MarketingAutomation::CONDITION_TYPES`
2. Add evaluator method in `AutomationRunner`:
```php
protected function evaluateMyCondition(array $config, Business $business): array
{
// Query CannaiQ or internal data
// Return ['matched' => true/false, 'products' => [...], ...]
}
```
3. Add case to `evaluateCondition()` switch
4. Update `getTypePresets()` if applicable
### Adding a New Action Type
1. Add constant to `MarketingAutomation::ACTION_TYPES`
2. Add executor method in `AutomationRunner`:
```php
protected function executeMyAction(array $config, array $conditionResult, Business $business): array
{
// Create promo, campaign, notification, etc.
// Return result details for logging
}
```
3. Add case to `executeAction()` switch
## Error Handling
- Failed runs are logged with full error details in `runs.details`
- Jobs retry up to 3 times (configurable)
- Automation is NOT auto-disabled on failure (manual intervention required)
- All exceptions are caught and logged; runs marked as `failed`
## Security
- All routes require authentication and business ownership
- Automations are scoped to business_id
- Run history is only visible to automation owner
- Manual triggers require active automation status
## Integration Points
- **CannaiQ API**: Market intelligence data source
- **MarketingPromo**: Action output for promotional offers
- **MarketingCampaign**: Action output for email/SMS campaigns
- **SendMarketingCampaignJob**: Immediate campaign dispatch

View File

@@ -0,0 +1,413 @@
@extends('layouts.seller')
@section('title', 'Create Automation')
@section('content')
<div class="space-y-6" x-data="automationForm()">
<!-- Breadcrumb -->
<div class="text-sm breadcrumbs">
<ul>
<li><a href="{{ route('seller.business.marketing.automations.index', $business) }}">Automations</a></li>
<li>Create</li>
</ul>
</div>
<!-- Header -->
<div>
<h1 class="text-2xl font-bold">Create Automation</h1>
<p class="text-base-content/70 mt-1">
@if($selectedPreset && isset($presets[$selectedPreset]))
Using template: {{ $presets[$selectedPreset]['name'] }}
@else
Set up an automated marketing workflow
@endif
</p>
</div>
<form method="POST" action="{{ route('seller.business.marketing.automations.store', $business) }}" class="space-y-6">
@csrf
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Main Form -->
<div class="lg:col-span-2 space-y-6">
<!-- Basic Info -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg mb-4">Basic Information</h2>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Automation Name</span>
</label>
<input type="text" name="name"
value="{{ old('name', $selectedPreset ? $presets[$selectedPreset]['name'] ?? '' : '') }}"
class="input input-bordered @error('name') input-error @enderror"
placeholder="e.g., Competitor Flash Sale"
required>
@error('name')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Description</span>
<span class="label-text-alt text-base-content/60">Optional</span>
</label>
<textarea name="description" rows="2"
class="textarea textarea-bordered @error('description') textarea-error @enderror"
placeholder="What does this automation do?">{{ old('description', $selectedPreset ? $presets[$selectedPreset]['description'] ?? '' : '') }}</textarea>
@error('description')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Visibility</span>
</label>
<select name="scope" class="select select-bordered">
<option value="internal" {{ old('scope') === 'internal' ? 'selected' : '' }}>Internal Only</option>
<option value="portal" {{ old('scope') === 'portal' ? 'selected' : '' }}>Visible in Portal</option>
</select>
</div>
</div>
</div>
<!-- Trigger -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg mb-4">Trigger</h2>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Trigger Type</span>
</label>
<select name="trigger_type" class="select select-bordered" x-model="triggerType">
@foreach(\App\Models\Marketing\MarketingAutomation::TRIGGER_TYPES as $key => $label)
<option value="{{ $key }}"
{{ old('trigger_type', $selectedPreset ? ($presets[$selectedPreset]['trigger_type'] ?? '') : '') === $key ? 'selected' : '' }}>
{{ $label }}
</option>
@endforeach
</select>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Frequency</span>
</label>
<select name="frequency" class="select select-bordered" x-model="frequency">
@foreach(\App\Models\Marketing\MarketingAutomation::FREQUENCIES as $key => $label)
<option value="{{ $key }}"
{{ old('frequency', $selectedPreset ? ($presets[$selectedPreset]['default_trigger_config']['frequency'] ?? 'daily') : 'daily') === $key ? 'selected' : '' }}>
{{ $label }}
</option>
@endforeach
</select>
</div>
<div class="form-control" x-show="frequency !== 'hourly'">
<label class="label">
<span class="label-text">Time of Day</span>
</label>
<input type="time" name="time_of_day"
value="{{ old('time_of_day', $selectedPreset ? ($presets[$selectedPreset]['default_trigger_config']['time_of_day'] ?? '09:00') : '09:00') }}"
class="input input-bordered">
</div>
</div>
</div>
</div>
<!-- Conditions -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg mb-4">Conditions</h2>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Condition Type</span>
</label>
<select name="condition_type" class="select select-bordered" x-model="conditionType">
@foreach(\App\Models\Marketing\MarketingAutomation::CONDITION_TYPES as $key => $label)
<option value="{{ $key }}"
{{ old('condition_type', $selectedPreset ? ($presets[$selectedPreset]['condition_type'] ?? '') : '') === $key ? 'selected' : '' }}>
{{ $label }}
</option>
@endforeach
</select>
</div>
<!-- Competitor Out of Stock Conditions -->
<div x-show="conditionType === 'competitor_out_of_stock_and_we_have_inventory'" class="space-y-4">
<div class="form-control">
<label class="label">
<span class="label-text">Category Filter</span>
<span class="label-text-alt text-base-content/60">Optional</span>
</label>
<input type="text" name="category"
value="{{ old('category') }}"
class="input input-bordered"
placeholder="e.g., infused_preroll">
</div>
<div class="grid grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Min Inventory Units</span>
</label>
<input type="number" name="min_inventory_units"
value="{{ old('min_inventory_units', 30) }}"
class="input input-bordered"
min="1">
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Min Price Advantage</span>
</label>
<input type="number" name="min_price_advantage"
value="{{ old('min_price_advantage', 0.1) }}"
class="input input-bordered"
step="0.01" min="0" max="1">
<label class="label">
<span class="label-text-alt">0.1 = 10% cheaper</span>
</label>
</div>
</div>
</div>
<!-- Slow Mover Conditions -->
<div x-show="conditionType === 'slow_mover_clearance'" class="space-y-4">
<div class="grid grid-cols-3 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Velocity Threshold (30d)</span>
</label>
<input type="number" name="velocity_threshold"
value="{{ old('velocity_threshold', 5) }}"
class="input input-bordered"
min="0">
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Min Inventory</span>
</label>
<input type="number" name="min_inventory_units"
value="{{ old('min_inventory_units', 50) }}"
class="input input-bordered"
min="1">
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Min Days in Stock</span>
</label>
<input type="number" name="min_days_in_stock"
value="{{ old('min_days_in_stock', 30) }}"
class="input input-bordered"
min="1">
</div>
</div>
</div>
<!-- New Store Launch (minimal conditions) -->
<div x-show="conditionType === 'new_store_launch'" class="alert alert-info">
<span class="icon-[lucide--info] size-5"></span>
<span>This automation triggers when your brand first appears at a new store in CannaiQ.</span>
</div>
</div>
</div>
<!-- Actions -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg mb-4">Actions</h2>
<!-- Promo Settings -->
<div class="mb-6">
<h3 class="font-medium mb-3">Create Promo</h3>
<div class="grid grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Promo Type</span>
</label>
<select name="promo_type" class="select select-bordered">
@foreach(\App\Models\Marketing\MarketingAutomation::PROMO_TYPES as $key => $label)
<option value="{{ $key }}"
{{ old('promo_type', $selectedPreset ? ($presets[$selectedPreset]['default_action_config']['create_promo']['promo_type'] ?? 'flash_bogo') : 'flash_bogo') === $key ? 'selected' : '' }}>
{{ $label }}
</option>
@endforeach
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Duration (hours)</span>
</label>
<input type="number" name="duration_hours"
value="{{ old('duration_hours', $selectedPreset ? ($presets[$selectedPreset]['default_action_config']['create_promo']['duration_hours'] ?? 24) : 24) }}"
class="input input-bordered"
min="1" max="720">
</div>
</div>
<div class="form-control mt-4">
<label class="label">
<span class="label-text">Discount Percent</span>
<span class="label-text-alt text-base-content/60">Optional</span>
</label>
<input type="number" name="discount_percent"
value="{{ old('discount_percent') }}"
class="input input-bordered"
min="1" max="100"
placeholder="e.g., 20">
</div>
</div>
<!-- Campaign Settings -->
<div class="border-t pt-4">
<h3 class="font-medium mb-3">Create Campaign</h3>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Channels</span>
</label>
<div class="flex gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="channels[]" value="email"
class="checkbox checkbox-primary"
{{ in_array('email', old('channels', $selectedPreset ? ($presets[$selectedPreset]['default_action_config']['create_campaign']['channels'] ?? ['email']) : ['email'])) ? 'checked' : '' }}>
<span>Email</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="channels[]" value="sms"
class="checkbox checkbox-primary"
{{ in_array('sms', old('channels', $selectedPreset ? ($presets[$selectedPreset]['default_action_config']['create_campaign']['channels'] ?? []) : [])) ? 'checked' : '' }}>
<span>SMS</span>
</label>
</div>
</div>
<div class="grid grid-cols-2 gap-4 mb-4">
<div class="form-control">
<label class="label">
<span class="label-text">Target List</span>
</label>
<select name="list_id" class="select select-bordered">
<option value="">Auto-select default list</option>
@foreach($lists as $list)
<option value="{{ $list->id }}" {{ old('list_id') == $list->id ? 'selected' : '' }}>
{{ $list->name }} ({{ $list->contacts_count }} contacts)
</option>
@endforeach
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Send Mode</span>
</label>
<select name="send_mode" class="select select-bordered">
<option value="immediate" {{ old('send_mode', 'immediate') === 'immediate' ? 'selected' : '' }}>Send Immediately</option>
<option value="schedule" {{ old('send_mode') === 'schedule' ? 'selected' : '' }}>Schedule (1 hour delay)</option>
<option value="draft" {{ old('send_mode') === 'draft' ? 'selected' : '' }}>Save as Draft</option>
</select>
</div>
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Email Subject Template</span>
</label>
<input type="text" name="subject_template"
value="{{ old('subject_template', $selectedPreset ? ($presets[$selectedPreset]['default_action_config']['create_campaign']['subject_template'] ?? '') : 'Special Offer: {product_name}') }}"
class="input input-bordered"
placeholder="Use {product_name}, {store_name}, {promo_text}">
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">SMS Body Template</span>
</label>
<textarea name="sms_body_template" rows="2"
class="textarea textarea-bordered"
placeholder="Use {product_name}, {store_name}, {promo_text}">{{ old('sms_body_template', $selectedPreset ? ($presets[$selectedPreset]['default_action_config']['create_campaign']['sms_body_template'] ?? '') : '') }}</textarea>
<label class="label">
<span class="label-text-alt">Max 320 characters recommended</span>
</label>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Email Body Template</span>
</label>
<textarea name="email_body_template" rows="4"
class="textarea textarea-bordered"
placeholder="HTML or plain text. Use {product_name}, {store_name}, {promo_text}">{{ old('email_body_template', $selectedPreset ? ($presets[$selectedPreset]['default_action_config']['create_campaign']['email_body_template'] ?? '') : '<p>Check out our special offer on {product_name}!</p>') }}</textarea>
</div>
</div>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="space-y-6">
<!-- Actions -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h3 class="font-medium mb-4">Save</h3>
<div class="space-y-2">
<button type="submit" class="btn btn-primary btn-block gap-2">
<span class="icon-[lucide--save] size-4"></span>
Create Automation
</button>
<a href="{{ route('seller.business.marketing.automations.index', $business) }}" class="btn btn-ghost btn-block">
Cancel
</a>
</div>
</div>
</div>
<!-- Template Presets -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h3 class="font-medium mb-4">Templates</h3>
<ul class="space-y-2">
@foreach($presets as $key => $preset)
<li>
<a href="{{ route('seller.business.marketing.automations.create', [$business, 'preset' => $key]) }}"
class="link link-hover text-sm {{ $selectedPreset === $key ? 'font-bold text-primary' : '' }}">
{{ $preset['name'] }}
</a>
</li>
@endforeach
</ul>
</div>
</div>
<!-- Placeholders Help -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h3 class="font-medium mb-4">Template Placeholders</h3>
<ul class="text-sm text-base-content/70 space-y-1">
<li><code class="text-xs bg-base-200 px-1 rounded">{store_name}</code> - Store name</li>
<li><code class="text-xs bg-base-200 px-1 rounded">{product_name}</code> - Product name</li>
<li><code class="text-xs bg-base-200 px-1 rounded">{promo_text}</code> - Promo description</li>
<li><code class="text-xs bg-base-200 px-1 rounded">{brand_name}</code> - Brand name</li>
<li><code class="text-xs bg-base-200 px-1 rounded">{category}</code> - Category</li>
</ul>
</div>
</div>
</div>
</div>
</form>
</div>
<script>
function automationForm() {
return {
triggerType: '{{ old('trigger_type', $selectedPreset ? ($presets[$selectedPreset]['trigger_type'] ?? 'scheduled_cannaiq_check') : 'scheduled_cannaiq_check') }}',
frequency: '{{ old('frequency', $selectedPreset ? ($presets[$selectedPreset]['default_trigger_config']['frequency'] ?? 'daily') : 'daily') }}',
conditionType: '{{ old('condition_type', $selectedPreset ? ($presets[$selectedPreset]['condition_type'] ?? 'competitor_out_of_stock_and_we_have_inventory') : 'competitor_out_of_stock_and_we_have_inventory') }}',
}
}
</script>
@endsection

View File

@@ -0,0 +1,430 @@
@extends('layouts.seller')
@section('title', 'Edit Automation')
@section('content')
<div class="space-y-6" x-data="automationForm()">
<!-- Breadcrumb -->
<div class="text-sm breadcrumbs">
<ul>
<li><a href="{{ route('seller.business.marketing.automations.index', $business) }}">Automations</a></li>
<li>{{ $automation->name }}</li>
<li>Edit</li>
</ul>
</div>
<!-- Header -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 class="text-2xl font-bold">Edit Automation</h1>
<p class="text-base-content/70 mt-1">{{ $automation->name }}</p>
</div>
<div class="flex items-center gap-2">
@if($automation->is_active)
<span class="badge badge-success gap-1">
<span class="icon-[lucide--check] size-3"></span>
Active
</span>
@else
<span class="badge badge-ghost gap-1">
<span class="icon-[lucide--pause] size-3"></span>
Paused
</span>
@endif
</div>
</div>
<form method="POST" action="{{ route('seller.business.marketing.automations.update', [$business, $automation]) }}" class="space-y-6">
@csrf
@method('PATCH')
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Main Form -->
<div class="lg:col-span-2 space-y-6">
<!-- Basic Info -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg mb-4">Basic Information</h2>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Automation Name</span>
</label>
<input type="text" name="name"
value="{{ old('name', $automation->name) }}"
class="input input-bordered @error('name') input-error @enderror"
required>
@error('name')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea name="description" rows="2"
class="textarea textarea-bordered">{{ old('description', $automation->description) }}</textarea>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Visibility</span>
</label>
<select name="scope" class="select select-bordered">
<option value="internal" {{ old('scope', $automation->scope) === 'internal' ? 'selected' : '' }}>Internal Only</option>
<option value="portal" {{ old('scope', $automation->scope) === 'portal' ? 'selected' : '' }}>Visible in Portal</option>
</select>
</div>
</div>
</div>
<!-- Trigger -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg mb-4">Trigger</h2>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Trigger Type</span>
</label>
<select name="trigger_type" class="select select-bordered" x-model="triggerType">
@foreach(\App\Models\Marketing\MarketingAutomation::TRIGGER_TYPES as $key => $label)
<option value="{{ $key }}" {{ old('trigger_type', $automation->trigger_type) === $key ? 'selected' : '' }}>
{{ $label }}
</option>
@endforeach
</select>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Frequency</span>
</label>
<select name="frequency" class="select select-bordered" x-model="frequency">
@foreach(\App\Models\Marketing\MarketingAutomation::FREQUENCIES as $key => $label)
<option value="{{ $key }}" {{ old('frequency', $automation->trigger_config['frequency'] ?? 'daily') === $key ? 'selected' : '' }}>
{{ $label }}
</option>
@endforeach
</select>
</div>
<div class="form-control" x-show="frequency !== 'hourly'">
<label class="label">
<span class="label-text">Time of Day</span>
</label>
<input type="time" name="time_of_day"
value="{{ old('time_of_day', $automation->trigger_config['time_of_day'] ?? '09:00') }}"
class="input input-bordered">
</div>
</div>
</div>
</div>
<!-- Conditions -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg mb-4">Conditions</h2>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Condition Type</span>
</label>
<select name="condition_type" class="select select-bordered" x-model="conditionType">
@foreach(\App\Models\Marketing\MarketingAutomation::CONDITION_TYPES as $key => $label)
<option value="{{ $key }}" {{ old('condition_type', $automation->condition_config['type'] ?? '') === $key ? 'selected' : '' }}>
{{ $label }}
</option>
@endforeach
</select>
</div>
<!-- Competitor Out of Stock Conditions -->
<div x-show="conditionType === 'competitor_out_of_stock_and_we_have_inventory'" class="space-y-4">
<div class="form-control">
<label class="label">
<span class="label-text">Category Filter</span>
</label>
<input type="text" name="category"
value="{{ old('category', $automation->condition_config['category'] ?? '') }}"
class="input input-bordered"
placeholder="e.g., infused_preroll">
</div>
<div class="grid grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Min Inventory Units</span>
</label>
<input type="number" name="min_inventory_units"
value="{{ old('min_inventory_units', $automation->condition_config['min_inventory_units'] ?? 30) }}"
class="input input-bordered"
min="1">
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Min Price Advantage</span>
</label>
<input type="number" name="min_price_advantage"
value="{{ old('min_price_advantage', $automation->condition_config['min_price_advantage'] ?? 0.1) }}"
class="input input-bordered"
step="0.01" min="0" max="1">
</div>
</div>
</div>
<!-- Slow Mover Conditions -->
<div x-show="conditionType === 'slow_mover_clearance'" class="space-y-4">
<div class="grid grid-cols-3 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Velocity Threshold</span>
</label>
<input type="number" name="velocity_threshold"
value="{{ old('velocity_threshold', $automation->condition_config['velocity_30d_threshold'] ?? 5) }}"
class="input input-bordered"
min="0">
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Min Inventory</span>
</label>
<input type="number" name="min_inventory_units"
value="{{ old('min_inventory_units', $automation->condition_config['min_inventory_units'] ?? 50) }}"
class="input input-bordered"
min="1">
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Min Days in Stock</span>
</label>
<input type="number" name="min_days_in_stock"
value="{{ old('min_days_in_stock', $automation->condition_config['min_days_in_stock'] ?? 30) }}"
class="input input-bordered"
min="1">
</div>
</div>
</div>
<!-- New Store Launch -->
<div x-show="conditionType === 'new_store_launch'" class="alert alert-info">
<span class="icon-[lucide--info] size-5"></span>
<span>This automation triggers when your brand first appears at a new store in CannaiQ.</span>
</div>
</div>
</div>
<!-- Actions -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg mb-4">Actions</h2>
<!-- Promo Settings -->
<div class="mb-6">
<h3 class="font-medium mb-3">Create Promo</h3>
<div class="grid grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Promo Type</span>
</label>
<select name="promo_type" class="select select-bordered">
@foreach(\App\Models\Marketing\MarketingAutomation::PROMO_TYPES as $key => $label)
<option value="{{ $key }}" {{ old('promo_type', $automation->action_config['create_promo']['promo_type'] ?? '') === $key ? 'selected' : '' }}>
{{ $label }}
</option>
@endforeach
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Duration (hours)</span>
</label>
<input type="number" name="duration_hours"
value="{{ old('duration_hours', $automation->action_config['create_promo']['duration_hours'] ?? 24) }}"
class="input input-bordered"
min="1" max="720">
</div>
</div>
<div class="form-control mt-4">
<label class="label">
<span class="label-text">Discount Percent</span>
</label>
<input type="number" name="discount_percent"
value="{{ old('discount_percent', $automation->action_config['create_promo']['discount_percent'] ?? '') }}"
class="input input-bordered"
min="1" max="100">
</div>
</div>
<!-- Campaign Settings -->
<div class="border-t pt-4">
<h3 class="font-medium mb-3">Create Campaign</h3>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Channels</span>
</label>
<div class="flex gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="channels[]" value="email"
class="checkbox checkbox-primary"
{{ in_array('email', old('channels', $automation->action_config['create_campaign']['channels'] ?? [])) ? 'checked' : '' }}>
<span>Email</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="channels[]" value="sms"
class="checkbox checkbox-primary"
{{ in_array('sms', old('channels', $automation->action_config['create_campaign']['channels'] ?? [])) ? 'checked' : '' }}>
<span>SMS</span>
</label>
</div>
</div>
<div class="grid grid-cols-2 gap-4 mb-4">
<div class="form-control">
<label class="label">
<span class="label-text">Target List</span>
</label>
<select name="list_id" class="select select-bordered">
<option value="">Auto-select default list</option>
@foreach($lists as $list)
<option value="{{ $list->id }}" {{ old('list_id', $automation->action_config['create_campaign']['list_id'] ?? '') == $list->id ? 'selected' : '' }}>
{{ $list->name }} ({{ $list->contacts_count }} contacts)
</option>
@endforeach
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Send Mode</span>
</label>
<select name="send_mode" class="select select-bordered">
<option value="immediate" {{ old('send_mode', $automation->action_config['create_campaign']['send_mode'] ?? '') === 'immediate' ? 'selected' : '' }}>Send Immediately</option>
<option value="schedule" {{ old('send_mode', $automation->action_config['create_campaign']['send_mode'] ?? '') === 'schedule' ? 'selected' : '' }}>Schedule (1 hour delay)</option>
<option value="draft" {{ old('send_mode', $automation->action_config['create_campaign']['send_mode'] ?? '') === 'draft' ? 'selected' : '' }}>Save as Draft</option>
</select>
</div>
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Email Subject Template</span>
</label>
<input type="text" name="subject_template"
value="{{ old('subject_template', $automation->action_config['create_campaign']['subject_template'] ?? '') }}"
class="input input-bordered">
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">SMS Body Template</span>
</label>
<textarea name="sms_body_template" rows="2"
class="textarea textarea-bordered">{{ old('sms_body_template', $automation->action_config['create_campaign']['sms_body_template'] ?? '') }}</textarea>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Email Body Template</span>
</label>
<textarea name="email_body_template" rows="4"
class="textarea textarea-bordered">{{ old('email_body_template', $automation->action_config['create_campaign']['email_body_template'] ?? '') }}</textarea>
</div>
</div>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="space-y-6">
<!-- Actions -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h3 class="font-medium mb-4">Save Changes</h3>
<div class="space-y-2">
<button type="submit" class="btn btn-primary btn-block gap-2">
<span class="icon-[lucide--save] size-4"></span>
Update Automation
</button>
<a href="{{ route('seller.business.marketing.automations.index', $business) }}" class="btn btn-ghost btn-block">
Cancel
</a>
</div>
</div>
</div>
<!-- Status -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h3 class="font-medium mb-4">Status</h3>
<form method="POST" action="{{ route('seller.business.marketing.automations.toggle', [$business, $automation]) }}">
@csrf
<button type="submit" class="btn btn-outline btn-block gap-2">
@if($automation->is_active)
<span class="icon-[lucide--pause] size-4"></span>
Pause Automation
@else
<span class="icon-[lucide--play] size-4"></span>
Enable Automation
@endif
</button>
</form>
</div>
</div>
<!-- Run History -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h3 class="font-medium mb-4">Run History</h3>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-base-content/60">Last Run</span>
<span>{{ $automation->last_run_at?->diffForHumans() ?? 'Never' }}</span>
</div>
@if($automation->last_status)
<div class="flex justify-between">
<span class="text-base-content/60">Last Status</span>
<span class="badge badge-{{ $automation->status_color }} badge-sm">{{ ucfirst($automation->last_status) }}</span>
</div>
@endif
</div>
<a href="{{ route('seller.business.marketing.automations.runs.index', [$business, $automation]) }}"
class="btn btn-ghost btn-sm btn-block mt-4 gap-2">
<span class="icon-[lucide--history] size-4"></span>
View All Runs
</a>
</div>
</div>
<!-- Danger Zone -->
<div class="card bg-error/10 border border-error/20">
<div class="card-body">
<h3 class="font-medium text-error mb-4">Danger Zone</h3>
<form method="POST" action="{{ route('seller.business.marketing.automations.destroy', [$business, $automation]) }}"
onsubmit="return confirm('Are you sure you want to delete this automation? This cannot be undone.')">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-error btn-outline btn-block gap-2">
<span class="icon-[lucide--trash-2] size-4"></span>
Delete Automation
</button>
</form>
</div>
</div>
</div>
</div>
</form>
</div>
<script>
function automationForm() {
return {
triggerType: '{{ old('trigger_type', $automation->trigger_type) }}',
frequency: '{{ old('frequency', $automation->trigger_config['frequency'] ?? 'daily') }}',
conditionType: '{{ old('condition_type', $automation->condition_config['type'] ?? '') }}',
}
}
</script>
@endsection

View File

@@ -0,0 +1,205 @@
@extends('layouts.seller')
@section('title', 'Marketing Automations')
@section('content')
<div class="space-y-6">
<!-- Header -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 class="text-2xl font-bold">Marketing Automations</h1>
<p class="text-base-content/70 mt-1">Automate your marketing based on CannaiQ intelligence</p>
</div>
<a href="{{ route('seller.business.marketing.automations.create', $business) }}" class="btn btn-primary gap-2">
<span class="icon-[lucide--plus] size-4"></span>
New Automation
</a>
</div>
<!-- Filters -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body py-3">
<form method="GET" class="flex flex-wrap items-center gap-3">
<select name="status" class="select select-bordered select-sm">
<option value="">All Status</option>
<option value="active" {{ request('status') === 'active' ? 'selected' : '' }}>Active</option>
<option value="inactive" {{ request('status') === 'inactive' ? 'selected' : '' }}>Inactive</option>
</select>
<select name="trigger_type" class="select select-bordered select-sm">
<option value="">All Triggers</option>
@foreach(\App\Models\Marketing\MarketingAutomation::TRIGGER_TYPES as $key => $label)
<option value="{{ $key }}" {{ request('trigger_type') === $key ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
</select>
<button type="submit" class="btn btn-sm btn-ghost">Filter</button>
@if(request()->hasAny(['status', 'trigger_type']))
<a href="{{ route('seller.business.marketing.automations.index', $business) }}" class="btn btn-sm btn-ghost">Clear</a>
@endif
</form>
</div>
</div>
<!-- Automations List -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
@if($automations->isEmpty())
<div class="text-center py-12 text-base-content/60">
<span class="icon-[lucide--bot] size-16 opacity-30 mx-auto block mb-4"></span>
<h3 class="font-semibold text-lg mb-2">No automations yet</h3>
<p class="mb-4">Create your first automation to start marketing on autopilot.</p>
<a href="{{ route('seller.business.marketing.automations.create', $business) }}" class="btn btn-primary">
Create Automation
</a>
</div>
@else
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Automation</th>
<th>Trigger</th>
<th>Schedule</th>
<th>Status</th>
<th>Last Run</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
@foreach($automations as $automation)
<tr>
<td>
<div class="font-medium">{{ $automation->name }}</div>
<div class="text-sm text-base-content/60">{{ $automation->condition_type_label }}</div>
</td>
<td>
<span class="badge badge-ghost badge-sm">{{ $automation->trigger_type_label }}</span>
</td>
<td>
<span class="text-sm">{{ $automation->frequency_label }}</span>
@if($automation->trigger_config['time_of_day'] ?? null)
<span class="text-xs text-base-content/60 block">
at {{ $automation->trigger_config['time_of_day'] }}
</span>
@endif
</td>
<td>
@if($automation->is_active)
<span class="badge badge-success badge-sm gap-1">
<span class="icon-[lucide--check] size-3"></span>
Active
</span>
@else
<span class="badge badge-ghost badge-sm gap-1">
<span class="icon-[lucide--pause] size-3"></span>
Paused
</span>
@endif
</td>
<td>
@if($automation->last_run_at)
<div class="text-sm">{{ $automation->last_run_at->diffForHumans() }}</div>
<span class="badge badge-{{ $automation->status_color }} badge-xs">
{{ ucfirst($automation->last_status) }}
</span>
@else
<span class="text-sm text-base-content/50">Never run</span>
@endif
</td>
<td class="text-right">
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm">
<span class="icon-[lucide--more-vertical] size-4"></span>
</div>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
<li>
<a href="{{ route('seller.business.marketing.automations.edit', [$business, $automation]) }}">
<span class="icon-[lucide--edit] size-4"></span>
Edit
</a>
</li>
<li>
<a href="{{ route('seller.business.marketing.automations.runs.index', [$business, $automation]) }}">
<span class="icon-[lucide--history] size-4"></span>
View Runs
</a>
</li>
<li>
<form method="POST" action="{{ route('seller.business.marketing.automations.run-now', [$business, $automation]) }}"
onsubmit="return confirm('Run this automation now?')">
@csrf
<button type="submit" class="w-full text-left">
<span class="icon-[lucide--play] size-4"></span>
Run Now
</button>
</form>
</li>
<li>
<form method="POST" action="{{ route('seller.business.marketing.automations.toggle', [$business, $automation]) }}">
@csrf
<button type="submit" class="w-full text-left">
@if($automation->is_active)
<span class="icon-[lucide--pause] size-4"></span>
Pause
@else
<span class="icon-[lucide--play] size-4"></span>
Enable
@endif
</button>
</form>
</li>
<li class="border-t mt-1 pt-1">
<form method="POST" action="{{ route('seller.business.marketing.automations.destroy', [$business, $automation]) }}"
onsubmit="return confirm('Delete this automation? This cannot be undone.')">
@csrf
@method('DELETE')
<button type="submit" class="w-full text-left text-error">
<span class="icon-[lucide--trash-2] size-4"></span>
Delete
</button>
</form>
</li>
</ul>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="mt-6">
{{ $automations->links() }}
</div>
@endif
</div>
</div>
<!-- Quick Start Presets -->
@if($automations->isEmpty())
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg mb-4">
<span class="icon-[lucide--sparkles] size-5 text-warning"></span>
Quick Start Templates
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
@foreach(\App\Models\Marketing\MarketingAutomation::getTypePresets() as $key => $preset)
<div class="card border border-base-200 hover:shadow-md transition-shadow">
<div class="card-body p-4">
<h3 class="font-medium">{{ $preset['name'] }}</h3>
<p class="text-sm text-base-content/60 mt-1">{{ $preset['description'] }}</p>
<div class="card-actions justify-end mt-3">
<a href="{{ route('seller.business.marketing.automations.create', [$business, 'preset' => $key]) }}"
class="btn btn-primary btn-sm">
Use Template
</a>
</div>
</div>
</div>
@endforeach
</div>
</div>
</div>
@endif
</div>
@endsection

View File

@@ -0,0 +1,168 @@
@extends('layouts.seller')
@section('title', 'Automation Run History')
@section('content')
<div class="space-y-6">
<!-- Breadcrumb -->
<div class="text-sm breadcrumbs">
<ul>
<li><a href="{{ route('seller.business.marketing.automations.index', $business) }}">Automations</a></li>
<li><a href="{{ route('seller.business.marketing.automations.edit', [$business, $automation]) }}">{{ $automation->name }}</a></li>
<li>Run History</li>
</ul>
</div>
<!-- Header -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 class="text-2xl font-bold">Run History</h1>
<p class="text-base-content/70 mt-1">{{ $automation->name }}</p>
</div>
<div class="flex gap-2">
<form method="POST" action="{{ route('seller.business.marketing.automations.run-now', [$business, $automation]) }}"
onsubmit="return confirm('Run this automation now?')">
@csrf
<button type="submit" class="btn btn-primary gap-2" {{ !$automation->is_active ? 'disabled' : '' }}>
<span class="icon-[lucide--play] size-4"></span>
Run Now
</button>
</form>
<a href="{{ route('seller.business.marketing.automations.edit', [$business, $automation]) }}" class="btn btn-ghost gap-2">
<span class="icon-[lucide--settings] size-4"></span>
Settings
</a>
</div>
</div>
<!-- Status Summary -->
<div class="grid grid-cols-2 md:grid-cols-5 gap-4">
<div class="card bg-base-100 shadow-sm">
<div class="card-body py-4">
<div class="text-sm text-base-content/60">Total Runs</div>
<div class="text-2xl font-bold">{{ $automation->runs()->count() }}</div>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body py-4">
<div class="text-sm text-base-content/60">Successful</div>
<div class="text-2xl font-bold text-success">{{ $automation->runs()->where('status', 'success')->count() }}</div>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body py-4">
<div class="text-sm text-base-content/60">Partial</div>
<div class="text-2xl font-bold text-warning">{{ $automation->runs()->where('status', 'partial')->count() }}</div>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body py-4">
<div class="text-sm text-base-content/60">Failed</div>
<div class="text-2xl font-bold text-error">{{ $automation->runs()->where('status', 'failed')->count() }}</div>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body py-4">
<div class="text-sm text-base-content/60">Skipped</div>
<div class="text-2xl font-bold text-base-content/50">{{ $automation->runs()->where('status', 'skipped')->count() }}</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body py-3">
<form method="GET" class="flex flex-wrap items-center gap-3">
<select name="status" class="select select-bordered select-sm">
<option value="">All Statuses</option>
@foreach(\App\Models\Marketing\MarketingAutomationRun::STATUSES as $key => $label)
<option value="{{ $key }}" {{ request('status') === $key ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
</select>
<button type="submit" class="btn btn-sm btn-ghost">Filter</button>
@if(request('status'))
<a href="{{ route('seller.business.marketing.automations.runs.index', [$business, $automation]) }}" class="btn btn-sm btn-ghost">Clear</a>
@endif
</form>
</div>
</div>
<!-- Runs Table -->
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
@if($runs->isEmpty())
<div class="text-center py-12 text-base-content/60">
<span class="icon-[lucide--clock] size-16 opacity-30 mx-auto block mb-4"></span>
<h3 class="font-semibold text-lg mb-2">No runs yet</h3>
<p>This automation hasn't been executed yet.</p>
</div>
@else
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Started</th>
<th>Duration</th>
<th>Status</th>
<th>Summary</th>
<th class="text-right">Details</th>
</tr>
</thead>
<tbody>
@foreach($runs as $run)
<tr x-data="{ open: false }">
<td>
<div class="text-sm">{{ $run->started_at->format('M j, Y') }}</div>
<div class="text-xs text-base-content/60">{{ $run->started_at->format('g:i:s A') }}</div>
</td>
<td>
<span class="text-sm">{{ $run->duration ?? '-' }}</span>
</td>
<td>
<span class="badge badge-{{ $run->status_color }}">
{{ $run->status_label }}
</span>
</td>
<td>
<span class="text-sm">{{ Str::limit($run->summary, 60) ?? '-' }}</span>
</td>
<td class="text-right">
<button type="button" class="btn btn-ghost btn-sm" @click="open = !open">
<span class="icon-[lucide--chevron-down] size-4 transition-transform" :class="{ 'rotate-180': open }"></span>
</button>
</td>
</tr>
<tr x-show="open" x-cloak>
<td colspan="5" class="bg-base-200/50">
<div class="p-4">
@if($run->summary)
<div class="mb-4">
<h4 class="font-medium text-sm mb-1">Summary</h4>
<p class="text-sm text-base-content/70">{{ $run->summary }}</p>
</div>
@endif
@if($run->details)
<div>
<h4 class="font-medium text-sm mb-2">Details</h4>
<pre class="text-xs bg-base-300 p-3 rounded-lg overflow-x-auto max-h-64">{{ json_encode($run->details, JSON_PRETTY_PRINT) }}</pre>
</div>
@else
<p class="text-sm text-base-content/50">No details available.</p>
@endif
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="mt-6">
{{ $runs->links() }}
</div>
@endif
</div>
</div>
</div>
@endsection

View File

@@ -1345,6 +1345,25 @@ Route::prefix('s')->name('seller.')->middleware('seller')->group(function () {
Route::post('/generate-copy', [\App\Http\Controllers\Seller\Marketing\PromoController::class, 'generateCopy'])->name('generate-copy');
});
// Marketing - Automations (Playbooks Engine)
// Automated marketing actions triggered by CannaiQ intelligence
Route::prefix('marketing/automations')->name('marketing.automations.')->group(function () {
Route::get('/', [\App\Http\Controllers\Seller\MarketingAutomationController::class, 'index'])->name('index');
Route::get('/create', [\App\Http\Controllers\Seller\MarketingAutomationController::class, 'create'])->name('create');
Route::post('/', [\App\Http\Controllers\Seller\MarketingAutomationController::class, 'store'])->name('store');
Route::get('/{automation}/edit', [\App\Http\Controllers\Seller\MarketingAutomationController::class, 'edit'])->name('edit');
Route::patch('/{automation}', [\App\Http\Controllers\Seller\MarketingAutomationController::class, 'update'])->name('update');
Route::delete('/{automation}', [\App\Http\Controllers\Seller\MarketingAutomationController::class, 'destroy'])->name('destroy');
// Actions
Route::post('/{automation}/toggle', [\App\Http\Controllers\Seller\MarketingAutomationController::class, 'toggle'])->name('toggle');
Route::post('/{automation}/run-now', [\App\Http\Controllers\Seller\MarketingAutomationController::class, 'runNow'])->name('run-now');
// Run History
Route::get('/{automation}/runs', [\App\Http\Controllers\Seller\MarketingAutomationRunController::class, 'index'])->name('runs.index');
Route::get('/{automation}/runs/{run}', [\App\Http\Controllers\Seller\MarketingAutomationRunController::class, 'show'])->name('runs.show');
});
// Messaging - Conversations (business-scoped)
// Flag: has_conversations
Route::prefix('messaging')->name('messaging.')->middleware(\App\Http\Middleware\EnsureBusinessHasConversations::class)->group(function () {