From dc0695c2ca55d3447b814b44bd8f13848922f92a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Mon, 13 Apr 2026 19:56:08 +0100 Subject: [PATCH] feat: sync user leads to resend (#283) ## Summary - add a `resend:sync-leads` command that syncs all `user_leads` into the Resend leads segment - make lead sync idempotent by creating contacts with the segment and falling back to adding existing contacts to the segment - schedule the command daily at `03:00` UTC and cover the command/fallback behavior with Pest tests ## Testing - php artisan test --compact tests/Feature/ResendSyncLeadsCommandTest.php --- .env.example | 1 + .env.production.example | 1 + .../Commands/ResendSyncLeadsCommand.php | 68 ++++++++++ app/Services/ResendService.php | 39 +++++- config/services.php | 1 + routes/console.php | 1 + tests/Feature/ResendSyncLeadsCommandTest.php | 128 ++++++++++++++++++ 7 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 app/Console/Commands/ResendSyncLeadsCommand.php create mode 100644 tests/Feature/ResendSyncLeadsCommandTest.php diff --git a/.env.example b/.env.example index bef3de4f..bdac0e42 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,7 @@ ADMIN_EMAIL= # Resend Email Service (set MAIL_MAILER=resend to use in production) RESEND_API_KEY= +RESEND_LEADS_SEGMENT_ID=a4c14fa2-84a7-484a-9699-3de716f1b3ef # Drip Emails (welcome, onboarding, promo codes, etc.) DRIP_EMAILS_ENABLED=true diff --git a/.env.production.example b/.env.production.example index 62acb956..15518f6b 100644 --- a/.env.production.example +++ b/.env.production.example @@ -43,6 +43,7 @@ REDIS_PORT=6379 # Email - Resend (Recommended for production) MAIL_MAILER=resend RESEND_API_KEY=your-resend-api-key +RESEND_LEADS_SEGMENT_ID=a4c14fa2-84a7-484a-9699-3de716f1b3ef MAIL_FROM_ADDRESS=no-reply@your-domain.com MAIL_FROM_NAME="Whisper Money" MAIL_DRIP_FROM_ADDRESS=hi@your-domain.com diff --git a/app/Console/Commands/ResendSyncLeadsCommand.php b/app/Console/Commands/ResendSyncLeadsCommand.php new file mode 100644 index 00000000..319cd2f9 --- /dev/null +++ b/app/Console/Commands/ResendSyncLeadsCommand.php @@ -0,0 +1,68 @@ +error('Resend API key not configured.'); + + return self::FAILURE; + } + + if (! config('services.resend.leads_segment_id')) { + $this->error('Resend leads segment ID not configured.'); + + return self::FAILURE; + } + + $leads = UserLead::query()->get(); + + if ($leads->isEmpty()) { + $this->info('No user leads to sync.'); + + return self::SUCCESS; + } + + $this->info("Syncing {$leads->count()} user leads to Resend..."); + + $progressBar = $this->output->createProgressBar($leads->count()); + $progressBar->start(); + + $failed = 0; + + foreach ($leads as $lead) { + try { + $resendService->syncLead($lead); + } catch (\Exception $exception) { + $failed++; + $this->newLine(); + $this->warn("Failed to sync {$lead->email}: {$exception->getMessage()}"); + } + + $progressBar->advance(); + } + + $progressBar->finish(); + $this->newLine(2); + + $synced = $leads->count() - $failed; + $this->info("Synced {$synced} user leads to Resend."); + + if ($failed > 0) { + $this->warn("Failed to sync {$failed} user leads."); + } + + return $failed > 0 ? self::FAILURE : self::SUCCESS; + } +} diff --git a/app/Services/ResendService.php b/app/Services/ResendService.php index 112037d7..68660293 100644 --- a/app/Services/ResendService.php +++ b/app/Services/ResendService.php @@ -3,7 +3,9 @@ namespace App\Services; use App\Models\User; +use App\Models\UserLead; use Resend; +use Resend\Exceptions\ErrorException; class ResendService { @@ -15,7 +17,7 @@ class ResendService $firstName = $nameParts[0]; $lastName = $nameParts[1] ?? ''; - $resend = Resend::client($apiKey); + $resend = $this->client($apiKey); $resend->contacts->create([ 'email' => $user->email, @@ -24,4 +26,39 @@ class ResendService 'unsubscribed' => false, ]); } + + public function syncLead(UserLead $lead): void + { + $apiKey = config('services.resend.key'); + $segmentId = config('services.resend.leads_segment_id'); + + $resend = $this->client($apiKey); + + try { + $resend->contacts->create([ + 'email' => $lead->email, + 'unsubscribed' => false, + 'segments' => [ + ['id' => $segmentId], + ], + ]); + } catch (ErrorException $exception) { + if (! $this->contactAlreadyExists($exception)) { + throw $exception; + } + + $resend->contacts->segments->add($lead->email, $segmentId); + } + } + + private function contactAlreadyExists(ErrorException $exception): bool + { + return $exception->getErrorCode() === 409 + || str_contains(strtolower($exception->getMessage()), 'already exists'); + } + + protected function client(string $apiKey): object + { + return Resend::client($apiKey); + } } diff --git a/config/services.php b/config/services.php index cd06196e..a5ce1ccf 100644 --- a/config/services.php +++ b/config/services.php @@ -20,6 +20,7 @@ return [ 'resend' => [ 'key' => env('RESEND_API_KEY'), + 'leads_segment_id' => env('RESEND_LEADS_SEGMENT_ID', 'a4c14fa2-84a7-484a-9699-3de716f1b3ef'), ], 'ses' => [ diff --git a/routes/console.php b/routes/console.php index 3afc170e..4454d8c8 100644 --- a/routes/console.php +++ b/routes/console.php @@ -9,3 +9,4 @@ Schedule::command('banking:sync')->everySixHours(); Schedule::command('banks:check-logos')->weekly(); Schedule::command('real-estate:apply-revaluation')->monthlyOn(1, '00:00'); Schedule::command('loans:generate-balances')->monthlyOn(1, '00:00'); +Schedule::command('resend:sync-leads')->dailyAt('03:00'); diff --git a/tests/Feature/ResendSyncLeadsCommandTest.php b/tests/Feature/ResendSyncLeadsCommandTest.php new file mode 100644 index 00000000..e8049d48 --- /dev/null +++ b/tests/Feature/ResendSyncLeadsCommandTest.php @@ -0,0 +1,128 @@ + 'test-api-key', + 'services.resend.leads_segment_id' => 'a4c14fa2-84a7-484a-9699-3de716f1b3ef', + ]); + + UserLead::factory()->count(3)->create(); + + $resendService = mock(ResendService::class); + $resendService->shouldReceive('syncLead')->times(3); + + artisan('resend:sync-leads') + ->expectsOutputToContain('Syncing 3 user leads to Resend...') + ->expectsOutputToContain('Synced 3 user leads to Resend.') + ->assertSuccessful(); +}); + +test('resend:sync-leads fails when api key is not configured', function () { + config([ + 'services.resend.key' => null, + 'services.resend.leads_segment_id' => 'a4c14fa2-84a7-484a-9699-3de716f1b3ef', + ]); + + artisan('resend:sync-leads') + ->expectsOutputToContain('Resend API key not configured.') + ->assertFailed(); +}); + +test('resend:sync-leads fails when leads segment id is not configured', function () { + config([ + 'services.resend.key' => 'test-api-key', + 'services.resend.leads_segment_id' => null, + ]); + + artisan('resend:sync-leads') + ->expectsOutputToContain('Resend leads segment ID not configured.') + ->assertFailed(); +}); + +test('resend:sync-leads handles empty user leads', function () { + config([ + 'services.resend.key' => 'test-api-key', + 'services.resend.leads_segment_id' => 'a4c14fa2-84a7-484a-9699-3de716f1b3ef', + ]); + + artisan('resend:sync-leads') + ->expectsOutputToContain('No user leads to sync.') + ->assertSuccessful(); +}); + +test('resend:sync-leads reports failures and continues syncing', function () { + config([ + 'services.resend.key' => 'test-api-key', + 'services.resend.leads_segment_id' => 'a4c14fa2-84a7-484a-9699-3de716f1b3ef', + ]); + + $firstLead = UserLead::factory()->create(['email' => 'first@example.com']); + $secondLead = UserLead::factory()->create(['email' => 'second@example.com']); + + $resendService = mock(ResendService::class); + $resendService->shouldReceive('syncLead')->once()->with(Mockery::on(fn (UserLead $lead) => $lead->is($firstLead))); + $resendService->shouldReceive('syncLead') + ->once() + ->with(Mockery::on(fn (UserLead $lead) => $lead->is($secondLead))) + ->andThrow(new RuntimeException('Duplicate request failed')); + + artisan('resend:sync-leads') + ->expectsOutputToContain('Failed to sync second@example.com: Duplicate request failed') + ->expectsOutputToContain('Synced 1 user leads to Resend.') + ->expectsOutputToContain('Failed to sync 1 user leads.') + ->assertFailed(); +}); + +test('syncLead adds an existing resend contact to the leads segment', function () { + config([ + 'services.resend.key' => 'test-api-key', + 'services.resend.leads_segment_id' => 'a4c14fa2-84a7-484a-9699-3de716f1b3ef', + ]); + + $lead = UserLead::factory()->create(['email' => 'lead@example.com']); + + $segmentsService = Mockery::mock(); + $segmentsService->shouldReceive('add') + ->once() + ->with('lead@example.com', 'a4c14fa2-84a7-484a-9699-3de716f1b3ef'); + + $contactsService = new class($segmentsService) + { + public function __construct(public object $segments) {} + + public function create(array $parameters): never + { + throw new ErrorException([ + 'message' => 'Contact already exists', + 'name' => 'conflict_error', + 'statusCode' => 409, + ]); + } + }; + + $client = new class($contactsService) + { + public function __construct(public object $contacts) {} + }; + + $service = new class($client) extends ResendService + { + public function __construct(private readonly object $client) {} + + protected function client(string $apiKey): object + { + expect($apiKey)->toBe('test-api-key'); + + return $this->client; + } + }; + + $service->syncLead($lead); +});