diff --git a/app/Http/Controllers/Link/CampaignController.php b/app/Http/Controllers/Link/CampaignController.php new file mode 100644 index 0000000..65f3021 --- /dev/null +++ b/app/Http/Controllers/Link/CampaignController.php @@ -0,0 +1,156 @@ +where('user_id', $account->id) + ->withCount('shortLinks') + ->withSum('shortLinks', 'clicks_total') + ->latest() + ->paginate(20) + ->withQueryString(); + + $base = Campaign::query()->where('user_id', $account->id); + + return view('links.campaigns.index', [ + 'campaigns' => $campaigns, + 'campaignCount' => (clone $base)->count(), + 'activeCount' => (clone $base)->where('status', Campaign::STATUS_ACTIVE)->count(), + 'linksInCampaigns' => ShortLink::query() + ->where('user_id', $account->id) + ->whereNotNull('campaign_id') + ->count(), + ]); + } + + public function create(): View + { + return view('links.campaigns.create', [ + 'campaign' => new Campaign(['status' => Campaign::STATUS_ACTIVE]), + ]); + } + + public function store(Request $request): RedirectResponse + { + $account = ladill_account(); + $data = $this->validated($request); + + $campaign = Campaign::create([ + ...$data, + 'user_id' => $account->id, + ]); + + return redirect() + ->route('link.campaigns.show', $campaign) + ->with('success', 'Campaign created.'); + } + + public function show(Campaign $campaign): View + { + $this->authorizeCampaign($campaign); + + $links = $campaign->shortLinks()->latest()->get(); + $availableLinks = ShortLink::query() + ->where('user_id', $campaign->user_id) + ->where(function ($q) use ($campaign) { + $q->whereNull('campaign_id')->orWhere('campaign_id', $campaign->id); + }) + ->orderBy('slug') + ->get(); + + return view('links.campaigns.show', [ + 'campaign' => $campaign, + 'links' => $links, + 'availableLinks' => $availableLinks, + 'totalClicks' => (int) $links->sum('clicks_total'), + ]); + } + + public function update(Request $request, Campaign $campaign): RedirectResponse + { + $this->authorizeCampaign($campaign); + $campaign->update($this->validated($request)); + + return back()->with('success', 'Campaign updated.'); + } + + public function destroy(Campaign $campaign): RedirectResponse + { + $this->authorizeCampaign($campaign); + + ShortLink::query() + ->where('campaign_id', $campaign->id) + ->update(['campaign_id' => null]); + + $campaign->delete(); + + return redirect() + ->route('link.campaigns.index') + ->with('success', 'Campaign deleted. Links were unassigned, not removed.'); + } + + public function attach(Request $request, Campaign $campaign): RedirectResponse + { + $this->authorizeCampaign($campaign); + + $validated = $request->validate([ + 'short_link_ids' => ['required', 'array', 'min:1'], + 'short_link_ids.*' => ['integer'], + ]); + + ShortLink::query() + ->where('user_id', $campaign->user_id) + ->whereIn('id', $validated['short_link_ids']) + ->update(['campaign_id' => $campaign->id]); + + return back()->with('success', 'Links added to campaign.'); + } + + public function detach(Campaign $campaign, ShortLink $link): RedirectResponse + { + $this->authorizeCampaign($campaign); + abort_unless((int) $link->user_id === (int) $campaign->user_id, 403); + abort_unless((int) $link->campaign_id === (int) $campaign->id, 404); + + $link->update(['campaign_id' => null]); + + return back()->with('success', 'Link removed from campaign.'); + } + + /** @return array */ + private function validated(Request $request): array + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:120'], + 'description' => ['nullable', 'string', 'max:2000'], + 'status' => ['required', Rule::in(array_keys(Campaign::STATUSES))], + 'starts_at' => ['nullable', 'date'], + 'ends_at' => ['nullable', 'date', 'after_or_equal:starts_at'], + ]); + + $data['description'] = $data['description'] ?? null; + $data['starts_at'] = $data['starts_at'] ?? null; + $data['ends_at'] = $data['ends_at'] ?? null; + + return $data; + } + + private function authorizeCampaign(Campaign $campaign): void + { + abort_unless((int) $campaign->user_id === (int) ladill_account()->id, 403); + } +} diff --git a/app/Models/Campaign.php b/app/Models/Campaign.php new file mode 100644 index 0000000..259966c --- /dev/null +++ b/app/Models/Campaign.php @@ -0,0 +1,81 @@ + 'Draft', + self::STATUS_ACTIVE => 'Active', + self::STATUS_ARCHIVED => 'Archived', + ]; + + protected $fillable = [ + 'user_id', + 'name', + 'description', + 'status', + 'starts_at', + 'ends_at', + ]; + + protected function casts(): array + { + return [ + 'starts_at' => 'datetime', + 'ends_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function shortLinks(): HasMany + { + return $this->hasMany(ShortLink::class); + } + + public function statusLabel(): string + { + return self::STATUSES[$this->status] ?? ucfirst((string) $this->status); + } + + public function isLive(): bool + { + if ($this->status !== self::STATUS_ACTIVE) { + return false; + } + + if ($this->starts_at && $this->starts_at->isFuture()) { + return false; + } + + if ($this->ends_at && $this->ends_at->isPast()) { + return false; + } + + return true; + } + + public function totalClicks(): int + { + return (int) $this->shortLinks()->sum('clicks_total'); + } + + public function linksCount(): int + { + return (int) $this->shortLinks()->count(); + } +} diff --git a/app/Models/ShortLink.php b/app/Models/ShortLink.php index 0795469..027e2ba 100644 --- a/app/Models/ShortLink.php +++ b/app/Models/ShortLink.php @@ -11,6 +11,7 @@ class ShortLink extends Model { protected $fillable = [ 'user_id', + 'campaign_id', 'source_app', 'source_kind', 'source_ref', @@ -40,6 +41,11 @@ class ShortLink extends Model return $this->belongsTo(User::class); } + public function campaign(): BelongsTo + { + return $this->belongsTo(Campaign::class); + } + public function clicks(): HasMany { return $this->hasMany(LinkClick::class); diff --git a/app/Models/User.php b/app/Models/User.php index 57b1f32..5c988be 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -49,6 +49,11 @@ class User extends Authenticatable return $this->hasMany(ShortLink::class); } + public function campaigns(): HasMany + { + return $this->hasMany(Campaign::class); + } + public function linkCustomDomains(): HasMany { return $this->hasMany(LinkCustomDomain::class); diff --git a/app/Services/Afia/AfiaService.php b/app/Services/Afia/AfiaService.php index ea6ae12..4da6591 100644 --- a/app/Services/Afia/AfiaService.php +++ b/app/Services/Afia/AfiaService.php @@ -97,11 +97,12 @@ class AfiaService { return <<id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->text('description')->nullable(); + $table->string('status', 20)->default('active')->index(); + $table->timestamp('starts_at')->nullable(); + $table->timestamp('ends_at')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'status']); + }); + + Schema::table('short_links', function (Blueprint $table) { + $table->foreignId('campaign_id')->nullable()->after('user_id')->constrained('campaigns')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('short_links', function (Blueprint $table) { + $table->dropConstrainedForeignId('campaign_id'); + }); + + Schema::dropIfExists('campaigns'); + } +}; diff --git a/resources/views/links/campaigns/_form.blade.php b/resources/views/links/campaigns/_form.blade.php new file mode 100644 index 0000000..251c85a --- /dev/null +++ b/resources/views/links/campaigns/_form.blade.php @@ -0,0 +1,42 @@ +
+ + + @error('name')

{{ $message }}

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

{{ $message }}

@enderror +
+ +
+ + + @error('status')

{{ $message }}

@enderror +
+ +
+
+ + + @error('starts_at')

{{ $message }}

@enderror +
+
+ + + @error('ends_at')

{{ $message }}

@enderror +
+
diff --git a/resources/views/links/campaigns/create.blade.php b/resources/views/links/campaigns/create.blade.php new file mode 100644 index 0000000..e64fb68 --- /dev/null +++ b/resources/views/links/campaigns/create.blade.php @@ -0,0 +1,20 @@ + + New campaign + +
+
+ ← Campaigns +

New campaign

+

Name a launch or promo, then attach short links on the next screen.

+
+ +
+ @csrf + @include('links.campaigns._form', ['campaign' => $campaign]) +
+ Cancel + +
+
+
+
diff --git a/resources/views/links/campaigns/index.blade.php b/resources/views/links/campaigns/index.blade.php new file mode 100644 index 0000000..89655a3 --- /dev/null +++ b/resources/views/links/campaigns/index.blade.php @@ -0,0 +1,100 @@ +@php + $statusColor = [ + 'draft' => 'bg-slate-100 text-slate-700', + 'active' => 'bg-emerald-50 text-emerald-700', + 'archived' => 'bg-amber-50 text-amber-800', + ]; +@endphp + + Campaigns + +
+ @foreach (['success', 'error'] as $flash) + @if (session($flash)) +
+

{{ session($flash) }}

+
+ @endif + @endforeach + +
+
+
+
+
+
+ Group links · Track performance +
+

Campaigns

+

+ Organise short links by launch, promo, or channel and see combined click totals in one place. +

+ +
+ +
+
+

{{ number_format($campaignCount) }}

+

Campaigns

+
+
+

{{ number_format($activeCount) }}

+

Active

+
+
+

{{ number_format($linksInCampaigns) }}

+

Links assigned

+
+
+
+
+
+ +
+
+

All campaigns

+
+ @if ($campaigns->isEmpty()) +
+

No campaigns yet. Create one to group short links for a promo or launch.

+
+ @else + + + + + + + + + + + @foreach ($campaigns as $campaign) + + + + + + + @endforeach + +
CampaignStatus
+

{{ $campaign->name }}

+ @if ($campaign->description) +

{{ $campaign->description }}

+ @endif +
+ + {{ $campaign->statusLabel() }} + +
+
{{ $campaigns->links() }}
+ @endif +
+
+
diff --git a/resources/views/links/campaigns/show.blade.php b/resources/views/links/campaigns/show.blade.php new file mode 100644 index 0000000..e506b24 --- /dev/null +++ b/resources/views/links/campaigns/show.blade.php @@ -0,0 +1,115 @@ +@php + $statusColor = [ + 'draft' => 'bg-slate-100 text-slate-700', + 'active' => 'bg-emerald-50 text-emerald-700', + 'archived' => 'bg-amber-50 text-amber-800', + ]; +@endphp + + {{ $campaign->name }} + +
+ @foreach (['success', 'error'] as $flash) + @if (session($flash)) +
+

{{ session($flash) }}

+
+ @endif + @endforeach + +
+
+ ← Campaigns +
+

{{ $campaign->name }}

+ + {{ $campaign->statusLabel() }} + +
+ @if ($campaign->description) +

{{ $campaign->description }}

+ @endif +
+
+ @csrf @method('DELETE') + +
+
+ +
+
+

{{ number_format($links->count()) }}

+

Short links

+
+
+

{{ number_format($totalClicks) }}

+

Total clicks

+
+
+

+ @if ($campaign->starts_at || $campaign->ends_at) + {{ optional($campaign->starts_at)->format('d M Y') ?? '…' }} + → + {{ optional($campaign->ends_at)->format('d M Y') ?? '…' }} + @else + No date range + @endif +

+

Schedule

+
+
+ +
+ @csrf @method('PATCH') +

Campaign details

+ @include('links.campaigns._form', ['campaign' => $campaign]) + +
+ +
+
+

Links in this campaign

+
+ @if ($links->isEmpty()) +

No links assigned yet.

+ @else + + @endif +
+ + @php + $unassigned = $availableLinks->whereNull('campaign_id'); + @endphp + @if ($unassigned->isNotEmpty()) +
+ @csrf +

Add short links

+
+ @foreach ($unassigned as $link) + + @endforeach +
+ +
+ @endif +
+
diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index 01b7614..7d28802 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -10,6 +10,8 @@ 'icon' => ''], ['name' => 'My Links', 'route' => route('user.links.index'), 'active' => request()->routeIs('user.links.*'), 'icon' => ''], + ['name' => 'Campaigns', 'route' => route('link.campaigns.index'), 'active' => request()->routeIs('link.campaigns.*'), + 'icon' => ''], ['name' => 'Analytics', 'route' => route('link.analytics.index'), 'active' => request()->routeIs('link.analytics.*'), 'icon' => ''], ['name' => 'Custom Domains', 'route' => route('link.domains.index'), 'active' => request()->routeIs('link.domains.*'), diff --git a/routes/web.php b/routes/web.php index ebada98..35e35de 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,6 +3,7 @@ use App\Http\Controllers\Auth\SsoLoginController; use App\Http\Controllers\Link\AfiaController; use App\Http\Controllers\Link\AnalyticsController; +use App\Http\Controllers\Link\CampaignController; use App\Http\Controllers\Link\CustomDomainController; use App\Http\Controllers\Link\LinkController; use App\Http\Controllers\Link\OverviewController; @@ -36,6 +37,16 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/dashboard', [OverviewController::class, 'index'])->name('link.dashboard'); Route::get('/analytics', [AnalyticsController::class, 'index'])->name('link.analytics.index'); + + Route::get('/campaigns', [CampaignController::class, 'index'])->name('link.campaigns.index'); + Route::get('/campaigns/create', [CampaignController::class, 'create'])->name('link.campaigns.create'); + Route::post('/campaigns', [CampaignController::class, 'store'])->name('link.campaigns.store'); + Route::get('/campaigns/{campaign}', [CampaignController::class, 'show'])->name('link.campaigns.show'); + Route::patch('/campaigns/{campaign}', [CampaignController::class, 'update'])->name('link.campaigns.update'); + Route::delete('/campaigns/{campaign}', [CampaignController::class, 'destroy'])->name('link.campaigns.destroy'); + Route::post('/campaigns/{campaign}/attach', [CampaignController::class, 'attach'])->name('link.campaigns.attach'); + Route::delete('/campaigns/{campaign}/links/{link}', [CampaignController::class, 'detach'])->name('link.campaigns.detach'); + Route::get('/domains', [CustomDomainController::class, 'index'])->name('link.domains.index'); Route::post('/domains', [CustomDomainController::class, 'store'])->name('link.domains.store'); Route::post('/domains/{customDomain}/verify', [CustomDomainController::class, 'verify'])->name('link.domains.verify'); diff --git a/tests/Feature/CampaignTest.php b/tests/Feature/CampaignTest.php new file mode 100644 index 0000000..8bd9d03 --- /dev/null +++ b/tests/Feature/CampaignTest.php @@ -0,0 +1,124 @@ +withoutMiddleware(EnsurePlatformSession::class); + if (method_exists($this, 'withoutVite')) { + $this->withoutVite(); + } + } + + private function user(): User + { + return User::create([ + 'public_id' => (string) Str::uuid(), + 'name' => 'Owner', + 'email' => 'owner+'.uniqid().'@example.com', + ]); + } + + private function link(User $user, array $attrs = []): ShortLink + { + return ShortLink::create(array_merge([ + 'user_id' => $user->id, + 'slug' => strtolower(Str::random(8)), + 'source_app' => 'link', + 'source_kind' => 'redirect', + 'is_managed_here' => true, + 'destination_url' => 'https://example.com', + 'is_active' => true, + 'clicks_total' => 12, + 'unique_clicks_total' => 7, + ], $attrs)); + } + + public function test_owner_can_create_campaign_and_attach_links(): void + { + $user = $this->user(); + $link = $this->link($user); + + $this->actingAs($user) + ->post(route('link.campaigns.store'), [ + 'name' => 'Launch week', + 'description' => 'Homepage CTAs', + 'status' => Campaign::STATUS_ACTIVE, + ]) + ->assertRedirect(); + + $campaign = Campaign::first(); + $this->assertNotNull($campaign); + $this->assertSame('Launch week', $campaign->name); + + $this->actingAs($user) + ->post(route('link.campaigns.attach', $campaign), [ + 'short_link_ids' => [$link->id], + ]) + ->assertRedirect() + ->assertSessionHas('success'); + + $this->assertSame($campaign->id, $link->fresh()->campaign_id); + + $this->actingAs($user) + ->get(route('link.campaigns.show', $campaign)) + ->assertOk() + ->assertSee('Launch week') + ->assertSee($link->slug); + } + + public function test_owner_can_detach_and_delete_campaign(): void + { + $user = $this->user(); + $campaign = Campaign::create([ + 'user_id' => $user->id, + 'name' => 'Temp', + 'status' => Campaign::STATUS_ACTIVE, + ]); + $link = $this->link($user, ['campaign_id' => $campaign->id]); + + $this->actingAs($user) + ->delete(route('link.campaigns.detach', [$campaign, $link])) + ->assertRedirect(); + + $this->assertNull($link->fresh()->campaign_id); + + $link->update(['campaign_id' => $campaign->id]); + + $this->actingAs($user) + ->delete(route('link.campaigns.destroy', $campaign)) + ->assertRedirect(route('link.campaigns.index')); + + $this->assertDatabaseMissing('campaigns', ['id' => $campaign->id]); + $this->assertNull($link->fresh()->campaign_id); + } + + public function test_index_lists_campaigns(): void + { + $user = $this->user(); + Campaign::create([ + 'user_id' => $user->id, + 'name' => 'Visible campaign', + 'status' => Campaign::STATUS_ACTIVE, + ]); + + $this->actingAs($user) + ->get(route('link.campaigns.index')) + ->assertOk() + ->assertSee('Visible campaign') + ->assertSee('New campaign'); + } +}