diff --git a/app/Http/Controllers/DiscordIntegrationController.php b/app/Http/Controllers/DiscordIntegrationController.php index 87a2fe05..330013b2 100644 --- a/app/Http/Controllers/DiscordIntegrationController.php +++ b/app/Http/Controllers/DiscordIntegrationController.php @@ -123,6 +123,10 @@ public function disconnect(): RedirectResponse if ($user->discord_early_adopter_role_granted_at) { $discord->removeEarlyAdopterRole($user->discord_id); } + + if ($user->discord_plugin_developer_role_granted_at) { + $discord->removePluginDeveloperRole($user->discord_id); + } } $user->update([ @@ -130,6 +134,7 @@ public function disconnect(): RedirectResponse 'discord_username' => null, 'discord_role_granted_at' => null, 'discord_early_adopter_role_granted_at' => null, + 'discord_plugin_developer_role_granted_at' => null, ]); return back()->with('success', 'Discord account disconnected successfully.'); diff --git a/app/Livewire/DiscordAccessBanner.php b/app/Livewire/DiscordAccessBanner.php index cd2a1df0..f8f32c26 100644 --- a/app/Livewire/DiscordAccessBanner.php +++ b/app/Livewire/DiscordAccessBanner.php @@ -14,6 +14,8 @@ class DiscordAccessBanner extends Component public bool $hasEarlyAdopterRole = false; + public bool $hasPluginDeveloperRole = false; + public bool $isGuildMember = false; public function mount(bool $inline = false): void @@ -29,6 +31,7 @@ public function checkRoleStatus(): void if (! $user || ! $user->discord_id) { $this->hasUltraRole = false; $this->hasEarlyAdopterRole = false; + $this->hasPluginDeveloperRole = false; $this->isGuildMember = false; return; @@ -43,12 +46,14 @@ public function checkRoleStatus(): void 'isGuildMember' => $discord->isGuildMember($user->discord_id), 'hasUltraRole' => $discord->hasUltraRole($user->discord_id), 'hasEarlyAdopterRole' => $discord->hasEarlyAdopterRole($user->discord_id), + 'hasPluginDeveloperRole' => $discord->hasPluginDeveloperRole($user->discord_id), ]; }); $this->isGuildMember = $status['isGuildMember']; $this->hasUltraRole = $status['hasUltraRole']; $this->hasEarlyAdopterRole = $status['hasEarlyAdopterRole']; + $this->hasPluginDeveloperRole = $status['hasPluginDeveloperRole'] ?? false; if ($this->hasUltraRole && ! $user->discord_role_granted_at) { $user->update(['discord_role_granted_at' => now()]); @@ -57,6 +62,10 @@ public function checkRoleStatus(): void if ($this->hasEarlyAdopterRole && ! $user->discord_early_adopter_role_granted_at) { $user->update(['discord_early_adopter_role_granted_at' => now()]); } + + if ($this->hasPluginDeveloperRole && ! $user->discord_plugin_developer_role_granted_at) { + $user->update(['discord_plugin_developer_role_granted_at' => now()]); + } } public function refreshStatus(): void @@ -142,6 +151,42 @@ public function requestEarlyAdopterRole(): void } } + public function requestPluginDeveloperRole(): void + { + $user = auth()->user(); + + if (! $user || ! $user->discord_id) { + session()->flash('error', 'Please connect your Discord account first.'); + + return; + } + + if (! $user->hasHadPluginApproved()) { + session()->flash('error', 'The Plugin Developer role is for developers who have had a plugin approved for the Marketplace.'); + + return; + } + + $discord = DiscordApi::make(); + + if (! $discord->isGuildMember($user->discord_id)) { + session()->flash('error', 'Please join the NativePHP Discord server first.'); + + return; + } + + $success = $discord->assignPluginDeveloperRole($user->discord_id); + + if ($success) { + $user->update(['discord_plugin_developer_role_granted_at' => now()]); + Cache::forget("discord_role_status_{$user->id}"); + $this->checkRoleStatus(); + session()->flash('success', 'Plugin Developer role assigned successfully!'); + } else { + session()->flash('error', 'Failed to assign Plugin Developer role. Please try again later.'); + } + } + public function render() { return view('livewire.discord-access-banner'); diff --git a/app/Models/User.php b/app/Models/User.php index ba4679fe..c54d183e 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,6 +3,8 @@ namespace App\Models; use App\Enums\GitHubAuthType; +use App\Enums\PluginActivityType; +use App\Enums\PluginStatus; use App\Enums\PriceTier; use App\Enums\Subscription; use App\Enums\TeamUserStatus; @@ -11,6 +13,7 @@ use Filament\Models\Contracts\HasName; use Filament\Panel; use Illuminate\Contracts\Auth\MustVerifyEmail; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -402,6 +405,22 @@ public function getEligiblePriceTiers(): array return $tiers; } + /** + * Whether any of the user's plugins has ever been approved for the Marketplace, + * even if it has since been rejected, withdrawn or deactivated. + */ + public function hasHadPluginApproved(): bool + { + return $this->plugins() + ->where(function (Builder $query): void { + $query->where('status', PluginStatus::Approved) + ->orWhereHas('activities', function (Builder $query): void { + $query->where('type', PluginActivityType::Approved); + }); + }) + ->exists(); + } + public function hasDiscordConnected(): bool { return ! empty($this->discord_id); @@ -709,6 +728,7 @@ protected function casts(): array 'claude_plugins_repo_access_granted_at' => 'datetime', 'discord_role_granted_at' => 'datetime', 'discord_early_adopter_role_granted_at' => 'datetime', + 'discord_plugin_developer_role_granted_at' => 'datetime', 'github_auth_type' => GitHubAuthType::class, 'github_token_expires_at' => 'datetime', 'github_app_migration_notified_at' => 'datetime', diff --git a/app/Support/DiscordApi.php b/app/Support/DiscordApi.php index ebb835f2..b9d729c7 100644 --- a/app/Support/DiscordApi.php +++ b/app/Support/DiscordApi.php @@ -13,7 +13,8 @@ public function __construct( private ?string $botToken, private ?string $guildId, private ?string $ultraRoleId, - private ?string $earlyAdopterRoleId + private ?string $earlyAdopterRoleId, + private ?string $pluginDeveloperRoleId ) {} public static function make(): static @@ -22,7 +23,8 @@ public static function make(): static config('services.discord.bot_token', ''), config('services.discord.guild_id', ''), config('services.discord.ultra_role_id', ''), - config('services.discord.early_adopter_role_id', '') + config('services.discord.early_adopter_role_id', ''), + config('services.discord.plugin_developer_role_id', '') ); } @@ -101,6 +103,21 @@ public function hasEarlyAdopterRole(string $discordUserId): bool return $this->hasRole($discordUserId, $this->earlyAdopterRoleId); } + public function assignPluginDeveloperRole(string $discordUserId): bool + { + return $this->assignRole($discordUserId, $this->pluginDeveloperRoleId, 'Plugin Developer'); + } + + public function removePluginDeveloperRole(string $discordUserId): bool + { + return $this->removeRole($discordUserId, $this->pluginDeveloperRoleId, 'Plugin Developer'); + } + + public function hasPluginDeveloperRole(string $discordUserId): bool + { + return $this->hasRole($discordUserId, $this->pluginDeveloperRoleId); + } + private function assignRole(string $discordUserId, ?string $roleId, string $roleName): bool { $response = Http::withToken($this->botToken, 'Bot') diff --git a/config/services.php b/config/services.php index 0d74bbf0..903e6f38 100644 --- a/config/services.php +++ b/config/services.php @@ -88,6 +88,7 @@ 'guild_id' => env('DISCORD_GUILD_ID'), 'ultra_role_id' => env('DISCORD_ULTRA_ROLE_ID'), 'early_adopter_role_id' => env('DISCORD_EARLY_ADOPTER_ROLE_ID'), + 'plugin_developer_role_id' => env('DISCORD_PLUGIN_DEVELOPER_ROLE_ID'), ], 'turnstile' => [ diff --git a/database/migrations/2026_10_02_105146_add_discord_plugin_developer_role_granted_at_to_users_table.php b/database/migrations/2026_10_02_105146_add_discord_plugin_developer_role_granted_at_to_users_table.php new file mode 100644 index 00000000..8907be37 --- /dev/null +++ b/database/migrations/2026_10_02_105146_add_discord_plugin_developer_role_granted_at_to_users_table.php @@ -0,0 +1,25 @@ +timestamp('discord_plugin_developer_role_granted_at')->nullable()->after('discord_early_adopter_role_granted_at'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('discord_plugin_developer_role_granted_at'); + }); + } +}; diff --git a/resources/views/livewire/customer/integrations.blade.php b/resources/views/livewire/customer/integrations.blade.php index 0eb2a98b..b036c1aa 100644 --- a/resources/views/livewire/customer/integrations.blade.php +++ b/resources/views/livewire/customer/integrations.blade.php @@ -32,7 +32,7 @@

Need help? Join our Discord community. @@ -130,7 +130,7 @@ @endif - @if(auth()->user()->hasMaxAccess() || auth()->user()->hasUltraAccess() || auth()->user()->isEapCustomer()) + @if(auth()->user()->hasMaxAccess() || auth()->user()->hasUltraAccess() || auth()->user()->isEapCustomer() || auth()->user()->hasHadPluginApproved()) @endif

diff --git a/resources/views/livewire/discord-access-banner.blade.php b/resources/views/livewire/discord-access-banner.blade.php index d3e9016f..6c7d4e11 100644 --- a/resources/views/livewire/discord-access-banner.blade.php +++ b/resources/views/livewire/discord-access-banner.blade.php @@ -43,6 +43,16 @@ Early Adopter Eligible @endif + + @if($hasPluginDeveloperRole) + + Plugin Developer Active + + @elseif(auth()->user()->hasHadPluginApproved()) + + Plugin Developer Eligible + + @endif @endif @else @@ -74,7 +84,13 @@ Requesting... @endif - @if(($hasUltraRole || !(auth()->user()->hasMaxAccess() || auth()->user()->hasUltraAccess())) && ($hasEarlyAdopterRole || !auth()->user()->isEapCustomer())) + @if(!$hasPluginDeveloperRole && auth()->user()->hasHadPluginApproved()) + + @endif + @if(($hasUltraRole || !(auth()->user()->hasMaxAccess() || auth()->user()->hasUltraAccess())) && ($hasEarlyAdopterRole || !auth()->user()->isEapCustomer()) && ($hasPluginDeveloperRole || !auth()->user()->hasHadPluginApproved())) Open Discord diff --git a/tests/Feature/DiscordIntegrationTest.php b/tests/Feature/DiscordIntegrationTest.php index 7284fba7..f3e8aeed 100644 --- a/tests/Feature/DiscordIntegrationTest.php +++ b/tests/Feature/DiscordIntegrationTest.php @@ -2,7 +2,9 @@ namespace Tests\Feature; +use App\Livewire\DiscordAccessBanner; use App\Models\License; +use App\Models\Plugin; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Http; @@ -24,6 +26,7 @@ protected function setUp(): void 'services.discord.guild_id' => 'test-guild-id', 'services.discord.ultra_role_id' => 'ultra-role-id', 'services.discord.early_adopter_role_id' => 'early-adopter-role-id', + 'services.discord.plugin_developer_role_id' => 'plugin-developer-role-id', ]); } @@ -188,4 +191,85 @@ public function disconnect_only_removes_early_adopter_role_when_max_role_was_not Http::assertSentCount(1); } + + #[Test] + public function callback_does_not_assign_plugin_developer_role_automatically(): void + { + $user = User::factory()->create(); + + Plugin::factory()->for($user)->approved()->create(); + + Http::fake([ + 'discord.com/api/oauth2/token' => Http::response([ + 'access_token' => 'test-access-token', + ]), + 'discord.com/api/v10/users/@me' => Http::response([ + 'id' => '999888777', + 'username' => 'plugindev', + ]), + 'discord.com/api/v10/guilds/test-guild-id/members/999888777' => Http::response([ + 'roles' => [], + ], 200), + ]); + + $this->actingAs($user) + ->get('/auth/discord/callback?code=test-code') + ->assertRedirect(route('customer.integrations')); + + $user->refresh(); + $this->assertEquals('999888777', $user->discord_id); + $this->assertNull($user->discord_plugin_developer_role_granted_at); + + Http::assertNotSent(fn ($request) => $request->method() === 'PUT'); + } + + #[Test] + public function disconnect_removes_plugin_developer_role(): void + { + $user = User::factory()->create([ + 'discord_id' => '999888777', + 'discord_username' => 'testuser', + 'discord_plugin_developer_role_granted_at' => now(), + ]); + + Http::fake([ + 'discord.com/api/v10/guilds/test-guild-id/members/999888777/roles/plugin-developer-role-id' => Http::response([], 204), + ]); + + $this->actingAs($user) + ->delete('/dashboard/discord/disconnect') + ->assertSessionHas('success', 'Discord account disconnected successfully.'); + + $user->refresh(); + $this->assertNull($user->discord_id); + $this->assertNull($user->discord_plugin_developer_role_granted_at); + + Http::assertSentCount(1); + } + + #[Test] + public function integrations_page_shows_discord_banner_to_approved_plugin_developers(): void + { + $user = User::factory()->create(); + + Plugin::factory()->for($user)->approved()->create(); + + $this->actingAs($user) + ->get(route('customer.integrations')) + ->assertOk() + ->assertSeeLivewire(DiscordAccessBanner::class); + } + + #[Test] + public function integrations_page_hides_discord_banner_from_developers_without_an_approved_plugin(): void + { + $user = User::factory()->create(); + + Plugin::factory()->for($user)->pending()->create(); + + $this->actingAs($user) + ->get(route('customer.integrations')) + ->assertOk() + ->assertDontSeeLivewire(DiscordAccessBanner::class); + } } diff --git a/tests/Feature/Livewire/DiscordAccessBannerTest.php b/tests/Feature/Livewire/DiscordAccessBannerTest.php index d4c0ff57..6b7eccf1 100644 --- a/tests/Feature/Livewire/DiscordAccessBannerTest.php +++ b/tests/Feature/Livewire/DiscordAccessBannerTest.php @@ -2,8 +2,11 @@ namespace Tests\Feature\Livewire; +use App\Enums\PluginActivityType; +use App\Enums\PluginStatus; use App\Livewire\DiscordAccessBanner; use App\Models\License; +use App\Models\Plugin; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Cache; @@ -25,6 +28,7 @@ protected function setUp(): void 'services.discord.guild_id' => 'test-guild-id', 'services.discord.ultra_role_id' => 'ultra-role-id', 'services.discord.early_adopter_role_id' => 'early-adopter-role-id', + 'services.discord.plugin_developer_role_id' => 'plugin-developer-role-id', ]); } @@ -232,6 +236,176 @@ public function it_shows_both_roles_active_when_user_has_both(): void ->assertSee('Early Adopter Active'); } + #[Test] + public function it_shows_plugin_developer_eligible_for_developer_with_approved_plugin(): void + { + $user = $this->connectedUser(); + + Plugin::factory()->for($user)->approved()->create(); + + $this->fakeDiscordApi(isGuildMember: true); + + Livewire::actingAs($user) + ->test(DiscordAccessBanner::class) + ->assertSee('Plugin Developer Eligible') + ->assertSee('Claim Plugin Developer Role'); + } + + #[Test] + public function it_does_not_offer_plugin_developer_role_without_an_approved_plugin(): void + { + $user = $this->connectedUser(); + + Plugin::factory()->for($user)->pending()->create(); + Plugin::factory()->approved()->create(); + + $this->fakeDiscordApi(isGuildMember: true); + + Livewire::actingAs($user) + ->test(DiscordAccessBanner::class) + ->assertDontSee('Plugin Developer Eligible') + ->assertDontSee('Claim Plugin Developer Role'); + } + + #[Test] + public function it_shows_plugin_developer_active_when_user_has_the_role(): void + { + $user = $this->connectedUser(); + + $this->fakeDiscordApi(isGuildMember: true, roles: ['plugin-developer-role-id']); + + Livewire::actingAs($user) + ->test(DiscordAccessBanner::class) + ->assertSee('Plugin Developer Active') + ->assertDontSee('Claim Plugin Developer Role'); + + $this->assertNotNull($user->fresh()->discord_plugin_developer_role_granted_at); + } + + #[Test] + public function it_assigns_plugin_developer_role_on_request(): void + { + $user = $this->connectedUser(); + + Plugin::factory()->for($user)->approved()->create(); + + $this->fakeDiscordApi(isGuildMember: true); + + Livewire::actingAs($user) + ->test(DiscordAccessBanner::class) + ->call('requestPluginDeveloperRole') + ->assertHasNoErrors(); + + $this->assertNotNull($user->fresh()->discord_plugin_developer_role_granted_at); + + Http::assertSent(fn ($request) => $request->method() === 'PUT' + && str_ends_with($request->url(), '/members/123456789/roles/plugin-developer-role-id')); + } + + #[Test] + public function it_assigns_plugin_developer_role_for_an_inactive_approved_plugin(): void + { + $user = $this->connectedUser(); + + Plugin::factory()->for($user)->approved()->inactive()->create(); + + $this->fakeDiscordApi(isGuildMember: true); + + Livewire::actingAs($user) + ->test(DiscordAccessBanner::class) + ->call('requestPluginDeveloperRole'); + + $this->assertNotNull($user->fresh()->discord_plugin_developer_role_granted_at); + } + + #[Test] + public function it_assigns_plugin_developer_role_when_the_only_approved_plugin_was_later_rejected(): void + { + $user = $this->connectedUser(); + $admin = User::factory()->create(); + + $plugin = Plugin::factory()->for($user)->approved()->create(); + $plugin->activities()->create([ + 'type' => PluginActivityType::Approved, + 'from_status' => PluginStatus::Pending->value, + 'to_status' => PluginStatus::Approved->value, + 'causer_id' => $admin->id, + ]); + $plugin->reject('No longer maintained', $admin->id); + + $this->assertTrue($plugin->fresh()->isRejected()); + + $this->fakeDiscordApi(isGuildMember: true); + + Livewire::actingAs($user) + ->test(DiscordAccessBanner::class) + ->assertSee('Plugin Developer Eligible') + ->call('requestPluginDeveloperRole'); + + $this->assertNotNull($user->fresh()->discord_plugin_developer_role_granted_at); + } + + #[Test] + public function it_does_not_assign_plugin_developer_role_without_an_approved_plugin(): void + { + $user = $this->connectedUser(); + + Plugin::factory()->for($user)->rejected()->create(); + Plugin::factory()->for($user)->draft()->create(); + + $this->fakeDiscordApi(isGuildMember: true); + + Livewire::actingAs($user) + ->test(DiscordAccessBanner::class) + ->call('requestPluginDeveloperRole'); + + $this->assertNull($user->fresh()->discord_plugin_developer_role_granted_at); + + Http::assertNotSent(fn ($request) => $request->method() === 'PUT'); + } + + #[Test] + public function it_does_not_assign_plugin_developer_role_when_not_guild_member(): void + { + $user = $this->connectedUser(); + + Plugin::factory()->for($user)->approved()->create(); + + $this->fakeDiscordApi(isGuildMember: false); + + Livewire::actingAs($user) + ->test(DiscordAccessBanner::class) + ->call('requestPluginDeveloperRole'); + + $this->assertNull($user->fresh()->discord_plugin_developer_role_granted_at); + } + + #[Test] + public function it_does_not_assign_plugin_developer_role_when_discord_is_not_connected(): void + { + $user = User::factory()->create(); + + Plugin::factory()->for($user)->approved()->create(); + + $this->fakeDiscordApi(isGuildMember: true); + + Livewire::actingAs($user) + ->test(DiscordAccessBanner::class) + ->call('requestPluginDeveloperRole'); + + $this->assertNull($user->fresh()->discord_plugin_developer_role_granted_at); + + Http::assertNothingSent(); + } + + private function connectedUser(): User + { + return User::factory()->create([ + 'discord_id' => '123456789', + 'discord_username' => 'testuser', + ]); + } + /** * @param array $roles */