diff --git a/app/Console/Commands/RunDueMarketingAutomations.php b/app/Console/Commands/RunDueMarketingAutomations.php new file mode 100644 index 00000000..23bdb0de --- /dev/null +++ b/app/Console/Commands/RunDueMarketingAutomations.php @@ -0,0 +1,108 @@ +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(" - {$automation->name}: Not due yet"); + + continue; + } + + $dueCount++; + + if ($dryRun) { + $this->line(" - {$automation->name}: 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(" - {$automation->name}: Dispatching..."); + + if ($sync) { + // Run synchronously + try { + $job = new RunMarketingAutomationJob($automation->id); + $job->handle(app(\App\Services\Marketing\AutomationRunner::class)); + $this->line(' Completed'); + } catch (\Exception $e) { + $this->error(" Failed: {$e->getMessage()}"); + } + } else { + // Dispatch to queue + RunMarketingAutomationJob::dispatch($automation->id); + $this->line(' Dispatched to queue'); + } + } + + 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; + } +} diff --git a/app/Http/Controllers/Seller/MarketingAutomationController.php b/app/Http/Controllers/Seller/MarketingAutomationController.php new file mode 100644 index 00000000..9b7f572e --- /dev/null +++ b/app/Http/Controllers/Seller/MarketingAutomationController.php @@ -0,0 +1,312 @@ +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'] ?? '

Check out our special offer!

', + ], + ]; + + 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'] ?? '

Check out our special offer!

', + ], + ]; + + 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); + } + } +} diff --git a/app/Http/Controllers/Seller/MarketingAutomationRunController.php b/app/Http/Controllers/Seller/MarketingAutomationRunController.php new file mode 100644 index 00000000..d3b92c98 --- /dev/null +++ b/app/Http/Controllers/Seller/MarketingAutomationRunController.php @@ -0,0 +1,66 @@ +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); + } + } +} diff --git a/app/Jobs/RunMarketingAutomationJob.php b/app/Jobs/RunMarketingAutomationJob.php new file mode 100644 index 00000000..1fcfa2f2 --- /dev/null +++ b/app/Jobs/RunMarketingAutomationJob.php @@ -0,0 +1,117 @@ +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'), + ]; + } +} diff --git a/app/Models/Marketing/MarketingAutomation.php b/app/Models/Marketing/MarketingAutomation.php new file mode 100644 index 00000000..0092d8dc --- /dev/null +++ b/app/Models/Marketing/MarketingAutomation.php @@ -0,0 +1,304 @@ + '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}.', + ], + ], + ], + ]; + } +} diff --git a/app/Models/Marketing/MarketingAutomationRun.php b/app/Models/Marketing/MarketingAutomationRun.php new file mode 100644 index 00000000..4f049d3e --- /dev/null +++ b/app/Models/Marketing/MarketingAutomationRun.php @@ -0,0 +1,169 @@ + '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; + } +} diff --git a/app/Services/Marketing/AutomationRunner.php b/app/Services/Marketing/AutomationRunner.php new file mode 100644 index 00000000..dd531f7e --- /dev/null +++ b/app/Services/Marketing/AutomationRunner.php @@ -0,0 +1,705 @@ +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'] + ?? '

Check out our latest special offers!

'; + + 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, + ]); + } +} diff --git a/database/migrations/2025_12_09_160030_create_marketing_automations_table.php b/database/migrations/2025_12_09_160030_create_marketing_automations_table.php new file mode 100644 index 00000000..de412f49 --- /dev/null +++ b/database/migrations/2025_12_09_160030_create_marketing_automations_table.php @@ -0,0 +1,58 @@ +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'); + } +}; diff --git a/docs/marketing/AUTOMATIONS.md b/docs/marketing/AUTOMATIONS.md new file mode 100644 index 00000000..be5344f9 --- /dev/null +++ b/docs/marketing/AUTOMATIONS.md @@ -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 diff --git a/resources/views/seller/marketing/automations/create.blade.php b/resources/views/seller/marketing/automations/create.blade.php new file mode 100644 index 00000000..638ce4ce --- /dev/null +++ b/resources/views/seller/marketing/automations/create.blade.php @@ -0,0 +1,413 @@ +@extends('layouts.seller') + +@section('title', 'Create Automation') + +@section('content') +
+ + + + +
+

Create Automation

+

+ @if($selectedPreset && isset($presets[$selectedPreset])) + Using template: {{ $presets[$selectedPreset]['name'] }} + @else + Set up an automated marketing workflow + @endif +

+
+ +
+ @csrf + +
+ +
+ +
+
+

Basic Information

+ +
+ + + @error('name') + + @enderror +
+ +
+ + + @error('description') + + @enderror +
+ +
+ + +
+
+
+ + +
+
+

Trigger

+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+
+
+ + +
+
+

Conditions

+ +
+ + +
+ + +
+
+ + +
+ +
+
+ + +
+
+ + + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ + This automation triggers when your brand first appears at a new store in CannaiQ. +
+
+
+ + +
+
+

Actions

+ + +
+

Create Promo

+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+

Create Campaign

+ +
+ +
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + + +
+ +
+ + +
+
+
+
+
+ + +
+ +
+
+

Save

+
+ + + Cancel + +
+
+
+ + +
+
+

Templates

+ +
+
+ + +
+
+

Template Placeholders

+
    +
  • {store_name} - Store name
  • +
  • {product_name} - Product name
  • +
  • {promo_text} - Promo description
  • +
  • {brand_name} - Brand name
  • +
  • {category} - Category
  • +
+
+
+
+
+
+
+ + +@endsection diff --git a/resources/views/seller/marketing/automations/edit.blade.php b/resources/views/seller/marketing/automations/edit.blade.php new file mode 100644 index 00000000..fcdfee7c --- /dev/null +++ b/resources/views/seller/marketing/automations/edit.blade.php @@ -0,0 +1,430 @@ +@extends('layouts.seller') + +@section('title', 'Edit Automation') + +@section('content') +
+ + + + +
+
+

Edit Automation

+

{{ $automation->name }}

+
+
+ @if($automation->is_active) + + + Active + + @else + + + Paused + + @endif +
+
+ +
+ @csrf + @method('PATCH') + +
+ +
+ +
+
+

Basic Information

+ +
+ + + @error('name') + + @enderror +
+ +
+ + +
+ +
+ + +
+
+
+ + +
+
+

Trigger

+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+
+
+ + +
+
+

Conditions

+ +
+ + +
+ + +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ + This automation triggers when your brand first appears at a new store in CannaiQ. +
+
+
+ + +
+
+

Actions

+ + +
+

Create Promo

+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+

Create Campaign

+ +
+ +
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+
+ + +
+ +
+
+

Save Changes

+
+ + + Cancel + +
+
+
+ + +
+
+

Status

+ + @csrf + + +
+
+ + +
+
+

Run History

+
+
+ Last Run + {{ $automation->last_run_at?->diffForHumans() ?? 'Never' }} +
+ @if($automation->last_status) +
+ Last Status + {{ ucfirst($automation->last_status) }} +
+ @endif +
+ + + View All Runs + +
+
+ + +
+
+

Danger Zone

+
+ @csrf + @method('DELETE') + +
+
+
+
+
+ +
+ + +@endsection diff --git a/resources/views/seller/marketing/automations/index.blade.php b/resources/views/seller/marketing/automations/index.blade.php new file mode 100644 index 00000000..81fa01c6 --- /dev/null +++ b/resources/views/seller/marketing/automations/index.blade.php @@ -0,0 +1,205 @@ +@extends('layouts.seller') + +@section('title', 'Marketing Automations') + +@section('content') +
+ +
+
+

Marketing Automations

+

Automate your marketing based on CannaiQ intelligence

+
+ + + New Automation + +
+ + +
+
+
+ + + + @if(request()->hasAny(['status', 'trigger_type'])) + Clear + @endif +
+
+
+ + +
+
+ @if($automations->isEmpty()) +
+ +

No automations yet

+

Create your first automation to start marketing on autopilot.

+ + Create Automation + +
+ @else +
+ + + + + + + + + + + + + @foreach($automations as $automation) + + + + + + + + + @endforeach + +
AutomationTriggerScheduleStatusLast RunActions
+
{{ $automation->name }}
+
{{ $automation->condition_type_label }}
+
+ {{ $automation->trigger_type_label }} + + {{ $automation->frequency_label }} + @if($automation->trigger_config['time_of_day'] ?? null) + + at {{ $automation->trigger_config['time_of_day'] }} + + @endif + + @if($automation->is_active) + + + Active + + @else + + + Paused + + @endif + + @if($automation->last_run_at) +
{{ $automation->last_run_at->diffForHumans() }}
+ + {{ ucfirst($automation->last_status) }} + + @else + Never run + @endif +
+ +
+
+ +
+ {{ $automations->links() }} +
+ @endif +
+
+ + + @if($automations->isEmpty()) +
+
+

+ + Quick Start Templates +

+
+ @foreach(\App\Models\Marketing\MarketingAutomation::getTypePresets() as $key => $preset) +
+
+

{{ $preset['name'] }}

+

{{ $preset['description'] }}

+ +
+
+ @endforeach +
+
+
+ @endif +
+@endsection diff --git a/resources/views/seller/marketing/automations/runs/index.blade.php b/resources/views/seller/marketing/automations/runs/index.blade.php new file mode 100644 index 00000000..7b4712cb --- /dev/null +++ b/resources/views/seller/marketing/automations/runs/index.blade.php @@ -0,0 +1,168 @@ +@extends('layouts.seller') + +@section('title', 'Automation Run History') + +@section('content') +
+ + + + +
+
+

Run History

+

{{ $automation->name }}

+
+
+
+ @csrf + +
+ + + Settings + +
+
+ + +
+
+
+
Total Runs
+
{{ $automation->runs()->count() }}
+
+
+
+
+
Successful
+
{{ $automation->runs()->where('status', 'success')->count() }}
+
+
+
+
+
Partial
+
{{ $automation->runs()->where('status', 'partial')->count() }}
+
+
+
+
+
Failed
+
{{ $automation->runs()->where('status', 'failed')->count() }}
+
+
+
+
+
Skipped
+
{{ $automation->runs()->where('status', 'skipped')->count() }}
+
+
+
+ + +
+
+
+ + + @if(request('status')) + Clear + @endif +
+
+
+ + +
+
+ @if($runs->isEmpty()) +
+ +

No runs yet

+

This automation hasn't been executed yet.

+
+ @else +
+ + + + + + + + + + + + @foreach($runs as $run) + + + + + + + + + + + @endforeach + +
StartedDurationStatusSummaryDetails
+
{{ $run->started_at->format('M j, Y') }}
+
{{ $run->started_at->format('g:i:s A') }}
+
+ {{ $run->duration ?? '-' }} + + + {{ $run->status_label }} + + + {{ Str::limit($run->summary, 60) ?? '-' }} + + +
+
+ @if($run->summary) +
+

Summary

+

{{ $run->summary }}

+
+ @endif + + @if($run->details) +
+

Details

+
{{ json_encode($run->details, JSON_PRETTY_PRINT) }}
+
+ @else +

No details available.

+ @endif +
+
+
+ +
+ {{ $runs->links() }} +
+ @endif +
+
+
+@endsection diff --git a/routes/seller.php b/routes/seller.php index 7d2e4303..1c552e7d 100644 --- a/routes/seller.php +++ b/routes/seller.php @@ -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 () {