diff --git a/app/Console/Commands/RetryLeadFailedJobsCommand.php b/app/Console/Commands/RetryLeadFailedJobsCommand.php new file mode 100644 index 00000000..32c6f78e --- /dev/null +++ b/app/Console/Commands/RetryLeadFailedJobsCommand.php @@ -0,0 +1,134 @@ +option('dry-run'); + + if ($isDryRun) { + $this->warn('DRY RUN — no changes will be made.'); + $this->newLine(); + } + + $failedJobs = DB::table('failed_jobs') + ->where('queue', 'emails') + ->get(); + + if ($failedJobs->isEmpty()) { + $this->info('No failed email jobs found.'); + + return self::SUCCESS; + } + + $retried = 0; + $forgotten = 0; + $skipped = 0; + + $progressBar = $this->output->createProgressBar($failedJobs->count()); + $progressBar->start(); + + foreach ($failedJobs as $job) { + $payload = json_decode($job->payload, true); + $displayName = $payload['displayName'] ?? ''; + $command = $payload['data']['command'] ?? ''; + + $leadId = $this->extractLeadId($command); + + if ($leadId === null) { + $skipped++; + $progressBar->advance(); + + continue; + } + + $lead = UserLead::find($leadId); + $action = $this->determineAction($lead, $displayName); + + if ($action === 'retry') { + $retried++; + if (! $isDryRun) { + $this->callSilently('queue:retry', ['id' => [$job->uuid]]); + } + } else { + $forgotten++; + if (! $isDryRun) { + $this->callSilently('queue:forget', ['id' => [$job->uuid]]); + } + } + + $progressBar->advance(); + } + + $progressBar->finish(); + $this->newLine(2); + + $this->table( + ['Action', 'Count'], + [ + ['retried'.($isDryRun ? ' (dry run)' : ''), $retried], + ['forgotten'.($isDryRun ? ' (dry run)' : ''), $forgotten], + ['skipped (no lead ID found)', $skipped], + ] + ); + + return self::SUCCESS; + } + + /** + * Extract the UserLead UUID from a serialized job payload command string. + * + * Handles both queued mailables (id stored as a single string) and + * queued notifications (id stored as a single-element array). + */ + private function extractLeadId(string $command): ?string + { + // Mail jobs store the lead as a single string ID + if (preg_match('/App\\\\Models\\\\UserLead";s:2:"id";s:\d+:"([0-9a-f-]{36})"/', $command, $matches)) { + return $matches[1]; + } + + // Notification jobs store notifiables as an array of IDs + if (preg_match('/App\\\\Models\\\\UserLead";s:2:"id";a:\d+:\{i:0;s:\d+:"([0-9a-f-]{36})"/', $command, $matches)) { + return $matches[1]; + } + + return null; + } + + /** + * Decide whether to retry or forget a failed job based on lead state. + * + * Rules: + * - Lead deleted → forget (DDoS cleanup or similar) + * - Lead verified → retry (Resend rate limit was the issue) + * - Lead unverified + VerifyUserLeadEmailNotification → retry (still needs the verification email) + * - Lead unverified + anything else → forget (waitlist emails to unverified leads are meaningless) + */ + private function determineAction(?UserLead $lead, string $displayName): string + { + if ($lead === null) { + return 'forget'; + } + + if ($lead->hasVerifiedEmail()) { + return 'retry'; + } + + if (str_contains($displayName, 'VerifyUserLeadEmailNotification')) { + return 'retry'; + } + + return 'forget'; + } +} diff --git a/app/Mail/WaitlistOvertaken.php b/app/Mail/WaitlistOvertaken.php index ab99992b..03c906ab 100644 --- a/app/Mail/WaitlistOvertaken.php +++ b/app/Mail/WaitlistOvertaken.php @@ -17,6 +17,13 @@ class WaitlistOvertaken extends Mailable implements ShouldQueue { use Queueable, SerializesModels; + /** + * Delete the job if its models no longer exist (e.g. lead was cleaned up after DDoS). + * + * @var bool + */ + public $deleteWhenMissingModels = true; + /** * The number of times the job may be attempted. * diff --git a/app/Mail/WaitlistReferralNotification.php b/app/Mail/WaitlistReferralNotification.php index 82fc420a..8e71b318 100644 --- a/app/Mail/WaitlistReferralNotification.php +++ b/app/Mail/WaitlistReferralNotification.php @@ -17,6 +17,13 @@ class WaitlistReferralNotification extends Mailable implements ShouldQueue { use Queueable, SerializesModels; + /** + * Delete the job if its models no longer exist (e.g. lead was cleaned up after DDoS). + * + * @var bool + */ + public $deleteWhenMissingModels = true; + /** * The number of times the job may be attempted. * diff --git a/app/Mail/WaitlistWelcome.php b/app/Mail/WaitlistWelcome.php index 3c2edcf1..22ea7c23 100644 --- a/app/Mail/WaitlistWelcome.php +++ b/app/Mail/WaitlistWelcome.php @@ -18,6 +18,13 @@ class WaitlistWelcome extends Mailable implements ShouldQueue use Queueable; use SerializesModels; + /** + * Delete the job if its models no longer exist (e.g. lead was cleaned up after DDoS). + * + * @var bool + */ + public $deleteWhenMissingModels = true; + /** * The number of times the job may be attempted. * diff --git a/app/Notifications/VerifyUserLeadEmailNotification.php b/app/Notifications/VerifyUserLeadEmailNotification.php index e46f247b..fe79508f 100644 --- a/app/Notifications/VerifyUserLeadEmailNotification.php +++ b/app/Notifications/VerifyUserLeadEmailNotification.php @@ -11,6 +11,13 @@ class VerifyUserLeadEmailNotification extends Notification implements ShouldQueu { use Queueable; + /** + * Delete the job if the notifiable model no longer exists. + * + * @var bool + */ + public $deleteWhenMissingModels = true; + public function __construct(private readonly string $verificationUrl) { $this->onQueue('emails'); diff --git a/phpstan.neon b/phpstan.neon index 5557562a..c745a4ee 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -49,6 +49,10 @@ parameters: path: app/Listeners/* - identifier: public.property.unused path: app/Listeners/* + - identifier: public.method.unused + path: app/Notifications/* + - identifier: public.property.unused + path: app/Notifications/* # Model relationship methods are called dynamically by Eloquent - identifier: public.method.unused path: app/Models/* diff --git a/tests/Feature/Commands/RetryLeadFailedJobsCommandTest.php b/tests/Feature/Commands/RetryLeadFailedJobsCommandTest.php new file mode 100644 index 00000000..9749b73f --- /dev/null +++ b/tests/Feature/Commands/RetryLeadFailedJobsCommandTest.php @@ -0,0 +1,157 @@ +insert([ + 'uuid' => $uuid, + 'connection' => 'database', + 'queue' => 'emails', + 'payload' => json_encode([ + 'uuid' => $uuid, + 'displayName' => $displayName, + 'job' => 'Illuminate\Queue\CallQueuedHandler@call', + 'data' => [ + 'commandName' => 'Illuminate\Mail\SendQueuedMailable', + 'command' => serializedMailLeadCommand($leadId), + ], + ]), + 'exception' => 'Too Many Requests', + 'failed_at' => now(), + ]); + + return $uuid; +} + +/** + * Insert a fake failed VerifyUserLeadEmailNotification job, referencing the given lead UUID. + */ +function insertFailedNotificationJob(string $leadId): string +{ + $uuid = (string) Str::uuid(); + + DB::table('failed_jobs')->insert([ + 'uuid' => $uuid, + 'connection' => 'database', + 'queue' => 'emails', + 'payload' => json_encode([ + 'uuid' => $uuid, + 'displayName' => 'App\Notifications\VerifyUserLeadEmailNotification', + 'job' => 'Illuminate\Queue\CallQueuedHandler@call', + 'data' => [ + 'commandName' => 'Illuminate\Notifications\SendQueuedNotifications', + 'command' => serializedNotificationLeadCommand($leadId), + ], + ]), + 'exception' => 'Too Many Requests', + 'failed_at' => now(), + ]); + + return $uuid; +} + +test('leads:retry-failed-jobs does nothing when no failed jobs exist', function () { + artisan('leads:retry-failed-jobs') + ->expectsOutputToContain('No failed email jobs found.') + ->assertSuccessful(); +}); + +test('leads:retry-failed-jobs forgets jobs for deleted leads', function () { + $deletedLeadId = (string) Str::uuid(); + insertFailedMailJob('App\Mail\WaitlistWelcome', $deletedLeadId); + + artisan('leads:retry-failed-jobs')->assertSuccessful(); + + expect(DB::table('failed_jobs')->count())->toBe(0); +}); + +test('leads:retry-failed-jobs retries jobs for verified leads', function () { + $lead = UserLead::factory()->create(); + insertFailedMailJob('App\Mail\WaitlistOvertaken', $lead->id); + + artisan('leads:retry-failed-jobs')->assertSuccessful(); + + expect(DB::table('failed_jobs')->count())->toBe(0); +}); + +test('leads:retry-failed-jobs forgets waitlist jobs for unverified leads', function () { + $lead = UserLead::factory()->unverified()->create(); + insertFailedMailJob('App\Mail\WaitlistOvertaken', $lead->id); + + artisan('leads:retry-failed-jobs')->assertSuccessful(); + + expect(DB::table('failed_jobs')->count())->toBe(0); +}); + +test('leads:retry-failed-jobs retries VerifyUserLeadEmailNotification for unverified leads', function () { + $lead = UserLead::factory()->unverified()->create(); + insertFailedNotificationJob($lead->id); + + artisan('leads:retry-failed-jobs')->assertSuccessful(); + + expect(DB::table('failed_jobs')->count())->toBe(0); +}); + +test('leads:retry-failed-jobs handles a mix of job types correctly', function () { + $verifiedLead = UserLead::factory()->create(); + $unverifiedLead = UserLead::factory()->unverified()->create(); + $deletedLeadId = (string) Str::uuid(); + + insertFailedMailJob('App\Mail\WaitlistOvertaken', $verifiedLead->id); // retry + insertFailedMailJob('App\Mail\WaitlistReferralNotification', $verifiedLead->id); // retry + insertFailedMailJob('App\Mail\WaitlistWelcome', $deletedLeadId); // forget + insertFailedMailJob('App\Mail\WaitlistOvertaken', $unverifiedLead->id); // forget + insertFailedNotificationJob($unverifiedLead->id); // retry + + artisan('leads:retry-failed-jobs')->assertSuccessful(); + + expect(DB::table('failed_jobs')->count())->toBe(0); +}); + +test('leads:retry-failed-jobs --dry-run does not modify failed_jobs', function () { + $lead = UserLead::factory()->create(); + $deletedLeadId = (string) Str::uuid(); + + insertFailedMailJob('App\Mail\WaitlistOvertaken', $lead->id); + insertFailedMailJob('App\Mail\WaitlistWelcome', $deletedLeadId); + + artisan('leads:retry-failed-jobs', ['--dry-run' => true]) + ->expectsOutputToContain('DRY RUN') + ->assertSuccessful(); + + // Nothing should be touched in dry-run mode + expect(DB::table('failed_jobs')->count())->toBe(2); +});