feat: add Weeztix OAuth, coupon codes, and Mailwizz mapping

Implement Weeztix integration per documentation: database config and
subscriber coupon_code, OAuth redirect/callback, admin setup UI with
company/coupon selection via AJAX, synchronous coupon creation on public
subscribe with duplicate and rate-limit handling, Mailwizz field mapping
for coupon codes, subscriber table and CSV export, and connection hint
on the pages list.

Made-with: Cursor
This commit is contained in:
2026-04-04 14:52:41 +02:00
parent 17e784fee7
commit d3abdb7ed9
30 changed files with 2272 additions and 5 deletions

View File

@@ -28,6 +28,7 @@ class PageController extends Controller
{
$query = PreregistrationPage::query()
->withCount('subscribers')
->with('weeztixConfig')
->orderByDesc('start_date');
if (! $request->user()?->isSuperadmin()) {

View File

@@ -89,7 +89,7 @@ class SubscriberController extends Controller
if ($phoneEnabled) {
$headers[] = 'Phone';
}
$headers = array_merge($headers, ['Registered At', 'Synced to Mailwizz', 'Synced At']);
$headers = array_merge($headers, ['Coupon Code', 'Registered At', 'Synced to Mailwizz', 'Synced At']);
fputcsv($handle, $headers);
foreach ($subscribers as $sub) {
@@ -97,6 +97,7 @@ class SubscriberController extends Controller
if ($phoneEnabled) {
$row[] = $sub->phoneDisplay() ?? '';
}
$row[] = $sub->coupon_code ?? '';
$row[] = $sub->created_at?->toDateTimeString() ?? '';
$row[] = $sub->synced_to_mailwizz ? 'Yes' : 'No';
$row[] = $sub->synced_at?->toDateTimeString() ?? '';

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\PreregistrationPage;
use App\Services\WeeztixService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use RuntimeException;
class WeeztixApiController extends Controller
{
public function companies(Request $request): JsonResponse
{
$request->validate([
'page_id' => ['required', 'integer', 'exists:preregistration_pages,id'],
]);
$page = PreregistrationPage::query()->findOrFail($request->integer('page_id'));
$this->authorize('update', $page);
$config = $page->weeztixConfig;
if ($config === null || ! $config->is_connected) {
return response()->json([
'message' => __('Niet verbonden met Weeztix.'),
], 422);
}
try {
$companies = (new WeeztixService($config))->getCompanies();
return response()->json(['companies' => $companies]);
} catch (RuntimeException) {
return response()->json([
'message' => __('Kon bedrijven niet laden. Vernieuw de verbinding indien nodig.'),
], 422);
}
}
public function coupons(Request $request): JsonResponse
{
$request->validate([
'page_id' => ['required', 'integer', 'exists:preregistration_pages,id'],
'company_guid' => ['required', 'string', 'max:255'],
]);
$page = PreregistrationPage::query()->findOrFail($request->integer('page_id'));
$this->authorize('update', $page);
$config = $page->weeztixConfig;
if ($config === null || ! $config->is_connected) {
return response()->json([
'message' => __('Niet verbonden met Weeztix.'),
], 422);
}
$companyGuid = $request->string('company_guid')->toString();
$previousGuid = $config->company_guid;
$config->setAttribute('company_guid', $companyGuid);
try {
$raw = (new WeeztixService($config))->getCoupons();
$coupons = $this->normalizeCouponsPayload($raw);
return response()->json(['coupons' => $coupons]);
} catch (RuntimeException) {
return response()->json([
'message' => __('Kon kortingsbonnen niet laden.'),
], 422);
} finally {
$config->setAttribute('company_guid', $previousGuid);
}
}
/**
* @param array<string, mixed> $raw
* @return list<array{guid: string, name: string}>
*/
private function normalizeCouponsPayload(array $raw): array
{
$list = $raw;
if (isset($raw['data']) && is_array($raw['data'])) {
$list = $raw['data'];
}
if (! is_array($list)) {
return [];
}
$out = [];
foreach ($list as $row) {
if (! is_array($row)) {
continue;
}
$guid = data_get($row, 'guid') ?? data_get($row, 'id');
if (! is_string($guid) || $guid === '') {
continue;
}
$name = data_get($row, 'name') ?? data_get($row, 'title') ?? $guid;
$out[] = [
'guid' => $guid,
'name' => is_string($name) ? $name : $guid,
];
}
return $out;
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\UpdateWeeztixConfigRequest;
use App\Models\PreregistrationPage;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class WeeztixController extends Controller
{
public function edit(PreregistrationPage $page): View
{
$this->authorize('update', $page);
$page->load('weeztixConfig');
return view('admin.weeztix.edit', compact('page'));
}
public function update(UpdateWeeztixConfigRequest $request, PreregistrationPage $page): RedirectResponse
{
$validated = $request->validated();
foreach (['client_id', 'client_secret'] as $key) {
if (array_key_exists($key, $validated) && $validated[$key] === '' && $page->weeztixConfig !== null) {
unset($validated[$key]);
}
}
$validated['redirect_uri'] = route('admin.weeztix.callback', absolute: true);
DB::transaction(function () use ($page, $validated): void {
$page->weeztixConfig()->updateOrCreate(
['preregistration_page_id' => $page->id],
array_merge($validated, ['preregistration_page_id' => $page->id])
);
});
return redirect()
->route('admin.pages.weeztix.edit', $page)
->with('status', __('Weeztix-configuratie opgeslagen.'));
}
public function destroy(PreregistrationPage $page): RedirectResponse
{
$this->authorize('update', $page);
$page->weeztixConfig()?->delete();
return redirect()
->route('admin.pages.weeztix.edit', $page)
->with('status', __('Weeztix-integratie verwijderd.'));
}
}

View File

@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\PreregistrationPage;
use App\Services\WeeztixService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use RuntimeException;
class WeeztixOAuthController extends Controller
{
public function redirect(PreregistrationPage $page): RedirectResponse
{
$this->authorize('update', $page);
$page->load('weeztixConfig');
$config = $page->weeztixConfig;
if ($config === null) {
return redirect()
->route('admin.pages.weeztix.edit', $page)
->with('error', __('Sla eerst je client ID en client secret op.'));
}
$clientId = $config->client_id;
if (! is_string($clientId) || $clientId === '') {
return redirect()
->route('admin.pages.weeztix.edit', $page)
->with('error', __('Vul een geldige Weeztix client ID in.'));
}
$state = Str::random(40);
session([
'weeztix_oauth_state' => $state,
'weeztix_page_id' => $page->id,
]);
$redirectUri = $config->redirect_uri;
if (! is_string($redirectUri) || $redirectUri === '') {
$redirectUri = route('admin.weeztix.callback', absolute: true);
}
$query = http_build_query([
'client_id' => $clientId,
'redirect_uri' => $redirectUri,
'response_type' => 'code',
'state' => $state,
]);
$authorizeBase = rtrim(config('weeztix.auth_base_url'), '/').'/tokens/authorize';
return redirect()->away($authorizeBase.'?'.$query);
}
public function callback(Request $request): RedirectResponse
{
if ($request->filled('error')) {
Log::warning('Weeztix OAuth provider error', [
'error' => $request->string('error')->toString(),
'description' => $request->string('error_description')->toString(),
]);
return $this->redirectToWeeztixEditWithSessionPage(__('Weeztix heeft de verbinding geweigerd. Probeer opnieuw.'));
}
$request->validate([
'state' => ['required', 'string'],
'code' => ['required', 'string'],
]);
$storedState = session('weeztix_oauth_state');
$pageId = session('weeztix_page_id');
if (! is_string($storedState) || $storedState === '' || ($pageId === null || (! is_int($pageId) && ! is_numeric($pageId)))) {
return redirect()
->route('admin.dashboard')
->with('error', __('Ongeldige OAuth-sessie. Start opnieuw vanaf de Weeztix-pagina.'));
}
if ($request->string('state')->toString() !== $storedState) {
abort(403, 'Invalid OAuth state');
}
$page = PreregistrationPage::query()->findOrFail((int) $pageId);
$this->authorize('update', $page);
$config = $page->weeztixConfig;
if ($config === null) {
session()->forget(['weeztix_oauth_state', 'weeztix_page_id']);
return redirect()
->route('admin.pages.weeztix.edit', $page)
->with('error', __('Geen Weeztix-configuratie gevonden voor deze pagina.'));
}
try {
$service = new WeeztixService($config);
$service->exchangeAuthorizationCode($request->string('code')->toString());
} catch (RuntimeException $e) {
Log::error('Weeztix OAuth callback failed', [
'page_id' => $page->id,
'message' => $e->getMessage(),
]);
session()->forget(['weeztix_oauth_state', 'weeztix_page_id']);
return redirect()
->route('admin.pages.weeztix.edit', $page)
->with('error', __('Verbinden met Weeztix is mislukt. Controleer je gegevens en probeer opnieuw.'));
}
session()->forget(['weeztix_oauth_state', 'weeztix_page_id']);
return redirect()
->route('admin.pages.weeztix.edit', $page)
->with('status', __('Succesvol verbonden met Weeztix.'));
}
private function redirectToWeeztixEditWithSessionPage(string $message): RedirectResponse
{
$pageId = session('weeztix_page_id');
session()->forget(['weeztix_oauth_state', 'weeztix_page_id']);
if (is_int($pageId) || is_numeric($pageId)) {
$page = PreregistrationPage::query()->find((int) $pageId);
if ($page !== null) {
return redirect()
->route('admin.pages.weeztix.edit', $page)
->with('error', $message);
}
}
return redirect()
->route('admin.dashboard')
->with('error', $message);
}
}

View File

@@ -4,13 +4,19 @@ declare(strict_types=1);
namespace App\Http\Controllers;
use App\Exceptions\WeeztixCouponCodeConflictException;
use App\Http\Requests\SubscribePublicPageRequest;
use App\Jobs\SyncSubscriberToMailwizz;
use App\Models\PageBlock;
use App\Models\PreregistrationPage;
use App\Models\Subscriber;
use App\Models\WeeztixConfig;
use App\Services\WeeztixService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Throwable;
class PublicPageController extends Controller
{
@@ -52,13 +58,70 @@ class PublicPageController extends Controller
$subscriber = $publicPage->subscribers()->create($validated);
$publicPage->loadMissing('weeztixConfig');
$weeztix = $publicPage->weeztixConfig;
if ($this->weeztixCanIssueCodes($weeztix)) {
$this->tryAttachWeeztixCouponCode($subscriber, $weeztix);
}
if ($publicPage->mailwizzConfig !== null) {
SyncSubscriberToMailwizz::dispatch($subscriber);
SyncSubscriberToMailwizz::dispatch($subscriber->fresh());
}
return response()->json([
'success' => true,
'message' => $publicPage->thank_you_message ?? __('Thank you for registering!'),
'coupon_code' => $subscriber->fresh()?->coupon_code,
]);
}
private function weeztixCanIssueCodes(?WeeztixConfig $config): bool
{
if ($config === null || ! $config->is_connected) {
return false;
}
$company = $config->company_guid;
$coupon = $config->coupon_guid;
return is_string($company) && $company !== '' && is_string($coupon) && $coupon !== '';
}
private function tryAttachWeeztixCouponCode(Subscriber $subscriber, WeeztixConfig $config): void
{
$freshConfig = $config->fresh();
if ($freshConfig === null) {
return;
}
$service = new WeeztixService($freshConfig);
$maxAttempts = 5;
for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
try {
$code = WeeztixService::generateUniqueCode(
is_string($freshConfig->code_prefix) && $freshConfig->code_prefix !== ''
? $freshConfig->code_prefix
: 'PREREG'
);
$service->createCouponCode($code);
$subscriber->update(['coupon_code' => $code]);
return;
} catch (WeeztixCouponCodeConflictException) {
continue;
} catch (Throwable $e) {
Log::error('Weeztix coupon creation failed', [
'subscriber_id' => $subscriber->id,
'message' => $e->getMessage(),
]);
return;
}
}
Log::warning('Weeztix coupon: exhausted duplicate retries', [
'subscriber_id' => $subscriber->id,
]);
}

View File

@@ -42,6 +42,7 @@ class UpdateMailwizzConfigRequest extends FormRequest
'field_first_name' => ['required', 'string', 'max:255'],
'field_last_name' => ['required', 'string', 'max:255'],
'field_phone' => ['nullable', 'string', 'max:255'],
'field_coupon_code' => ['nullable', 'string', 'max:255'],
'tag_field' => ['required', 'string', 'max:255'],
'tag_value' => ['required', 'string', 'max:255'],
];

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Admin;
use App\Models\PreregistrationPage;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateWeeztixConfigRequest 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, ValidationRule|string>>
*/
public function rules(): array
{
/** @var PreregistrationPage $page */
$page = $this->route('page');
return [
'client_id' => [
'sometimes',
Rule::requiredIf(fn (): bool => $page->weeztixConfig === null),
'nullable',
'string',
'max:2048',
],
'client_secret' => [
'sometimes',
Rule::requiredIf(fn (): bool => $page->weeztixConfig === null),
'nullable',
'string',
'max:2048',
],
'company_guid' => ['nullable', 'string', 'max:255'],
'company_name' => ['nullable', 'string', 'max:255'],
'coupon_guid' => ['nullable', 'string', 'max:255'],
'coupon_name' => ['nullable', 'string', 'max:255'],
'code_prefix' => ['nullable', 'string', 'max:32'],
'usage_count' => ['nullable', 'integer', 'min:1', 'max:99999'],
];
}
}