feat: clean Weeztix and Mailwizz when admin deletes subscriber
Run CleanupSubscriberIntegrationsService before delete: remove coupon code in Weeztix via list+DELETE API; update Mailwizz contact to strip configured source tag from the tag field and clear the mapped coupon field. Extract MailwizzCheckboxlistTags and MailwizzSubscriberFormPayload for shared sync/cleanup behaviour. Add WeeztixService list and delete helpers. Integration failures are logged only; local delete always proceeds. Feature tests cover Mailwizz strip+clear and Weeztix delete paths. Made-with: Cursor
This commit is contained in:
@@ -10,6 +10,7 @@ use App\Http\Requests\Admin\IndexSubscriberRequest;
|
||||
use App\Http\Requests\Admin\QueueMailwizzSyncRequest;
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Models\Subscriber;
|
||||
use App\Services\CleanupSubscriberIntegrationsService;
|
||||
use App\Services\DispatchUnsyncedMailwizzSyncJobsService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\View\View;
|
||||
@@ -34,8 +35,13 @@ class SubscriberController extends Controller
|
||||
return view('admin.subscribers.index', compact('page', 'subscribers', 'unsyncedMailwizzCount'));
|
||||
}
|
||||
|
||||
public function destroy(DestroySubscriberRequest $request, PreregistrationPage $page, Subscriber $subscriber): RedirectResponse
|
||||
{
|
||||
public function destroy(
|
||||
DestroySubscriberRequest $request,
|
||||
PreregistrationPage $page,
|
||||
Subscriber $subscriber,
|
||||
CleanupSubscriberIntegrationsService $cleanupIntegrations
|
||||
): RedirectResponse {
|
||||
$cleanupIntegrations->runBeforeDelete($subscriber);
|
||||
$subscriber->delete();
|
||||
|
||||
return redirect()
|
||||
|
||||
@@ -6,7 +6,9 @@ namespace App\Jobs;
|
||||
|
||||
use App\Models\MailwizzConfig;
|
||||
use App\Models\Subscriber;
|
||||
use App\Services\MailwizzCheckboxlistTags;
|
||||
use App\Services\MailwizzService;
|
||||
use App\Services\MailwizzSubscriberFormPayload;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
|
||||
@@ -131,29 +133,6 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueueAfterCommit
|
||||
return true;
|
||||
}
|
||||
|
||||
private function buildBasePayload(Subscriber $subscriber, MailwizzConfig $config, bool $phoneEnabled): array
|
||||
{
|
||||
$data = [
|
||||
$config->field_email => $subscriber->email,
|
||||
$config->field_first_name => $subscriber->first_name,
|
||||
$config->field_last_name => $subscriber->last_name,
|
||||
];
|
||||
|
||||
if ($phoneEnabled && $config->field_phone !== null && $config->field_phone !== '') {
|
||||
$phone = $subscriber->phoneDisplay();
|
||||
if ($phone !== null && $phone !== '') {
|
||||
$data[$config->field_phone] = $phone;
|
||||
}
|
||||
}
|
||||
|
||||
$couponField = $config->field_coupon_code;
|
||||
if (is_string($couponField) && $couponField !== '' && $subscriber->coupon_code !== null && $subscriber->coupon_code !== '') {
|
||||
$data[$couponField] = $subscriber->coupon_code;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function createInMailwizz(
|
||||
MailwizzService $service,
|
||||
Subscriber $subscriber,
|
||||
@@ -161,7 +140,7 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueueAfterCommit
|
||||
string $listUid
|
||||
): void {
|
||||
$page = $subscriber->preregistrationPage;
|
||||
$data = $this->buildBasePayload($subscriber, $config, $page->isPhoneFieldEnabledForSubscribers());
|
||||
$data = MailwizzSubscriberFormPayload::baseFields($subscriber, $config, $page->isPhoneFieldEnabledForSubscribers());
|
||||
$tagField = $config->tag_field;
|
||||
$tagValue = $config->tag_value;
|
||||
if ($tagField !== null && $tagField !== '' && $tagValue !== null && $tagValue !== '') {
|
||||
@@ -179,7 +158,7 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueueAfterCommit
|
||||
string $subscriberUid
|
||||
): void {
|
||||
$page = $subscriber->preregistrationPage;
|
||||
$data = $this->buildBasePayload($subscriber, $config, $page->isPhoneFieldEnabledForSubscribers());
|
||||
$data = MailwizzSubscriberFormPayload::baseFields($subscriber, $config, $page->isPhoneFieldEnabledForSubscribers());
|
||||
|
||||
$tagField = $config->tag_field;
|
||||
$tagValue = $config->tag_value;
|
||||
@@ -188,46 +167,11 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueueAfterCommit
|
||||
if ($full === null) {
|
||||
throw new RuntimeException('Mailwizz getSubscriber returned an empty payload.');
|
||||
}
|
||||
$existingCsv = $this->extractTagCsvFromResponse($full, $tagField);
|
||||
$merged = $this->mergeCheckboxlistTags($existingCsv, $tagValue);
|
||||
$existingCsv = MailwizzCheckboxlistTags::extractCsvFromSubscriberResponse($full, $tagField);
|
||||
$merged = MailwizzCheckboxlistTags::mergeValueIntoCsv($existingCsv, $tagValue);
|
||||
$data[$tagField] = $merged;
|
||||
}
|
||||
|
||||
$service->updateSubscriber($listUid, $subscriberUid, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $apiResponse
|
||||
*/
|
||||
private function extractTagCsvFromResponse(array $apiResponse, string $tagField): string
|
||||
{
|
||||
$record = data_get($apiResponse, 'data.record');
|
||||
if (! is_array($record)) {
|
||||
$record = data_get($apiResponse, 'data');
|
||||
}
|
||||
if (! is_array($record)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$raw = $record[$tagField] ?? data_get($record, "fields.{$tagField}");
|
||||
|
||||
if (is_array($raw)) {
|
||||
return implode(',', array_map(static fn (mixed $v): string => is_scalar($v) ? (string) $v : '', $raw));
|
||||
}
|
||||
|
||||
return is_string($raw) ? $raw : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function mergeCheckboxlistTags(string $existingCsv, string $newValue): array
|
||||
{
|
||||
$parts = array_filter(array_map('trim', explode(',', $existingCsv)), static fn (string $s): bool => $s !== '');
|
||||
if (! in_array($newValue, $parts, true)) {
|
||||
$parts[] = $newValue;
|
||||
}
|
||||
|
||||
return array_values($parts);
|
||||
}
|
||||
}
|
||||
|
||||
141
app/Services/CleanupSubscriberIntegrationsService.php
Normal file
141
app/Services/CleanupSubscriberIntegrationsService.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\MailwizzConfig;
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Models\Subscriber;
|
||||
use App\Models\WeeztixConfig;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Before a subscriber row is deleted, best-effort cleanup in Weeztix (coupon code) and Mailwizz (strip source tag, clear coupon field).
|
||||
* Failures are logged only; local delete must still proceed.
|
||||
*/
|
||||
final class CleanupSubscriberIntegrationsService
|
||||
{
|
||||
public function runBeforeDelete(Subscriber $subscriber): void
|
||||
{
|
||||
$subscriber->loadMissing(['preregistrationPage.mailwizzConfig', 'preregistrationPage.weeztixConfig']);
|
||||
$page = $subscriber->preregistrationPage;
|
||||
if ($page === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->cleanupWeeztixIfApplicable($subscriber, $page);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('CleanupSubscriberIntegrations: Weeztix cleanup failed', [
|
||||
'subscriber_id' => $subscriber->id,
|
||||
'preregistration_page_id' => $page->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->cleanupMailwizzIfApplicable($subscriber, $page);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('CleanupSubscriberIntegrations: Mailwizz cleanup failed', [
|
||||
'subscriber_id' => $subscriber->id,
|
||||
'preregistration_page_id' => $page->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function cleanupWeeztixIfApplicable(Subscriber $subscriber, PreregistrationPage $page): void
|
||||
{
|
||||
$config = $page->weeztixConfig;
|
||||
if ($config === null || ! $config->is_connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
$code = $subscriber->coupon_code;
|
||||
if (! is_string($code) || trim($code) === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->weeztixCanManageCodes($config)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$fresh = $config->fresh();
|
||||
if ($fresh === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
(new WeeztixService($fresh))->deleteCouponCodeByCodeString($code);
|
||||
}
|
||||
|
||||
private function weeztixCanManageCodes(WeeztixConfig $config): bool
|
||||
{
|
||||
$company = $config->company_guid;
|
||||
$coupon = $config->coupon_guid;
|
||||
|
||||
return is_string($company) && $company !== '' && is_string($coupon) && $coupon !== '';
|
||||
}
|
||||
|
||||
private function cleanupMailwizzIfApplicable(Subscriber $subscriber, PreregistrationPage $page): void
|
||||
{
|
||||
$config = $page->mailwizzConfig;
|
||||
if ($config === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->mailwizzConfigAllowsUpdate($config)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$apiKey = $config->api_key;
|
||||
if (! is_string($apiKey) || $apiKey === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$service = new MailwizzService($apiKey);
|
||||
$listUid = $config->list_uid;
|
||||
|
||||
$search = $service->searchSubscriber($listUid, $subscriber->email);
|
||||
if ($search === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$subscriberUid = $search['subscriber_uid'];
|
||||
$phoneEnabled = $page->isPhoneFieldEnabledForSubscribers();
|
||||
$data = MailwizzSubscriberFormPayload::baseFields($subscriber, $config, $phoneEnabled);
|
||||
|
||||
$couponField = $config->field_coupon_code;
|
||||
if (is_string($couponField) && $couponField !== '') {
|
||||
$data[$couponField] = '';
|
||||
}
|
||||
|
||||
$tagField = $config->tag_field;
|
||||
$tagValue = $config->tag_value;
|
||||
if ($tagField !== null && $tagField !== '' && $tagValue !== null && $tagValue !== '') {
|
||||
$full = $service->getSubscriber($listUid, $subscriberUid);
|
||||
if ($full === null) {
|
||||
throw new RuntimeException('Mailwizz getSubscriber returned an empty payload.');
|
||||
}
|
||||
$existingCsv = MailwizzCheckboxlistTags::extractCsvFromSubscriberResponse($full, $tagField);
|
||||
$data[$tagField] = MailwizzCheckboxlistTags::removeValueFromCsv($existingCsv, $tagValue);
|
||||
}
|
||||
|
||||
$service->updateSubscriber($listUid, $subscriberUid, $data);
|
||||
}
|
||||
|
||||
private function mailwizzConfigAllowsUpdate(MailwizzConfig $config): bool
|
||||
{
|
||||
if ($config->list_uid === '' || $config->field_email === '' || $config->field_first_name === '' || $config->field_last_name === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$hasTagWork = $config->tag_field !== null && $config->tag_field !== ''
|
||||
&& $config->tag_value !== null && $config->tag_value !== '';
|
||||
$hasCouponField = is_string($config->field_coupon_code) && $config->field_coupon_code !== '';
|
||||
|
||||
return $hasTagWork || $hasCouponField;
|
||||
}
|
||||
}
|
||||
64
app/Services/MailwizzCheckboxlistTags.php
Normal file
64
app/Services/MailwizzCheckboxlistTags.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
/**
|
||||
* Parses and mutates Mailwizz checkboxlist-style tag fields (comma-separated in API, array on write).
|
||||
*/
|
||||
final class MailwizzCheckboxlistTags
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $apiResponse getSubscriber JSON payload
|
||||
*/
|
||||
public static function extractCsvFromSubscriberResponse(array $apiResponse, string $tagField): string
|
||||
{
|
||||
$record = data_get($apiResponse, 'data.record');
|
||||
if (! is_array($record)) {
|
||||
$record = data_get($apiResponse, 'data');
|
||||
}
|
||||
if (! is_array($record)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$raw = $record[$tagField] ?? data_get($record, "fields.{$tagField}");
|
||||
|
||||
if (is_array($raw)) {
|
||||
return implode(',', array_map(static fn (mixed $v): string => is_scalar($v) ? (string) $v : '', $raw));
|
||||
}
|
||||
|
||||
return is_string($raw) ? $raw : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function mergeValueIntoCsv(string $existingCsv, string $newValue): array
|
||||
{
|
||||
$parts = array_filter(array_map('trim', explode(',', $existingCsv)), static fn (string $s): bool => $s !== '');
|
||||
if (! in_array($newValue, $parts, true)) {
|
||||
$parts[] = $newValue;
|
||||
}
|
||||
|
||||
return array_values($parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function removeValueFromCsv(string $existingCsv, string $valueToRemove): array
|
||||
{
|
||||
$needle = trim($valueToRemove);
|
||||
$parts = array_filter(array_map('trim', explode(',', $existingCsv)), static fn (string $s): bool => $s !== '');
|
||||
|
||||
if ($needle === '') {
|
||||
return array_values($parts);
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
$parts,
|
||||
static fn (string $s): bool => $s !== $needle
|
||||
));
|
||||
}
|
||||
}
|
||||
40
app/Services/MailwizzSubscriberFormPayload.php
Normal file
40
app/Services/MailwizzSubscriberFormPayload.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\MailwizzConfig;
|
||||
use App\Models\Subscriber;
|
||||
|
||||
/**
|
||||
* Shared Mailwizz list subscriber field payload for create/update (excluding tag merge logic).
|
||||
*/
|
||||
final class MailwizzSubscriberFormPayload
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function baseFields(Subscriber $subscriber, MailwizzConfig $config, bool $phoneEnabled): array
|
||||
{
|
||||
$data = [
|
||||
$config->field_email => $subscriber->email,
|
||||
$config->field_first_name => $subscriber->first_name,
|
||||
$config->field_last_name => $subscriber->last_name,
|
||||
];
|
||||
|
||||
if ($phoneEnabled && $config->field_phone !== null && $config->field_phone !== '') {
|
||||
$phone = $subscriber->phoneDisplay();
|
||||
if ($phone !== null && $phone !== '') {
|
||||
$data[$config->field_phone] = $phone;
|
||||
}
|
||||
}
|
||||
|
||||
$couponField = $config->field_coupon_code;
|
||||
if (is_string($couponField) && $couponField !== '' && $subscriber->coupon_code !== null && $subscriber->coupon_code !== '') {
|
||||
$data[$couponField] = $subscriber->coupon_code;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -250,6 +250,159 @@ final class WeeztixService
|
||||
throw new RuntimeException('Weeztix API rate limited after retries.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists coupon codes for the coupon selected in config (GET /coupon/{guid}/codes).
|
||||
*
|
||||
* @return list<array{guid: string, code: string}>
|
||||
*/
|
||||
public function listCouponCodesForConfiguredCoupon(): 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';
|
||||
$json = $this->apiRequest('get', $url, []);
|
||||
|
||||
return $this->normalizeCouponCodeListResponse($json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-deletes a coupon code in Weeztix by matching the human-readable code string.
|
||||
*/
|
||||
public function deleteCouponCodeByCodeString(string $code): void
|
||||
{
|
||||
$trimmed = trim($code);
|
||||
if ($trimmed === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->assertCompanyGuid();
|
||||
$couponGuid = $this->config->coupon_guid;
|
||||
if (! is_string($couponGuid) || $couponGuid === '') {
|
||||
throw new LogicException('Weeztix coupon is not configured.');
|
||||
}
|
||||
|
||||
$rows = $this->listCouponCodesForConfiguredCoupon();
|
||||
$codeGuid = null;
|
||||
foreach ($rows as $row) {
|
||||
if (strcasecmp($row['code'], $trimmed) === 0) {
|
||||
$codeGuid = $row['guid'];
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($codeGuid === null) {
|
||||
Log::info('Weeztix: coupon code not found when deleting (already removed or unknown)', [
|
||||
'code' => $trimmed,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$url = config('weeztix.api_base_url').'/coupon/'.$couponGuid.'/codes/'.$codeGuid;
|
||||
$token = $this->getValidAccessToken();
|
||||
$response = $this->sendApiRequest('delete', $url, [], $token);
|
||||
|
||||
if ($response->status() === 401) {
|
||||
$this->refreshAccessToken();
|
||||
$this->config->refresh();
|
||||
$response = $this->sendApiRequest('delete', $url, [], (string) $this->config->access_token);
|
||||
}
|
||||
|
||||
if ($response->status() === 404) {
|
||||
Log::info('Weeztix: coupon code already deleted remotely', [
|
||||
'code' => $trimmed,
|
||||
'code_guid' => $codeGuid,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
$this->logFailedResponse('deleteCouponCodeByCodeString', $url, $response);
|
||||
|
||||
throw new RuntimeException('Weeztix API delete coupon code failed: '.$response->status());
|
||||
}
|
||||
|
||||
Log::debug('Weeztix API', [
|
||||
'action' => 'deleteCouponCodeByCodeString',
|
||||
'url' => $url,
|
||||
'http_status' => $response->status(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $json
|
||||
* @return list<array{guid: string, code: string}>
|
||||
*/
|
||||
private function normalizeCouponCodeListResponse(array $json): array
|
||||
{
|
||||
$candidates = [
|
||||
data_get($json, 'data'),
|
||||
data_get($json, 'data.codes'),
|
||||
data_get($json, 'data.records'),
|
||||
data_get($json, 'codes'),
|
||||
$json,
|
||||
];
|
||||
|
||||
foreach ($candidates as $raw) {
|
||||
if (! is_array($raw)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->isListArray($raw)) {
|
||||
$normalized = $this->normalizeCouponCodeRows($raw);
|
||||
if ($normalized !== []) {
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $rows
|
||||
* @return list<array{guid: string, code: string}>
|
||||
*/
|
||||
private function normalizeCouponCodeRows(array $rows): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$guid = data_get($row, 'guid')
|
||||
?? data_get($row, 'id')
|
||||
?? data_get($row, 'coupon_code_guid');
|
||||
$code = data_get($row, 'code')
|
||||
?? data_get($row, 'coupon_code')
|
||||
?? data_get($row, 'name');
|
||||
if (! is_string($guid) || $guid === '' || ! is_string($code) || $code === '') {
|
||||
continue;
|
||||
}
|
||||
$out[] = ['guid' => $guid, 'code' => $code];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $arr
|
||||
*/
|
||||
private function isListArray(array $arr): bool
|
||||
{
|
||||
if ($arr === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return array_keys($arr) === range(0, count($arr) - 1);
|
||||
}
|
||||
|
||||
public static function generateUniqueCode(string $prefix = 'PREREG', int $length = 6): string
|
||||
{
|
||||
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
|
||||
Reference in New Issue
Block a user