diff --git a/app/Console/Commands/QueueUnsyncedMailwizzSubscribersCommand.php b/app/Console/Commands/QueueUnsyncedMailwizzSubscribersCommand.php new file mode 100644 index 0000000..684fc85 --- /dev/null +++ b/app/Console/Commands/QueueUnsyncedMailwizzSubscribersCommand.php @@ -0,0 +1,46 @@ +option('page'); + $pageId = null; + if ($pageOption !== null && $pageOption !== '') { + if (! is_numeric($pageOption)) { + $this->error(__('The --page option must be a numeric ID.')); + + return self::FAILURE; + } + $pageId = (int) $pageOption; + } + + $eligible = $dispatcher->eligibleSubscribersQuery() + ->when($pageId !== null, fn ($q) => $q->where('preregistration_page_id', $pageId)) + ->count(); + + if ($this->option('dry-run')) { + $this->info(__(':count subscriber(s) would be processed.', ['count' => $eligible])); + + return self::SUCCESS; + } + + $processed = $dispatcher->dispatch($pageId); + $this->info(__('Queued sync for :count subscriber(s). Duplicate jobs for the same subscriber are skipped while one is already pending.', ['count' => $processed])); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Admin/MailwizzApiController.php b/app/Http/Controllers/Admin/MailwizzApiController.php index a208399..8c6772b 100644 --- a/app/Http/Controllers/Admin/MailwizzApiController.php +++ b/app/Http/Controllers/Admin/MailwizzApiController.php @@ -5,9 +5,10 @@ declare(strict_types=1); namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; +use App\Services\MailwizzService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Http; +use RuntimeException; class MailwizzApiController extends Controller { @@ -15,22 +16,15 @@ class MailwizzApiController extends Controller { $request->validate(['api_key' => ['required', 'string']]); - $response = Http::withHeaders([ - 'X-Api-Key' => $request->string('api_key')->toString(), - ])->get('https://www.mailwizz.nl/api/lists'); + try { + $service = new MailwizzService($request->string('api_key')->toString()); - if ($response->failed()) { - return response()->json(['message' => __('Invalid API key or connection failed.')], 422); + return response()->json([ + 'lists' => $service->getLists(), + ]); + } catch (RuntimeException $e) { + return response()->json(['message' => $e->getMessage()], 422); } - - $json = $response->json(); - if (is_array($json) && ($json['status'] ?? null) === 'error') { - return response()->json(['message' => __('Invalid API key or connection failed.')], 422); - } - - return response()->json([ - 'lists' => $this->normalizeListsPayload(is_array($json) ? $json : []), - ]); } public function fields(Request $request): JsonResponse @@ -40,116 +34,14 @@ class MailwizzApiController extends Controller 'list_uid' => ['required', 'string'], ]); - $listUid = $request->string('list_uid')->toString(); - $response = Http::withHeaders([ - 'X-Api-Key' => $request->string('api_key')->toString(), - ])->get("https://www.mailwizz.nl/api/lists/{$listUid}/fields"); + try { + $service = new MailwizzService($request->string('api_key')->toString()); - if ($response->failed()) { - return response()->json(['message' => __('Failed to fetch list fields.')], 422); + return response()->json([ + 'fields' => $service->getListFields($request->string('list_uid')->toString()), + ]); + } catch (RuntimeException $e) { + return response()->json(['message' => $e->getMessage()], 422); } - - $json = $response->json(); - if (is_array($json) && ($json['status'] ?? null) === 'error') { - return response()->json(['message' => __('Failed to fetch list fields.')], 422); - } - - return response()->json([ - 'fields' => $this->normalizeFieldsPayload(is_array($json) ? $json : []), - ]); - } - - /** - * @return list - */ - private function normalizeListsPayload(array $json): array - { - $out = []; - $records = data_get($json, 'data.records'); - if (! is_array($records)) { - return $out; - } - - foreach ($records as $row) { - if (! is_array($row)) { - continue; - } - $uid = data_get($row, 'general.list_uid') ?? data_get($row, 'list_uid'); - $name = data_get($row, 'general.name') ?? data_get($row, 'name'); - if (is_string($uid) && $uid !== '' && is_string($name) && $name !== '') { - $out[] = ['list_uid' => $uid, 'name' => $name]; - } - } - - return $out; - } - - /** - * @return list}> - */ - private function normalizeFieldsPayload(array $json): array - { - $out = []; - $records = data_get($json, 'data.records'); - if (! is_array($records)) { - return $out; - } - - foreach ($records as $row) { - if (! is_array($row)) { - continue; - } - $tag = data_get($row, 'tag'); - $label = data_get($row, 'label'); - $typeId = data_get($row, 'type.identifier'); - if (! is_string($tag) || $tag === '' || ! is_string($label)) { - continue; - } - $typeIdentifier = is_string($typeId) ? $typeId : ''; - $rawOptions = data_get($row, 'options'); - $options = $this->normalizeFieldOptions($rawOptions); - - $out[] = [ - 'tag' => $tag, - 'label' => $label, - 'type_identifier' => $typeIdentifier, - 'options' => $options, - ]; - } - - return $out; - } - - /** - * @return array - */ - private function normalizeFieldOptions(mixed $rawOptions): array - { - if ($rawOptions === null || $rawOptions === '') { - return []; - } - - if (is_string($rawOptions)) { - $decoded = json_decode($rawOptions, true); - if (is_array($decoded)) { - $rawOptions = $decoded; - } else { - return []; - } - } - - if (! is_array($rawOptions)) { - return []; - } - - $out = []; - foreach ($rawOptions as $key => $value) { - if (is_string($key) || is_int($key)) { - $k = (string) $key; - $out[$k] = is_scalar($value) ? (string) $value : ''; - } - } - - return $out; } } diff --git a/app/Http/Controllers/Admin/SubscriberController.php b/app/Http/Controllers/Admin/SubscriberController.php index 6009655..f429142 100644 --- a/app/Http/Controllers/Admin/SubscriberController.php +++ b/app/Http/Controllers/Admin/SubscriberController.php @@ -6,7 +6,10 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; use App\Http\Requests\Admin\IndexSubscriberRequest; +use App\Http\Requests\Admin\QueueMailwizzSyncRequest; use App\Models\PreregistrationPage; +use App\Services\DispatchUnsyncedMailwizzSyncJobsService; +use Illuminate\Http\RedirectResponse; use Illuminate\View\View; use Symfony\Component\HttpFoundation\StreamedResponse; @@ -21,7 +24,42 @@ class SubscriberController extends Controller ->paginate(25) ->withQueryString(); - return view('admin.subscribers.index', compact('page', 'subscribers')); + $page->loadMissing('mailwizzConfig'); + $unsyncedMailwizzCount = $page->mailwizzConfig !== null + ? (int) $page->subscribers()->where('synced_to_mailwizz', false)->count() + : 0; + + return view('admin.subscribers.index', compact('page', 'subscribers', 'unsyncedMailwizzCount')); + } + + public function queueMailwizzSync( + QueueMailwizzSyncRequest $request, + PreregistrationPage $page, + DispatchUnsyncedMailwizzSyncJobsService $dispatcher + ): RedirectResponse { + $page->loadMissing('mailwizzConfig'); + + if ($page->mailwizzConfig === null) { + return redirect() + ->route('admin.pages.subscribers.index', $page) + ->with('error', __('This page has no Mailwizz integration.')); + } + + $count = $dispatcher->dispatch($page->id); + + if ($count === 0) { + return redirect() + ->route('admin.pages.subscribers.index', $page) + ->with('status', __('There are no unsynced subscribers to queue for this page.')); + } + + return redirect() + ->route('admin.pages.subscribers.index', $page) + ->with('status', trans_choice( + 'Queued Mailwizz sync for :count subscriber.|Queued Mailwizz sync for :count subscribers.', + $count, + ['count' => $count] + )); } public function export(IndexSubscriberRequest $request, PreregistrationPage $page): StreamedResponse diff --git a/app/Http/Controllers/PublicPageController.php b/app/Http/Controllers/PublicPageController.php index 9cba519..27c9d23 100644 --- a/app/Http/Controllers/PublicPageController.php +++ b/app/Http/Controllers/PublicPageController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Controllers; use App\Http\Requests\SubscribePublicPageRequest; +use App\Jobs\SyncSubscriberToMailwizz; use App\Models\PreregistrationPage; use Illuminate\Http\JsonResponse; use Illuminate\View\View; @@ -33,11 +34,10 @@ class PublicPageController extends Controller ], 422); } - $publicPage->subscribers()->create($validated); + $subscriber = $publicPage->subscribers()->create($validated); - // Mailwizz sync will be wired up in Phase 4 - if ($publicPage->mailwizzConfig) { - // SyncSubscriberToMailwizz::dispatch($subscriber); + if ($publicPage->mailwizzConfig !== null) { + SyncSubscriberToMailwizz::dispatch($subscriber); } return response()->json([ diff --git a/app/Http/Requests/Admin/QueueMailwizzSyncRequest.php b/app/Http/Requests/Admin/QueueMailwizzSyncRequest.php new file mode 100644 index 0000000..511695d --- /dev/null +++ b/app/Http/Requests/Admin/QueueMailwizzSyncRequest.php @@ -0,0 +1,29 @@ +route('page'); + if (! $page instanceof PreregistrationPage) { + return false; + } + + return $this->user()?->can('update', $page) ?? false; + } + + /** + * @return array> + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Jobs/SyncSubscriberToMailwizz.php b/app/Jobs/SyncSubscriberToMailwizz.php new file mode 100644 index 0000000..fb6127a --- /dev/null +++ b/app/Jobs/SyncSubscriberToMailwizz.php @@ -0,0 +1,220 @@ + + */ + public array $backoff = [10, 30, 60]; + + public function __construct(public Subscriber $subscriber) + { + $this->onQueue('mailwizz'); + } + + public function uniqueId(): string + { + return (string) $this->subscriber->getKey(); + } + + public function handle(): void + { + $subscriber = Subscriber::query() + ->with(['preregistrationPage.mailwizzConfig']) + ->find($this->subscriber->id); + + if ($subscriber === null) { + return; + } + + $page = $subscriber->preregistrationPage; + $config = $page?->mailwizzConfig; + + if ($page === null || $config === null) { + return; + } + + if (! $this->configIsComplete($config, $page->phone_enabled)) { + Log::warning('SyncSubscriberToMailwizz: incomplete Mailwizz config', [ + 'subscriber_id' => $subscriber->id, + 'page_id' => $page->id, + ]); + $this->fail(new RuntimeException('Incomplete Mailwizz configuration.')); + + return; + } + + $apiKey = $config->api_key; + if (! is_string($apiKey) || $apiKey === '') { + Log::warning('SyncSubscriberToMailwizz: missing API key', ['subscriber_id' => $subscriber->id]); + $this->fail(new RuntimeException('Mailwizz API key is missing.')); + + return; + } + + $service = new MailwizzService($apiKey); + $listUid = $config->list_uid; + + $search = $service->searchSubscriber($listUid, $subscriber->email); + + if ($search === null) { + $this->createInMailwizz($service, $subscriber, $config, $listUid); + } else { + $this->updateInMailwizz($service, $subscriber, $config, $listUid, $search['subscriber_uid']); + } + + $subscriber->update([ + 'synced_to_mailwizz' => true, + 'synced_at' => now(), + ]); + } + + public function failed(?Throwable $exception): void + { + Log::error('SyncSubscriberToMailwizz failed', [ + 'subscriber_id' => $this->subscriber->id, + 'message' => $exception?->getMessage(), + ]); + } + + private function configIsComplete(MailwizzConfig $config, bool $phoneEnabled): bool + { + if ($config->list_uid === '' || $config->field_email === '' || $config->field_first_name === '' || $config->field_last_name === '') { + return false; + } + + if ($phoneEnabled && ($config->field_phone === null || $config->field_phone === '')) { + return false; + } + + if ($config->tag_field === null || $config->tag_field === '' || $config->tag_value === null || $config->tag_value === '') { + return false; + } + + return true; + } + + private function buildBasePayload(Subscriber $subscriber, MailwizzConfig $config, bool $phoneEnabled): array + { + $data = [ + $config->field_email => $subscriber->email, + $config->field_first_name => $subscriber->first_name, + $config->field_last_name => $subscriber->last_name, + ]; + + if ($phoneEnabled && $config->field_phone !== null && $config->field_phone !== '') { + $phone = $subscriber->phone; + if ($phone !== null && $phone !== '') { + $data[$config->field_phone] = $phone; + } + } + + return $data; + } + + private function createInMailwizz( + MailwizzService $service, + Subscriber $subscriber, + MailwizzConfig $config, + string $listUid + ): void { + $page = $subscriber->preregistrationPage; + $data = $this->buildBasePayload($subscriber, $config, (bool) $page->phone_enabled); + $tagField = $config->tag_field; + $tagValue = $config->tag_value; + if ($tagField !== null && $tagField !== '' && $tagValue !== null && $tagValue !== '') { + $data[$tagField] = [$tagValue]; + } + + $service->createSubscriber($listUid, $data); + } + + private function updateInMailwizz( + MailwizzService $service, + Subscriber $subscriber, + MailwizzConfig $config, + string $listUid, + string $subscriberUid + ): void { + $page = $subscriber->preregistrationPage; + $data = $this->buildBasePayload($subscriber, $config, (bool) $page->phone_enabled); + + $tagField = $config->tag_field; + $tagValue = $config->tag_value; + if ($tagField !== null && $tagField !== '' && $tagValue !== null && $tagValue !== '') { + $full = $service->getSubscriber($listUid, $subscriberUid); + if ($full === null) { + throw new RuntimeException('Mailwizz getSubscriber returned an empty payload.'); + } + $existingCsv = $this->extractTagCsvFromResponse($full, $tagField); + $merged = $this->mergeCheckboxlistTags($existingCsv, $tagValue); + $data[$tagField] = $merged; + } + + $service->updateSubscriber($listUid, $subscriberUid, $data); + } + + /** + * @param array $apiResponse + */ + private function extractTagCsvFromResponse(array $apiResponse, string $tagField): string + { + $record = data_get($apiResponse, 'data.record'); + if (! is_array($record)) { + $record = data_get($apiResponse, 'data'); + } + if (! is_array($record)) { + return ''; + } + + $raw = $record[$tagField] ?? data_get($record, "fields.{$tagField}"); + + if (is_array($raw)) { + return implode(',', array_map(static fn (mixed $v): string => is_scalar($v) ? (string) $v : '', $raw)); + } + + return is_string($raw) ? $raw : ''; + } + + /** + * @return list + */ + private function mergeCheckboxlistTags(string $existingCsv, string $newValue): array + { + $parts = array_filter(array_map('trim', explode(',', $existingCsv)), static fn (string $s): bool => $s !== ''); + if (! in_array($newValue, $parts, true)) { + $parts[] = $newValue; + } + + return array_values($parts); + } +} diff --git a/app/Services/DispatchUnsyncedMailwizzSyncJobsService.php b/app/Services/DispatchUnsyncedMailwizzSyncJobsService.php new file mode 100644 index 0000000..d4f9cf8 --- /dev/null +++ b/app/Services/DispatchUnsyncedMailwizzSyncJobsService.php @@ -0,0 +1,46 @@ +eligibleSubscribersQuery(); + if ($preregistrationPageId !== null) { + $query->where('preregistration_page_id', $preregistrationPageId); + } + + $count = 0; + $query->chunkById(200, function ($subscribers) use (&$count): void { + foreach ($subscribers as $subscriber) { + SyncSubscriberToMailwizz::dispatch($subscriber); + $count++; + } + }); + + return $count; + } + + /** + * @return Builder + */ + public function eligibleSubscribersQuery(): Builder + { + return Subscriber::query() + ->where('synced_to_mailwizz', false) + ->whereHas('preregistrationPage.mailwizzConfig'); + } +} diff --git a/app/Services/MailwizzService.php b/app/Services/MailwizzService.php new file mode 100644 index 0000000..d35ea1d --- /dev/null +++ b/app/Services/MailwizzService.php @@ -0,0 +1,311 @@ + $this->apiKey])->asForm(); + } + + private function logInteraction(string $action, string $url, Response $response): void + { + Log::debug('Mailwizz API', [ + 'action' => $action, + 'url' => $url, + 'http_status' => $response->status(), + 'response_preview' => mb_substr($response->body(), 0, 2000), + ]); + } + + /** + * @return list + */ + public function getLists(): array + { + $url = "{$this->baseUrl}/lists"; + $response = $this->client()->get($url); + $this->logInteraction('getLists', $url, $response); + + if ($response->failed()) { + throw new RuntimeException(__('Invalid API key or connection failed.')); + } + + $json = $response->json(); + if (! is_array($json)) { + throw new RuntimeException(__('Invalid API key or connection failed.')); + } + + if (($json['status'] ?? null) === 'error') { + throw new RuntimeException(__('Invalid API key or connection failed.')); + } + + return $this->normalizeListsPayload($json); + } + + /** + * @return list}> + */ + public function getListFields(string $listUid): array + { + $url = "{$this->baseUrl}/lists/{$listUid}/fields"; + $response = $this->client()->get($url); + $this->logInteraction('getListFields', $url, $response); + + if ($response->failed()) { + throw new RuntimeException(__('Failed to fetch list fields.')); + } + + $json = $response->json(); + if (! is_array($json)) { + throw new RuntimeException(__('Failed to fetch list fields.')); + } + + if (($json['status'] ?? null) === 'error') { + throw new RuntimeException(__('Failed to fetch list fields.')); + } + + return $this->normalizeFieldsPayload($json); + } + + /** + * Search by email. Mailwizz uses query param EMAIL regardless of list field mapping. + * + * @return array{subscriber_uid: string}|null + */ + public function searchSubscriber(string $listUid, string $email): ?array + { + $url = "{$this->baseUrl}/lists/{$listUid}/subscribers/search-by-email"; + $response = $this->client()->get($url, [ + 'EMAIL' => $email, + ]); + $this->logInteraction('searchSubscriber', $url, $response); + + if ($response->failed()) { + if ($response->status() === 404) { + return null; + } + + throw new RuntimeException(__('Mailwizz subscriber search failed.')); + } + + $json = $response->json(); + if (! is_array($json)) { + return null; + } + + if (($json['status'] ?? null) !== 'success') { + return null; + } + + $uid = data_get($json, 'data.subscriber_uid') + ?? data_get($json, 'data.record.subscriber_uid'); + + if (! is_string($uid) || $uid === '') { + return null; + } + + return ['subscriber_uid' => $uid]; + } + + /** + * Full subscriber payload from Mailwizz (structure varies; callers inspect data.record). + * + * @return array|null + */ + public function getSubscriber(string $listUid, string $subscriberUid): ?array + { + $url = "{$this->baseUrl}/lists/{$listUid}/subscribers/{$subscriberUid}"; + $response = $this->client()->get($url); + $this->logInteraction('getSubscriber', $url, $response); + + if ($response->failed()) { + throw new RuntimeException(__('Mailwizz could not load subscriber.')); + } + + $json = $response->json(); + if (! is_array($json) || ($json['status'] ?? null) !== 'success') { + return null; + } + + return $json; + } + + /** + * @param array $data Form fields; checkboxlist values as string[] per field tag + * @return array + */ + public function createSubscriber(string $listUid, array $data): array + { + $url = "{$this->baseUrl}/lists/{$listUid}/subscribers"; + $response = $this->client()->post($url, $this->encodeFormPayload($data)); + $this->logInteraction('createSubscriber', $url, $response); + + if ($response->failed()) { + throw new RuntimeException(__('Mailwizz rejected subscriber creation.')); + } + + $json = $response->json(); + if (! is_array($json) || ($json['status'] ?? null) !== 'success') { + $msg = is_array($json) ? (string) ($json['error'] ?? $json['message'] ?? '') : ''; + + throw new RuntimeException($msg !== '' ? $msg : __('Mailwizz rejected subscriber creation.')); + } + + return $json; + } + + /** + * @param array $data + * @return array + */ + public function updateSubscriber(string $listUid, string $subscriberUid, array $data): array + { + $url = "{$this->baseUrl}/lists/{$listUid}/subscribers/{$subscriberUid}"; + $response = $this->client()->put($url, $this->encodeFormPayload($data)); + $this->logInteraction('updateSubscriber', $url, $response); + + if ($response->failed()) { + throw new RuntimeException(__('Mailwizz rejected subscriber update.')); + } + + $json = $response->json(); + if (! is_array($json) || ($json['status'] ?? null) !== 'success') { + $msg = is_array($json) ? (string) ($json['error'] ?? $json['message'] ?? '') : ''; + + throw new RuntimeException($msg !== '' ? $msg : __('Mailwizz rejected subscriber update.')); + } + + return $json; + } + + /** + * @param array $data + * @return array + */ + private function encodeFormPayload(array $data): array + { + $out = []; + foreach ($data as $key => $value) { + if (is_array($value)) { + $out[$key] = $value; + + continue; + } + if ($value === null) { + continue; + } + $out[$key] = $value; + } + + return $out; + } + + /** + * @return list + */ + private function normalizeListsPayload(array $json): array + { + $out = []; + $records = data_get($json, 'data.records'); + if (! is_array($records)) { + return $out; + } + + foreach ($records as $row) { + if (! is_array($row)) { + continue; + } + $uid = data_get($row, 'general.list_uid') ?? data_get($row, 'list_uid'); + $name = data_get($row, 'general.name') ?? data_get($row, 'name'); + if (is_string($uid) && $uid !== '' && is_string($name) && $name !== '') { + $out[] = ['list_uid' => $uid, 'name' => $name]; + } + } + + return $out; + } + + /** + * @return list}> + */ + private function normalizeFieldsPayload(array $json): array + { + $out = []; + $records = data_get($json, 'data.records'); + if (! is_array($records)) { + return $out; + } + + foreach ($records as $row) { + if (! is_array($row)) { + continue; + } + $tag = data_get($row, 'tag'); + $label = data_get($row, 'label'); + $typeId = data_get($row, 'type.identifier'); + if (! is_string($tag) || $tag === '' || ! is_string($label)) { + continue; + } + $typeIdentifier = is_string($typeId) ? $typeId : ''; + $rawOptions = data_get($row, 'options'); + $options = $this->normalizeFieldOptions($rawOptions); + + $out[] = [ + 'tag' => $tag, + 'label' => $label, + 'type_identifier' => $typeIdentifier, + 'options' => $options, + ]; + } + + return $out; + } + + /** + * @return array + */ + private function normalizeFieldOptions(mixed $rawOptions): array + { + if ($rawOptions === null || $rawOptions === '') { + return []; + } + + if (is_string($rawOptions)) { + $decoded = json_decode($rawOptions, true); + if (is_array($decoded)) { + $rawOptions = $decoded; + } else { + return []; + } + } + + if (! is_array($rawOptions)) { + return []; + } + + $out = []; + foreach ($rawOptions as $key => $value) { + if (is_string($key) || is_int($key)) { + $k = (string) $key; + $out[$k] = is_scalar($value) ? (string) $value : ''; + } + } + + return $out; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index bfcc1ad..bfb1b86 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -13,6 +13,9 @@ return Application::configure(basePath: dirname(__DIR__)) commands: __DIR__.'/../routes/console.php', health: '/up', ) + ->withCommands([ + __DIR__.'/../app/Console/Commands', + ]) ->withMiddleware(function (Middleware $middleware): void { $middleware->alias([ 'role' => CheckRole::class, diff --git a/resources/views/admin/subscribers/index.blade.php b/resources/views/admin/subscribers/index.blade.php index 431a8f5..d205a31 100644 --- a/resources/views/admin/subscribers/index.blade.php +++ b/resources/views/admin/subscribers/index.blade.php @@ -24,10 +24,21 @@

{{ $message }}

@enderror - - {{ __('Export CSV') }} - +
+ @if ($unsyncedMailwizzCount > 0) +
+ @csrf + +
+ @endif + + {{ __('Export CSV') }} + +
diff --git a/routes/web.php b/routes/web.php index 17e7eb5..ae6fece 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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 Route::get('pages/{page}/subscribers/export', [SubscriberController::class, 'export'])->name('pages.subscribers.export'); + 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'); // Mailwizz configuration (nested under pages) diff --git a/tests/Feature/QueueUnsyncedMailwizzSubscribersTest.php b/tests/Feature/QueueUnsyncedMailwizzSubscribersTest.php new file mode 100644 index 0000000..b7df7e7 --- /dev/null +++ b/tests/Feature/QueueUnsyncedMailwizzSubscribersTest.php @@ -0,0 +1,122 @@ +makePageWithMailwizz(); + Subscriber::query()->create([ + 'preregistration_page_id' => $page->id, + 'first_name' => 'A', + 'last_name' => 'B', + 'email' => 'a@example.com', + 'synced_to_mailwizz' => false, + ]); + + $exit = Artisan::call('mailwizz:queue-unsynced', ['--dry-run' => true]); + $this->assertSame(0, $exit); + $this->assertStringContainsString('1', Artisan::output()); + } + + public function test_owner_can_queue_mailwizz_sync_from_subscribers_index(): void + { + Http::fake(function (Request $request) { + $url = $request->url(); + if (str_contains($url, 'search-by-email')) { + return Http::response(['status' => 'error']); + } + if ($request->method() === 'POST' && preg_match('#/lists/[^/]+/subscribers$#', $url) === 1) { + return Http::response(['status' => 'success']); + } + + return Http::response(['status' => 'error'], 500); + }); + + $user = User::factory()->create(['role' => 'user']); + $page = $this->makePageWithMailwizzForUser($user); + Subscriber::query()->create([ + 'preregistration_page_id' => $page->id, + 'first_name' => 'A', + 'last_name' => 'B', + 'email' => 'syncme@example.com', + 'synced_to_mailwizz' => false, + ]); + + $response = $this->actingAs($user)->post(route('admin.pages.subscribers.queue-mailwizz-sync', $page)); + + $response->assertRedirect(route('admin.pages.subscribers.index', $page)); + $response->assertSessionHas('status'); + $this->assertDatabaseHas('subscribers', [ + 'email' => 'syncme@example.com', + 'synced_to_mailwizz' => true, + ]); + } + + public function test_other_user_cannot_queue_mailwizz_sync(): void + { + $owner = User::factory()->create(['role' => 'user']); + $intruder = User::factory()->create(['role' => 'user']); + $page = $this->makePageWithMailwizzForUser($owner); + + $response = $this->actingAs($intruder)->post(route('admin.pages.subscribers.queue-mailwizz-sync', $page)); + + $response->assertForbidden(); + } + + private function makePageWithMailwizz(): PreregistrationPage + { + $user = User::factory()->create(['role' => 'user']); + + return $this->makePageWithMailwizzForUser($user); + } + + private function makePageWithMailwizzForUser(User $user): PreregistrationPage + { + $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, + ]); + + MailwizzConfig::query()->create([ + 'preregistration_page_id' => $page->id, + 'api_key' => 'fake-api-key', + 'list_uid' => 'list-uid-1', + 'list_name' => 'Main list', + 'field_email' => 'EMAIL', + 'field_first_name' => 'FNAME', + 'field_last_name' => 'LNAME', + 'field_phone' => null, + 'tag_field' => 'TAGS', + 'tag_value' => 'preregister-source', + ]); + + return $page; + } +} diff --git a/tests/Feature/SyncSubscriberToMailwizzTest.php b/tests/Feature/SyncSubscriberToMailwizzTest.php new file mode 100644 index 0000000..7d27b04 --- /dev/null +++ b/tests/Feature/SyncSubscriberToMailwizzTest.php @@ -0,0 +1,131 @@ +url(); + if (str_contains($url, 'search-by-email')) { + return Http::response(['status' => 'error']); + } + if ($request->method() === 'POST' && preg_match('#/lists/[^/]+/subscribers$#', $url) === 1) { + return Http::response(['status' => 'success']); + } + + return Http::response(['status' => 'error'], 500); + }); + + $page = $this->makePageWithMailwizz(); + + $this->postJson(route('public.subscribe', ['publicPage' => $page->slug]), [ + 'first_name' => 'Ada', + 'last_name' => 'Lovelace', + 'email' => 'ada@example.com', + ])->assertOk(); + + $subscriber = Subscriber::query()->where('email', 'ada@example.com')->first(); + $this->assertNotNull($subscriber); + $this->assertTrue($subscriber->synced_to_mailwizz); + $this->assertNotNull($subscriber->synced_at); + } + + public function test_subscribe_with_mailwizz_config_runs_sync_update_path_with_tag_merge(): void + { + Http::fake(function (Request $request) { + $url = $request->url(); + if (str_contains($url, 'search-by-email')) { + return Http::response(['status' => 'success', 'data' => ['subscriber_uid' => 'sub-uid-1']]); + } + if ($request->method() === 'GET' && str_contains($url, '/subscribers/sub-uid-1') && ! str_contains($url, 'search-by-email')) { + return Http::response([ + 'status' => 'success', + 'data' => [ + 'record' => [ + 'TAGS' => 'existing-one', + ], + ], + ]); + } + if ($request->method() === 'PUT' && str_contains($url, '/subscribers/sub-uid-1')) { + $body = $request->body(); + $this->assertStringContainsString('TAGS', $body); + $this->assertStringContainsString('existing-one', $body); + $this->assertStringContainsString('new-source-tag', $body); + + return Http::response(['status' => 'success']); + } + + return Http::response(['status' => 'error'], 500); + }); + + $page = $this->makePageWithMailwizz([ + 'tag_field' => 'TAGS', + 'tag_value' => 'new-source-tag', + ]); + + $this->postJson(route('public.subscribe', ['publicPage' => $page->slug]), [ + 'first_name' => 'Grace', + 'last_name' => 'Hopper', + 'email' => 'grace@example.com', + ])->assertOk(); + + $subscriber = Subscriber::query()->where('email', 'grace@example.com')->first(); + $this->assertNotNull($subscriber); + $this->assertTrue($subscriber->synced_to_mailwizz); + } + + /** + * @param array $configOverrides + */ + private function makePageWithMailwizz(array $configOverrides = []): PreregistrationPage + { + $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, + ]); + + MailwizzConfig::query()->create(array_merge([ + 'preregistration_page_id' => $page->id, + 'api_key' => 'fake-api-key', + 'list_uid' => 'list-uid-1', + 'list_name' => 'Main list', + 'field_email' => 'EMAIL', + 'field_first_name' => 'FNAME', + 'field_last_name' => 'LNAME', + 'field_phone' => null, + 'tag_field' => 'TAGS', + 'tag_value' => 'preregister-source', + ], $configOverrides)); + + return $page->fresh(['mailwizzConfig']); + } +}