From d802ce2a7c0045e5df2c9223c6ae2f597a97eb20 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sun, 5 Apr 2026 11:34:01 +0200 Subject: [PATCH] feat(subscribe): queue Weeztix coupon, then Mailwizz; document queues - RegisterSubscriberOnPage: persist subscriber then dispatch integrations - IssueWeeztixCouponForSubscriber on weeztix queue; dispatches Mailwizz after coupon attempt (idempotent if coupon_code already set); failed() fallback - SyncSubscriberToMailwizz implements ShouldQueueAfterCommit - Deployment: worker listens weeztix,mailwizz,default; warn against sync in prod - .env.example: QUEUE_CONNECTION notes for subscribe UX Made-with: Cursor --- .env.example | 4 + app/Http/Controllers/PublicPageController.php | 74 +-------- app/Jobs/IssueWeeztixCouponForSubscriber.php | 153 ++++++++++++++++++ app/Jobs/SyncSubscriberToMailwizz.php | 4 +- app/Services/RegisterSubscriberOnPage.php | 49 ++++++ documentation/DEPLOYMENT-STRATEGY.md | 9 +- 6 files changed, 222 insertions(+), 71 deletions(-) create mode 100644 app/Jobs/IssueWeeztixCouponForSubscriber.php create mode 100644 app/Services/RegisterSubscriberOnPage.php diff --git a/.env.example b/.env.example index 740a556..8c8c910 100644 --- a/.env.example +++ b/.env.example @@ -45,6 +45,10 @@ SESSION_DOMAIN=null BROADCAST_CONNECTION=log FILESYSTEM_DISK=local + +# Use "database" or "redis" in production and run `php artisan queue:work` (see documentation/DEPLOYMENT-STRATEGY.md). +# Avoid "sync" in production: Mailwizz (and Weeztix coupon) jobs would run inside the HTTP request; a thrown error +# can return 5xx to the visitor even though the subscriber row is already saved — confusing UX. QUEUE_CONNECTION=database CACHE_STORE=database diff --git a/app/Http/Controllers/PublicPageController.php b/app/Http/Controllers/PublicPageController.php index 5945564..7ea5ea5 100644 --- a/app/Http/Controllers/PublicPageController.php +++ b/app/Http/Controllers/PublicPageController.php @@ -4,22 +4,20 @@ 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 App\Services\RegisterSubscriberOnPage; use Illuminate\Http\JsonResponse; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Log; use Illuminate\View\View; -use Throwable; class PublicPageController extends Controller { + public function __construct( + private readonly RegisterSubscriberOnPage $registerSubscriberOnPage + ) {} + public function show(PreregistrationPage $publicPage): View { $publicPage->load(['blocks' => fn ($q) => $q->orderBy('sort_order')]); @@ -56,17 +54,7 @@ class PublicPageController extends Controller ], 422); } - $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->fresh()); - } + $this->registerSubscriberOnPage->storeAndQueueIntegrations($publicPage, $validated); return response()->json([ 'success' => true, @@ -74,56 +62,6 @@ class PublicPageController extends Controller ]); } - 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, - ]); - } - private function resolvePageState(PreregistrationPage $page): string { if ($page->isBeforeStart()) { diff --git a/app/Jobs/IssueWeeztixCouponForSubscriber.php b/app/Jobs/IssueWeeztixCouponForSubscriber.php new file mode 100644 index 0000000..6c6d1f5 --- /dev/null +++ b/app/Jobs/IssueWeeztixCouponForSubscriber.php @@ -0,0 +1,153 @@ + + */ + public array $backoff = [5, 15, 45]; + + /** + * Seconds before the unique lock expires if the worker dies before releasing it. + */ + public int $uniqueFor = 300; + + public function __construct(public Subscriber $subscriber) + { + $this->onQueue('weeztix'); + } + + public function uniqueId(): string + { + return 'weeztix-coupon-subscriber-'.$this->subscriber->getKey(); + } + + public function handle(): void + { + $subscriber = Subscriber::query() + ->with(['preregistrationPage.weeztixConfig', 'preregistrationPage.mailwizzConfig']) + ->find($this->subscriber->id); + + if ($subscriber === null) { + return; + } + + $page = $subscriber->preregistrationPage; + if ($page === null) { + return; + } + + $config = $page->weeztixConfig; + $couponMissing = ! is_string($subscriber->coupon_code) || $subscriber->coupon_code === ''; + + if ($couponMissing && $this->weeztixCanIssueCodes($config)) { + $this->tryAttachWeeztixCouponCode($subscriber, $config); + } + + $this->dispatchMailwizzIfNeeded($subscriber); + } + + public function failed(?Throwable $exception): void + { + Log::error('IssueWeeztixCouponForSubscriber failed', [ + 'subscriber_id' => $this->subscriber->id, + 'message' => $exception?->getMessage(), + ]); + + $subscriber = Subscriber::query() + ->with('preregistrationPage.mailwizzConfig') + ->find($this->subscriber->id); + + if ($subscriber !== null) { + $this->dispatchMailwizzIfNeeded($subscriber); + } + } + + private function dispatchMailwizzIfNeeded(Subscriber $subscriber): void + { + $page = $subscriber->preregistrationPage; + $page?->loadMissing('mailwizzConfig'); + + if ($page?->mailwizzConfig !== null) { + SyncSubscriberToMailwizz::dispatch($subscriber->fresh()); + } + } + + 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/Jobs/SyncSubscriberToMailwizz.php b/app/Jobs/SyncSubscriberToMailwizz.php index 2aa1445..79c7a3e 100644 --- a/app/Jobs/SyncSubscriberToMailwizz.php +++ b/app/Jobs/SyncSubscriberToMailwizz.php @@ -9,7 +9,7 @@ use App\Models\Subscriber; use App\Services\MailwizzService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeUnique; -use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Contracts\Queue\ShouldQueueAfterCommit; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; @@ -17,7 +17,7 @@ use Illuminate\Support\Facades\Log; use RuntimeException; use Throwable; -class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue +class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueueAfterCommit { use Dispatchable; use InteractsWithQueue; diff --git a/app/Services/RegisterSubscriberOnPage.php b/app/Services/RegisterSubscriberOnPage.php new file mode 100644 index 0000000..7ff93e0 --- /dev/null +++ b/app/Services/RegisterSubscriberOnPage.php @@ -0,0 +1,49 @@ + $validated + */ + public function storeAndQueueIntegrations(PreregistrationPage $page, array $validated): Subscriber + { + $subscriber = $page->subscribers()->create($validated); + + $page->loadMissing('weeztixConfig', 'mailwizzConfig'); + $weeztix = $page->weeztixConfig; + + if ($this->weeztixCanIssueCodes($weeztix)) { + IssueWeeztixCouponForSubscriber::dispatch($subscriber); + } elseif ($page->mailwizzConfig !== null) { + SyncSubscriberToMailwizz::dispatch($subscriber->fresh()); + } + + return $subscriber; + } + + 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 !== ''; + } +} diff --git a/documentation/DEPLOYMENT-STRATEGY.md b/documentation/DEPLOYMENT-STRATEGY.md index 5be2501..f50ce09 100644 --- a/documentation/DEPLOYMENT-STRATEGY.md +++ b/documentation/DEPLOYMENT-STRATEGY.md @@ -269,6 +269,11 @@ Laravel ships with a `public/.htaccess` that works with Apache. Verify `mod_rewr ### 4.7 Set up cron for queue worker + scheduler +Public registration saves the subscriber in the database first, then queues **Weeztix** (coupon code) and **Mailwizz** sync jobs. Those jobs must be processed by a worker. + +- **Production:** set `QUEUE_CONNECTION=database` (or `redis`) — never rely on `sync`, or a Mailwizz/Weeztix failure can return HTTP 5xx to the visitor while the subscriber is already stored (confusing UX). +- **Queues:** coupon jobs use `weeztix`; Mailwizz uses `mailwizz`. The worker should listen to both (order below prioritises `weeztix` so coupon creation tends to run before sync when both are pending). + In DirectAdmin → Cron Jobs, add: ``` @@ -276,9 +281,11 @@ In DirectAdmin → Cron Jobs, add: * * * * * cd /home/username/preregister && php artisan schedule:run >> /dev/null 2>&1 # Queue worker - process one job per run (every minute) -* * * * * cd /home/username/preregister && php artisan queue:work --once --queue=mailwizz >> /dev/null 2>&1 +* * * * * cd /home/username/preregister && php artisan queue:work --once --queue=weeztix,mailwizz,default >> /dev/null 2>&1 ``` +For higher throughput, use a persistent supervisor-managed worker instead of `--once` in cron; keep the same `--queue=weeztix,mailwizz,default` order. + ### 4.8 Directory permissions ```bash