Files
hub/app/Http/Controllers/Seller/MarketingAutomationController.php
kelly 50bb3fce77 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
2025-12-09 16:41:32 -07:00

313 lines
13 KiB
PHP

<?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);
}
}
}