From 952a5d4be784634ba1b2095621fabed3fd86d56d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Wed, 28 Jan 2026 21:25:58 +0100 Subject: [PATCH] feat: Sync new users to Resend contacts (#85) ## Summary - Add `SyncUserToResendListener` to automatically sync users to Resend contacts on registration - Add `ResendService` for Resend API interactions - Add `resend:sync` command to bulk sync existing users ## Changes - **New**: `app/Listeners/SyncUserToResendListener.php` - Auto-discovered queued listener - **New**: `app/Services/ResendService.php` - Service for Resend contacts API - **New**: `app/Console/Commands/ResendSyncCommand.php` - Bulk sync command - **New**: Tests for listener and command ## Usage New users are automatically synced on registration. For existing users: ```bash php artisan resend:sync ``` ## Test plan - [x] New user registration triggers contact sync - [x] `resend:sync` command syncs all existing users - [x] Graceful handling when API key is not configured - [x] Duplicate contacts are handled by Resend (no errors) --- app/Console/Commands/ResendSyncCommand.php | 62 +++++++++++++++++++ app/Listeners/SyncUserToResendListener.php | 24 +++++++ app/Services/ResendService.php | 27 ++++++++ .../ScheduleDripEmailsListenerTest.php | 6 +- .../SyncUserToResendListenerTest.php | 40 ++++++++++++ tests/Feature/ResendSyncCommandTest.php | 37 +++++++++++ 6 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 app/Console/Commands/ResendSyncCommand.php create mode 100644 app/Listeners/SyncUserToResendListener.php create mode 100644 app/Services/ResendService.php create mode 100644 tests/Feature/Listeners/SyncUserToResendListenerTest.php create mode 100644 tests/Feature/ResendSyncCommandTest.php diff --git a/app/Console/Commands/ResendSyncCommand.php b/app/Console/Commands/ResendSyncCommand.php new file mode 100644 index 00000000..732a3381 --- /dev/null +++ b/app/Console/Commands/ResendSyncCommand.php @@ -0,0 +1,62 @@ +error('Resend API key not configured.'); + + return self::FAILURE; + } + + $users = User::all(); + + if ($users->isEmpty()) { + $this->info('No users to sync.'); + + return self::SUCCESS; + } + + $this->info("Syncing {$users->count()} users to Resend..."); + + $bar = $this->output->createProgressBar($users->count()); + $bar->start(); + + $failed = 0; + + foreach ($users as $user) { + try { + $resendService->createContact($user); + } catch (\Exception $e) { + $failed++; + $this->newLine(); + $this->warn("Failed to sync {$user->email}: {$e->getMessage()}"); + } + + $bar->advance(); + } + + $bar->finish(); + $this->newLine(2); + + $synced = $users->count() - $failed; + $this->info("Synced {$synced} users to Resend."); + + if ($failed > 0) { + $this->warn("Failed to sync {$failed} users."); + } + + return $failed > 0 ? self::FAILURE : self::SUCCESS; + } +} diff --git a/app/Listeners/SyncUserToResendListener.php b/app/Listeners/SyncUserToResendListener.php new file mode 100644 index 00000000..cb0842b9 --- /dev/null +++ b/app/Listeners/SyncUserToResendListener.php @@ -0,0 +1,24 @@ +resendService->createContact($event->user); + } +} diff --git a/app/Services/ResendService.php b/app/Services/ResendService.php new file mode 100644 index 00000000..112037d7 --- /dev/null +++ b/app/Services/ResendService.php @@ -0,0 +1,27 @@ +name, 2); + $firstName = $nameParts[0]; + $lastName = $nameParts[1] ?? ''; + + $resend = Resend::client($apiKey); + + $resend->contacts->create([ + 'email' => $user->email, + 'first_name' => $firstName, + 'last_name' => $lastName, + 'unsubscribed' => false, + ]); + } +} diff --git a/tests/Feature/Listeners/ScheduleDripEmailsListenerTest.php b/tests/Feature/Listeners/ScheduleDripEmailsListenerTest.php index 554030fe..023205e4 100644 --- a/tests/Feature/Listeners/ScheduleDripEmailsListenerTest.php +++ b/tests/Feature/Listeners/ScheduleDripEmailsListenerTest.php @@ -88,5 +88,9 @@ test('no drip emails are dispatched when disabled', function () { event(new Registered($user)); - Queue::assertNothingPushed(); + Queue::assertNotPushed(SendWelcomeEmailJob::class); + Queue::assertNotPushed(SendOnboardingReminderEmailJob::class); + Queue::assertNotPushed(SendPromoCodeEmailJob::class); + Queue::assertNotPushed(SendImportHelpEmailJob::class); + Queue::assertNotPushed(SendFeedbackEmailJob::class); }); diff --git a/tests/Feature/Listeners/SyncUserToResendListenerTest.php b/tests/Feature/Listeners/SyncUserToResendListenerTest.php new file mode 100644 index 00000000..26aab6a6 --- /dev/null +++ b/tests/Feature/Listeners/SyncUserToResendListenerTest.php @@ -0,0 +1,40 @@ + 'test-api-key']); + + $user = User::factory()->create([ + 'name' => 'Steve Wozniak', + 'email' => 'steve@example.com', + ]); + + $resendService = mock(ResendService::class); + $resendService->shouldReceive('createContact') + ->once() + ->with(Mockery::on(fn ($u) => $u->id === $user->id)); + + (new SyncUserToResendListener($resendService))->handle(new Registered($user)); +}); + +test('listener skips sync when api key is not configured', function () { + config(['services.resend.key' => null]); + + Log::shouldReceive('warning') + ->once() + ->with('Resend API key not configured, skipping contact sync'); + + $resendService = mock(ResendService::class); + $resendService->shouldNotReceive('createContact'); + + $user = User::factory()->create(); + + (new SyncUserToResendListener($resendService))->handle(new Registered($user)); +}); diff --git a/tests/Feature/ResendSyncCommandTest.php b/tests/Feature/ResendSyncCommandTest.php new file mode 100644 index 00000000..eacd3bd3 --- /dev/null +++ b/tests/Feature/ResendSyncCommandTest.php @@ -0,0 +1,37 @@ + 'test-api-key']); + + $users = User::factory()->count(3)->create(); + + $resendService = mock(ResendService::class); + $resendService->shouldReceive('createContact')->times(3); + + artisan('resend:sync') + ->expectsOutputToContain('Syncing 3 users to Resend...') + ->expectsOutputToContain('Synced 3 users to Resend.') + ->assertSuccessful(); +}); + +test('resend:sync fails when api key is not configured', function () { + config(['services.resend.key' => null]); + + artisan('resend:sync') + ->expectsOutputToContain('Resend API key not configured.') + ->assertFailed(); +}); + +test('resend:sync handles empty users', function () { + config(['services.resend.key' => 'test-api-key']); + + artisan('resend:sync') + ->expectsOutputToContain('No users to sync.') + ->assertSuccessful(); +});