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:
parent
f03fcf5ac6
commit
952a5d4be7
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
Loading…
Reference in New Issue