diff --git a/app/Console/Commands/SendUserLeadInvitations.php b/app/Console/Commands/SendUserLeadInvitations.php index 3f3d1fed..8b0d276c 100644 --- a/app/Console/Commands/SendUserLeadInvitations.php +++ b/app/Console/Commands/SendUserLeadInvitations.php @@ -15,6 +15,7 @@ class SendUserLeadInvitations extends Command { protected $signature = 'leads:send-invitations {--limit=50 : Maximum number of leads to invite in this batch} + {--email= : Invite a specific pending lead by email address} {--cohort= : Restrict to a single cohort (founder, founder_referrer, early_bird, waitlist)} {--dry-run : Show what would happen without sending or creating Stripe codes} {--force : Skip confirmation prompt}'; @@ -31,9 +32,10 @@ class SendUserLeadInvitations extends Command } $dryRun = (bool) $this->option('dry-run'); + $emailFilter = $this->resolveEmailFilter(); $cohortFilter = $this->resolveCohortFilter(); - if ($cohortFilter === false) { + if ($emailFilter === false || $cohortFilter === false) { return self::FAILURE; } @@ -41,11 +43,20 @@ class SendUserLeadInvitations extends Command ->whereNotNull('position') ->where('position', '>', 0) ->whereNull('invitation_sent_at') - ->orderBy('position') - ->limit($limit * 5) // over-fetch when filtering by cohort + ->when( + $emailFilter !== null, + fn ($query) => $query->where('email', $emailFilter), + fn ($query) => $query->orderBy('position')->limit($limit * 5), // over-fetch when filtering by cohort + ) ->get(); if ($candidates->isEmpty()) { + if ($emailFilter !== null) { + $this->error("No pending lead found for {$emailFilter}."); + + return self::FAILURE; + } + $this->info('No pending leads found.'); return self::SUCCESS; @@ -136,6 +147,25 @@ class SendUserLeadInvitations extends Command return $failed === 0 ? self::SUCCESS : self::FAILURE; } + private function resolveEmailFilter(): string|false|null + { + $email = $this->option('email'); + + if ($email === null || $email === '') { + return null; + } + + $email = strtolower(trim((string) $email)); + + if (! filter_var($email, FILTER_VALIDATE_EMAIL)) { + $this->error("Invalid email `{$email}`."); + + return false; + } + + return $email; + } + private function resolveCohortFilter(): LeadCohort|false|null { $cohort = $this->option('cohort'); diff --git a/tests/Feature/Commands/SendUserLeadInvitationsTest.php b/tests/Feature/Commands/SendUserLeadInvitationsTest.php index 55481fd8..ecefea3b 100644 --- a/tests/Feature/Commands/SendUserLeadInvitationsTest.php +++ b/tests/Feature/Commands/SendUserLeadInvitationsTest.php @@ -80,3 +80,30 @@ it('records the cohort on each invited lead', function (): void { expect($cohorts[9])->toBe(LeadCohort::Founder); expect($cohorts[10])->toBe(LeadCohort::EarlyBird); }); + +it('sends an invitation to a specific pending lead by email', function (): void { + UserLead::factory()->count(12)->state(new Sequence( + ...array_map(fn (int $i) => ['position' => $i, 'email_verified_at' => now()], range(1, 12)), + ))->create(); + + $target = UserLead::factory()->ranked(42)->create(['email' => 'target@example.com']); + + $this->artisan('leads:send-invitations', ['--email' => 'target@example.com', '--force' => true]) + ->assertSuccessful(); + + Mail::assertQueued(UserLeadInvitation::class, 1); + Mail::assertQueued(UserLeadInvitation::class, fn (UserLeadInvitation $mail): bool => $mail->hasTo('target@example.com')); + + expect($target->refresh()->invitation_sent_at)->not->toBeNull() + ->and(UserLead::query()->whereNotNull('invitation_sent_at')->pluck('email')->all())->toBe(['target@example.com']); +}); + +it('fails when the requested email is not a pending lead', function (): void { + UserLead::factory()->ranked(1)->create(['email' => 'already@example.com', 'invitation_sent_at' => now()]); + + $this->artisan('leads:send-invitations', ['--email' => 'already@example.com', '--force' => true]) + ->expectsOutput('No pending lead found for already@example.com.') + ->assertFailed(); + + Mail::assertNothingQueued(); +});