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
This commit is contained in:
2026-04-05 11:34:01 +02:00
parent 7ed660ec55
commit d802ce2a7c
6 changed files with 222 additions and 71 deletions

View File

@@ -45,6 +45,10 @@ SESSION_DOMAIN=null
BROADCAST_CONNECTION=log BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local 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 QUEUE_CONNECTION=database
CACHE_STORE=database CACHE_STORE=database

View File

@@ -4,22 +4,20 @@ declare(strict_types=1);
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Exceptions\WeeztixCouponCodeConflictException;
use App\Http\Requests\SubscribePublicPageRequest; use App\Http\Requests\SubscribePublicPageRequest;
use App\Jobs\SyncSubscriberToMailwizz;
use App\Models\PageBlock; use App\Models\PageBlock;
use App\Models\PreregistrationPage; use App\Models\PreregistrationPage;
use App\Models\Subscriber; use App\Services\RegisterSubscriberOnPage;
use App\Models\WeeztixConfig;
use App\Services\WeeztixService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View; use Illuminate\View\View;
use Throwable;
class PublicPageController extends Controller class PublicPageController extends Controller
{ {
public function __construct(
private readonly RegisterSubscriberOnPage $registerSubscriberOnPage
) {}
public function show(PreregistrationPage $publicPage): View public function show(PreregistrationPage $publicPage): View
{ {
$publicPage->load(['blocks' => fn ($q) => $q->orderBy('sort_order')]); $publicPage->load(['blocks' => fn ($q) => $q->orderBy('sort_order')]);
@@ -56,17 +54,7 @@ class PublicPageController extends Controller
], 422); ], 422);
} }
$subscriber = $publicPage->subscribers()->create($validated); $this->registerSubscriberOnPage->storeAndQueueIntegrations($publicPage, $validated);
$publicPage->loadMissing('weeztixConfig');
$weeztix = $publicPage->weeztixConfig;
if ($this->weeztixCanIssueCodes($weeztix)) {
$this->tryAttachWeeztixCouponCode($subscriber, $weeztix);
}
if ($publicPage->mailwizzConfig !== null) {
SyncSubscriberToMailwizz::dispatch($subscriber->fresh());
}
return response()->json([ return response()->json([
'success' => true, '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 private function resolvePageState(PreregistrationPage $page): string
{ {
if ($page->isBeforeStart()) { if ($page->isBeforeStart()) {

View File

@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Exceptions\WeeztixCouponCodeConflictException;
use App\Models\Subscriber;
use App\Models\WeeztixConfig;
use App\Services\WeeztixService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Creates the Weeztix coupon code after the subscriber row exists, then queues Mailwizz sync
* so the external APIs never block the public HTTP response and Mailwizz runs after coupon_code is set when possible.
*/
final class IssueWeeztixCouponForSubscriber implements ShouldBeUnique, ShouldQueueAfterCommit
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 3;
/**
* @var list<int>
*/
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,
]);
}
}

View File

@@ -9,7 +9,7 @@ use App\Models\Subscriber;
use App\Services\MailwizzService; use App\Services\MailwizzService;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
@@ -17,7 +17,7 @@ use Illuminate\Support\Facades\Log;
use RuntimeException; use RuntimeException;
use Throwable; use Throwable;
class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueueAfterCommit
{ {
use Dispatchable; use Dispatchable;
use InteractsWithQueue; use InteractsWithQueue;

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Jobs\IssueWeeztixCouponForSubscriber;
use App\Jobs\SyncSubscriberToMailwizz;
use App\Models\PreregistrationPage;
use App\Models\Subscriber;
use App\Models\WeeztixConfig;
/**
* Orchestrates public registration: local persist first, then queue external integrations
* so Weeztix/Mailwizz failures never prevent a subscriber row from being stored.
*/
final class RegisterSubscriberOnPage
{
/**
* @param array<string, mixed> $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 !== '';
}
}

View File

@@ -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 ### 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: 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 * * * * * cd /home/username/preregister && php artisan schedule:run >> /dev/null 2>&1
# Queue worker - process one job per run (every minute) # 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 ### 4.8 Directory permissions
```bash ```bash