From 7eda51f52ada4db98a119927a6cee81c022e19a2 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sun, 5 Apr 2026 11:57:16 +0200 Subject: [PATCH] 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 --- .../Admin/SubscriberController.php | 10 +- app/Jobs/SyncSubscriberToMailwizz.php | 68 +------- .../CleanupSubscriberIntegrationsService.php | 141 ++++++++++++++++ app/Services/MailwizzCheckboxlistTags.php | 64 ++++++++ .../MailwizzSubscriberFormPayload.php | 40 +++++ app/Services/WeeztixService.php | 153 ++++++++++++++++++ tests/Feature/DestroySubscriberTest.php | 140 ++++++++++++++++ 7 files changed, 552 insertions(+), 64 deletions(-) create mode 100644 app/Services/CleanupSubscriberIntegrationsService.php create mode 100644 app/Services/MailwizzCheckboxlistTags.php create mode 100644 app/Services/MailwizzSubscriberFormPayload.php diff --git a/app/Http/Controllers/Admin/SubscriberController.php b/app/Http/Controllers/Admin/SubscriberController.php index b7c7bfe..d296b1e 100644 --- a/app/Http/Controllers/Admin/SubscriberController.php +++ b/app/Http/Controllers/Admin/SubscriberController.php @@ -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() diff --git a/app/Jobs/SyncSubscriberToMailwizz.php b/app/Jobs/SyncSubscriberToMailwizz.php index 93df6a0..2c474d0 100644 --- a/app/Jobs/SyncSubscriberToMailwizz.php +++ b/app/Jobs/SyncSubscriberToMailwizz.php @@ -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 $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 - */ - 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); - } } diff --git a/app/Services/CleanupSubscriberIntegrationsService.php b/app/Services/CleanupSubscriberIntegrationsService.php new file mode 100644 index 0000000..c7ef115 --- /dev/null +++ b/app/Services/CleanupSubscriberIntegrationsService.php @@ -0,0 +1,141 @@ +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; + } +} diff --git a/app/Services/MailwizzCheckboxlistTags.php b/app/Services/MailwizzCheckboxlistTags.php new file mode 100644 index 0000000..77973f2 --- /dev/null +++ b/app/Services/MailwizzCheckboxlistTags.php @@ -0,0 +1,64 @@ + $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 + */ + 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 + */ + 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 + )); + } +} diff --git a/app/Services/MailwizzSubscriberFormPayload.php b/app/Services/MailwizzSubscriberFormPayload.php new file mode 100644 index 0000000..e958d35 --- /dev/null +++ b/app/Services/MailwizzSubscriberFormPayload.php @@ -0,0 +1,40 @@ + + */ + 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; + } +} diff --git a/app/Services/WeeztixService.php b/app/Services/WeeztixService.php index 3c9ee95..826875a 100644 --- a/app/Services/WeeztixService.php +++ b/app/Services/WeeztixService.php @@ -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 + */ + 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 $json + * @return list + */ + 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 $rows + * @return list + */ + 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 $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'; diff --git a/tests/Feature/DestroySubscriberTest.php b/tests/Feature/DestroySubscriberTest.php index 2ed8abe..31afe22 100644 --- a/tests/Feature/DestroySubscriberTest.php +++ b/tests/Feature/DestroySubscriberTest.php @@ -4,10 +4,14 @@ declare(strict_types=1); namespace Tests\Feature; +use App\Models\MailwizzConfig; use App\Models\PreregistrationPage; use App\Models\Subscriber; use App\Models\User; +use App\Models\WeeztixConfig; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\Client\Request; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; use Tests\TestCase; @@ -128,4 +132,140 @@ class DestroySubscriberTest extends TestCase $response->assertForbidden(); $this->assertDatabaseHas('subscribers', ['id' => $subscriber->id]); } + + public function test_delete_strips_mailwizz_source_tag_and_clears_coupon_field(): void + { + Http::fake(function (Request $request) { + $url = $request->url(); + if (str_contains($url, 'search-by-email')) { + return Http::response(['status' => 'success', 'data' => ['subscriber_uid' => 'sub-to-clean']]); + } + if ($request->method() === 'GET' && str_contains($url, '/subscribers/sub-to-clean') && ! str_contains($url, 'search-by-email')) { + return Http::response([ + 'status' => 'success', + 'data' => [ + 'record' => [ + 'TAGS' => 'preregister-source,other-tag', + 'COUPON' => 'PREREG-OLD', + ], + ], + ]); + } + if ($request->method() === 'PUT' && str_contains($url, '/subscribers/sub-to-clean')) { + $body = $request->body(); + $this->assertStringContainsString('other-tag', $body); + $this->assertStringNotContainsString('preregister-source', $body); + $this->assertStringContainsString('COUPON', $body); + + return Http::response(['status' => 'success']); + } + + return Http::response(['status' => 'error'], 500); + }); + + $user = User::factory()->create(['role' => 'user']); + $page = $this->makePageForDestroyTest($user); + MailwizzConfig::query()->create([ + 'preregistration_page_id' => $page->id, + 'api_key' => 'fake-api-key', + 'list_uid' => 'list-uid-1', + 'list_name' => 'Main list', + 'field_email' => 'EMAIL', + 'field_first_name' => 'FNAME', + 'field_last_name' => 'LNAME', + 'field_phone' => null, + 'field_coupon_code' => 'COUPON', + 'tag_field' => 'TAGS', + 'tag_value' => 'preregister-source', + ]); + + $subscriber = Subscriber::query()->create([ + 'preregistration_page_id' => $page->id, + 'first_name' => 'Clean', + 'last_name' => 'Up', + 'email' => 'cleanup@example.com', + 'coupon_code' => 'PREREG-LOCAL', + ]); + + $response = $this->actingAs($user)->delete(route('admin.pages.subscribers.destroy', [$page, $subscriber])); + + $response->assertRedirect(route('admin.pages.subscribers.index', $page)); + $this->assertDatabaseMissing('subscribers', ['id' => $subscriber->id]); + Http::assertSentCount(3); + } + + public function test_delete_removes_coupon_code_in_weeztix_when_configured(): void + { + Http::fake(function (Request $request) { + $url = $request->url(); + if ($request->method() === 'GET' && preg_match('#/coupon/coupon-guid-test/codes$#', $url) === 1) { + return Http::response([ + 'data' => [ + ['guid' => 'wzx-code-guid', 'code' => 'PREREG-DEL99'], + ], + ], 200); + } + if ($request->method() === 'DELETE' && str_contains($url, '/coupon/coupon-guid-test/codes/wzx-code-guid')) { + return Http::response(null, 204); + } + + return Http::response(['status' => 'error'], 500); + }); + + $user = User::factory()->create(['role' => 'user']); + $page = $this->makePageForDestroyTest($user); + WeeztixConfig::query()->create([ + 'preregistration_page_id' => $page->id, + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + 'redirect_uri' => 'https://app.test/callback', + 'access_token' => 'access-token', + 'refresh_token' => 'refresh-token', + 'token_expires_at' => now()->addHour(), + 'refresh_token_expires_at' => now()->addMonth(), + 'company_guid' => 'company-guid-test', + 'company_name' => 'Test Co', + 'coupon_guid' => 'coupon-guid-test', + 'coupon_name' => 'PreReg', + 'is_connected' => true, + ]); + + $subscriber = Subscriber::query()->create([ + 'preregistration_page_id' => $page->id, + 'first_name' => 'Weez', + 'last_name' => 'Tix', + 'email' => 'weez@example.com', + 'coupon_code' => 'PREREG-DEL99', + ]); + + $response = $this->actingAs($user)->delete(route('admin.pages.subscribers.destroy', [$page, $subscriber])); + + $response->assertRedirect(route('admin.pages.subscribers.index', $page)); + $this->assertDatabaseMissing('subscribers', ['id' => $subscriber->id]); + + Http::assertSent(function (Request $request): bool { + return $request->method() === 'DELETE' + && str_contains($request->url(), '/coupon/coupon-guid-test/codes/wzx-code-guid'); + }); + } + + private function makePageForDestroyTest(User $user): PreregistrationPage + { + return PreregistrationPage::query()->create([ + 'slug' => (string) Str::uuid(), + 'user_id' => $user->id, + 'title' => 'Fest', + 'heading' => 'Fest', + 'intro_text' => null, + 'thank_you_message' => null, + 'expired_message' => null, + 'ticketshop_url' => null, + 'start_date' => now()->subDay(), + 'end_date' => now()->addMonth(), + 'phone_enabled' => false, + 'background_image' => null, + 'logo_image' => null, + 'is_active' => true, + ]); + } }