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
This commit is contained in:
Víctor Falcón 2026-04-13 19:56:08 +01:00 committed by GitHub
parent ea9956f21d
commit dc0695c2ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 238 additions and 1 deletions

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,68 @@
<?php
namespace App\Console\Commands;
use App\Models\UserLead;
use App\Services\ResendService;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
#[Signature('resend:sync-leads')]
#[Description('Sync all user leads to the Resend leads segment')]
class ResendSyncLeadsCommand extends Command
{
public function handle(ResendService $resendService): int
{
if (! config('services.resend.key')) {
$this->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;
}
}

View File

@ -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);
}
}

View File

@ -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' => [

View File

@ -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');

View File

@ -0,0 +1,128 @@
<?php
use App\Models\UserLead;
use App\Services\ResendService;
use Resend\Exceptions\ErrorException;
use function Pest\Laravel\artisan;
use function Pest\Laravel\mock;
test('resend:sync-leads syncs all user leads to resend', function () {
config([
'services.resend.key' => '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);
});