Skip to content

Commit 4c06632

Browse files
simonhampclaude
andauthored
Fix plugin approval notification bypass and add resend command (#338)
Disable the status field in the Filament plugin edit form to prevent bypassing Plugin::approve() (which skips notifications, activity logging, and Satis sync). Add an artisan command to resend NewPluginAvailable notifications for specific approved plugins. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3bba4ca commit 4c06632

File tree

3 files changed

+182
-1
lines changed

3 files changed

+182
-1
lines changed
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
namespace App\Console\Commands;
4+
5+
use App\Models\Plugin;
6+
use App\Models\User;
7+
use App\Notifications\NewPluginAvailable;
8+
use Illuminate\Console\Command;
9+
use Illuminate\Support\Facades\Notification;
10+
11+
class ResendNewPluginNotifications extends Command
12+
{
13+
protected $signature = 'plugins:resend-new-plugin-notifications
14+
{plugins* : Plugin names (vendor/package) to resend notifications for}
15+
{--dry-run : Preview what would happen without sending notifications}';
16+
17+
protected $description = 'Resend NewPluginAvailable notifications to opted-in users for specified plugins';
18+
19+
public function handle(): int
20+
{
21+
$pluginNames = $this->argument('plugins');
22+
$dryRun = $this->option('dry-run');
23+
24+
$plugins = Plugin::query()
25+
->whereIn('name', $pluginNames)
26+
->where('status', 'approved')
27+
->get();
28+
29+
$missingNames = collect($pluginNames)->diff($plugins->pluck('name'));
30+
31+
if ($missingNames->isNotEmpty()) {
32+
foreach ($missingNames as $name) {
33+
$this->error("Plugin not found or not approved: {$name}");
34+
}
35+
36+
return Command::FAILURE;
37+
}
38+
39+
$recipients = User::query()
40+
->where('receives_new_plugin_notifications', true)
41+
->whereNotIn('id', $plugins->pluck('user_id'))
42+
->get();
43+
44+
if ($recipients->isEmpty()) {
45+
$this->warn('No opted-in users found to notify.');
46+
47+
return Command::SUCCESS;
48+
}
49+
50+
$this->info("Plugins: {$plugins->pluck('name')->implode(', ')}");
51+
$this->info("Recipients: {$recipients->count()} opted-in users");
52+
53+
if ($dryRun) {
54+
$this->warn('[DRY RUN] No notifications will be sent.');
55+
56+
return Command::SUCCESS;
57+
}
58+
59+
foreach ($plugins as $plugin) {
60+
$pluginRecipients = $recipients->where('id', '!=', $plugin->user_id);
61+
62+
Notification::send($pluginRecipients, new NewPluginAvailable($plugin));
63+
64+
$this->info("Sent NewPluginAvailable for {$plugin->name} to {$pluginRecipients->count()} users.");
65+
}
66+
67+
$this->newLine();
68+
$this->info('Done. All notifications queued.');
69+
70+
return Command::SUCCESS;
71+
}
72+
}

app/Filament/Resources/PluginResource.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,9 @@ public static function form(Schema $schema): Schema
7575
->suffixIconColor('gray'),
7676

7777
Forms\Components\Select::make('status')
78-
->options(PluginStatus::class),
78+
->options(PluginStatus::class)
79+
->disabled()
80+
->helperText('Use the Approve/Reject actions to change status'),
7981

8082
Forms\Components\Toggle::make('is_official')
8183
->label('Official (First-Party)')
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<?php
2+
3+
namespace Tests\Feature;
4+
5+
use App\Models\Plugin;
6+
use App\Models\User;
7+
use App\Notifications\NewPluginAvailable;
8+
use Illuminate\Foundation\Testing\RefreshDatabase;
9+
use Illuminate\Support\Facades\Notification;
10+
use Tests\TestCase;
11+
12+
class ResendNewPluginNotificationsTest extends TestCase
13+
{
14+
use RefreshDatabase;
15+
16+
public function test_sends_notifications_to_opted_in_users(): void
17+
{
18+
Notification::fake();
19+
20+
$author = User::factory()->create();
21+
$optedIn = User::factory()->create(['receives_new_plugin_notifications' => true]);
22+
$optedOut = User::factory()->create(['receives_new_plugin_notifications' => false]);
23+
24+
$plugin = Plugin::factory()->approved()->for($author)->create();
25+
26+
$this->artisan('plugins:resend-new-plugin-notifications', [
27+
'plugins' => [$plugin->name],
28+
])->assertSuccessful();
29+
30+
Notification::assertSentTo($optedIn, NewPluginAvailable::class);
31+
Notification::assertNotSentTo($optedOut, NewPluginAvailable::class);
32+
}
33+
34+
public function test_does_not_send_to_plugin_author(): void
35+
{
36+
Notification::fake();
37+
38+
$author = User::factory()->create(['receives_new_plugin_notifications' => true]);
39+
$plugin = Plugin::factory()->approved()->for($author)->create();
40+
41+
$this->artisan('plugins:resend-new-plugin-notifications', [
42+
'plugins' => [$plugin->name],
43+
])->assertSuccessful();
44+
45+
Notification::assertNotSentTo($author, NewPluginAvailable::class);
46+
}
47+
48+
public function test_fails_when_plugin_not_found(): void
49+
{
50+
$this->artisan('plugins:resend-new-plugin-notifications', [
51+
'plugins' => ['nonexistent/plugin'],
52+
])->assertFailed();
53+
}
54+
55+
public function test_fails_when_plugin_is_not_approved(): void
56+
{
57+
$plugin = Plugin::factory()->pending()->create();
58+
59+
$this->artisan('plugins:resend-new-plugin-notifications', [
60+
'plugins' => [$plugin->name],
61+
])->assertFailed();
62+
}
63+
64+
public function test_dry_run_does_not_send_notifications(): void
65+
{
66+
Notification::fake();
67+
68+
$user = User::factory()->create(['receives_new_plugin_notifications' => true]);
69+
$plugin = Plugin::factory()->approved()->create();
70+
71+
$this->artisan('plugins:resend-new-plugin-notifications', [
72+
'plugins' => [$plugin->name],
73+
'--dry-run' => true,
74+
])->assertSuccessful();
75+
76+
Notification::assertNothingSent();
77+
}
78+
79+
public function test_handles_multiple_plugins(): void
80+
{
81+
Notification::fake();
82+
83+
$user = User::factory()->create(['receives_new_plugin_notifications' => true]);
84+
$plugin1 = Plugin::factory()->approved()->create();
85+
$plugin2 = Plugin::factory()->approved()->create();
86+
87+
$this->artisan('plugins:resend-new-plugin-notifications', [
88+
'plugins' => [$plugin1->name, $plugin2->name],
89+
])->assertSuccessful();
90+
91+
Notification::assertSentTo($user, NewPluginAvailable::class, 2);
92+
}
93+
94+
public function test_succeeds_with_no_opted_in_users(): void
95+
{
96+
Notification::fake();
97+
98+
$plugin = Plugin::factory()->approved()->create();
99+
User::query()->update(['receives_new_plugin_notifications' => false]);
100+
101+
$this->artisan('plugins:resend-new-plugin-notifications', [
102+
'plugins' => [$plugin->name],
103+
])->assertSuccessful();
104+
105+
Notification::assertNothingSent();
106+
}
107+
}

0 commit comments

Comments
 (0)