Invite leads by email (#377)

## Summary
- add --email option to leads:send-invitations
- target one pending queued lead by email
- cover targeted invite and missing pending lead cases

## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact
tests/Feature/Commands/SendUserLeadInvitationsTest.php
This commit is contained in:
Víctor Falcón 2026-05-11 11:28:23 +01:00 committed by GitHub
parent 3526e5fd12
commit a20eeff378
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 60 additions and 3 deletions

View File

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

View File

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