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
313 lines
13 KiB
PHP
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);
|
|
}
|
|
}
|
|
}
|