feat: delete subscribers from page subscriber list

Adds DELETE route, form request authorization, admin UI with confirm, Dutch strings, and feature tests.

Made-with: Cursor
This commit is contained in:
2026-04-04 01:29:32 +02:00
parent 3c9b1d9810
commit ed85e5c537
6 changed files with 205 additions and 2 deletions

View File

@@ -5,9 +5,11 @@ declare(strict_types=1);
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\DestroySubscriberRequest;
use App\Http\Requests\Admin\IndexSubscriberRequest; use App\Http\Requests\Admin\IndexSubscriberRequest;
use App\Http\Requests\Admin\QueueMailwizzSyncRequest; use App\Http\Requests\Admin\QueueMailwizzSyncRequest;
use App\Models\PreregistrationPage; use App\Models\PreregistrationPage;
use App\Models\Subscriber;
use App\Services\DispatchUnsyncedMailwizzSyncJobsService; use App\Services\DispatchUnsyncedMailwizzSyncJobsService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\View\View; use Illuminate\View\View;
@@ -32,6 +34,15 @@ class SubscriberController extends Controller
return view('admin.subscribers.index', compact('page', 'subscribers', 'unsyncedMailwizzCount')); return view('admin.subscribers.index', compact('page', 'subscribers', 'unsyncedMailwizzCount'));
} }
public function destroy(DestroySubscriberRequest $request, PreregistrationPage $page, Subscriber $subscriber): RedirectResponse
{
$subscriber->delete();
return redirect()
->route('admin.pages.subscribers.index', $page)
->with('status', __('Subscriber removed.'));
}
public function queueMailwizzSync( public function queueMailwizzSync(
QueueMailwizzSyncRequest $request, QueueMailwizzSyncRequest $request,
PreregistrationPage $page, PreregistrationPage $page,

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Admin;
use App\Models\PreregistrationPage;
use App\Models\Subscriber;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class DestroySubscriberRequest extends FormRequest
{
public function authorize(): bool
{
$page = $this->route('page');
$subscriber = $this->route('subscriber');
if (! $page instanceof PreregistrationPage || ! $subscriber instanceof Subscriber) {
return false;
}
if ($subscriber->preregistration_page_id !== $page->id) {
return false;
}
return $this->user()?->can('update', $page) ?? false;
}
/**
* @return array<string, array<int, ValidationRule|string>>
*/
public function rules(): array
{
return [];
}
}

View File

@@ -18,5 +18,9 @@
"Thank you for registering!": "Bedankt voor je registratie!", "Thank you for registering!": "Bedankt voor je registratie!",
"You are already registered for this event.": "Je bent al geregistreerd voor dit evenement.", "You are already registered for this event.": "Je bent al geregistreerd voor dit evenement.",
"Please enter a valid email address.": "Voer een geldig e-mailadres in.", "Please enter a valid email address.": "Voer een geldig e-mailadres in.",
"Please enter a valid phone number (815 digits).": "Voer een geldig telefoonnummer in (8 tot 15 cijfers)." "Please enter a valid phone number (815 digits).": "Voer een geldig telefoonnummer in (8 tot 15 cijfers).",
"Subscriber removed.": "Abonnee verwijderd.",
"Delete this subscriber? This cannot be undone.": "Deze abonnee verwijderen? Dit kan niet ongedaan worden gemaakt.",
"Remove": "Verwijderen",
"Actions": "Acties"
} }

View File

@@ -54,6 +54,7 @@
@endif @endif
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Registered at') }}</th> <th class="px-4 py-3 font-semibold text-slate-700">{{ __('Registered at') }}</th>
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Mailwizz') }}</th> <th class="px-4 py-3 font-semibold text-slate-700">{{ __('Mailwizz') }}</th>
<th class="w-px whitespace-nowrap px-4 py-3 font-semibold text-slate-700">{{ __('Actions') }}</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-100"> <tbody class="divide-y divide-slate-100">
@@ -77,10 +78,29 @@
</span> </span>
@endif @endif
</td> </td>
<td class="whitespace-nowrap px-4 py-3 text-right">
@can('update', $page)
<form
method="post"
action="{{ route('admin.pages.subscribers.destroy', [$page, $subscriber]) }}"
class="inline"
onsubmit="return confirm(@js(__('Delete this subscriber? This cannot be undone.')));"
>
@csrf
@method('DELETE')
<button
type="submit"
class="rounded-lg border border-red-200 bg-white px-2.5 py-1 text-xs font-semibold text-red-700 hover:bg-red-50"
>
{{ __('Remove') }}
</button>
</form>
@endcan
</td>
</tr> </tr>
@empty @empty
<tr> <tr>
<td colspan="{{ $page->isPhoneFieldEnabledForSubscribers() ? 6 : 5 }}" class="px-4 py-12 text-center text-slate-500"> <td colspan="{{ $page->isPhoneFieldEnabledForSubscribers() ? 7 : 6 }}" class="px-4 py-12 text-center text-slate-500">
{{ __('No subscribers match your criteria.') }} {{ __('No subscribers match your criteria.') }}
</td> </td>
</tr> </tr>

View File

@@ -33,6 +33,7 @@ Route::middleware(['auth', 'verified'])->prefix('admin')->name('admin.')->group(
// Subscribers (nested under pages) — export before index so the path is unambiguous // Subscribers (nested under pages) — export before index so the path is unambiguous
Route::get('pages/{page}/subscribers/export', [SubscriberController::class, 'export'])->name('pages.subscribers.export'); Route::get('pages/{page}/subscribers/export', [SubscriberController::class, 'export'])->name('pages.subscribers.export');
Route::delete('pages/{page}/subscribers/{subscriber}', [SubscriberController::class, 'destroy'])->name('pages.subscribers.destroy');
Route::post('pages/{page}/subscribers/queue-mailwizz-sync', [SubscriberController::class, 'queueMailwizzSync'])->name('pages.subscribers.queue-mailwizz-sync'); Route::post('pages/{page}/subscribers/queue-mailwizz-sync', [SubscriberController::class, 'queueMailwizzSync'])->name('pages.subscribers.queue-mailwizz-sync');
Route::get('pages/{page}/subscribers', [SubscriberController::class, 'index'])->name('pages.subscribers.index'); Route::get('pages/{page}/subscribers', [SubscriberController::class, 'index'])->name('pages.subscribers.index');

View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\PreregistrationPage;
use App\Models\Subscriber;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
class DestroySubscriberTest extends TestCase
{
use RefreshDatabase;
public function test_page_owner_can_delete_subscriber_on_that_page(): void
{
$user = User::factory()->create(['role' => 'user']);
$page = PreregistrationPage::query()->create([
'slug' => (string) Str::uuid(),
'user_id' => $user->id,
'title' => 'Fest',
'heading' => 'Fest',
'intro_text' => null,
'thank_you_message' => null,
'expired_message' => null,
'ticketshop_url' => null,
'start_date' => now()->subDay(),
'end_date' => now()->addMonth(),
'phone_enabled' => false,
'background_image' => null,
'logo_image' => null,
'is_active' => true,
]);
$subscriber = Subscriber::query()->create([
'preregistration_page_id' => $page->id,
'first_name' => 'Ada',
'last_name' => 'Lovelace',
'email' => 'ada@example.com',
]);
$response = $this->actingAs($user)->delete(route('admin.pages.subscribers.destroy', [$page, $subscriber]));
$response->assertRedirect(route('admin.pages.subscribers.index', $page));
$response->assertSessionHas('status');
$this->assertDatabaseMissing('subscribers', ['id' => $subscriber->id]);
}
public function test_other_user_cannot_delete_subscriber(): void
{
$owner = User::factory()->create(['role' => 'user']);
$intruder = User::factory()->create(['role' => 'user']);
$page = PreregistrationPage::query()->create([
'slug' => (string) Str::uuid(),
'user_id' => $owner->id,
'title' => 'Fest',
'heading' => 'Fest',
'intro_text' => null,
'thank_you_message' => null,
'expired_message' => null,
'ticketshop_url' => null,
'start_date' => now()->subDay(),
'end_date' => now()->addMonth(),
'phone_enabled' => false,
'background_image' => null,
'logo_image' => null,
'is_active' => true,
]);
$subscriber = Subscriber::query()->create([
'preregistration_page_id' => $page->id,
'first_name' => 'A',
'last_name' => 'B',
'email' => 'x@example.com',
]);
$response = $this->actingAs($intruder)->delete(route('admin.pages.subscribers.destroy', [$page, $subscriber]));
$response->assertForbidden();
$this->assertDatabaseHas('subscribers', ['id' => $subscriber->id]);
}
public function test_cannot_delete_subscriber_using_wrong_page_in_url(): void
{
$user = User::factory()->create(['role' => 'user']);
$pageA = PreregistrationPage::query()->create([
'slug' => (string) Str::uuid(),
'user_id' => $user->id,
'title' => 'A',
'heading' => 'A',
'intro_text' => null,
'thank_you_message' => null,
'expired_message' => null,
'ticketshop_url' => null,
'start_date' => now()->subDay(),
'end_date' => now()->addMonth(),
'phone_enabled' => false,
'background_image' => null,
'logo_image' => null,
'is_active' => true,
]);
$pageB = PreregistrationPage::query()->create([
'slug' => (string) Str::uuid(),
'user_id' => $user->id,
'title' => 'B',
'heading' => 'B',
'intro_text' => null,
'thank_you_message' => null,
'expired_message' => null,
'ticketshop_url' => null,
'start_date' => now()->subDay(),
'end_date' => now()->addMonth(),
'phone_enabled' => false,
'background_image' => null,
'logo_image' => null,
'is_active' => true,
]);
$subscriber = Subscriber::query()->create([
'preregistration_page_id' => $pageB->id,
'first_name' => 'A',
'last_name' => 'B',
'email' => 'y@example.com',
]);
$response = $this->actingAs($user)->delete(route('admin.pages.subscribers.destroy', [$pageA, $subscriber]));
$response->assertForbidden();
$this->assertDatabaseHas('subscribers', ['id' => $subscriber->id]);
}
}