Skip to content

Commit 32c7dac

Browse files
simonhampclaude
andcommitted
Add command to grant new plugins to existing bundle owners
Artisan command `plugins:grant-to-bundle-owners` creates free licenses for users who purchased a bundle and sends staggered email notifications. Integrated into Filament's AttachAction with an opt-in toggle so it triggers automatically when adding a plugin to a bundle. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 864d848 commit 32c7dac

4 files changed

Lines changed: 442 additions & 1 deletion

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
<?php
2+
3+
namespace App\Console\Commands;
4+
5+
use App\Models\Plugin;
6+
use App\Models\PluginBundle;
7+
use App\Models\PluginLicense;
8+
use App\Models\User;
9+
use App\Notifications\BundlePluginAdded;
10+
use Illuminate\Console\Command;
11+
12+
class GrantPluginToBundleOwners extends Command
13+
{
14+
protected $signature = 'plugins:grant-to-bundle-owners
15+
{bundle : The bundle slug}
16+
{plugin : The plugin name (vendor/package)}
17+
{--dry-run : Preview what would happen without making changes}
18+
{--no-email : Grant the plugin without sending notification emails}';
19+
20+
protected $description = 'Grant a plugin to all users who have purchased a specific bundle and notify them via email';
21+
22+
public function handle(): int
23+
{
24+
$bundle = PluginBundle::where('slug', $this->argument('bundle'))->first();
25+
26+
if (! $bundle) {
27+
$this->error("Bundle not found: {$this->argument('bundle')}");
28+
29+
return Command::FAILURE;
30+
}
31+
32+
$plugin = Plugin::where('name', $this->argument('plugin'))->first();
33+
34+
if (! $plugin) {
35+
$this->error("Plugin not found: {$this->argument('plugin')}");
36+
37+
return Command::FAILURE;
38+
}
39+
40+
$dryRun = $this->option('dry-run');
41+
$noEmail = $this->option('no-email');
42+
43+
// Find all unique users who have purchased this bundle
44+
// (they have at least one active PluginLicense linked to this bundle)
45+
$userIds = PluginLicense::where('plugin_bundle_id', $bundle->id)
46+
->active()
47+
->distinct()
48+
->pluck('user_id');
49+
50+
$users = User::whereIn('id', $userIds)->get();
51+
52+
if ($users->isEmpty()) {
53+
$this->warn('No users found who have purchased this bundle.');
54+
55+
return Command::SUCCESS;
56+
}
57+
58+
$this->info("Bundle: {$bundle->name} (slug: {$bundle->slug})");
59+
$this->info("Plugin: {$plugin->name}");
60+
$this->info("Users found: {$users->count()}");
61+
62+
if ($dryRun) {
63+
$this->warn('[DRY RUN] No changes will be made.');
64+
}
65+
66+
$this->newLine();
67+
68+
$granted = 0;
69+
$skipped = 0;
70+
71+
foreach ($users as $user) {
72+
// Check if user already has an active license for this plugin
73+
$existingLicense = PluginLicense::where('user_id', $user->id)
74+
->where('plugin_id', $plugin->id)
75+
->active()
76+
->exists();
77+
78+
if ($existingLicense) {
79+
$this->line(" Skipped {$user->email} — already has an active license");
80+
$skipped++;
81+
82+
continue;
83+
}
84+
85+
if (! $dryRun) {
86+
PluginLicense::create([
87+
'user_id' => $user->id,
88+
'plugin_id' => $plugin->id,
89+
'plugin_bundle_id' => $bundle->id,
90+
'price_paid' => 0,
91+
'currency' => 'USD',
92+
'is_grandfathered' => false,
93+
'purchased_at' => now(),
94+
]);
95+
96+
if (! $noEmail) {
97+
$user->notify(
98+
(new BundlePluginAdded($plugin, $bundle))
99+
->delay(now()->addSeconds($granted * 2))
100+
);
101+
}
102+
}
103+
104+
$this->line(" Granted to {$user->email}");
105+
$granted++;
106+
}
107+
108+
$this->newLine();
109+
$this->info("Granted: {$granted}");
110+
$this->info("Skipped (already licensed): {$skipped}");
111+
112+
if ($dryRun) {
113+
$this->warn('This was a dry run. Run again without --dry-run to apply changes.');
114+
}
115+
116+
return Command::SUCCESS;
117+
}
118+
}

app/Filament/Resources/PluginBundleResource/RelationManagers/PluginsRelationManager.php

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22

33
namespace App\Filament\Resources\PluginBundleResource\RelationManagers;
44

5+
use Filament\Forms;
56
use Filament\Resources\RelationManagers\RelationManager;
67
use Filament\Tables;
78
use Filament\Tables\Table;
9+
use Illuminate\Support\Facades\Artisan;
810

911
class PluginsRelationManager extends RelationManager
1012
{
@@ -46,7 +48,33 @@ public function table(Table $table): Table
4648
->headerActions([
4749
Tables\Actions\AttachAction::make()
4850
->preloadRecordSelect()
49-
->recordSelectSearchColumns(['name']),
51+
->recordSelectSearchColumns(['name'])
52+
->form(fn (Tables\Actions\AttachAction $action): array => [
53+
$action->getRecordSelect(),
54+
Forms\Components\Toggle::make('grant_to_existing_owners')
55+
->label('Grant to existing bundle owners')
56+
->helperText('Create free licenses for users who already purchased this bundle and send them an email.')
57+
->default(true),
58+
])
59+
->after(function (array $data) {
60+
if (! ($data['grant_to_existing_owners'] ?? false)) {
61+
return;
62+
}
63+
64+
/** @var \App\Models\PluginBundle $bundle */
65+
$bundle = $this->getOwnerRecord();
66+
67+
$plugin = \App\Models\Plugin::find($data['recordId']);
68+
69+
if (! $plugin) {
70+
return;
71+
}
72+
73+
Artisan::call('plugins:grant-to-bundle-owners', [
74+
'bundle' => $bundle->slug,
75+
'plugin' => $plugin->name,
76+
]);
77+
}),
5078
])
5179
->actions([
5280
Tables\Actions\DetachAction::make(),
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
3+
namespace App\Notifications;
4+
5+
use App\Models\Plugin;
6+
use App\Models\PluginBundle;
7+
use Illuminate\Bus\Queueable;
8+
use Illuminate\Contracts\Queue\ShouldQueue;
9+
use Illuminate\Notifications\Messages\MailMessage;
10+
use Illuminate\Notifications\Notification;
11+
12+
class BundlePluginAdded extends Notification implements ShouldQueue
13+
{
14+
use Queueable;
15+
16+
public function __construct(
17+
public Plugin $plugin,
18+
public PluginBundle $bundle,
19+
) {}
20+
21+
/**
22+
* @return array<int, string>
23+
*/
24+
public function via(object $notifiable): array
25+
{
26+
return ['mail'];
27+
}
28+
29+
public function toMail(object $notifiable): MailMessage
30+
{
31+
$parts = explode('/', $this->plugin->name ?? '');
32+
$vendor = $parts[0] ?? '';
33+
$package = $parts[1] ?? '';
34+
35+
$pluginUrl = "https://nativephp.com/plugins/{$vendor}/{$package}";
36+
37+
return (new MailMessage)
38+
->subject("New plugin added to your {$this->bundle->name} bundle!")
39+
->greeting('Great news!')
40+
->line("We've added **{$this->plugin->name}** to the **{$this->bundle->name}** bundle — and because you already own the bundle, it's yours for free.")
41+
->action('Check it out', $pluginUrl)
42+
->line('Thank you for being a NativePHP customer!');
43+
}
44+
45+
/**
46+
* @return array<string, mixed>
47+
*/
48+
public function toArray(object $notifiable): array
49+
{
50+
return [
51+
'plugin_id' => $this->plugin->id,
52+
'plugin_name' => $this->plugin->name,
53+
'bundle_id' => $this->bundle->id,
54+
'bundle_name' => $this->bundle->name,
55+
];
56+
}
57+
}

0 commit comments

Comments
 (0)