Skip to content

Commit 94c0f65

Browse files
simonhampclaude
andcommitted
Add command to send plugin submission reminder notifications
New artisan command `plugins:send-submission-reminders` emails and notifies users with unapproved plugins to finalize their submissions, listing each plugin by Composer package name. Supports --dry-run flag. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2bc906d commit 94c0f65

3 files changed

Lines changed: 270 additions & 0 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
3+
namespace App\Console\Commands;
4+
5+
use App\Enums\PluginStatus;
6+
use App\Models\User;
7+
use App\Notifications\PluginSubmissionReminder;
8+
use Illuminate\Console\Command;
9+
10+
class SendPluginSubmissionReminders extends Command
11+
{
12+
protected $signature = 'plugins:send-submission-reminders
13+
{--dry-run : Show what would be sent without actually sending}';
14+
15+
protected $description = 'Send a reminder to users with unapproved plugin submissions to finalize their configuration';
16+
17+
public function handle(): int
18+
{
19+
$dryRun = $this->option('dry-run');
20+
21+
if ($dryRun) {
22+
$this->info('DRY RUN - No notifications will be sent');
23+
}
24+
25+
$users = User::query()
26+
->whereHas('plugins', function ($query) {
27+
$query->whereIn('status', [PluginStatus::Draft, PluginStatus::Pending, PluginStatus::Rejected]);
28+
})
29+
->with(['plugins' => function ($query) {
30+
$query->whereIn('status', [PluginStatus::Draft, PluginStatus::Pending, PluginStatus::Rejected])
31+
->orderBy('name');
32+
}])
33+
->get();
34+
35+
$this->info("Found {$users->count()} user(s) with unapproved plugins");
36+
37+
$sent = 0;
38+
39+
foreach ($users as $user) {
40+
$pluginNames = $user->plugins->pluck('name')->join(', ');
41+
42+
if ($dryRun) {
43+
$this->line("Would send to: {$user->email} ({$user->plugins->count()} plugin(s): {$pluginNames})");
44+
} else {
45+
$user->notify(new PluginSubmissionReminder($user->plugins));
46+
$this->line("Sent to: {$user->email} ({$user->plugins->count()} plugin(s): {$pluginNames})");
47+
}
48+
49+
$sent++;
50+
}
51+
52+
$this->newLine();
53+
$this->info($dryRun ? "Would send: {$sent} notification(s)" : "Sent: {$sent} notification(s)");
54+
55+
return Command::SUCCESS;
56+
}
57+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
namespace App\Notifications;
4+
5+
use App\Models\Plugin;
6+
use Illuminate\Bus\Queueable;
7+
use Illuminate\Contracts\Queue\ShouldQueue;
8+
use Illuminate\Notifications\Messages\MailMessage;
9+
use Illuminate\Notifications\Notification;
10+
use Illuminate\Support\Collection;
11+
12+
class PluginSubmissionReminder extends Notification implements ShouldQueue
13+
{
14+
use Queueable;
15+
16+
/**
17+
* @param Collection<int, Plugin> $plugins
18+
*/
19+
public function __construct(public Collection $plugins) {}
20+
21+
/**
22+
* @return array<int, string>
23+
*/
24+
public function via(object $notifiable): array
25+
{
26+
return ['mail', 'database'];
27+
}
28+
29+
public function toMail(object $notifiable): MailMessage
30+
{
31+
$message = (new MailMessage)
32+
->subject('Action Required: Finalize Your Plugin Submission')
33+
->greeting("Hi {$notifiable->name},")
34+
->line('We\'ve recently updated the plugin submission process with new requirements. Please review your pending plugin submissions to ensure they are configured correctly — particularly whether you intended to submit a **free** or **paid** plugin.')
35+
->line('The following plugins need your attention:');
36+
37+
foreach ($this->plugins as $plugin) {
38+
$message->line("- **{$plugin->name}** ({$plugin->status->label()})");
39+
}
40+
41+
$message->action('Review Your Plugins', route('customer.plugins.index'))
42+
->line('Please visit your plugin dashboard, review each submission, and re-submit when ready.')
43+
->salutation("Thanks,\n\nThe NativePHP Team");
44+
45+
return $message;
46+
}
47+
48+
/**
49+
* @return array<string, mixed>
50+
*/
51+
public function toArray(object $notifiable): array
52+
{
53+
return [
54+
'title' => 'Action Required: Finalize Your Plugin Submissions',
55+
'body' => 'Please review your pending plugin submissions to ensure they are configured correctly.',
56+
'plugin_names' => $this->plugins->pluck('name')->all(),
57+
];
58+
}
59+
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
<?php
2+
3+
namespace Tests\Feature;
4+
5+
use App\Models\Plugin;
6+
use App\Models\User;
7+
use App\Notifications\PluginSubmissionReminder;
8+
use Illuminate\Foundation\Testing\RefreshDatabase;
9+
use Illuminate\Support\Facades\Notification;
10+
use Tests\TestCase;
11+
12+
class SendPluginSubmissionRemindersTest extends TestCase
13+
{
14+
use RefreshDatabase;
15+
16+
public function test_sends_notification_to_user_with_draft_plugin(): void
17+
{
18+
Notification::fake();
19+
20+
$user = User::factory()->create();
21+
Plugin::factory()->draft()->for($user)->create();
22+
23+
$this->artisan('plugins:send-submission-reminders')
24+
->assertExitCode(0);
25+
26+
Notification::assertSentTo($user, PluginSubmissionReminder::class);
27+
}
28+
29+
public function test_sends_notification_to_user_with_pending_plugin(): void
30+
{
31+
Notification::fake();
32+
33+
$user = User::factory()->create();
34+
Plugin::factory()->pending()->for($user)->create();
35+
36+
$this->artisan('plugins:send-submission-reminders')
37+
->assertExitCode(0);
38+
39+
Notification::assertSentTo($user, PluginSubmissionReminder::class);
40+
}
41+
42+
public function test_sends_notification_to_user_with_rejected_plugin(): void
43+
{
44+
Notification::fake();
45+
46+
$user = User::factory()->create();
47+
Plugin::factory()->rejected()->for($user)->create();
48+
49+
$this->artisan('plugins:send-submission-reminders')
50+
->assertExitCode(0);
51+
52+
Notification::assertSentTo($user, PluginSubmissionReminder::class);
53+
}
54+
55+
public function test_does_not_send_to_user_with_only_approved_plugins(): void
56+
{
57+
Notification::fake();
58+
59+
$user = User::factory()->create();
60+
Plugin::factory()->approved()->for($user)->create();
61+
62+
$this->artisan('plugins:send-submission-reminders')
63+
->assertExitCode(0);
64+
65+
Notification::assertNothingSent();
66+
}
67+
68+
public function test_sends_one_notification_per_user_with_multiple_plugins(): void
69+
{
70+
Notification::fake();
71+
72+
$user = User::factory()->create();
73+
Plugin::factory()->draft()->for($user)->create(['name' => 'acme/plugin-one']);
74+
Plugin::factory()->pending()->for($user)->create(['name' => 'acme/plugin-two']);
75+
76+
$this->artisan('plugins:send-submission-reminders')
77+
->assertExitCode(0);
78+
79+
Notification::assertSentToTimes($user, PluginSubmissionReminder::class, 1);
80+
81+
Notification::assertSentTo($user, PluginSubmissionReminder::class, function ($notification) {
82+
return $notification->plugins->count() === 2;
83+
});
84+
}
85+
86+
public function test_notification_includes_plugin_names(): void
87+
{
88+
Notification::fake();
89+
90+
$user = User::factory()->create();
91+
Plugin::factory()->draft()->for($user)->create(['name' => 'acme/my-plugin']);
92+
93+
$this->artisan('plugins:send-submission-reminders')
94+
->assertExitCode(0);
95+
96+
Notification::assertSentTo($user, PluginSubmissionReminder::class, function ($notification) {
97+
return $notification->plugins->first()->name === 'acme/my-plugin';
98+
});
99+
}
100+
101+
public function test_excludes_approved_plugins_from_notification(): void
102+
{
103+
Notification::fake();
104+
105+
$user = User::factory()->create();
106+
Plugin::factory()->draft()->for($user)->create(['name' => 'acme/draft-one']);
107+
Plugin::factory()->approved()->for($user)->create(['name' => 'acme/approved-one']);
108+
109+
$this->artisan('plugins:send-submission-reminders')
110+
->assertExitCode(0);
111+
112+
Notification::assertSentTo($user, PluginSubmissionReminder::class, function ($notification) {
113+
return $notification->plugins->count() === 1
114+
&& $notification->plugins->first()->name === 'acme/draft-one';
115+
});
116+
}
117+
118+
public function test_dry_run_does_not_send_notifications(): void
119+
{
120+
Notification::fake();
121+
122+
$user = User::factory()->create();
123+
Plugin::factory()->draft()->for($user)->create();
124+
125+
$this->artisan('plugins:send-submission-reminders', ['--dry-run' => true])
126+
->assertExitCode(0);
127+
128+
Notification::assertNothingSent();
129+
}
130+
131+
public function test_notification_email_contains_expected_content(): void
132+
{
133+
$user = User::factory()->create(['name' => 'Jane']);
134+
$plugin = Plugin::factory()->draft()->for($user)->create(['name' => 'acme/test-plugin']);
135+
136+
$notification = new PluginSubmissionReminder(collect([$plugin]));
137+
$mail = $notification->toMail($user);
138+
139+
$this->assertStringContainsString('Action Required', $mail->subject);
140+
$this->assertStringContainsString('acme/test-plugin', implode(' ', array_map(fn ($line) => (string) $line, $mail->introLines)));
141+
}
142+
143+
public function test_notification_database_array_contains_plugin_names(): void
144+
{
145+
$user = User::factory()->create();
146+
$plugin = Plugin::factory()->draft()->for($user)->create(['name' => 'acme/test-plugin']);
147+
148+
$notification = new PluginSubmissionReminder(collect([$plugin]));
149+
$data = $notification->toArray($user);
150+
151+
$this->assertArrayHasKey('plugin_names', $data);
152+
$this->assertContains('acme/test-plugin', $data['plugin_names']);
153+
}
154+
}

0 commit comments

Comments
 (0)