From da35c4e3b36a2335ecf51b8bbf11e9058c512dfb Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 21 Aug 2026 13:45:44 +0100 Subject: [PATCH] Add Discord Master role for Masterclass purchasers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Masterclass students can now claim a "Master" role in the NativePHP Discord server, unlocking private channels. Follows the existing Ultra and Early Adopter role pattern. The entitlement check uses direct product ownership rather than the team-inheriting hasProductLicense(), so the role tracks course access exactly: Ultra team members don't get it, since they can't watch the course either. Also fixes the banner's visibility gate on the Integrations page, which previously only admitted Max/Ultra/EAP customers — a course-only customer saw no Discord section at all. Requires DISCORD_MASTER_ROLE_ID to be set in the environment. Co-Authored-By: Claude Opus 5 (1M context) --- .../DiscordIntegrationController.php | 12 ++ app/Livewire/DiscordAccessBanner.php | 45 ++++++ app/Models/User.php | 15 ++ app/Support/DiscordApi.php | 21 ++- config/services.php | 1 + ..._master_role_granted_at_to_users_table.php | 25 ++++ .../livewire/customer/integrations.blade.php | 4 +- .../livewire/discord-access-banner.blade.php | 18 ++- tests/Feature/DiscordIntegrationTest.php | 116 +++++++++++++++ .../Livewire/DiscordAccessBannerTest.php | 137 ++++++++++++++++++ 10 files changed, 389 insertions(+), 5 deletions(-) create mode 100644 database/migrations/2026_08_12_211754_add_discord_master_role_granted_at_to_users_table.php diff --git a/app/Http/Controllers/DiscordIntegrationController.php b/app/Http/Controllers/DiscordIntegrationController.php index 87a2fe05..afe3fb1a 100644 --- a/app/Http/Controllers/DiscordIntegrationController.php +++ b/app/Http/Controllers/DiscordIntegrationController.php @@ -89,6 +89,13 @@ public function handleCallback(): RedirectResponse } } + if ($user->hasPurchasedMasterclass()) { + if ($discord->assignMasterRole($discordUser['id'])) { + $user->update(['discord_master_role_granted_at' => now()]); + $rolesAssigned[] = 'Master'; + } + } + if (count($rolesAssigned) > 0) { $roleNames = implode(' and ', $rolesAssigned); @@ -123,6 +130,10 @@ public function disconnect(): RedirectResponse if ($user->discord_early_adopter_role_granted_at) { $discord->removeEarlyAdopterRole($user->discord_id); } + + if ($user->discord_master_role_granted_at) { + $discord->removeMasterRole($user->discord_id); + } } $user->update([ @@ -130,6 +141,7 @@ public function disconnect(): RedirectResponse 'discord_username' => null, 'discord_role_granted_at' => null, 'discord_early_adopter_role_granted_at' => null, + 'discord_master_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..be9cd726 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 $hasMasterRole = 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->hasMasterRole = 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), + 'hasMasterRole' => $discord->hasMasterRole($user->discord_id), ]; }); $this->isGuildMember = $status['isGuildMember']; $this->hasUltraRole = $status['hasUltraRole']; $this->hasEarlyAdopterRole = $status['hasEarlyAdopterRole']; + $this->hasMasterRole = $status['hasMasterRole'] ?? 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->hasMasterRole && ! $user->discord_master_role_granted_at) { + $user->update(['discord_master_role_granted_at' => now()]); + } } public function refreshStatus(): void @@ -142,6 +151,42 @@ public function requestEarlyAdopterRole(): void } } + public function requestMasterRole(): void + { + $user = auth()->user(); + + if (! $user || ! $user->discord_id) { + session()->flash('error', 'Please connect your Discord account first.'); + + return; + } + + if (! $user->hasPurchasedMasterclass()) { + session()->flash('error', 'The Master role is for Masterclass students.'); + + return; + } + + $discord = DiscordApi::make(); + + if (! $discord->isGuildMember($user->discord_id)) { + session()->flash('error', 'Please join the NativePHP Discord server first.'); + + return; + } + + $success = $discord->assignMasterRole($user->discord_id); + + if ($success) { + $user->update(['discord_master_role_granted_at' => now()]); + Cache::forget("discord_role_status_{$user->id}"); + $this->checkRoleStatus(); + session()->flash('success', 'Master role assigned successfully!'); + } else { + session()->flash('error', 'Failed to assign Master 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 dd144d68..0632f5fe 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -9,6 +9,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; @@ -128,6 +129,19 @@ public function hasProductLicense(Product $product): bool return $this->hasProductAccessViaTeam($product); } + /** + * Check if user has purchased The NativePHP Masterclass. + * + * Mirrors the direct-ownership check used to gate the course itself, so the + * Discord Master role tracks course access rather than team membership. + */ + public function hasPurchasedMasterclass(): bool + { + return $this->productLicenses() + ->whereHas('product', fn (Builder $query) => $query->where('slug', 'nativephp-masterclass')) + ->exists(); + } + /** * @return HasMany */ @@ -579,6 +593,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_master_role_granted_at' => 'datetime', ]; } } diff --git a/app/Support/DiscordApi.php b/app/Support/DiscordApi.php index ebb835f2..758a67b7 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 $masterRoleId = null ) {} 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.master_role_id', '') ); } @@ -101,6 +103,21 @@ public function hasEarlyAdopterRole(string $discordUserId): bool return $this->hasRole($discordUserId, $this->earlyAdopterRoleId); } + public function assignMasterRole(string $discordUserId): bool + { + return $this->assignRole($discordUserId, $this->masterRoleId, 'Master'); + } + + public function removeMasterRole(string $discordUserId): bool + { + return $this->removeRole($discordUserId, $this->masterRoleId, 'Master'); + } + + public function hasMasterRole(string $discordUserId): bool + { + return $this->hasRole($discordUserId, $this->masterRoleId); + } + 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 ddd1b38b..73054b71 100644 --- a/config/services.php +++ b/config/services.php @@ -58,6 +58,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'), + 'master_role_id' => env('DISCORD_MASTER_ROLE_ID'), ], 'turnstile' => [ diff --git a/database/migrations/2026_08_12_211754_add_discord_master_role_granted_at_to_users_table.php b/database/migrations/2026_08_12_211754_add_discord_master_role_granted_at_to_users_table.php new file mode 100644 index 00000000..ac07ef63 --- /dev/null +++ b/database/migrations/2026_08_12_211754_add_discord_master_role_granted_at_to_users_table.php @@ -0,0 +1,25 @@ +timestamp('discord_master_role_granted_at')->nullable()->after('discord_early_adopter_role_granted_at'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('discord_master_role_granted_at'); + }); + } +}; diff --git a/resources/views/livewire/customer/integrations.blade.php b/resources/views/livewire/customer/integrations.blade.php index ccb97de9..451ab0a8 100644 --- a/resources/views/livewire/customer/integrations.blade.php +++ b/resources/views/livewire/customer/integrations.blade.php @@ -29,7 +29,7 @@
  • GitHub: Max license holders can access the private nativephp/mobile repository. Plugin Dev Kit license holders and Ultra subscribers can access nativephp/claude-code.
  • -
  • Discord: Max license holders and Ultra subscribers receive a special "Ultra" role in the NativePHP Discord server. Early Access Program customers receive the "Early Adopter" role.
  • +
  • Discord: Max license holders and Ultra subscribers receive a special "Ultra" role in the NativePHP Discord server. Early Access Program customers receive the "Early Adopter" role. Masterclass students receive the "Master" role, unlocking private channels.

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

diff --git a/resources/views/livewire/discord-access-banner.blade.php b/resources/views/livewire/discord-access-banner.blade.php index 58ec9f8c..9774583b 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($hasMasterRole) + + Master Role Active + + @elseif(auth()->user()->hasPurchasedMasterclass()) + + Master Eligible + + @endif @endif @else @@ -74,7 +84,13 @@ Requesting... @endif - @if(($hasUltraRole || !(auth()->user()->hasMaxAccess() || auth()->user()->hasUltraAccess())) && ($hasEarlyAdopterRole || !auth()->user()->isEapCustomer())) + @if(!$hasMasterRole && auth()->user()->hasPurchasedMasterclass()) + + @endif + @if(($hasUltraRole || !(auth()->user()->hasMaxAccess() || auth()->user()->hasUltraAccess())) && ($hasEarlyAdopterRole || !auth()->user()->isEapCustomer()) && ($hasMasterRole || !auth()->user()->hasPurchasedMasterclass())) Open Discord diff --git a/tests/Feature/DiscordIntegrationTest.php b/tests/Feature/DiscordIntegrationTest.php index 7284fba7..de69136a 100644 --- a/tests/Feature/DiscordIntegrationTest.php +++ b/tests/Feature/DiscordIntegrationTest.php @@ -3,6 +3,8 @@ namespace Tests\Feature; use App\Models\License; +use App\Models\Product; +use App\Models\ProductLicense; 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.master_role_id' => 'master-role-id', ]); } @@ -137,6 +140,119 @@ public function callback_does_not_assign_early_adopter_role_for_non_eap_customer $this->assertNull($user->discord_early_adopter_role_granted_at); } + #[Test] + public function integrations_page_shows_discord_banner_for_masterclass_owner(): void + { + $user = User::factory()->create(); + + ProductLicense::factory()->create([ + 'user_id' => $user->id, + 'product_id' => Product::where('slug', 'nativephp-masterclass')->firstOrFail()->id, + ]); + + $this->actingAs($user) + ->get(route('customer.integrations')) + ->assertOk() + ->assertSee('Connect your Discord account to receive your roles.'); + } + + #[Test] + public function integrations_page_hides_discord_banner_for_users_without_roles(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('customer.integrations')) + ->assertOk() + ->assertDontSee('Connect your Discord account to receive your roles.'); + } + + #[Test] + public function callback_assigns_master_role_for_masterclass_owner(): void + { + $user = User::factory()->create(); + + ProductLicense::factory()->create([ + 'user_id' => $user->id, + 'product_id' => Product::where('slug', 'nativephp-masterclass')->firstOrFail()->id, + ]); + + 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' => 'student', + ]), + 'discord.com/api/v10/guilds/test-guild-id/members/999888777' => Http::response([ + 'roles' => [], + ], 200), + 'discord.com/api/v10/guilds/test-guild-id/members/999888777/roles/master-role-id' => Http::response([], 204), + ]); + + $response = $this->actingAs($user) + ->get('/auth/discord/callback?code=test-code'); + + $response->assertRedirect(route('customer.integrations')); + $response->assertSessionHas('success'); + + $user->refresh(); + $this->assertEquals('999888777', $user->discord_id); + $this->assertNotNull($user->discord_master_role_granted_at); + } + + #[Test] + public function callback_does_not_assign_master_role_without_the_masterclass(): void + { + $user = User::factory()->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' => 'newuser', + ]), + 'discord.com/api/v10/guilds/test-guild-id/members/999888777' => Http::response([ + 'roles' => [], + ], 200), + ]); + + $response = $this->actingAs($user) + ->get('/auth/discord/callback?code=test-code'); + + $response->assertRedirect(route('customer.integrations')); + + $this->assertNull($user->fresh()->discord_master_role_granted_at); + } + + #[Test] + public function disconnect_removes_master_role(): void + { + $user = User::factory()->create([ + 'discord_id' => '999888777', + 'discord_username' => 'testuser', + 'discord_master_role_granted_at' => now(), + ]); + + Http::fake([ + 'discord.com/api/v10/guilds/test-guild-id/members/999888777/roles/master-role-id' => Http::response([], 204), + ]); + + $response = $this->actingAs($user) + ->delete('/dashboard/discord/disconnect'); + + $response->assertSessionHas('success', 'Discord account disconnected successfully.'); + + $user->refresh(); + $this->assertNull($user->discord_id); + $this->assertNull($user->discord_master_role_granted_at); + + Http::assertSentCount(1); + } + #[Test] public function disconnect_removes_early_adopter_role(): void { diff --git a/tests/Feature/Livewire/DiscordAccessBannerTest.php b/tests/Feature/Livewire/DiscordAccessBannerTest.php index d4c0ff57..f7015af1 100644 --- a/tests/Feature/Livewire/DiscordAccessBannerTest.php +++ b/tests/Feature/Livewire/DiscordAccessBannerTest.php @@ -4,6 +4,8 @@ use App\Livewire\DiscordAccessBanner; use App\Models\License; +use App\Models\Product; +use App\Models\ProductLicense; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Cache; @@ -25,6 +27,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.master_role_id' => 'master-role-id', ]); } @@ -232,6 +235,140 @@ public function it_shows_both_roles_active_when_user_has_both(): void ->assertSee('Early Adopter Active'); } + #[Test] + public function it_shows_master_role_active_when_user_has_master_role(): void + { + $user = User::factory()->create([ + 'discord_id' => '123456789', + 'discord_username' => 'testuser', + ]); + + $this->fakeDiscordApi(isGuildMember: true, roles: ['master-role-id']); + + Livewire::actingAs($user) + ->test(DiscordAccessBanner::class) + ->assertSee('Master Role Active'); + + $this->assertNotNull($user->fresh()->discord_master_role_granted_at); + } + + #[Test] + public function it_shows_master_eligible_for_masterclass_owner_without_role(): void + { + $user = User::factory()->create([ + 'discord_id' => '123456789', + 'discord_username' => 'testuser', + ]); + + $this->grantMasterclass($user); + + $this->fakeDiscordApi(isGuildMember: true); + + Livewire::actingAs($user) + ->test(DiscordAccessBanner::class) + ->assertSee('Master Eligible'); + } + + #[Test] + public function it_shows_request_master_role_button_for_masterclass_owner(): void + { + $user = User::factory()->create([ + 'discord_id' => '123456789', + 'discord_username' => 'testuser', + ]); + + $this->grantMasterclass($user); + + $this->fakeDiscordApi(isGuildMember: true); + + Livewire::actingAs($user) + ->test(DiscordAccessBanner::class) + ->assertSee('Request Master Role'); + } + + #[Test] + public function it_does_not_show_request_master_role_button_without_the_masterclass(): void + { + $user = User::factory()->create([ + 'discord_id' => '123456789', + 'discord_username' => 'testuser', + ]); + + $this->fakeDiscordApi(isGuildMember: true); + + Livewire::actingAs($user) + ->test(DiscordAccessBanner::class) + ->assertDontSee('Request Master Role') + ->assertDontSee('Master Eligible'); + } + + #[Test] + public function it_assigns_master_role_on_request(): void + { + $user = User::factory()->create([ + 'discord_id' => '123456789', + 'discord_username' => 'testuser', + ]); + + $this->grantMasterclass($user); + + $this->fakeDiscordApi(isGuildMember: true); + + Livewire::actingAs($user) + ->test(DiscordAccessBanner::class) + ->call('requestMasterRole') + ->assertHasNoErrors(); + + $this->assertNotNull($user->fresh()->discord_master_role_granted_at); + + Http::assertSent(fn ($request) => $request->method() === 'PUT' + && str_contains($request->url(), '/members/123456789/roles/master-role-id')); + } + + #[Test] + public function it_does_not_assign_master_role_without_the_masterclass(): void + { + $user = User::factory()->create([ + 'discord_id' => '123456789', + 'discord_username' => 'testuser', + ]); + + $this->fakeDiscordApi(isGuildMember: true); + + Livewire::actingAs($user) + ->test(DiscordAccessBanner::class) + ->call('requestMasterRole'); + + $this->assertNull($user->fresh()->discord_master_role_granted_at); + } + + #[Test] + public function it_does_not_assign_master_role_when_not_guild_member(): void + { + $user = User::factory()->create([ + 'discord_id' => '123456789', + 'discord_username' => 'testuser', + ]); + + $this->grantMasterclass($user); + + $this->fakeDiscordApi(isGuildMember: false); + + Livewire::actingAs($user) + ->test(DiscordAccessBanner::class) + ->call('requestMasterRole'); + + $this->assertNull($user->fresh()->discord_master_role_granted_at); + } + + private function grantMasterclass(User $user): void + { + ProductLicense::factory()->create([ + 'user_id' => $user->id, + 'product_id' => Product::where('slug', 'nativephp-masterclass')->firstOrFail()->id, + ]); + } + /** * @param array $roles */