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 @@

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 */