Skip to content

Commit 892d1b5

Browse files
committed
Phases 196-200: Email Marketing Module — 23 tests passing
5 migrations (mailing_lists, subscribers, pivot, email_campaigns, campaign_sends), 4 models (MailingList/Subscriber/EmailCampaign/CampaignSend with send()/cancel()/ openRate()/clickRate()/markOpened()/markClicked()), MarketingPolicy, 4 controllers (Dashboard/MailingList/Subscriber/Campaign with CSV import), 8 React pages (Dashboard, MailingLists CRUD+Show, Subscribers, Campaigns CRUD+Show), Sidebar Marketing section. 23/23 feature tests passing. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent bcca3d0 commit 892d1b5

29 files changed

Lines changed: 2438 additions & 0 deletions

erp/app/Modules/Core/Providers/CoreServiceProvider.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use App\Modules\Helpdesk\Providers\HelpdeskServiceProvider;
1717
use App\Modules\Accounting\Providers\AccountingServiceProvider;
1818
use App\Modules\Fleet\Providers\FleetServiceProvider;
19+
use App\Modules\Marketing\Providers\MarketingServiceProvider;
1920
use Illuminate\Support\Facades\Gate;
2021
use Illuminate\Support\ServiceProvider;
2122

@@ -33,6 +34,7 @@ public function register(): void
3334
$this->app->register(HelpdeskServiceProvider::class);
3435
$this->app->register(AccountingServiceProvider::class);
3536
$this->app->register(FleetServiceProvider::class);
37+
$this->app->register(MarketingServiceProvider::class);
3638
}
3739

3840
public function boot(): void
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
<?php
2+
3+
namespace App\Modules\Marketing\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Marketing\Models\EmailCampaign;
7+
use App\Modules\Marketing\Models\MailingList;
8+
use Illuminate\Http\RedirectResponse;
9+
use Illuminate\Http\Request;
10+
use Inertia\Inertia;
11+
use Inertia\Response;
12+
13+
class EmailCampaignController extends Controller
14+
{
15+
public function index(): Response
16+
{
17+
$campaigns = EmailCampaign::with('mailingList')
18+
->orderByDesc('created_at')
19+
->get()
20+
->map(fn ($c) => [
21+
'id' => $c->id,
22+
'name' => $c->name,
23+
'subject' => $c->subject,
24+
'status' => $c->status,
25+
'list_name' => $c->mailingList?->name,
26+
'total_recipients' => $c->total_recipients,
27+
'open_rate' => $c->openRate(),
28+
'click_rate' => $c->clickRate(),
29+
'sent_at' => $c->sent_at?->toDateTimeString(),
30+
]);
31+
32+
return Inertia::render('Marketing/Campaigns/Index', [
33+
'campaigns' => $campaigns,
34+
]);
35+
}
36+
37+
public function create(): Response
38+
{
39+
$mailingLists = MailingList::where('is_active', true)
40+
->orderBy('name')
41+
->get(['id', 'name']);
42+
43+
return Inertia::render('Marketing/Campaigns/Create', [
44+
'mailingLists' => $mailingLists,
45+
]);
46+
}
47+
48+
public function store(Request $request): RedirectResponse
49+
{
50+
$data = $request->validate([
51+
'name' => 'required|string|max:255',
52+
'subject' => 'required|string|max:255',
53+
'preview_text' => 'nullable|string|max:255',
54+
'body_html' => 'required|string',
55+
'body_text' => 'nullable|string',
56+
'from_name' => 'nullable|string|max:255',
57+
'from_email' => 'nullable|email',
58+
'mailing_list_id' => 'nullable|exists:mailing_lists,id',
59+
'scheduled_at' => 'nullable|date',
60+
]);
61+
62+
$data['tenant_id'] = auth()->user()->tenant_id;
63+
$data['created_by'] = auth()->id();
64+
65+
$campaign = EmailCampaign::create($data);
66+
67+
return redirect()->route('marketing.campaigns.show', $campaign)
68+
->with('success', 'Campaign created.');
69+
}
70+
71+
public function show(EmailCampaign $campaign): Response
72+
{
73+
$sends = $campaign->sends()
74+
->with('subscriber')
75+
->orderByDesc('created_at')
76+
->limit(50)
77+
->get();
78+
79+
return Inertia::render('Marketing/Campaigns/Show', [
80+
'campaign' => array_merge($campaign->toArray(), [
81+
'open_rate' => $campaign->openRate(),
82+
'click_rate' => $campaign->clickRate(),
83+
'list_name' => $campaign->mailingList?->name,
84+
]),
85+
'sends' => $sends,
86+
]);
87+
}
88+
89+
public function edit(EmailCampaign $campaign): Response
90+
{
91+
$mailingLists = MailingList::where('is_active', true)
92+
->orderBy('name')
93+
->get(['id', 'name']);
94+
95+
return Inertia::render('Marketing/Campaigns/Edit', [
96+
'campaign' => $campaign,
97+
'mailingLists' => $mailingLists,
98+
]);
99+
}
100+
101+
public function update(Request $request, EmailCampaign $campaign): RedirectResponse
102+
{
103+
if ($campaign->status !== 'draft') {
104+
return redirect()->back()->with('error', 'Only draft campaigns can be edited.');
105+
}
106+
107+
$data = $request->validate([
108+
'name' => 'required|string|max:255',
109+
'subject' => 'required|string|max:255',
110+
'preview_text' => 'nullable|string|max:255',
111+
'body_html' => 'required|string',
112+
'body_text' => 'nullable|string',
113+
'from_name' => 'nullable|string|max:255',
114+
'from_email' => 'nullable|email',
115+
'mailing_list_id' => 'nullable|exists:mailing_lists,id',
116+
'scheduled_at' => 'nullable|date',
117+
]);
118+
119+
$campaign->update($data);
120+
121+
return redirect()->route('marketing.campaigns.show', $campaign)
122+
->with('success', 'Campaign updated.');
123+
}
124+
125+
public function destroy(EmailCampaign $campaign): RedirectResponse
126+
{
127+
if (!in_array($campaign->status, ['draft', 'cancelled'])) {
128+
return redirect()->back()->with('error', 'Only draft or cancelled campaigns can be deleted.');
129+
}
130+
131+
$campaign->delete();
132+
133+
return redirect()->route('marketing.campaigns.index')
134+
->with('success', 'Campaign deleted.');
135+
}
136+
137+
public function send(EmailCampaign $campaign): RedirectResponse
138+
{
139+
$campaign->send();
140+
141+
return redirect()->route('marketing.campaigns.show', $campaign)
142+
->with('success', 'Campaign sent successfully.');
143+
}
144+
145+
public function cancel(EmailCampaign $campaign): RedirectResponse
146+
{
147+
$campaign->cancel();
148+
149+
return redirect()->route('marketing.campaigns.show', $campaign)
150+
->with('success', 'Campaign cancelled.');
151+
}
152+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
<?php
2+
3+
namespace App\Modules\Marketing\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Marketing\Models\MailingList;
7+
use App\Modules\Marketing\Models\Subscriber;
8+
use Illuminate\Http\RedirectResponse;
9+
use Illuminate\Http\Request;
10+
use Inertia\Inertia;
11+
use Inertia\Response;
12+
13+
class MailingListController extends Controller
14+
{
15+
public function index(): Response
16+
{
17+
$lists = MailingList::withCount('subscribers')
18+
->orderByDesc('created_at')
19+
->get();
20+
21+
return Inertia::render('Marketing/MailingLists/Index', [
22+
'lists' => $lists,
23+
]);
24+
}
25+
26+
public function create(): Response
27+
{
28+
return Inertia::render('Marketing/MailingLists/Create');
29+
}
30+
31+
public function store(Request $request): RedirectResponse
32+
{
33+
$data = $request->validate([
34+
'name' => 'required|string|max:255',
35+
'description' => 'nullable|string',
36+
'is_active' => 'boolean',
37+
]);
38+
39+
$data['tenant_id'] = auth()->user()->tenant_id;
40+
41+
MailingList::create($data);
42+
43+
return redirect()->route('marketing.mailing-lists.index')
44+
->with('success', 'Mailing list created.');
45+
}
46+
47+
public function show(MailingList $mailingList): Response
48+
{
49+
$subscribers = $mailingList->subscribers()
50+
->orderByDesc('mailing_list_subscriber.mailing_list_id')
51+
->paginate(25);
52+
53+
return Inertia::render('Marketing/MailingLists/Show', [
54+
'list' => $mailingList,
55+
'subscribers' => $subscribers,
56+
]);
57+
}
58+
59+
public function edit(MailingList $mailingList): Response
60+
{
61+
return Inertia::render('Marketing/MailingLists/Edit', [
62+
'list' => $mailingList,
63+
]);
64+
}
65+
66+
public function update(Request $request, MailingList $mailingList): RedirectResponse
67+
{
68+
$data = $request->validate([
69+
'name' => 'required|string|max:255',
70+
'description' => 'nullable|string',
71+
'is_active' => 'boolean',
72+
]);
73+
74+
$mailingList->update($data);
75+
76+
return redirect()->route('marketing.mailing-lists.index')
77+
->with('success', 'Mailing list updated.');
78+
}
79+
80+
public function destroy(MailingList $mailingList): RedirectResponse
81+
{
82+
$mailingList->delete();
83+
84+
return redirect()->route('marketing.mailing-lists.index')
85+
->with('success', 'Mailing list deleted.');
86+
}
87+
88+
public function addSubscriber(Request $request, MailingList $mailingList): RedirectResponse
89+
{
90+
$data = $request->validate([
91+
'email' => 'required|email',
92+
'name' => 'nullable|string|max:255',
93+
]);
94+
95+
$tenantId = auth()->user()->tenant_id;
96+
97+
$subscriber = Subscriber::firstOrCreate(
98+
['tenant_id' => $tenantId, 'email' => $data['email']],
99+
[
100+
'name' => $data['name'] ?? null,
101+
'status' => 'subscribed',
102+
'subscribed_at' => now(),
103+
]
104+
);
105+
106+
$mailingList->subscribers()->syncWithoutDetaching([$subscriber->id]);
107+
108+
return redirect()->back()->with('success', 'Subscriber added.');
109+
}
110+
111+
public function removeSubscriber(MailingList $mailingList, Subscriber $subscriber): RedirectResponse
112+
{
113+
$mailingList->subscribers()->detach($subscriber->id);
114+
115+
return redirect()->back()->with('success', 'Subscriber removed.');
116+
}
117+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
namespace App\Modules\Marketing\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Marketing\Models\EmailCampaign;
7+
use App\Modules\Marketing\Models\Subscriber;
8+
use Inertia\Inertia;
9+
use Inertia\Response;
10+
11+
class MarketingDashboardController extends Controller
12+
{
13+
public function index(): Response
14+
{
15+
$totalSubscribers = Subscriber::count();
16+
$activeSubscribers = Subscriber::where('status', 'subscribed')->count();
17+
$totalCampaigns = EmailCampaign::count();
18+
19+
$campaignsSentThisMonth = EmailCampaign::where('status', 'sent')
20+
->whereYear('sent_at', now()->year)
21+
->whereMonth('sent_at', now()->month)
22+
->count();
23+
24+
$sentCampaigns = EmailCampaign::where('status', 'sent')
25+
->where('sent_count', '>', 0)
26+
->get();
27+
28+
$avgOpenRate = $sentCampaigns->count() > 0
29+
? round($sentCampaigns->avg(fn ($c) => $c->openRate()), 1)
30+
: 0;
31+
32+
$avgClickRate = $sentCampaigns->count() > 0
33+
? round($sentCampaigns->avg(fn ($c) => $c->clickRate()), 1)
34+
: 0;
35+
36+
$recentCampaigns = EmailCampaign::with('mailingList')
37+
->orderByDesc('created_at')
38+
->limit(5)
39+
->get()
40+
->map(fn ($c) => [
41+
'id' => $c->id,
42+
'name' => $c->name,
43+
'subject' => $c->subject,
44+
'status' => $c->status,
45+
'list_name' => $c->mailingList?->name,
46+
'total_recipients' => $c->total_recipients,
47+
'open_rate' => $c->openRate(),
48+
'click_rate' => $c->clickRate(),
49+
'sent_at' => $c->sent_at?->toDateTimeString(),
50+
]);
51+
52+
return Inertia::render('Marketing/Dashboard', [
53+
'stats' => [
54+
'totalSubscribers' => $totalSubscribers,
55+
'activeSubscribers' => $activeSubscribers,
56+
'totalCampaigns' => $totalCampaigns,
57+
'campaignsSentThisMonth' => $campaignsSentThisMonth,
58+
'avgOpenRate' => $avgOpenRate,
59+
'avgClickRate' => $avgClickRate,
60+
],
61+
'recentCampaigns' => $recentCampaigns,
62+
]);
63+
}
64+
}

0 commit comments

Comments
 (0)