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:
@@ -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
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
153
app/Jobs/IssueWeeztixCouponForSubscriber.php
Normal file
153
app/Jobs/IssueWeeztixCouponForSubscriber.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
49
app/Services/RegisterSubscriberOnPage.php
Normal file
49
app/Services/RegisterSubscriberOnPage.php
Normal 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 !== '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user