diff --git a/app/Console/Commands/ResendNewPluginNotifications.php b/app/Console/Commands/ResendNewPluginNotifications.php index d87a063e..e1616805 100644 --- a/app/Console/Commands/ResendNewPluginNotifications.php +++ b/app/Console/Commands/ResendNewPluginNotifications.php @@ -4,7 +4,7 @@ use App\Models\Plugin; use App\Models\User; -use App\Notifications\NewPluginAvailable; +use App\Notifications\NewPluginsAvailable; use Illuminate\Console\Command; use Illuminate\Support\Facades\Notification; @@ -14,7 +14,7 @@ class ResendNewPluginNotifications extends Command {plugins* : Plugin names (vendor/package) to resend notifications for} {--dry-run : Preview what would happen without sending notifications}'; - protected $description = 'Resend NewPluginAvailable notifications to opted-in users for specified plugins'; + protected $description = 'Email opted-in users a single combined digest for the specified plugins'; public function handle(): int { @@ -57,13 +57,11 @@ public function handle(): int return Command::SUCCESS; } - foreach ($plugins as $plugin) { - $pluginRecipients = $recipients->where('id', '!=', $plugin->user_id); + Notification::send($recipients, new NewPluginsAvailable($plugins)); - Notification::send($pluginRecipients, new NewPluginAvailable($plugin)); + $plugins->each->update(['new_plugin_notified_at' => now()]); - $this->info("Sent NewPluginAvailable for {$plugin->name} to {$pluginRecipients->count()} users."); - } + $this->info("Sent a single digest covering {$plugins->count()} plugin(s) to {$recipients->count()} users."); $this->newLine(); $this->info('Done. All notifications queued.'); diff --git a/app/Filament/Resources/PluginResource/Pages/ListPlugins.php b/app/Filament/Resources/PluginResource/Pages/ListPlugins.php index ba97676c..165240e6 100644 --- a/app/Filament/Resources/PluginResource/Pages/ListPlugins.php +++ b/app/Filament/Resources/PluginResource/Pages/ListPlugins.php @@ -3,7 +3,10 @@ namespace App\Filament\Resources\PluginResource\Pages; use App\Filament\Resources\PluginResource; +use App\Jobs\SendPendingPluginEmailDigest; +use App\Models\Plugin; use Filament\Actions; +use Filament\Notifications\Notification; use Filament\Resources\Pages\ListRecords; class ListPlugins extends ListRecords @@ -13,6 +16,26 @@ class ListPlugins extends ListRecords protected function getHeaderActions(): array { return [ + Actions\Action::make('sendPendingNewPluginNotifications') + ->label(fn () => 'Email Subscribers ('.Plugin::query()->pendingNewPluginNotification()->count().')') + ->icon('heroicon-o-envelope') + ->color('warning') + ->visible(fn () => Plugin::query()->pendingNewPluginNotification()->count() > 0) + ->requiresConfirmation() + ->modalHeading('Email Pending Plugin Notifications') + ->modalDescription(fn () => 'This will email every subscribed user a single digest covering the ' + .Plugin::query()->pendingNewPluginNotification()->count() + .' plugin(s) approved since the last digest.') + ->action(function (): void { + SendPendingPluginEmailDigest::dispatch(); + + Notification::make() + ->title('Digest queued') + ->body('The digest email is being sent to subscribed users.') + ->success() + ->send(); + }), + Actions\CreateAction::make(), ]; } diff --git a/app/Jobs/SendPendingPluginEmailDigest.php b/app/Jobs/SendPendingPluginEmailDigest.php new file mode 100644 index 00000000..18198c30 --- /dev/null +++ b/app/Jobs/SendPendingPluginEmailDigest.php @@ -0,0 +1,42 @@ +pendingNewPluginNotification()->get(); + + if ($plugins->isEmpty()) { + return; + } + + $recipients = User::query() + ->whereNotNull('email_verified_at') + ->where('receives_new_plugin_notifications', true) + ->get(); + + foreach ($recipients as $recipient) { + $notifiablePlugins = $plugins->reject(fn (Plugin $plugin) => $plugin->user_id === $recipient->id); + + if ($notifiablePlugins->isEmpty()) { + continue; + } + + $recipient->notify(new NewPluginsAvailable($notifiablePlugins)); + } + + Plugin::query() + ->whereIn('id', $plugins->pluck('id')) + ->update(['new_plugin_notified_at' => now()]); + } +} diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index d81629b1..0b44b512 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -393,6 +393,19 @@ protected function featured(Builder $query): Builder return $query->where('featured', true); } + /** + * Approved plugins that have not yet been included in an emailed "new plugins" digest. + * + * @param Builder $query + * @return Builder + */ + #[Scope] + protected function pendingNewPluginNotification(Builder $query): Builder + { + return $query->where('status', PluginStatus::Approved) + ->whereNull('new_plugin_notified_at'); + } + public function getPackagistUrl(): string { return "https://packagist.org/packages/{$this->name}"; @@ -814,6 +827,7 @@ protected function casts(): array 'type' => PluginType::class, 'tier' => PluginTier::class, 'approved_at' => 'datetime', + 'new_plugin_notified_at' => 'datetime', 'featured' => 'boolean', 'is_active' => 'boolean', 'is_official' => 'boolean', diff --git a/app/Notifications/NewPluginsAvailable.php b/app/Notifications/NewPluginsAvailable.php new file mode 100644 index 00000000..26ccd4ee --- /dev/null +++ b/app/Notifications/NewPluginsAvailable.php @@ -0,0 +1,73 @@ + $plugins + */ + public function __construct( + public Collection $plugins + ) {} + + /** + * @return array + */ + public function via(object $notifiable): array + { + if (! $notifiable->receives_new_plugin_notifications) { + return []; + } + + return ['mail', 'database']; + } + + public function toMail(object $notifiable): MailMessage + { + /** @var User $notifiable */ + $unsubscribeUrl = NotificationUnsubscribeController::signedUnsubscribeUrl($notifiable); + + $count = $this->plugins->count(); + + $mail = (new MailMessage) + ->subject($count === 1 + ? "New Plugin: {$this->plugins->first()->name}" + : "{$count} New Plugins on the NativePHP Marketplace") + ->greeting($count === 1 ? 'A new plugin is available!' : 'New plugins are available!') + ->line($count === 1 + ? 'The following plugin has just been added to the NativePHP Plugin Marketplace:' + : 'The following plugins have just been added to the NativePHP Plugin Marketplace:'); + + foreach ($this->plugins as $plugin) { + $mail->line('**['.$plugin->name.']('.route('plugins.show', $plugin->routeParams()).')**'); + } + + return $mail->line('[Unsubscribe from new plugin notifications]('.$unsubscribeUrl.').'); + } + + /** + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'title' => $this->plugins->count() === 1 + ? "New Plugin: {$this->plugins->first()->name}" + : "{$this->plugins->count()} New Plugins on the NativePHP Marketplace", + 'plugin_ids' => $this->plugins->pluck('id')->all(), + 'plugin_names' => $this->plugins->pluck('name')->all(), + ]; + } +} diff --git a/database/migrations/2026_08_21_120000_add_new_plugin_notified_at_to_plugins_table.php b/database/migrations/2026_08_21_120000_add_new_plugin_notified_at_to_plugins_table.php new file mode 100644 index 00000000..72ce9d29 --- /dev/null +++ b/database/migrations/2026_08_21_120000_add_new_plugin_notified_at_to_plugins_table.php @@ -0,0 +1,22 @@ +timestamp('new_plugin_notified_at')->nullable()->after('approved_at'); + }); + } + + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->dropColumn('new_plugin_notified_at'); + }); + } +}; diff --git a/tests/Feature/Filament/SendPendingNewPluginNotificationsActionTest.php b/tests/Feature/Filament/SendPendingNewPluginNotificationsActionTest.php new file mode 100644 index 00000000..99ffdcdb --- /dev/null +++ b/tests/Feature/Filament/SendPendingNewPluginNotificationsActionTest.php @@ -0,0 +1,57 @@ +admin = User::factory()->create(['email' => 'admin@test.com']); + config(['filament.users' => ['admin@test.com']]); + } + + public function test_action_hidden_when_no_plugins_are_pending(): void + { + Livewire::actingAs($this->admin) + ->test(ListPlugins::class) + ->assertActionHidden('sendPendingNewPluginNotifications'); + } + + public function test_action_visible_when_plugins_are_pending(): void + { + Plugin::factory()->approved()->create(); + + Livewire::actingAs($this->admin) + ->test(ListPlugins::class) + ->assertActionVisible('sendPendingNewPluginNotifications'); + } + + public function test_action_dispatches_the_digest_job(): void + { + Bus::fake([SendPendingPluginEmailDigest::class]); + + Plugin::factory()->approved()->create(); + + Livewire::actingAs($this->admin) + ->test(ListPlugins::class) + ->callAction('sendPendingNewPluginNotifications') + ->assertNotified(); + + Bus::assertDispatched(SendPendingPluginEmailDigest::class); + } +} diff --git a/tests/Feature/Jobs/SendPendingPluginEmailDigestTest.php b/tests/Feature/Jobs/SendPendingPluginEmailDigestTest.php new file mode 100644 index 00000000..c0732e02 --- /dev/null +++ b/tests/Feature/Jobs/SendPendingPluginEmailDigestTest.php @@ -0,0 +1,131 @@ +create(); + $optedIn = User::factory()->create(['receives_new_plugin_notifications' => true]); + + $pluginOne = Plugin::factory()->approved()->for($author)->create(); + $pluginTwo = Plugin::factory()->approved()->for($author)->create(); + + (new SendPendingPluginEmailDigest)->handle(); + + Notification::assertSentTo($optedIn, NewPluginsAvailable::class, function ($notification) use ($pluginOne, $pluginTwo) { + return $notification->plugins->pluck('id')->sort()->values()->all() + === collect([$pluginOne->id, $pluginTwo->id])->sort()->values()->all(); + }); + } + + public function test_job_does_not_notify_opted_out_users(): void + { + Notification::fake(); + + $author = User::factory()->create(); + $optedOut = User::factory()->create(['receives_new_plugin_notifications' => false]); + Plugin::factory()->approved()->for($author)->create(); + + (new SendPendingPluginEmailDigest)->handle(); + + Notification::assertNotSentTo($optedOut, NewPluginsAvailable::class); + } + + public function test_job_does_not_notify_unverified_users(): void + { + Notification::fake(); + + $author = User::factory()->create(); + $unverified = User::factory()->unverified()->create(['receives_new_plugin_notifications' => true]); + Plugin::factory()->approved()->for($author)->create(); + + (new SendPendingPluginEmailDigest)->handle(); + + Notification::assertNotSentTo($unverified, NewPluginsAvailable::class); + } + + public function test_job_excludes_a_recipients_own_plugins_from_their_digest(): void + { + Notification::fake(); + + $author = User::factory()->create(['receives_new_plugin_notifications' => true]); + $otherAuthor = User::factory()->create(); + + $ownPlugin = Plugin::factory()->approved()->for($author)->create(); + $otherPlugin = Plugin::factory()->approved()->for($otherAuthor)->create(); + + (new SendPendingPluginEmailDigest)->handle(); + + Notification::assertSentTo($author, NewPluginsAvailable::class, function ($notification) use ($ownPlugin, $otherPlugin) { + return ! $notification->plugins->contains('id', $ownPlugin->id) + && $notification->plugins->contains('id', $otherPlugin->id); + }); + } + + public function test_job_skips_a_recipient_entirely_when_all_pending_plugins_are_their_own(): void + { + Notification::fake(); + + $author = User::factory()->create(['receives_new_plugin_notifications' => true]); + Plugin::factory()->approved()->for($author)->create(); + + (new SendPendingPluginEmailDigest)->handle(); + + Notification::assertNotSentTo($author, NewPluginsAvailable::class); + } + + public function test_job_marks_pending_plugins_as_notified(): void + { + Notification::fake(); + + $author = User::factory()->create(); + $plugin = Plugin::factory()->approved()->for($author)->create(); + + $this->assertNull($plugin->new_plugin_notified_at); + + (new SendPendingPluginEmailDigest)->handle(); + + $this->assertNotNull($plugin->fresh()->new_plugin_notified_at); + } + + public function test_job_ignores_plugins_already_notified(): void + { + Notification::fake(); + + $author = User::factory()->create(); + $recipient = User::factory()->create(['receives_new_plugin_notifications' => true]); + + Plugin::factory()->approved()->for($author)->create([ + 'new_plugin_notified_at' => now()->subDay(), + ]); + + (new SendPendingPluginEmailDigest)->handle(); + + Notification::assertNotSentTo($recipient, NewPluginsAvailable::class); + } + + public function test_job_does_nothing_when_no_plugins_are_pending(): void + { + Notification::fake(); + + User::factory()->create(['receives_new_plugin_notifications' => true]); + + (new SendPendingPluginEmailDigest)->handle(); + + Notification::assertNothingSent(); + } +} diff --git a/tests/Feature/Notifications/NewPluginsAvailableTest.php b/tests/Feature/Notifications/NewPluginsAvailableTest.php new file mode 100644 index 00000000..bd226677 --- /dev/null +++ b/tests/Feature/Notifications/NewPluginsAvailableTest.php @@ -0,0 +1,113 @@ +create(['receives_new_plugin_notifications' => false]); + $plugin = Plugin::factory()->create(); + + $notification = new NewPluginsAvailable(new Collection([$plugin])); + + $this->assertEmpty($notification->via($user)); + } + + public function test_via_returns_mail_and_database_when_user_opted_in(): void + { + $user = User::factory()->create(['receives_new_plugin_notifications' => true]); + $plugin = Plugin::factory()->create(); + + $notification = new NewPluginsAvailable(new Collection([$plugin])); + + $this->assertEquals(['mail', 'database'], $notification->via($user)); + } + + public function test_mail_subject_names_the_single_plugin_when_only_one(): void + { + $user = User::factory()->create(); + $plugin = Plugin::factory()->create(['name' => 'acme/awesome-plugin']); + + $notification = new NewPluginsAvailable(new Collection([$plugin])); + $mail = $notification->toMail($user); + + $this->assertEquals('New Plugin: acme/awesome-plugin', $mail->subject); + } + + public function test_mail_subject_uses_a_count_when_multiple_plugins(): void + { + $user = User::factory()->create(); + $plugins = Plugin::factory()->count(3)->create(); + + $notification = new NewPluginsAvailable($plugins); + $mail = $notification->toMail($user); + + $this->assertEquals('3 New Plugins on the NativePHP Marketplace', $mail->subject); + } + + public function test_mail_lists_every_plugin(): void + { + $user = User::factory()->create(); + $pluginOne = Plugin::factory()->create(['name' => 'acme/one']); + $pluginTwo = Plugin::factory()->create(['name' => 'acme/two']); + + $notification = new NewPluginsAvailable(new Collection([$pluginOne, $pluginTwo])); + $html = $notification->toMail($user)->render()->toHtml(); + + $this->assertStringContainsString('acme/one', $html); + $this->assertStringContainsString('acme/two', $html); + } + + public function test_mail_links_to_each_plugin_page(): void + { + $user = User::factory()->create(); + $plugin = Plugin::factory()->create(['name' => 'acme/awesome-plugin']); + + $notification = new NewPluginsAvailable(new Collection([$plugin])); + $html = $notification->toMail($user)->render()->toHtml(); + + $this->assertStringContainsString( + route('plugins.show', ['vendor' => 'acme', 'package' => 'awesome-plugin']), + $html + ); + } + + public function test_database_notification_contains_all_plugin_ids(): void + { + $user = User::factory()->create(); + $pluginOne = Plugin::factory()->create(); + $pluginTwo = Plugin::factory()->create(); + + $notification = new NewPluginsAvailable(new Collection([$pluginOne, $pluginTwo])); + $data = $notification->toArray($user); + + $this->assertEquals([$pluginOne->id, $pluginTwo->id], $data['plugin_ids']); + } + + public function test_mail_contains_signed_unsubscribe_link(): void + { + $user = User::factory()->create(); + $plugin = Plugin::factory()->create(); + + $notification = new NewPluginsAvailable(new Collection([$plugin])); + $mail = $notification->toMail($user); + + $baseUrl = route('notifications.unsubscribe', ['user' => $user]); + $found = collect($mail->introLines)->concat($mail->outroLines)->contains(function ($line) use ($baseUrl) { + return str_contains($line, 'Unsubscribe from new plugin notifications') + && str_contains($line, $baseUrl); + }); + + $this->assertTrue($found, 'Mail should contain a signed unsubscribe link.'); + } +} diff --git a/tests/Feature/ResendNewPluginNotificationsTest.php b/tests/Feature/ResendNewPluginNotificationsTest.php index b97171d9..d9791051 100644 --- a/tests/Feature/ResendNewPluginNotificationsTest.php +++ b/tests/Feature/ResendNewPluginNotificationsTest.php @@ -4,7 +4,7 @@ use App\Models\Plugin; use App\Models\User; -use App\Notifications\NewPluginAvailable; +use App\Notifications\NewPluginsAvailable; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Notification; use Tests\TestCase; @@ -27,8 +27,8 @@ public function test_sends_notifications_to_opted_in_users(): void 'plugins' => [$plugin->name], ])->assertSuccessful(); - Notification::assertSentTo($optedIn, NewPluginAvailable::class); - Notification::assertNotSentTo($optedOut, NewPluginAvailable::class); + Notification::assertSentTo($optedIn, NewPluginsAvailable::class); + Notification::assertNotSentTo($optedOut, NewPluginsAvailable::class); } public function test_does_not_send_to_plugin_author(): void @@ -42,7 +42,7 @@ public function test_does_not_send_to_plugin_author(): void 'plugins' => [$plugin->name], ])->assertSuccessful(); - Notification::assertNotSentTo($author, NewPluginAvailable::class); + Notification::assertNotSentTo($author, NewPluginsAvailable::class); } public function test_fails_when_plugin_not_found(): void @@ -76,7 +76,7 @@ public function test_dry_run_does_not_send_notifications(): void Notification::assertNothingSent(); } - public function test_handles_multiple_plugins(): void + public function test_handles_multiple_plugins_as_a_single_combined_digest(): void { Notification::fake(); @@ -88,7 +88,42 @@ public function test_handles_multiple_plugins(): void 'plugins' => [$plugin1->name, $plugin2->name], ])->assertSuccessful(); - Notification::assertSentTo($user, NewPluginAvailable::class, 2); + Notification::assertSentTo($user, NewPluginsAvailable::class, 1); + Notification::assertSentTo($user, NewPluginsAvailable::class, function ($notification) use ($plugin1, $plugin2) { + return $notification->plugins->pluck('id')->sort()->values()->all() + === collect([$plugin1->id, $plugin2->id])->sort()->values()->all(); + }); + } + + public function test_excludes_all_specified_plugin_authors_from_the_combined_digest(): void + { + Notification::fake(); + + $authorOne = User::factory()->create(['receives_new_plugin_notifications' => true]); + $authorTwo = User::factory()->create(['receives_new_plugin_notifications' => true]); + $plugin1 = Plugin::factory()->approved()->for($authorOne)->create(); + $plugin2 = Plugin::factory()->approved()->for($authorTwo)->create(); + + $this->artisan('plugins:resend-new-plugin-notifications', [ + 'plugins' => [$plugin1->name, $plugin2->name], + ])->assertSuccessful(); + + Notification::assertNotSentTo($authorOne, NewPluginsAvailable::class); + Notification::assertNotSentTo($authorTwo, NewPluginsAvailable::class); + } + + public function test_marks_sent_plugins_as_notified(): void + { + Notification::fake(); + + User::factory()->create(['receives_new_plugin_notifications' => true]); + $plugin = Plugin::factory()->approved()->create(); + + $this->artisan('plugins:resend-new-plugin-notifications', [ + 'plugins' => [$plugin->name], + ])->assertSuccessful(); + + $this->assertNotNull($plugin->fresh()->new_plugin_notified_at); } public function test_succeeds_with_no_opted_in_users(): void