diff --git a/app/Exceptions/WeeztixCouponCodeConflictException.php b/app/Exceptions/WeeztixCouponCodeConflictException.php new file mode 100644 index 0000000..6d44c7e --- /dev/null +++ b/app/Exceptions/WeeztixCouponCodeConflictException.php @@ -0,0 +1,9 @@ +withCount('subscribers') + ->with('weeztixConfig') ->orderByDesc('start_date'); if (! $request->user()?->isSuperadmin()) { diff --git a/app/Http/Controllers/Admin/SubscriberController.php b/app/Http/Controllers/Admin/SubscriberController.php index 63f0e3c..b7c7bfe 100644 --- a/app/Http/Controllers/Admin/SubscriberController.php +++ b/app/Http/Controllers/Admin/SubscriberController.php @@ -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() ?? ''; diff --git a/app/Http/Controllers/Admin/WeeztixApiController.php b/app/Http/Controllers/Admin/WeeztixApiController.php new file mode 100644 index 0000000..5906b7e --- /dev/null +++ b/app/Http/Controllers/Admin/WeeztixApiController.php @@ -0,0 +1,111 @@ +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 $raw + * @return list + */ + 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; + } +} diff --git a/app/Http/Controllers/Admin/WeeztixController.php b/app/Http/Controllers/Admin/WeeztixController.php new file mode 100644 index 0000000..c85a2f8 --- /dev/null +++ b/app/Http/Controllers/Admin/WeeztixController.php @@ -0,0 +1,59 @@ +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.')); + } +} diff --git a/app/Http/Controllers/Admin/WeeztixOAuthController.php b/app/Http/Controllers/Admin/WeeztixOAuthController.php new file mode 100644 index 0000000..be3afdf --- /dev/null +++ b/app/Http/Controllers/Admin/WeeztixOAuthController.php @@ -0,0 +1,141 @@ +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); + } +} diff --git a/app/Http/Controllers/PublicPageController.php b/app/Http/Controllers/PublicPageController.php index fc16f1b..cc74daf 100644 --- a/app/Http/Controllers/PublicPageController.php +++ b/app/Http/Controllers/PublicPageController.php @@ -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, ]); } diff --git a/app/Http/Requests/Admin/UpdateMailwizzConfigRequest.php b/app/Http/Requests/Admin/UpdateMailwizzConfigRequest.php index ca0a0ba..22ce6ae 100644 --- a/app/Http/Requests/Admin/UpdateMailwizzConfigRequest.php +++ b/app/Http/Requests/Admin/UpdateMailwizzConfigRequest.php @@ -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'], ]; diff --git a/app/Http/Requests/Admin/UpdateWeeztixConfigRequest.php b/app/Http/Requests/Admin/UpdateWeeztixConfigRequest.php new file mode 100644 index 0000000..69080e1 --- /dev/null +++ b/app/Http/Requests/Admin/UpdateWeeztixConfigRequest.php @@ -0,0 +1,55 @@ +route('page'); + if (! $page instanceof PreregistrationPage) { + return false; + } + + return $this->user()?->can('update', $page) ?? false; + } + + /** + * @return array> + */ + 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'], + ]; + } +} diff --git a/app/Jobs/SyncSubscriberToMailwizz.php b/app/Jobs/SyncSubscriberToMailwizz.php index 4df6014..2aa1445 100644 --- a/app/Jobs/SyncSubscriberToMailwizz.php +++ b/app/Jobs/SyncSubscriberToMailwizz.php @@ -134,6 +134,11 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue } } + $couponField = $config->field_coupon_code; + if (is_string($couponField) && $couponField !== '' && $subscriber->coupon_code !== null && $subscriber->coupon_code !== '') { + $data[$couponField] = $subscriber->coupon_code; + } + return $data; } diff --git a/app/Models/MailwizzConfig.php b/app/Models/MailwizzConfig.php index 79ad445..a9f2c60 100644 --- a/app/Models/MailwizzConfig.php +++ b/app/Models/MailwizzConfig.php @@ -21,6 +21,7 @@ class MailwizzConfig extends Model 'field_first_name', 'field_last_name', 'field_phone', + 'field_coupon_code', 'tag_field', 'tag_value', ]; diff --git a/app/Models/PreregistrationPage.php b/app/Models/PreregistrationPage.php index f03e3cd..73ca190 100644 --- a/app/Models/PreregistrationPage.php +++ b/app/Models/PreregistrationPage.php @@ -162,6 +162,11 @@ class PreregistrationPage extends Model return $this->hasOne(MailwizzConfig::class); } + public function weeztixConfig(): HasOne + { + return $this->hasOne(WeeztixConfig::class); + } + public function isBeforeStart(): bool { return Carbon::now()->lt($this->start_date); diff --git a/app/Models/Subscriber.php b/app/Models/Subscriber.php index 302d7a6..f13a0a5 100644 --- a/app/Models/Subscriber.php +++ b/app/Models/Subscriber.php @@ -22,6 +22,7 @@ class Subscriber extends Model 'phone', 'synced_to_mailwizz', 'synced_at', + 'coupon_code', ]; protected function casts(): array @@ -95,7 +96,8 @@ class Subscriber extends Model $q->where('first_name', 'like', $like) ->orWhere('last_name', 'like', $like) ->orWhere('email', 'like', $like) - ->orWhere('phone', 'like', $like); + ->orWhere('phone', 'like', $like) + ->orWhere('coupon_code', 'like', $like); }); } } diff --git a/app/Models/WeeztixConfig.php b/app/Models/WeeztixConfig.php new file mode 100644 index 0000000..3295182 --- /dev/null +++ b/app/Models/WeeztixConfig.php @@ -0,0 +1,64 @@ + 'encrypted', + 'client_secret' => 'encrypted', + 'access_token' => 'encrypted', + 'refresh_token' => 'encrypted', + 'token_expires_at' => 'datetime', + 'refresh_token_expires_at' => 'datetime', + 'is_connected' => 'boolean', + ]; + } + + public function preregistrationPage(): BelongsTo + { + return $this->belongsTo(PreregistrationPage::class); + } + + public function isTokenExpired(): bool + { + return ! $this->token_expires_at || $this->token_expires_at->isPast(); + } + + public function isRefreshTokenExpired(): bool + { + if ($this->refresh_token_expires_at === null) { + return false; + } + + return $this->refresh_token_expires_at->isPast(); + } +} diff --git a/app/Services/WeeztixService.php b/app/Services/WeeztixService.php new file mode 100644 index 0000000..3c0df8e --- /dev/null +++ b/app/Services/WeeztixService.php @@ -0,0 +1,464 @@ +config->access_token && ! $this->config->isTokenExpired()) { + return (string) $this->config->access_token; + } + + $this->refreshAccessToken(); + $this->config->refresh(); + + $token = $this->config->access_token; + if (! is_string($token) || $token === '') { + throw new RuntimeException('Weeztix access token missing after refresh.'); + } + + return $token; + } + + /** + * @return list + */ + public function getCompanies(): array + { + $token = $this->getValidAccessToken(); + $url = config('weeztix.user_profile_url'); + + $response = Http::withHeaders([ + 'Authorization' => 'Bearer '.$token, + ]) + ->acceptJson() + ->get($url); + + if ($response->status() === 401) { + $this->refreshAccessToken(); + $this->config->refresh(); + $response = Http::withHeaders([ + 'Authorization' => 'Bearer '.(string) $this->config->access_token, + ]) + ->acceptJson() + ->get($url); + } + + if ($response->failed()) { + $this->logFailedResponse('getCompanies', $url, $response); + + throw new RuntimeException('Weeztix user profile request failed: '.$response->status()); + } + + Log::debug('Weeztix API', [ + 'action' => 'getCompanies', + 'url' => $url, + 'http_status' => $response->status(), + ]); + + $json = $response->json(); + if (! is_array($json)) { + return []; + } + + return $this->normalizeCompaniesFromProfile($json); + } + + /** + * Exchange OAuth authorization code for tokens (admin callback). + */ + public function exchangeAuthorizationCode(string $code): void + { + $redirectUri = $this->config->redirect_uri; + if (! is_string($redirectUri) || $redirectUri === '') { + throw new LogicException('Weeztix redirect_uri is not set.'); + } + + $tokenUrl = config('weeztix.auth_base_url').'/tokens'; + $response = Http::asForm()->post($tokenUrl, [ + 'grant_type' => 'authorization_code', + 'client_id' => $this->config->client_id, + 'client_secret' => $this->config->client_secret, + 'redirect_uri' => $redirectUri, + 'code' => $code, + ]); + + if ($response->failed()) { + Log::error('Weeztix OAuth code exchange failed', [ + 'url' => $tokenUrl, + 'status' => $response->status(), + 'body' => $response->json(), + ]); + + throw new RuntimeException('Weeztix OAuth code exchange failed: '.$response->status()); + } + + $json = $response->json(); + if (! is_array($json)) { + throw new RuntimeException('Weeztix token response was not valid JSON.'); + } + + $this->applyTokenResponseToConfig($json); + $this->hydrateCompanyFromTokenInfo($json); + + Log::debug('Weeztix API', [ + 'action' => 'oauth_authorization_code', + 'url' => $tokenUrl, + 'http_status' => $response->status(), + ]); + } + + /** + * @return array + */ + public function getCoupons(): array + { + $this->assertCompanyGuid(); + + $url = config('weeztix.api_base_url').'/coupon'; + + return $this->apiRequest('get', $url, []); + } + + /** + * Creates a single coupon code on the coupon selected in config. + * + * @return array + */ + /** + * @return array + * + * @throws WeeztixCouponCodeConflictException When the code already exists (HTTP 409). + */ + public function createCouponCode(string $code): array + { + $this->assertCompanyGuid(); + $couponGuid = $this->config->coupon_guid; + if (! is_string($couponGuid) || $couponGuid === '') { + throw new LogicException('Weeztix coupon is not configured.'); + } + + $url = config('weeztix.api_base_url').'/coupon/'.$couponGuid.'/codes'; + + $payload = [ + 'usage_count' => $this->config->usage_count, + 'applies_to_count' => null, + 'codes' => [ + ['code' => $code], + ], + ]; + + $rateAttempts = 3; + for ($rateAttempt = 0; $rateAttempt < $rateAttempts; $rateAttempt++) { + $token = $this->getValidAccessToken(); + $response = $this->sendApiRequest('put', $url, $payload, $token); + + if ($response->status() === 401) { + $this->refreshAccessToken(); + $this->config->refresh(); + $response = $this->sendApiRequest('put', $url, $payload, (string) $this->config->access_token); + } + + if ($response->status() === 429) { + $waitSeconds = min(8, 2 ** $rateAttempt); + Log::warning('Weeztix API rate limited', [ + 'url' => $url, + 'retry_in_seconds' => $waitSeconds, + ]); + sleep($waitSeconds); + + continue; + } + + if ($response->status() === 409) { + throw new WeeztixCouponCodeConflictException('Weeztix coupon code already exists.'); + } + + if ($response->failed()) { + $this->logFailedResponse('createCouponCode', $url, $response); + + throw new RuntimeException('Weeztix API request failed: '.$response->status()); + } + + Log::debug('Weeztix API', [ + 'action' => 'createCouponCode', + 'url' => $url, + 'http_status' => $response->status(), + ]); + + $json = $response->json(); + + return is_array($json) ? $json : []; + } + + Log::error('Weeztix API rate limited after retries', ['url' => $url]); + + throw new RuntimeException('Weeztix API rate limited after retries.'); + } + + public static function generateUniqueCode(string $prefix = 'PREREG', int $length = 6): string + { + $chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + $code = ''; + for ($i = 0; $i < $length; $i++) { + $code .= $chars[random_int(0, strlen($chars) - 1)]; + } + + return strtoupper($prefix).'-'.$code; + } + + private function assertCompanyGuid(): void + { + $guid = $this->config->company_guid; + if (! is_string($guid) || $guid === '') { + throw new LogicException('Weeztix company is not configured.'); + } + } + + /** + * @param array $data + * @return array + */ + private function apiRequest(string $method, string $url, array $data = []): array + { + $this->assertCompanyGuid(); + + $token = $this->getValidAccessToken(); + $response = $this->sendApiRequest($method, $url, $data, $token); + + if ($response->status() === 401) { + $this->refreshAccessToken(); + $this->config->refresh(); + $response = $this->sendApiRequest($method, $url, $data, (string) $this->config->access_token); + } + + if ($response->failed()) { + $this->logFailedResponse('apiRequest', $url, $response); + + throw new RuntimeException('Weeztix API request failed: '.$response->status()); + } + + Log::debug('Weeztix API', [ + 'action' => 'apiRequest', + 'method' => $method, + 'url' => $url, + 'http_status' => $response->status(), + ]); + + $json = $response->json(); + + return is_array($json) ? $json : []; + } + + /** + * @param array $data + */ + private function sendApiRequest(string $method, string $url, array $data, string $token): Response + { + $client = Http::withHeaders([ + 'Authorization' => 'Bearer '.$token, + 'Company' => (string) $this->config->company_guid, + ])->acceptJson(); + + return match (strtolower($method)) { + 'get' => $client->get($url, $data), + 'post' => $client->asJson()->post($url, $data), + 'put' => $client->asJson()->put($url, $data), + 'patch' => $client->asJson()->patch($url, $data), + 'delete' => $client->delete($url, $data), + default => throw new InvalidArgumentException('Unsupported HTTP method: '.$method), + }; + } + + private function refreshAccessToken(): void + { + if (! $this->config->refresh_token || $this->config->isRefreshTokenExpired()) { + $this->config->update([ + 'is_connected' => false, + ]); + $this->config->refresh(); + + throw new RuntimeException('Weeztix refresh token missing or expired; reconnect OAuth.'); + } + + $tokenUrl = config('weeztix.auth_base_url').'/tokens'; + $response = Http::asForm()->post($tokenUrl, [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $this->config->refresh_token, + 'client_id' => $this->config->client_id, + 'client_secret' => $this->config->client_secret, + ]); + + if ($response->failed()) { + Log::error('Weeztix token refresh failed', [ + 'url' => $tokenUrl, + 'status' => $response->status(), + 'body' => $response->json(), + ]); + $this->config->update([ + 'is_connected' => false, + ]); + $this->config->refresh(); + + throw new RuntimeException('Weeztix token refresh failed: '.$response->status()); + } + + Log::debug('Weeztix API', [ + 'action' => 'refresh_token', + 'url' => $tokenUrl, + 'http_status' => $response->status(), + ]); + + $json = $response->json(); + if (! is_array($json)) { + throw new RuntimeException('Weeztix token refresh returned invalid JSON.'); + } + + $this->applyTokenResponseToConfig($json); + } + + /** + * @param array $json + */ + private function applyTokenResponseToConfig(array $json): void + { + $access = $json['access_token'] ?? null; + $refresh = $json['refresh_token'] ?? null; + if (! is_string($access) || $access === '') { + throw new RuntimeException('Weeztix token response missing access_token.'); + } + + $expiresIn = isset($json['expires_in']) ? (int) $json['expires_in'] : 0; + $refreshExpiresIn = isset($json['refresh_token_expires_in']) ? (int) $json['refresh_token_expires_in'] : 0; + + $updates = [ + 'access_token' => $access, + 'token_expires_at' => $expiresIn > 0 ? Carbon::now()->addSeconds($expiresIn) : null, + 'is_connected' => true, + ]; + + if (is_string($refresh) && $refresh !== '') { + $updates['refresh_token'] = $refresh; + $updates['refresh_token_expires_at'] = $refreshExpiresIn > 0 + ? Carbon::now()->addSeconds($refreshExpiresIn) + : null; + } + + $this->config->update($updates); + $this->config->refresh(); + } + + /** + * When the token response includes exactly one company, store it to reduce admin steps. + * + * @param array $json + */ + private function hydrateCompanyFromTokenInfo(array $json): void + { + $companies = data_get($json, 'info.companies'); + if (! is_array($companies) || count($companies) !== 1) { + return; + } + + $row = $companies[0]; + if (! is_array($row)) { + return; + } + + $guid = data_get($row, 'guid') ?? data_get($row, 'id'); + $name = data_get($row, 'name'); + if (! is_string($guid) || $guid === '') { + return; + } + + $this->config->update([ + 'company_guid' => $guid, + 'company_name' => is_string($name) ? $name : null, + ]); + $this->config->refresh(); + } + + /** + * @param array $profile + * @return list + */ + private function normalizeCompaniesFromProfile(array $profile): array + { + $fromInfo = data_get($profile, 'info.companies'); + if (is_array($fromInfo) && $fromInfo !== []) { + $normalized = $this->normalizeCompanyRows($fromInfo); + if ($normalized !== []) { + return $normalized; + } + } + + $companies = data_get($profile, 'companies'); + if (is_array($companies) && $companies !== []) { + return $this->normalizeCompanyRows($companies); + } + + $defaultId = data_get($profile, 'default_company_id'); + if (is_string($defaultId) && $defaultId !== '') { + return [ + ['guid' => $defaultId, 'name' => null], + ]; + } + + return []; + } + + /** + * @param array $rows + * @return list + */ + private function normalizeCompanyRows(array $rows): array + { + $out = []; + foreach ($rows 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'); + $out[] = [ + 'guid' => $guid, + 'name' => is_string($name) ? $name : null, + ]; + } + + return $out; + } + + private function logFailedResponse(string $action, string $url, Response $response): void + { + Log::error('Weeztix API request failed', [ + 'action' => $action, + 'url' => $url, + 'status' => $response->status(), + 'body' => $response->json(), + ]); + } +} diff --git a/config/weeztix.php b/config/weeztix.php new file mode 100644 index 0000000..4f82888 --- /dev/null +++ b/config/weeztix.php @@ -0,0 +1,40 @@ + rtrim((string) env('WEEZTIX_AUTH_BASE_URL', 'https://auth.openticket.tech'), '/'), + + /* + |-------------------------------------------------------------------------- + | Weeztix API base URL + |-------------------------------------------------------------------------- + */ + + 'api_base_url' => rtrim((string) env('WEEZTIX_API_BASE_URL', 'https://api.weeztix.com'), '/'), + + /* + |-------------------------------------------------------------------------- + | Current user profile (token validity + company hints) + |-------------------------------------------------------------------------- + | + | Documented at https://docs.weeztix.com/docs/introduction/issue-request/ + | Uses auth.weeztix.com by default; override if your token issuer uses another host. + | + */ + + 'user_profile_url' => (string) env('WEEZTIX_USER_PROFILE_URL', 'https://auth.weeztix.com/users/me'), + +]; diff --git a/database/migrations/2026_04_04_230000_create_weeztix_configs_table.php b/database/migrations/2026_04_04_230000_create_weeztix_configs_table.php new file mode 100644 index 0000000..8b67b42 --- /dev/null +++ b/database/migrations/2026_04_04_230000_create_weeztix_configs_table.php @@ -0,0 +1,44 @@ +id(); + $table->foreignId('preregistration_page_id')->constrained()->cascadeOnDelete(); + + $table->text('client_id'); + $table->text('client_secret'); + $table->string('redirect_uri'); + + $table->text('access_token')->nullable(); + $table->text('refresh_token')->nullable(); + $table->timestamp('token_expires_at')->nullable(); + $table->timestamp('refresh_token_expires_at')->nullable(); + + $table->string('company_guid')->nullable(); + $table->string('company_name')->nullable(); + + $table->string('coupon_guid')->nullable(); + $table->string('coupon_name')->nullable(); + + $table->string('code_prefix')->default('PREREG'); + $table->integer('usage_count')->default(1); + + $table->boolean('is_connected')->default(false); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('weeztix_configs'); + } +}; diff --git a/database/migrations/2026_04_04_230001_add_coupon_code_to_subscribers_table.php b/database/migrations/2026_04_04_230001_add_coupon_code_to_subscribers_table.php new file mode 100644 index 0000000..20d1f36 --- /dev/null +++ b/database/migrations/2026_04_04_230001_add_coupon_code_to_subscribers_table.php @@ -0,0 +1,24 @@ +string('coupon_code')->nullable()->after('synced_at'); + }); + } + + public function down(): void + { + Schema::table('subscribers', function (Blueprint $table) { + $table->dropColumn('coupon_code'); + }); + } +}; diff --git a/database/migrations/2026_04_04_230002_add_field_coupon_code_to_mailwizz_configs_table.php b/database/migrations/2026_04_04_230002_add_field_coupon_code_to_mailwizz_configs_table.php new file mode 100644 index 0000000..1a6f611 --- /dev/null +++ b/database/migrations/2026_04_04_230002_add_field_coupon_code_to_mailwizz_configs_table.php @@ -0,0 +1,24 @@ +string('field_coupon_code')->nullable()->after('field_phone'); + }); + } + + public function down(): void + { + Schema::table('mailwizz_configs', function (Blueprint $table) { + $table->dropColumn('field_coupon_code'); + }); + } +}; diff --git a/documentation/CURSOR-PROMPT-WEEZTIX.md b/documentation/CURSOR-PROMPT-WEEZTIX.md new file mode 100644 index 0000000..d10f764 --- /dev/null +++ b/documentation/CURSOR-PROMPT-WEEZTIX.md @@ -0,0 +1,709 @@ +# Cursor Prompt — Weeztix Coupon Code Integration + +> Paste this into Cursor chat with `@Codebase` and `@PreRegister-Development-Prompt.md` as context. + +--- + +## Role & Context + +You are a senior full-stack developer and integration architect. You're working on the PreRegister Laravel 11 application (Blade + Tailwind CSS + Alpine.js). No React, no Vue, no Livewire. + +The application already has a Mailwizz integration that syncs subscribers to an email marketing platform. You are now adding a **second integration: Weeztix** — a ticket sales platform. When a visitor pre-registers on a page, a unique coupon code is generated via the Weeztix API and assigned to the subscriber. This coupon code can then be forwarded to Mailwizz so the subscriber receives a personalized discount email. + +--- + +## Part 1: Weeztix Platform Concepts + +### Coupon vs CouponCode + +Weeztix separates these into two resources: + +- **Coupon** = a template/definition. Defines the discount type (percentage, fixed amount), value, what it applies to (orders or products). The Coupon is configured by the event organizer in Weeztix dashboard. +- **CouponCode** = the actual code a visitor enters in the ticket shop to get the discount. Each CouponCode belongs to a Coupon and inherits its discount settings. CouponCodes are unique strings like `PREREG-A7X9K2`. + +**In our flow:** The user selects an existing Coupon in the backend. When a visitor registers, we create a unique CouponCode under that Coupon via the API. + +### Authentication: OAuth2 Authorization Code Grant + +Weeztix uses the OAuth2 Authorization Code flow. Key details: + +**Endpoints:** +| Action | Method | URL | +|---|---|---| +| Authorize (redirect user) | GET | `https://auth.openticket.tech/tokens/authorize` | +| Exchange code for token | POST | `https://auth.openticket.tech/tokens` | +| Refresh token | POST | `https://auth.openticket.tech/tokens` | +| API requests | Various | `https://api.weeztix.com/...` | + +**Authorization redirect parameters:** +``` +https://auth.openticket.tech/tokens/authorize? + client_id={OAUTH_CLIENT_ID} + &redirect_uri={OAUTH_CLIENT_REDIRECT} + &response_type=code + &state={random_state} +``` + +**Token exchange (POST to `https://auth.openticket.tech/tokens`):** +```json +{ + "grant_type": "authorization_code", + "client_id": "...", + "client_secret": "...", + "redirect_uri": "...", + "code": "..." +} +``` + +**Token response:** +```json +{ + "token_type": "Bearer", + "expires_in": 259200, + "access_token": "THE_ACTUAL_TOKEN", + "refresh_token": "REFRESH_TOKEN", + "refresh_token_expires_in": 31535999 +} +``` +- `access_token` expires in ~3 days (259200 seconds) +- `refresh_token` expires in ~365 days, can only be used once + +**Refresh token (POST to `https://auth.openticket.tech/tokens`):** +```json +{ + "grant_type": "refresh_token", + "refresh_token": "...", + "client_id": "...", + "client_secret": "..." +} +``` +Returns a new `access_token` and a new `refresh_token`. + +**API requests require:** +- Header: `Authorization: Bearer {access_token}` +- Header: `Company: {company_guid}` (to scope requests to a specific company) + +### API Endpoints We Need + +| Action | Method | URL | Notes | +|---|---|---|---| +| Get coupons | GET | `https://api.weeztix.com/coupon` | Returns coupons for the company (Company header) | +| Add coupon codes | POST | `https://api.weeztix.com/coupon/{coupon_guid}/couponCode` | Creates one or more codes under a coupon | + +**Add CouponCodes request body:** +```json +{ + "usage_count": 1, + "applies_to_count": null, + "codes": [ + { + "code": "PREREG-A7X9K2" + } + ] +} +``` +- `usage_count`: how many times the code can be used (1 = single use per subscriber) +- `applies_to_count`: null = unlimited items in the order can use the discount +- `codes`: array of code objects, each with a unique `code` string + +**Important:** Duplicate CouponCodes cannot be added to a Coupon. Generate unique codes. + +--- + +## Part 2: Database Changes + +### New Migration: `weeztix_configs` table + +```php +Schema::create('weeztix_configs', function (Blueprint $table) { + $table->id(); + $table->foreignId('preregistration_page_id')->constrained()->cascadeOnDelete(); + + // OAuth credentials (encrypted at rest) + $table->text('client_id'); + $table->text('client_secret'); + $table->string('redirect_uri'); + + // OAuth tokens (encrypted at rest) + $table->text('access_token')->nullable(); + $table->text('refresh_token')->nullable(); + $table->timestamp('token_expires_at')->nullable(); + $table->timestamp('refresh_token_expires_at')->nullable(); + + // Company context + $table->string('company_guid')->nullable(); + $table->string('company_name')->nullable(); + + // Selected coupon + $table->string('coupon_guid')->nullable(); + $table->string('coupon_name')->nullable(); + + // CouponCode settings + $table->string('code_prefix')->default('PREREG'); // prefix for generated codes + $table->integer('usage_count')->default(1); // how many times a code can be used + + $table->boolean('is_connected')->default(false); // OAuth flow completed? + $table->timestamps(); +}); +``` + +### Modify `subscribers` table + +Add a column to store the generated coupon code per subscriber: + +```php +Schema::table('subscribers', function (Blueprint $table) { + $table->string('coupon_code')->nullable()->after('synced_at'); +}); +``` + +### Model: `WeeztixConfig` + +```php +class WeeztixConfig extends Model +{ + protected $fillable = [ + 'preregistration_page_id', 'client_id', 'client_secret', 'redirect_uri', + 'access_token', 'refresh_token', 'token_expires_at', 'refresh_token_expires_at', + 'company_guid', 'company_name', 'coupon_guid', 'coupon_name', + 'code_prefix', 'usage_count', 'is_connected', + ]; + + protected $casts = [ + 'client_id' => 'encrypted', + 'client_secret' => 'encrypted', + 'access_token' => 'encrypted', + 'refresh_token' => 'encrypted', + 'token_expires_at' => 'datetime', + 'refresh_token_expires_at' => 'datetime', + 'is_connected' => 'boolean', + ]; + + public function preregistrationPage(): BelongsTo + { + return $this->belongsTo(PreregistrationPage::class); + } + + public function isTokenExpired(): bool + { + return !$this->token_expires_at || $this->token_expires_at->isPast(); + } + + public function isRefreshTokenExpired(): bool + { + return !$this->refresh_token_expires_at || $this->refresh_token_expires_at->isPast(); + } +} +``` + +### Update `PreregistrationPage` model + +Add: +```php +public function weeztixConfig(): HasOne +{ + return $this->hasOne(WeeztixConfig::class); +} +``` + +--- + +## Part 3: WeeztixService + +Create `app/Services/WeeztixService.php` — encapsulates all Weeztix API communication with automatic token refresh. + +```php +class WeeztixService +{ + private WeeztixConfig $config; + + public function __construct(WeeztixConfig $config) + { + $this->config = $config; + } + + /** + * Get a valid access token, refreshing if necessary. + * Updates the config model with new tokens. + */ + public function getValidAccessToken(): string + + /** + * Refresh the access token using the refresh token. + * Stores the new tokens in the config. + * Throws exception if refresh token is also expired (re-auth needed). + */ + private function refreshAccessToken(): void + + /** + * Make an authenticated API request. + * Automatically refreshes token if expired. + */ + private function apiRequest(string $method, string $url, array $data = []): array + + /** + * Get all companies the token has access to. + * GET https://auth.weeztix.com/users/me (or similar endpoint) + */ + public function getCompanies(): array + + /** + * Get all coupons for the configured company. + * GET https://api.weeztix.com/coupon + * Header: Company: {company_guid} + */ + public function getCoupons(): array + + /** + * Create a unique coupon code under the configured coupon. + * POST https://api.weeztix.com/coupon/{coupon_guid}/couponCode + * Header: Company: {company_guid} + */ + public function createCouponCode(string $code): array + + /** + * Generate a unique code string. + * Format: {prefix}-{random alphanumeric} + * Example: PREREG-A7X9K2 + */ + public static function generateUniqueCode(string $prefix = 'PREREG', int $length = 6): string +} +``` + +### Key Implementation Details for WeeztixService + +**Token refresh logic:** +``` +1. Check if access_token exists and is not expired +2. If expired, check if refresh_token exists and is not expired +3. If refresh_token valid: POST to https://auth.openticket.tech/tokens with grant_type=refresh_token +4. Store new access_token, refresh_token, and their expiry timestamps +5. If refresh_token also expired: mark config as disconnected, throw exception (user must re-authorize) +``` + +**API request wrapper:** +```php +private function apiRequest(string $method, string $url, array $data = []): array +{ + $token = $this->getValidAccessToken(); + + $response = Http::withHeaders([ + 'Authorization' => "Bearer {$token}", + 'Company' => $this->config->company_guid, + ])->{$method}($url, $data); + + if ($response->status() === 401) { + // Token might have been revoked, try refresh once + $this->refreshAccessToken(); + $token = $this->config->access_token; + + $response = Http::withHeaders([ + 'Authorization' => "Bearer {$token}", + 'Company' => $this->config->company_guid, + ])->{$method}($url, $data); + } + + if ($response->failed()) { + Log::error('Weeztix API request failed', [ + 'url' => $url, + 'status' => $response->status(), + 'body' => $response->json(), + ]); + throw new \RuntimeException("Weeztix API request failed: {$response->status()}"); + } + + return $response->json(); +} +``` + +**Unique code generation:** +```php +public static function generateUniqueCode(string $prefix = 'PREREG', int $length = 6): string +{ + $chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no 0/O/1/I to avoid confusion + $code = ''; + for ($i = 0; $i < $length; $i++) { + $code .= $chars[random_int(0, strlen($chars) - 1)]; + } + return strtoupper($prefix) . '-' . $code; +} +``` + +--- + +## Part 4: OAuth Flow — Routes & Controller + +### Routes + +```php +// Inside the admin auth middleware group: + +// Weeztix configuration (per page) +Route::get('pages/{page}/weeztix', [WeeztixController::class, 'edit'])->name('pages.weeztix.edit'); +Route::put('pages/{page}/weeztix', [WeeztixController::class, 'update'])->name('pages.weeztix.update'); +Route::delete('pages/{page}/weeztix', [WeeztixController::class, 'destroy'])->name('pages.weeztix.destroy'); + +// OAuth callback (needs to be accessible during OAuth flow) +Route::get('weeztix/callback', [WeeztixOAuthController::class, 'callback'])->name('weeztix.callback'); + +// AJAX endpoints for dynamic loading +Route::post('weeztix/coupons', [WeeztixApiController::class, 'coupons'])->name('weeztix.coupons'); +``` + +### OAuth Flow + +**Step 1: User enters client_id and client_secret in the backend form.** + +**Step 2: User clicks "Connect to Weeztix" button.** + +The controller builds the authorization URL and redirects: +```php +public function redirect(PreregistrationPage $page) +{ + $config = $page->weeztixConfig; + $state = Str::random(40); + + // Store state + page ID in session for the callback + session(['weeztix_oauth_state' => $state, 'weeztix_page_id' => $page->id]); + + $query = http_build_query([ + 'client_id' => $config->client_id, + 'redirect_uri' => route('admin.weeztix.callback'), + 'response_type' => 'code', + 'state' => $state, + ]); + + return redirect("https://auth.openticket.tech/tokens/authorize?{$query}"); +} +``` + +**Step 3: Weeztix redirects back to our callback URL with `code` and `state`.** + +```php +public function callback(Request $request) +{ + // Verify state + $storedState = session('weeztix_oauth_state'); + $pageId = session('weeztix_page_id'); + abort_if($request->state !== $storedState, 403, 'Invalid state'); + + // Exchange code for tokens + $response = Http::post('https://auth.openticket.tech/tokens', [ + 'grant_type' => 'authorization_code', + 'client_id' => $config->client_id, + 'client_secret' => $config->client_secret, + 'redirect_uri' => route('admin.weeztix.callback'), + 'code' => $request->code, + ]); + + // Store tokens in weeztix_configs + $config->update([ + 'access_token' => $data['access_token'], + 'refresh_token' => $data['refresh_token'], + 'token_expires_at' => now()->addSeconds($data['expires_in']), + 'refresh_token_expires_at' => now()->addSeconds($data['refresh_token_expires_in']), + 'is_connected' => true, + ]); + + // Redirect back to the Weeztix config page + return redirect()->route('admin.pages.weeztix.edit', $pageId) + ->with('success', 'Successfully connected to Weeztix!'); +} +``` + +**Step 4: After OAuth, the user selects a Company and Coupon (see Part 5).** + +--- + +## Part 5: Backend Configuration UI + +### Weeztix Configuration Page (tab/section within page edit) + +Build a multi-step configuration interface similar to the Mailwizz config: + +``` +┌─── Weeztix Integration ────────────────────────────────┐ +│ │ +│ Step 1: OAuth Credentials │ +│ ┌────────────────────────────────────────────────┐ │ +│ │ Client ID: [________________________] │ │ +│ │ Client Secret: [________________________] │ │ +│ │ Redirect URI: https://preregister.crewli.nl │ │ +│ │ /admin/weeztix/callback │ │ +│ │ (auto-generated, read-only) │ │ +│ │ │ │ +│ │ [Connect to Weeztix] ← OAuth redirect button │ │ +│ └────────────────────────────────────────────────┘ │ +│ │ +│ Step 2: Select Coupon (shown after OAuth success) │ +│ ┌────────────────────────────────────────────────┐ │ +│ │ Status: ✅ Connected │ │ +│ │ Token expires: 2026-04-07 14:30 │ │ +│ │ │ │ +│ │ Coupon: [▼ Select a coupon ] │ │ +│ │ - Early Bird 20% discount │ │ +│ │ - Pre-register €5 korting │ │ +│ │ │ │ +│ │ Code Prefix: [PREREG ] │ │ +│ │ Usage per Code: [1] │ │ +│ │ │ │ +│ │ [Save Configuration] │ │ +│ └────────────────────────────────────────────────┘ │ +│ │ +│ [Disconnect Weeztix] │ +└─────────────────────────────────────────────────────────┘ +``` + +**Instructions to show the user:** +> "Before connecting, create an OAuth Client in your Weeztix dashboard and set the redirect URI to: `{route('admin.weeztix.callback')}`. Also create a Coupon with the desired discount settings. You will select this Coupon here after connecting." + +**Loading Coupons (AJAX):** +After successful OAuth, use Alpine.js to fetch coupons via: +``` +POST /admin/weeztix/coupons +Body: { page_id: ... } +``` +The controller uses `WeeztixService::getCoupons()` and returns the list as JSON. + +--- + +## Part 6: Frontend — Coupon Code Generation on Registration + +### Updated Subscription Flow + +When a visitor registers on a public page: + +``` +1. Validate form input +2. Check for duplicate email +3. Store subscriber in database +4. IF Weeztix is configured AND connected: + a. Generate a unique coupon code: PREREG-A7X9K2 + b. Create the coupon code in Weeztix via API + c. Store the coupon code on the subscriber record +5. IF Mailwizz is configured: + a. Dispatch SyncSubscriberToMailwizz job + b. The job should include the coupon_code in the Mailwizz subscriber data + (if a Mailwizz field mapping for coupon_code is configured) +6. Return success with thank-you message +``` + +### Job: `CreateWeeztixCouponCode` + +Create a queued job (or execute synchronously if speed is critical — the visitor should see the coupon code in the thank-you message): + +```php +class CreateWeeztixCouponCode implements ShouldQueue +{ + use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + + public function __construct( + private Subscriber $subscriber + ) {} + + public function handle(): void + { + $page = $this->subscriber->preregistrationPage; + $config = $page->weeztixConfig; + + if (!$config || !$config->is_connected || !$config->coupon_guid) { + return; + } + + $service = new WeeztixService($config); + $code = WeeztixService::generateUniqueCode($config->code_prefix); + + try { + $service->createCouponCode($code); + + $this->subscriber->update(['coupon_code' => $code]); + } catch (\Exception $e) { + Log::error('Failed to create Weeztix coupon code', [ + 'subscriber_id' => $this->subscriber->id, + 'error' => $e->getMessage(), + ]); + throw $e; // Let the job retry + } + } +} +``` + +**IMPORTANT DECISION: Sync vs Async** + +If the coupon code should be shown in the thank-you message immediately after registration, the Weeztix API call must be **synchronous** (not queued). In that case, call the service directly in `PublicPageController@subscribe` instead of dispatching a job: + +```php +// In PublicPageController@subscribe, after storing the subscriber: +if ($page->weeztixConfig && $page->weeztixConfig->is_connected) { + try { + $service = new WeeztixService($page->weeztixConfig); + $code = WeeztixService::generateUniqueCode($page->weeztixConfig->code_prefix); + $service->createCouponCode($code); + $subscriber->update(['coupon_code' => $code]); + } catch (\Exception $e) { + Log::error('Weeztix coupon creation failed', ['error' => $e->getMessage()]); + // Don't fail the registration — the subscriber is already saved + } +} + +// Then dispatch the Mailwizz sync job (which includes the coupon_code) +if ($page->mailwizzConfig) { + SyncSubscriberToMailwizz::dispatch($subscriber->fresh()); +} + +return response()->json([ + 'success' => true, + 'message' => $page->thank_you_message ?? 'Bedankt voor je registratie!', + 'coupon_code' => $subscriber->coupon_code, // null if Weeztix not configured +]); +``` + +### Show Coupon Code in Thank-You State + +Update the Alpine.js success state to display the coupon code if present: + +```html +
+

+ + +
+``` + +--- + +## Part 7: Mailwizz Integration — Forward Coupon Code + +### Add Coupon Code Field Mapping to Mailwizz Config + +Add a new field to `mailwizz_configs`: + +```php +// New migration +Schema::table('mailwizz_configs', function (Blueprint $table) { + $table->string('field_coupon_code')->nullable()->after('field_phone'); + // This maps to a Mailwizz custom field (e.g., 'COUPON' tag) +}); +``` + +### Update Mailwizz Configuration Wizard + +In the field mapping step, add an additional mapping: +- **Coupon Code** → show text fields from the Mailwizz list → let user select the field where the coupon code should be stored (e.g., a custom field with tag `COUPON`) +- This mapping is optional — only shown if Weeztix is also configured + +### Update SyncSubscriberToMailwizz Job + +When building the Mailwizz subscriber data array, include the coupon code if the mapping exists: + +```php +// In the sync job, when building the data array: +if ($config->field_coupon_code && $subscriber->coupon_code) { + $data[$config->field_coupon_code] = $subscriber->coupon_code; +} +``` + +This allows the Mailwizz email template to include `[COUPON]` as a merge tag, so each subscriber receives their personal coupon code in the email. + +--- + +## Part 8: Subscriber Management — Show Coupon Codes + +### Update Subscribers Index + +Add the coupon code column to the subscribers table in the backend: + +| First Name | Last Name | Email | Phone | Coupon Code | Synced | Registered | +|---|---|---|---|---|---|---| +| Bert | Hausmans | bert@... | +316... | PREREG-A7X9K2 | ✅ | 2026-04-04 | + +### Update CSV Export + +Add the `coupon_code` column to the CSV export. + +--- + +## Part 9: Implementation Order + +### Step 1: Database +- Create `weeztix_configs` migration +- Add `coupon_code` to `subscribers` migration +- Add `field_coupon_code` to `mailwizz_configs` migration +- Create `WeeztixConfig` model +- Update `PreregistrationPage` model with `weeztixConfig` relationship +- Run migrations + +### Step 2: WeeztixService +- Create `app/Services/WeeztixService.php` +- Implement token management (get valid token, refresh, detect expiry) +- Implement `getCoupons()` and `createCouponCode()` +- Add unique code generation + +### Step 3: OAuth Flow +- Create `WeeztixOAuthController` (redirect + callback) +- Add routes for OAuth +- Handle state validation and token storage + +### Step 4: Backend Configuration UI +- Create `WeeztixController` (edit, update, destroy) +- Create `WeeztixApiController` (coupons AJAX endpoint) +- Create Blade view `admin/weeztix/edit.blade.php` +- Build the multi-step form with Alpine.js +- Add link/tab in the page edit navigation + +### Step 5: Frontend Coupon Generation +- Update `PublicPageController@subscribe` to generate coupon codes +- Update Alpine.js success state to show coupon code +- Add copy-to-clipboard functionality + +### Step 6: Mailwizz Coupon Forwarding +- Update Mailwizz config migration and model +- Update Mailwizz configuration wizard (add coupon_code field mapping) +- Update `SyncSubscriberToMailwizz` job to include coupon code + +### Step 7: Backend Updates +- Update subscribers index to show coupon codes +- Update CSV export to include coupon codes +- Add Weeztix connection status indicator on the pages index + +### Step 8: Error Handling & Edge Cases +- Handle Weeztix API downtime (don't fail registration) +- Handle expired refresh tokens (show "Reconnect" button) +- Handle duplicate coupon codes (retry with new code) +- Handle Weeztix rate limiting +- Log all API interactions + +--- + +## Important Rules + +- **OAuth credentials must be encrypted** — use Laravel's `encrypted` cast +- **Token refresh must be automatic** — the user should never have to manually refresh +- **Registration must not fail** if Weeztix is down — coupon code is a bonus, not a requirement +- **Coupon codes must be unique** — use the unambiguous character set (no 0/O/1/I) +- **Blade + Alpine.js + Tailwind only** — no additional JS frameworks +- **All text in Dutch** — labels, messages, instructions +- **Don't break existing integrations** — Mailwizz sync must continue working, form blocks must remain functional +- **Refer to the Weeztix API documentation** for exact request/response formats: + - Authentication: https://docs.weeztix.com/docs/introduction/authentication/ + - Request token: https://docs.weeztix.com/docs/introduction/authentication/request-token + - Refresh token: https://docs.weeztix.com/docs/introduction/authentication/refresh-token + - Get Coupons: https://docs.weeztix.com/api/dashboard/get-coupons + - Add CouponCodes: https://docs.weeztix.com/api/dashboard/add-coupon-codes + - Issuing requests (Company header): https://docs.weeztix.com/docs/introduction/issue-request/ diff --git a/resources/js/app.js b/resources/js/app.js index ed7a874..aebd16a 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -420,6 +420,7 @@ document.addEventListener('alpine:init', () => { redirectSecondsLeft: null, redirectTimer: null, strings: config.strings || {}, + couponCode: '', copyPageLink() { const url = this.pageShareUrl; @@ -434,6 +435,19 @@ document.addEventListener('alpine:init', () => { }); }, + copyCouponCode() { + const code = this.couponCode; + if (!code) { + return; + } + navigator.clipboard.writeText(code).then(() => { + this.copyFeedback = this.strings?.couponCopied || ''; + setTimeout(() => { + this.copyFeedback = ''; + }, 2500); + }); + }, + init() { if (this.phase === 'before') { this.tickCountdown(); @@ -552,6 +566,8 @@ document.addEventListener('alpine:init', () => { if (res.ok && data.success) { this.phase = 'thanks'; this.thankYouMessage = data.message ?? ''; + this.couponCode = + typeof data.coupon_code === 'string' && data.coupon_code !== '' ? data.coupon_code : ''; this.startRedirectCountdownIfNeeded(); return; } @@ -574,6 +590,7 @@ document.addEventListener('alpine:init', () => { fieldsUrl: cfg.fieldsUrl, phoneEnabled: cfg.phoneEnabled, hasExistingConfig: cfg.hasExistingConfig, + hasWeeztixIntegration: cfg.hasWeeztixIntegration === true, existing: cfg.existing, csrf: cfg.csrf, step: 1, @@ -586,6 +603,7 @@ document.addEventListener('alpine:init', () => { fieldFirstName: '', fieldLastName: '', fieldPhone: '', + fieldCouponCode: '', tagField: '', tagValue: '', loading: false, @@ -597,6 +615,7 @@ document.addEventListener('alpine:init', () => { this.fieldFirstName = this.existing.field_first_name ?? ''; this.fieldLastName = this.existing.field_last_name ?? ''; this.fieldPhone = this.existing.field_phone ?? ''; + this.fieldCouponCode = this.existing.field_coupon_code ?? ''; this.tagField = this.existing.tag_field ?? ''; this.tagValue = this.existing.tag_value ?? ''; this.selectedListUid = this.existing.list_uid ?? ''; @@ -706,6 +725,7 @@ document.addEventListener('alpine:init', () => { this.fieldFirstName = this.existing.field_first_name || this.fieldFirstName; this.fieldLastName = this.existing.field_last_name || this.fieldLastName; this.fieldPhone = this.existing.field_phone || this.fieldPhone; + this.fieldCouponCode = this.existing.field_coupon_code || this.fieldCouponCode; this.tagField = this.existing.tag_field || this.tagField; this.tagValue = this.existing.tag_value || this.tagValue; } @@ -746,6 +766,104 @@ document.addEventListener('alpine:init', () => { this.$refs.saveForm.requestSubmit(); }, })); + + Alpine.data('weeztixSetup', (cfg) => ({ + pageId: cfg.pageId, + companiesUrl: cfg.companiesUrl, + couponsUrl: cfg.couponsUrl, + csrf: cfg.csrf, + isConnected: cfg.isConnected === true, + callbackUrl: cfg.callbackUrl, + errorMessage: '', + companies: [], + coupons: [], + companyGuid: '', + companyName: '', + couponGuid: '', + couponName: '', + codePrefix: 'PREREG', + usageCount: 1, + strings: cfg.strings || {}, + + async init() { + if (cfg.existing) { + this.codePrefix = cfg.existing.code_prefix || 'PREREG'; + this.usageCount = + typeof cfg.existing.usage_count === 'number' ? cfg.existing.usage_count : 1; + this.companyGuid = cfg.existing.company_guid || ''; + this.companyName = cfg.existing.company_name || ''; + this.couponGuid = cfg.existing.coupon_guid || ''; + this.couponName = cfg.existing.coupon_name || ''; + } + if (this.isConnected) { + await this.loadCompanies(); + if (this.companyGuid) { + await this.loadCouponsForGuid(this.companyGuid); + } + } + }, + + async postJson(url, body) { + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-CSRF-TOKEN': this.csrf, + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify(body), + }); + const data = await res.json().catch(() => ({})); + return { res, data }; + }, + + syncCompanyNameFromSelection() { + const c = this.companies.find((x) => x.guid === this.companyGuid); + this.companyName = c && c.name ? c.name : ''; + }, + + syncCouponName() { + const c = this.coupons.find((x) => x.guid === this.couponGuid); + this.couponName = c ? c.name : ''; + }, + + async loadCompanies() { + this.errorMessage = ''; + const { res, data } = await this.postJson(this.companiesUrl, { page_id: this.pageId }); + if (!res.ok) { + this.errorMessage = data.message || this.strings.genericError; + return; + } + this.companies = Array.isArray(data.companies) ? data.companies : []; + this.syncCompanyNameFromSelection(); + }, + + async onCompanyChange() { + this.syncCompanyNameFromSelection(); + this.couponGuid = ''; + this.couponName = ''; + this.coupons = []; + if (!this.companyGuid) { + return; + } + await this.loadCouponsForGuid(this.companyGuid); + }, + + async loadCouponsForGuid(guid) { + this.errorMessage = ''; + const { res, data } = await this.postJson(this.couponsUrl, { + page_id: this.pageId, + company_guid: guid, + }); + if (!res.ok) { + this.errorMessage = data.message || this.strings.loadCouponsError; + return; + } + this.coupons = Array.isArray(data.coupons) ? data.coupons : []; + this.syncCouponName(); + }, + })); }); window.Alpine = Alpine; diff --git a/resources/views/admin/mailwizz/edit.blade.php b/resources/views/admin/mailwizz/edit.blade.php index 0568137..a33a6b9 100644 --- a/resources/views/admin/mailwizz/edit.blade.php +++ b/resources/views/admin/mailwizz/edit.blade.php @@ -1,5 +1,7 @@ @php $config = $page->mailwizzConfig; + $page->loadMissing('weeztixConfig'); + $hasWeeztixForCouponMap = $page->weeztixConfig !== null && $page->weeztixConfig->is_connected; $existing = $config !== null ? [ 'list_uid' => $config->list_uid, @@ -8,6 +10,7 @@ 'field_first_name' => $config->field_first_name, 'field_last_name' => $config->field_last_name, 'field_phone' => $config->field_phone, + 'field_coupon_code' => $config->field_coupon_code, 'tag_field' => $config->tag_field, 'tag_value' => $config->tag_value, ] @@ -27,6 +30,7 @@ 'csrf' => csrf_token(), 'phoneEnabled' => $page->isPhoneFieldEnabledForSubscribers(), 'hasExistingConfig' => $config !== null, + 'hasWeeztixIntegration' => $hasWeeztixForCouponMap, 'existing' => $existing, 'strings' => [ 'apiKeyRequired' => __('Enter your Mailwizz API key to continue.'), @@ -190,6 +194,16 @@ +
+ +

{{ __('Koppel aan een tekstveld in Mailwizz om de persoonlijke code in e-mails te tonen.') }}

+ +
+ diff --git a/resources/views/admin/pages/edit.blade.php b/resources/views/admin/pages/edit.blade.php index ad36d85..6cdeb97 100644 --- a/resources/views/admin/pages/edit.blade.php +++ b/resources/views/admin/pages/edit.blade.php @@ -13,8 +13,9 @@ {{ __('Public URL') }}: {{ url('/r/'.$page->slug) }}

@can('update', $page) -

+

{{ __('Mailwizz integration') }} → + {{ __('Weeztix integration') }} →

@endcan
diff --git a/resources/views/admin/pages/index.blade.php b/resources/views/admin/pages/index.blade.php index e599961..394f515 100644 --- a/resources/views/admin/pages/index.blade.php +++ b/resources/views/admin/pages/index.blade.php @@ -75,6 +75,12 @@ @can('update', $page) {{ __('Mailwizz') }} @endcan + @can('update', $page) + {{ __('Weeztix') }} + @if ($page->weeztixConfig?->is_connected) + + @endif + @endcan + + @endif + +
+
+ +
+

{{ __('Stap 1: OAuth-gegevens') }}

+

+ {{ __('Maak eerst een OAuth-client in het Weeztix-dashboard en stel de redirect-URI exact in op:') }} +

+

+

+ {{ __('Maak daarna een korting (coupon) in Weeztix; die kies je hierna in stap 2.') }} +

+ +
+ @csrf + @method('PUT') +
+ + +
+
+ + +
+ +
+ + @if ($wz !== null) + + @endif +
+ + @if ($wz !== null) +
+

{{ __('Stap 2: Bedrijf en kortingsbon') }}

+

{{ __('Na een geslaagde verbinding kun je een bedrijf en bestaande coupon uit Weeztix kiezen.') }}

+ +
+ @csrf + @method('PUT') + +
+ + + +
+ +
+ + + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ @endif +
+ +@endsection diff --git a/resources/views/components/blocks/form.blade.php b/resources/views/components/blocks/form.blade.php index 7551f51..52b4bfc 100644 --- a/resources/views/components/blocks/form.blade.php +++ b/resources/views/components/blocks/form.blade.php @@ -156,6 +156,30 @@

+

filled($redirectAfterSubmit) ? $redirectAfterSubmit : null, 'strings' => [ 'linkCopied' => __('Link gekopieerd!'), + 'couponCopied' => __('Kortingscode gekopieerd!'), 'redirectCountdown' => __('You will be redirected in :seconds s…'), ], ]))" diff --git a/routes/web.php b/routes/web.php index 2951594..294ac8c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -8,6 +8,9 @@ use App\Http\Controllers\Admin\MailwizzController; use App\Http\Controllers\Admin\PageController; use App\Http\Controllers\Admin\SubscriberController; use App\Http\Controllers\Admin\UserController; +use App\Http\Controllers\Admin\WeeztixApiController; +use App\Http\Controllers\Admin\WeeztixController; +use App\Http\Controllers\Admin\WeeztixOAuthController; use App\Http\Controllers\ProfileController; use App\Http\Controllers\PublicPageController; use Illuminate\Support\Facades\Route; @@ -46,6 +49,16 @@ Route::middleware(['auth', 'verified'])->prefix('admin')->name('admin.')->group( Route::post('mailwizz/lists', [MailwizzApiController::class, 'lists'])->name('mailwizz.lists'); Route::post('mailwizz/fields', [MailwizzApiController::class, 'fields'])->name('mailwizz.fields'); + // Weeztix configuration (nested under pages) + Route::get('pages/{page}/weeztix', [WeeztixController::class, 'edit'])->name('pages.weeztix.edit'); + Route::put('pages/{page}/weeztix', [WeeztixController::class, 'update'])->name('pages.weeztix.update'); + Route::delete('pages/{page}/weeztix', [WeeztixController::class, 'destroy'])->name('pages.weeztix.destroy'); + Route::get('pages/{page}/weeztix/oauth/redirect', [WeeztixOAuthController::class, 'redirect'])->name('pages.weeztix.oauth.redirect'); + Route::get('weeztix/callback', [WeeztixOAuthController::class, 'callback'])->name('weeztix.callback'); + + Route::post('weeztix/companies', [WeeztixApiController::class, 'companies'])->name('weeztix.companies'); + Route::post('weeztix/coupons', [WeeztixApiController::class, 'coupons'])->name('weeztix.coupons'); + // User management (superadmin only) Route::middleware('role:superadmin')->group(function () { Route::resource('users', UserController::class)->except(['show']); diff --git a/tests/Feature/SyncSubscriberToMailwizzTest.php b/tests/Feature/SyncSubscriberToMailwizzTest.php index 96999a1..7d57620 100644 --- a/tests/Feature/SyncSubscriberToMailwizzTest.php +++ b/tests/Feature/SyncSubscriberToMailwizzTest.php @@ -133,6 +133,43 @@ class SyncSubscriberToMailwizzTest extends TestCase $this->assertTrue($subscriber->synced_to_mailwizz); } + public function test_mailwizz_sync_includes_coupon_code_when_mapped(): 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) { + $body = $request->body(); + $this->assertStringContainsString('COUPON', $body); + $this->assertStringContainsString('PREREG-TEST99', $body); + + return Http::response(['status' => 'success']); + } + + return Http::response(['status' => 'error'], 500); + }); + + $page = $this->makePageWithMailwizz([ + 'field_coupon_code' => 'COUPON', + ]); + + $subscriber = Subscriber::query()->create([ + 'preregistration_page_id' => $page->id, + 'first_name' => 'Coupon', + 'last_name' => 'User', + 'email' => 'coupon-map@example.com', + 'coupon_code' => 'PREREG-TEST99', + 'synced_to_mailwizz' => false, + ]); + + SyncSubscriberToMailwizz::dispatchSync($subscriber); + + $subscriber->refresh(); + $this->assertTrue($subscriber->synced_to_mailwizz); + } + /** * @param array $configOverrides */ @@ -164,6 +201,7 @@ class SyncSubscriberToMailwizzTest extends TestCase 'field_first_name' => 'FNAME', 'field_last_name' => 'LNAME', 'field_phone' => null, + 'field_coupon_code' => null, 'tag_field' => 'TAGS', 'tag_value' => 'preregister-source', ], $configOverrides));