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:
108
app/Console/Commands/RunDueMarketingAutomations.php
Normal file
108
app/Console/Commands/RunDueMarketingAutomations.php
Normal 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;
|
||||
}
|
||||
}
|
||||
312
app/Http/Controllers/Seller/MarketingAutomationController.php
Normal file
312
app/Http/Controllers/Seller/MarketingAutomationController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
117
app/Jobs/RunMarketingAutomationJob.php
Normal file
117
app/Jobs/RunMarketingAutomationJob.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
304
app/Models/Marketing/MarketingAutomation.php
Normal file
304
app/Models/Marketing/MarketingAutomation.php
Normal 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}.',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
169
app/Models/Marketing/MarketingAutomationRun.php
Normal file
169
app/Models/Marketing/MarketingAutomationRun.php
Normal 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;
|
||||
}
|
||||
}
|
||||
705
app/Services/Marketing/AutomationRunner.php
Normal file
705
app/Services/Marketing/AutomationRunner.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
287
docs/marketing/AUTOMATIONS.md
Normal file
287
docs/marketing/AUTOMATIONS.md
Normal 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
|
||||
413
resources/views/seller/marketing/automations/create.blade.php
Normal file
413
resources/views/seller/marketing/automations/create.blade.php
Normal 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
|
||||
430
resources/views/seller/marketing/automations/edit.blade.php
Normal file
430
resources/views/seller/marketing/automations/edit.blade.php
Normal 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
|
||||
205
resources/views/seller/marketing/automations/index.blade.php
Normal file
205
resources/views/seller/marketing/automations/index.blade.php
Normal 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
|
||||
@@ -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
|
||||
@@ -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 () {
|
||||
|
||||
Reference in New Issue
Block a user