Files
hub/app/Http/Controllers/Portal/CampaignController.php
kelly c7fdc67060 feat: add dispensary marketing portal (Phase 5)
Add white-labeled marketing portal for dispensary partners:

- Business branding settings table and model for white-labeling
- Portal middleware (EnsureMarketingPortalAccess) with contact_type check
- Portal route group at /portal/{business}/*
- DashboardController with stats and CannaiQ recommendations
- PromoController for viewing recommended/existing promos
- CampaignController with create/send/schedule/cancel
- ListController for managing contact lists
- Policies for MarketingCampaign and MarketingPromo
- White-labeled portal layout with custom branding CSS
- Marketing Portal link in Filament admin for buyer businesses
- MarketingPortalUserSeeder for development testing
- PORTAL_ACCESS.md documentation
2025-12-09 15:59:40 -07:00

250 lines
8.1 KiB
PHP

<?php
namespace App\Http\Controllers\Portal;
use App\Http\Controllers\Controller;
use App\Jobs\SendMarketingCampaignJob;
use App\Models\Branding\BusinessBrandingSetting;
use App\Models\Business;
use App\Models\Marketing\MarketingCampaign;
use App\Models\Marketing\MarketingList;
use App\Models\Marketing\MarketingPromo;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class CampaignController extends Controller
{
public function index(Request $request, Business $business)
{
$branding = BusinessBrandingSetting::forBusiness($business);
$campaigns = MarketingCampaign::where('business_id', $business->id)
->with('list')
->when($request->status, fn ($q, $status) => $q->where('status', $status))
->when($request->channel, fn ($q, $channel) => $q->where('channel', $channel))
->latest()
->paginate(15);
$statuses = [
'draft' => 'Draft',
'scheduled' => 'Scheduled',
'sending' => 'Sending',
'sent' => 'Sent',
'completed' => 'Completed',
'cancelled' => 'Cancelled',
'failed' => 'Failed',
];
$channels = MarketingCampaign::CHANNELS;
return view('portal.campaigns.index', compact(
'business',
'branding',
'campaigns',
'statuses',
'channels'
));
}
public function create(Request $request, Business $business)
{
$branding = BusinessBrandingSetting::forBusiness($business);
// Get lists for this business
$lists = MarketingList::where('business_id', $business->id)
->withCount('contacts')
->orderBy('name')
->get();
// Pre-populate from promo if provided
$promo = null;
if ($request->query('promo_id')) {
$promo = MarketingPromo::where('business_id', $business->id)
->find($request->query('promo_id'));
}
// Pre-select channel if provided
$preselectedChannel = $request->query('channel', 'email');
$channels = MarketingCampaign::CHANNELS;
return view('portal.campaigns.create', compact(
'business',
'branding',
'lists',
'promo',
'preselectedChannel',
'channels'
));
}
public function store(Request $request, Business $business)
{
$branding = BusinessBrandingSetting::forBusiness($business);
$validated = $request->validate([
'name' => 'required|string|max:255',
'channel' => 'required|in:email,sms',
'list_id' => 'required|exists:marketing_lists,id',
'subject' => 'required_if:channel,email|nullable|string|max:255',
'body' => 'required|string',
'send_at' => 'nullable|date|after:now',
'promo_id' => 'nullable|exists:marketing_promos,id',
]);
// Verify list belongs to this business
$list = MarketingList::where('business_id', $business->id)
->findOrFail($validated['list_id']);
// Build campaign data
$campaignData = [
'business_id' => $business->id,
'name' => $validated['name'],
'channel' => $validated['channel'],
'list_id' => $list->id,
'subject' => $validated['subject'] ?? null,
'body' => $validated['body'],
'status' => 'draft',
'created_by' => Auth::id(),
// Use branding defaults for from fields
'from_name' => $branding->effective_from_name,
'from_email' => $branding->effective_from_email,
];
// Link to promo if provided
if (! empty($validated['promo_id'])) {
$promo = MarketingPromo::where('business_id', $business->id)
->find($validated['promo_id']);
if ($promo) {
$campaignData['source_type'] = 'promo';
$campaignData['source_id'] = $promo->id;
}
}
// Set schedule if provided
if (! empty($validated['send_at'])) {
$campaignData['send_at'] = $validated['send_at'];
$campaignData['status'] = 'scheduled';
}
$campaign = MarketingCampaign::create($campaignData);
if ($campaign->status === 'scheduled') {
return redirect()
->route('portal.campaigns.show', [$business->slug, $campaign])
->with('success', 'Campaign scheduled successfully.');
}
return redirect()
->route('portal.campaigns.show', [$business->slug, $campaign])
->with('success', 'Campaign created as draft. Review and send when ready.');
}
public function show(Request $request, Business $business, MarketingCampaign $campaign)
{
// Ensure campaign belongs to this business
if ($campaign->business_id !== $business->id) {
abort(404);
}
$branding = BusinessBrandingSetting::forBusiness($business);
$campaign->load(['list', 'logs']);
// Get stats
$stats = [
'total_recipients' => $campaign->total_recipients,
'sent' => $campaign->total_sent,
'delivered' => $campaign->total_delivered,
'opened' => $campaign->total_opened,
'clicked' => $campaign->total_clicked,
'failed' => $campaign->total_failed,
];
return view('portal.campaigns.show', compact(
'business',
'branding',
'campaign',
'stats'
));
}
public function sendNow(Request $request, Business $business, MarketingCampaign $campaign)
{
// Ensure campaign belongs to this business
if ($campaign->business_id !== $business->id) {
abort(404);
}
if (! in_array($campaign->status, ['draft', 'scheduled'])) {
return back()->with('error', 'This campaign cannot be sent.');
}
// Count recipients
$recipientCount = $campaign->list?->contacts()->count() ?? 0;
if ($recipientCount === 0) {
return back()->with('error', 'No recipients in the selected list.');
}
// Update campaign
$campaign->update([
'status' => 'sending',
'total_recipients' => $recipientCount,
'sent_at' => now(),
]);
// Dispatch job
SendMarketingCampaignJob::dispatch($campaign);
return redirect()
->route('portal.campaigns.show', [$business->slug, $campaign])
->with('success', "Campaign is now sending to {$recipientCount} recipients.");
}
public function schedule(Request $request, Business $business, MarketingCampaign $campaign)
{
// Ensure campaign belongs to this business
if ($campaign->business_id !== $business->id) {
abort(404);
}
if ($campaign->status !== 'draft') {
return back()->with('error', 'Only draft campaigns can be scheduled.');
}
$validated = $request->validate([
'send_at' => 'required|date|after:now',
]);
$campaign->update([
'status' => 'scheduled',
'send_at' => $validated['send_at'],
]);
return redirect()
->route('portal.campaigns.show', [$business->slug, $campaign])
->with('success', 'Campaign scheduled for '.$campaign->send_at->format('M j, Y g:i A'));
}
public function cancel(Request $request, Business $business, MarketingCampaign $campaign)
{
// Ensure campaign belongs to this business
if ($campaign->business_id !== $business->id) {
abort(404);
}
if (! in_array($campaign->status, ['draft', 'scheduled'])) {
return back()->with('error', 'This campaign cannot be cancelled.');
}
$campaign->update([
'status' => 'cancelled',
]);
return redirect()
->route('portal.campaigns.index', $business->slug)
->with('success', 'Campaign cancelled.');
}
}