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)
This commit is contained in:
Víctor Falcón 2026-01-28 21:25:58 +01:00 committed by GitHub
parent f03fcf5ac6
commit 952a5d4be7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 195 additions and 1 deletions

View File

@ -0,0 +1,62 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use App\Services\ResendService;
use Illuminate\Console\Command;
class ResendSyncCommand extends Command
{
protected $signature = 'resend:sync';
protected $description = 'Sync all users to Resend contacts';
public function handle(ResendService $resendService): int
{
if (! config('services.resend.key')) {
$this->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;
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Listeners;
use App\Services\ResendService;
use Illuminate\Auth\Events\Registered;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
class SyncUserToResendListener implements ShouldQueue
{
public function __construct(public ResendService $resendService) {}
public function handle(Registered $event): void
{
if (! config('services.resend.key')) {
Log::warning('Resend API key not configured, skipping contact sync');
return;
}
$this->resendService->createContact($event->user);
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Services;
use App\Models\User;
use Resend;
class ResendService
{
public function createContact(User $user): void
{
$apiKey = config('services.resend.key');
$nameParts = explode(' ', $user->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,
]);
}
}

View File

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

View File

@ -0,0 +1,40 @@
<?php
use App\Listeners\SyncUserToResendListener;
use App\Models\User;
use App\Services\ResendService;
use Illuminate\Auth\Events\Registered;
use Illuminate\Support\Facades\Log;
use function Pest\Laravel\mock;
test('user is synced to resend contacts', function () {
config(['services.resend.key' => '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));
});

View File

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