Compare commits

..

1 Commits

Author SHA1 Message Date
845665c8be feat: per-subscriber Mailwizz sync button on admin list
Add POST route and form request to queue SyncSubscriberToMailwizz for one
subscriber when the page has Mailwizz configured. Include Dutch strings
and feature tests for auth and edge cases.

Made-with: Cursor
2026-04-05 13:45:30 +02:00
6 changed files with 225 additions and 14 deletions

View File

@@ -8,6 +8,8 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\DestroySubscriberRequest; 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\Http\Requests\Admin\SyncSubscriberMailwizzRequest;
use App\Jobs\SyncSubscriberToMailwizz;
use App\Models\PreregistrationPage; use App\Models\PreregistrationPage;
use App\Models\Subscriber; use App\Models\Subscriber;
use App\Services\CleanupSubscriberIntegrationsService; use App\Services\CleanupSubscriberIntegrationsService;
@@ -79,6 +81,26 @@ class SubscriberController extends Controller
)); ));
} }
public function syncSubscriberMailwizz(
SyncSubscriberMailwizzRequest $request,
PreregistrationPage $page,
Subscriber $subscriber
): RedirectResponse {
$page->loadMissing('mailwizzConfig');
if ($page->mailwizzConfig === null) {
return redirect()
->route('admin.pages.subscribers.index', $page)
->with('error', __('This page has no Mailwizz integration.'));
}
SyncSubscriberToMailwizz::dispatch($subscriber->fresh());
return redirect()
->route('admin.pages.subscribers.index', $page)
->with('status', __('Mailwizz sync has been queued for this subscriber.'));
}
public function export(IndexSubscriberRequest $request, PreregistrationPage $page): StreamedResponse public function export(IndexSubscriberRequest $request, PreregistrationPage $page): StreamedResponse
{ {
$search = $request->validated('search'); $search = $request->validated('search');

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 SyncSubscriberMailwizzRequest 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

@@ -24,6 +24,9 @@
"Subscriber removed.": "Abonnee verwijderd.", "Subscriber removed.": "Abonnee verwijderd.",
"Delete this subscriber? This cannot be undone.": "Deze abonnee verwijderen? Dit kan niet ongedaan worden gemaakt.", "Delete this subscriber? This cannot be undone.": "Deze abonnee verwijderen? Dit kan niet ongedaan worden gemaakt.",
"Remove": "Verwijderen", "Remove": "Verwijderen",
"Sync Mailwizz": "Mailwizz sync",
"Mailwizz sync has been queued for this subscriber.": "Mailwizz-synchronisatie is in de wachtrij gezet voor deze abonnee.",
"Queue a Mailwizz sync for this subscriber? The tag and coupon code will be sent when the queue worker runs.": "Mailwizz-synchronisatie voor deze abonnee in de wachtrij zetten? De tag en kortingscode worden verstuurd zodra de queue-worker draait.",
"Actions": "Acties", "Actions": "Acties",
"Fix background to viewport": "Achtergrond vastzetten op het scherm", "Fix background to viewport": "Achtergrond vastzetten op het scherm",
"When enabled, the background image and overlay stay fixed while visitors scroll long content.": "Als dit aan staat, blijven de achtergrondafbeelding en de overlay stilstaan terwijl bezoekers door lange inhoud scrollen." "When enabled, the background image and overlay stay fixed while visitors scroll long content.": "Als dit aan staat, blijven de achtergrondafbeelding en de overlay stilstaan terwijl bezoekers door lange inhoud scrollen."

View File

@@ -82,21 +82,39 @@
</td> </td>
<td class="whitespace-nowrap px-4 py-3 text-right"> <td class="whitespace-nowrap px-4 py-3 text-right">
@can('update', $page) @can('update', $page)
<form <div class="inline-flex flex-wrap items-center justify-end gap-1.5">
method="post" @if ($page->mailwizzConfig !== null)
action="{{ route('admin.pages.subscribers.destroy', [$page, $subscriber]) }}" <form
class="inline" method="post"
onsubmit="return confirm(@js(__('Delete this subscriber? This cannot be undone.')));" action="{{ route('admin.pages.subscribers.sync-mailwizz', [$page, $subscriber]) }}"
> class="inline"
@csrf onsubmit="return confirm(@js(__('Queue a Mailwizz sync for this subscriber? The tag and coupon code will be sent when the queue worker runs.')));"
@method('DELETE') >
<button @csrf
type="submit" <button
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" type="submit"
class="rounded-lg border border-indigo-200 bg-white px-2.5 py-1 text-xs font-semibold text-indigo-700 hover:bg-indigo-50"
>
{{ __('Sync Mailwizz') }}
</button>
</form>
@endif
<form
method="post"
action="{{ route('admin.pages.subscribers.destroy', [$page, $subscriber]) }}"
class="inline"
onsubmit="return confirm(@js(__('Delete this subscriber? This cannot be undone.')));"
> >
{{ __('Remove') }} @csrf
</button> @method('DELETE')
</form> <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>
</div>
@endcan @endcan
</td> </td>
</tr> </tr>

View File

@@ -38,6 +38,7 @@ Route::middleware(['auth', 'verified'])->prefix('admin')->name('admin.')->group(
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::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::post('pages/{page}/subscribers/{subscriber}/sync-mailwizz', [SubscriberController::class, 'syncSubscriberMailwizz'])->name('pages.subscribers.sync-mailwizz');
Route::get('pages/{page}/subscribers', [SubscriberController::class, 'index'])->name('pages.subscribers.index'); Route::get('pages/{page}/subscribers', [SubscriberController::class, 'index'])->name('pages.subscribers.index');
// Mailwizz configuration (nested under pages) // Mailwizz configuration (nested under pages)

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Feature; namespace Tests\Feature;
use App\Jobs\SyncSubscriberToMailwizz;
use App\Models\MailwizzConfig; use App\Models\MailwizzConfig;
use App\Models\PreregistrationPage; use App\Models\PreregistrationPage;
use App\Models\Subscriber; use App\Models\Subscriber;
@@ -12,6 +13,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request; use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Tests\TestCase; use Tests\TestCase;
@@ -80,6 +82,135 @@ class QueueUnsyncedMailwizzSubscribersTest extends TestCase
$response->assertForbidden(); $response->assertForbidden();
} }
public function test_owner_can_queue_single_subscriber_mailwizz_sync(): void
{
Queue::fake();
$user = User::factory()->create(['role' => 'user']);
$page = $this->makePageWithMailwizzForUser($user);
$subscriber = Subscriber::query()->create([
'preregistration_page_id' => $page->id,
'first_name' => 'One',
'last_name' => 'Off',
'email' => 'oneoff@example.com',
'synced_to_mailwizz' => true,
]);
$response = $this->actingAs($user)->post(
route('admin.pages.subscribers.sync-mailwizz', [$page, $subscriber])
);
$response->assertRedirect(route('admin.pages.subscribers.index', $page));
$response->assertSessionHas('status');
Queue::assertPushed(SyncSubscriberToMailwizz::class, function (SyncSubscriberToMailwizz $job) use ($subscriber): bool {
return $job->subscriberId === $subscriber->id;
});
}
public function test_other_user_cannot_queue_single_subscriber_mailwizz_sync(): void
{
Queue::fake();
$owner = User::factory()->create(['role' => 'user']);
$intruder = User::factory()->create(['role' => 'user']);
$page = $this->makePageWithMailwizzForUser($owner);
$subscriber = Subscriber::query()->create([
'preregistration_page_id' => $page->id,
'first_name' => 'A',
'last_name' => 'B',
'email' => 'ab@example.com',
]);
$response = $this->actingAs($intruder)->post(
route('admin.pages.subscribers.sync-mailwizz', [$page, $subscriber])
);
$response->assertForbidden();
Queue::assertNothingPushed();
}
public function test_single_subscriber_mailwizz_sync_redirects_with_error_when_page_has_no_mailwizz(): void
{
Queue::fake();
$user = User::factory()->create(['role' => 'user']);
$page = PreregistrationPage::query()->create([
'slug' => (string) Str::uuid(),
'user_id' => $user->id,
'title' => 'Fest',
'heading' => 'Join',
'intro_text' => null,
'thank_you_message' => null,
'expired_message' => null,
'ticketshop_url' => null,
'start_date' => now()->subHour(),
'end_date' => now()->addMonth(),
'phone_enabled' => false,
'is_active' => true,
]);
$subscriber = Subscriber::query()->create([
'preregistration_page_id' => $page->id,
'first_name' => 'A',
'last_name' => 'B',
'email' => 'nomw@example.com',
]);
$response = $this->actingAs($user)->post(
route('admin.pages.subscribers.sync-mailwizz', [$page, $subscriber])
);
$response->assertRedirect(route('admin.pages.subscribers.index', $page));
$response->assertSessionHas('error');
Queue::assertNothingPushed();
}
public function test_cannot_queue_single_subscriber_mailwizz_sync_with_mismatched_page(): void
{
Queue::fake();
$user = User::factory()->create(['role' => 'user']);
$pageA = $this->makePageWithMailwizzForUser($user);
$pageB = PreregistrationPage::query()->create([
'slug' => (string) Str::uuid(),
'user_id' => $user->id,
'title' => 'Other',
'heading' => 'Other',
'intro_text' => null,
'thank_you_message' => null,
'expired_message' => null,
'ticketshop_url' => null,
'start_date' => now()->subHour(),
'end_date' => now()->addMonth(),
'phone_enabled' => false,
'is_active' => true,
]);
MailwizzConfig::query()->create([
'preregistration_page_id' => $pageB->id,
'api_key' => 'fake-api-key',
'list_uid' => 'list-uid-2',
'list_name' => 'List B',
'field_email' => 'EMAIL',
'field_first_name' => 'FNAME',
'field_last_name' => 'LNAME',
'field_phone' => null,
'tag_field' => 'TAGS',
'tag_value' => 'b-source',
]);
$subscriber = Subscriber::query()->create([
'preregistration_page_id' => $pageB->id,
'first_name' => 'A',
'last_name' => 'B',
'email' => 'on-b@example.com',
]);
$response = $this->actingAs($user)->post(
route('admin.pages.subscribers.sync-mailwizz', [$pageA, $subscriber])
);
$response->assertForbidden();
Queue::assertNothingPushed();
}
private function makePageWithMailwizz(): PreregistrationPage private function makePageWithMailwizz(): PreregistrationPage
{ {
$user = User::factory()->create(['role' => 'user']); $user = User::factory()->create(['role' => 'user']);