feat: Phase 4 - Mailwizz integration with subscriber sync and retry
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\DispatchUnsyncedMailwizzSyncJobsService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class QueueUnsyncedMailwizzSubscribersCommand extends Command
|
||||
{
|
||||
protected $signature = 'mailwizz:queue-unsynced
|
||||
{--page= : Limit to a preregistration page ID}
|
||||
{--dry-run : Only show how many subscribers would be processed}';
|
||||
|
||||
protected $description = 'Queue Mailwizz sync jobs for subscribers not yet synced (skips duplicates already queued via unique job lock)';
|
||||
|
||||
public function handle(DispatchUnsyncedMailwizzSyncJobsService $dispatcher): int
|
||||
{
|
||||
$pageOption = $this->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;
|
||||
}
|
||||
}
|
||||
@@ -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<array{list_uid: string, name: string}>
|
||||
*/
|
||||
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<array{tag: string, label: string, type_identifier: string, options: array<string, string>}>
|
||||
*/
|
||||
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<string, string>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([
|
||||
|
||||
29
app/Http/Requests/Admin/QueueMailwizzSyncRequest.php
Normal file
29
app/Http/Requests/Admin/QueueMailwizzSyncRequest.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use App\Models\PreregistrationPage;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class QueueMailwizzSyncRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
$page = $this->route('page');
|
||||
if (! $page instanceof PreregistrationPage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->user()?->can('update', $page) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
220
app/Jobs/SyncSubscriberToMailwizz.php
Normal file
220
app/Jobs/SyncSubscriberToMailwizz.php
Normal file
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\MailwizzConfig;
|
||||
use App\Models\Subscriber;
|
||||
use App\Services\MailwizzService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
/**
|
||||
* Seconds before the unique lock expires if the worker dies before releasing it.
|
||||
*/
|
||||
public int $uniqueFor = 86400;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
/**
|
||||
* @var list<int>
|
||||
*/
|
||||
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<string, mixed> $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<string>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
46
app/Services/DispatchUnsyncedMailwizzSyncJobsService.php
Normal file
46
app/Services/DispatchUnsyncedMailwizzSyncJobsService.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Jobs\SyncSubscriberToMailwizz;
|
||||
use App\Models\Subscriber;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
final class DispatchUnsyncedMailwizzSyncJobsService
|
||||
{
|
||||
/**
|
||||
* Dispatch a Mailwizz sync job for each subscriber that is not synced yet and whose page has Mailwizz configured.
|
||||
* Duplicate jobs for the same subscriber are skipped while a job is already pending or running (unique lock on SyncSubscriberToMailwizz).
|
||||
*
|
||||
* @return int Number of eligible subscribers (dispatch was attempted for each; some attempts may be skipped as duplicates)
|
||||
*/
|
||||
public function dispatch(?int $preregistrationPageId = null): int
|
||||
{
|
||||
$query = $this->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<Subscriber>
|
||||
*/
|
||||
public function eligibleSubscribersQuery(): Builder
|
||||
{
|
||||
return Subscriber::query()
|
||||
->where('synced_to_mailwizz', false)
|
||||
->whereHas('preregistrationPage.mailwizzConfig');
|
||||
}
|
||||
}
|
||||
311
app/Services/MailwizzService.php
Normal file
311
app/Services/MailwizzService.php
Normal file
@@ -0,0 +1,311 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
|
||||
final class MailwizzService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $apiKey,
|
||||
private readonly string $baseUrl = 'https://www.mailwizz.nl/api'
|
||||
) {}
|
||||
|
||||
private function client(): PendingRequest
|
||||
{
|
||||
return Http::withHeaders(['X-Api-Key' => $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<array{list_uid: string, name: string}>
|
||||
*/
|
||||
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<array{tag: string, label: string, type_identifier: string, options: array<string, string>}>
|
||||
*/
|
||||
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<string, mixed>|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<string, mixed> $data Form fields; checkboxlist values as string[] per field tag
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<array{list_uid: string, name: string}>
|
||||
*/
|
||||
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<array{tag: string, label: string, type_identifier: string, options: array<string, string>}>
|
||||
*/
|
||||
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<string, string>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -24,10 +24,21 @@
|
||||
<p class="text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</form>
|
||||
<a href="{{ route('admin.pages.subscribers.export', $page) }}{{ request()->filled('search') ? '?'.http_build_query(['search' => request('search')]) : '' }}"
|
||||
class="inline-flex items-center justify-center rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 shadow-sm hover:bg-slate-50">
|
||||
{{ __('Export CSV') }}
|
||||
</a>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@if ($unsyncedMailwizzCount > 0)
|
||||
<form method="post" action="{{ route('admin.pages.subscribers.queue-mailwizz-sync', $page) }}" class="inline"
|
||||
onsubmit="return confirm(@js(__('Queue Mailwizz sync jobs for all :count not-yet-synced subscribers on this page? Duplicates are skipped if a job is already pending.', ['count' => $unsyncedMailwizzCount])));">
|
||||
@csrf
|
||||
<button type="submit" class="inline-flex items-center justify-center rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500">
|
||||
{{ __('Queue Mailwizz sync') }} ({{ $unsyncedMailwizzCount }})
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
<a href="{{ route('admin.pages.subscribers.export', $page) }}{{ request()->filled('search') ? '?'.http_build_query(['search' => request('search')]) : '' }}"
|
||||
class="inline-flex items-center justify-center rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 shadow-sm hover:bg-slate-50">
|
||||
{{ __('Export CSV') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
|
||||
@@ -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)
|
||||
|
||||
122
tests/Feature/QueueUnsyncedMailwizzSubscribersTest.php
Normal file
122
tests/Feature/QueueUnsyncedMailwizzSubscribersTest.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\MailwizzConfig;
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Models\Subscriber;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class QueueUnsyncedMailwizzSubscribersTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_artisan_dry_run_counts_unsynced_subscribers_with_mailwizz(): void
|
||||
{
|
||||
$page = $this->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;
|
||||
}
|
||||
}
|
||||
131
tests/Feature/SyncSubscriberToMailwizzTest.php
Normal file
131
tests/Feature/SyncSubscriberToMailwizzTest.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\MailwizzConfig;
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Models\Subscriber;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SyncSubscriberToMailwizzTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_subscribe_with_mailwizz_config_runs_sync_create_path_and_marks_synced(): 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);
|
||||
});
|
||||
|
||||
$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<string, mixed> $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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user