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:
parent
ea9956f21d
commit
dc0695c2ca
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue