diff --git a/app/Http/Controllers/DiscordIntegrationController.php b/app/Http/Controllers/DiscordIntegrationController.php index 9abad300..23b886f8 100644 --- a/app/Http/Controllers/DiscordIntegrationController.php +++ b/app/Http/Controllers/DiscordIntegrationController.php @@ -70,23 +70,30 @@ public function handleCallback(): RedirectResponse if (! $discord->isGuildMember($discordUser['id'])) { return to_route('customer.integrations') - ->with('warning', 'Discord account connected! Please join the NativePHP Discord server to receive the Max role.'); + ->with('warning', 'Discord account connected! Please join the NativePHP Discord server to receive your roles.'); } - if ($user->hasMaxAccess()) { - $success = $discord->assignMaxRole($discordUser['id']); + $rolesAssigned = []; - if ($success) { - $user->update([ - 'discord_role_granted_at' => now(), - ]); + if ($user->hasMaxAccess()) { + if ($discord->assignMaxRole($discordUser['id'])) { + $user->update(['discord_role_granted_at' => now()]); + $rolesAssigned[] = 'Max'; + } + } - return to_route('customer.integrations') - ->with('success', 'Discord account connected and Max role assigned!'); + if ($user->isEapCustomer()) { + if ($discord->assignEarlyAdopterRole($discordUser['id'])) { + $user->update(['discord_early_adopter_role_granted_at' => now()]); + $rolesAssigned[] = 'Early Adopter'; } + } + + if (count($rolesAssigned) > 0) { + $roleNames = implode(' and ', $rolesAssigned); return to_route('customer.integrations') - ->with('warning', 'Discord account connected, but we could not assign the Max role. Please try again later.'); + ->with('success', "Discord account connected and {$roleNames} role(s) assigned!"); } return to_route('customer.integrations') @@ -106,15 +113,23 @@ public function disconnect(): RedirectResponse { $user = Auth::user(); - if ($user->discord_role_granted_at && $user->discord_id) { + if ($user->discord_id) { $discord = DiscordApi::make(); - $discord->removeMaxRole($user->discord_id); + + if ($user->discord_role_granted_at) { + $discord->removeMaxRole($user->discord_id); + } + + if ($user->discord_early_adopter_role_granted_at) { + $discord->removeEarlyAdopterRole($user->discord_id); + } } $user->update([ 'discord_id' => null, 'discord_username' => null, 'discord_role_granted_at' => null, + 'discord_early_adopter_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 dd882880..8e3fb501 100644 --- a/app/Livewire/DiscordAccessBanner.php +++ b/app/Livewire/DiscordAccessBanner.php @@ -12,6 +12,8 @@ class DiscordAccessBanner extends Component public bool $hasMaxRole = false; + public bool $hasEarlyAdopterRole = false; + public bool $isGuildMember = false; public function mount(bool $inline = false): void @@ -26,6 +28,7 @@ public function checkRoleStatus(): void if (! $user || ! $user->discord_id) { $this->hasMaxRole = false; + $this->hasEarlyAdopterRole = false; $this->isGuildMember = false; return; @@ -39,15 +42,21 @@ public function checkRoleStatus(): void return [ 'isGuildMember' => $discord->isGuildMember($user->discord_id), 'hasMaxRole' => $discord->hasMaxRole($user->discord_id), + 'hasEarlyAdopterRole' => $discord->hasEarlyAdopterRole($user->discord_id), ]; }); $this->isGuildMember = $status['isGuildMember']; $this->hasMaxRole = $status['hasMaxRole']; + $this->hasEarlyAdopterRole = $status['hasEarlyAdopterRole']; if ($this->hasMaxRole && ! $user->discord_role_granted_at) { $user->update(['discord_role_granted_at' => now()]); } + + if ($this->hasEarlyAdopterRole && ! $user->discord_early_adopter_role_granted_at) { + $user->update(['discord_early_adopter_role_granted_at' => now()]); + } } public function refreshStatus(): void @@ -97,6 +106,42 @@ public function requestMaxRole(): void } } + public function requestEarlyAdopterRole(): void + { + $user = auth()->user(); + + if (! $user || ! $user->discord_id) { + session()->flash('error', 'Please connect your Discord account first.'); + + return; + } + + if (! $user->isEapCustomer()) { + session()->flash('error', 'The Early Adopter role is for early access program customers.'); + + return; + } + + $discord = DiscordApi::make(); + + if (! $discord->isGuildMember($user->discord_id)) { + session()->flash('error', 'Please join the NativePHP Discord server first.'); + + return; + } + + $success = $discord->assignEarlyAdopterRole($user->discord_id); + + if ($success) { + $user->update(['discord_early_adopter_role_granted_at' => now()]); + Cache::forget("discord_role_status_{$user->id}"); + $this->checkRoleStatus(); + session()->flash('success', 'Early Adopter role assigned successfully!'); + } else { + session()->flash('error', 'Failed to assign Early Adopter 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 c8bfc783..455778bd 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -570,6 +570,7 @@ protected function casts(): array 'mobile_repo_access_granted_at' => 'datetime', 'claude_plugins_repo_access_granted_at' => 'datetime', 'discord_role_granted_at' => 'datetime', + 'discord_early_adopter_role_granted_at' => 'datetime', ]; } } diff --git a/app/Support/DiscordApi.php b/app/Support/DiscordApi.php index 07dabe38..35e20912 100644 --- a/app/Support/DiscordApi.php +++ b/app/Support/DiscordApi.php @@ -12,7 +12,8 @@ class DiscordApi public function __construct( private ?string $botToken, private ?string $guildId, - private ?string $maxRoleId + private ?string $maxRoleId, + private ?string $earlyAdopterRoleId ) {} public static function make(): static @@ -20,7 +21,8 @@ public static function make(): static return new static( config('services.discord.bot_token', ''), config('services.discord.guild_id', ''), - config('services.discord.max_role_id', '') + config('services.discord.max_role_id', ''), + config('services.discord.early_adopter_role_id', '') ); } @@ -70,6 +72,36 @@ public function isGuildMember(string $discordUserId): bool } public function assignMaxRole(string $discordUserId): bool + { + return $this->assignRole($discordUserId, $this->maxRoleId, 'Max'); + } + + public function removeMaxRole(string $discordUserId): bool + { + return $this->removeRole($discordUserId, $this->maxRoleId, 'Max'); + } + + public function hasMaxRole(string $discordUserId): bool + { + return $this->hasRole($discordUserId, $this->maxRoleId); + } + + public function assignEarlyAdopterRole(string $discordUserId): bool + { + return $this->assignRole($discordUserId, $this->earlyAdopterRoleId, 'Early Adopter'); + } + + public function removeEarlyAdopterRole(string $discordUserId): bool + { + return $this->removeRole($discordUserId, $this->earlyAdopterRoleId, 'Early Adopter'); + } + + public function hasEarlyAdopterRole(string $discordUserId): bool + { + return $this->hasRole($discordUserId, $this->earlyAdopterRoleId); + } + + private function assignRole(string $discordUserId, ?string $roleId, string $roleName): bool { $response = Http::withToken($this->botToken, 'Bot') ->put(sprintf( @@ -77,11 +109,11 @@ public function assignMaxRole(string $discordUserId): bool self::BASE_URL, $this->guildId, $discordUserId, - $this->maxRoleId + $roleId )); if ($response->failed()) { - Log::error('Failed to assign Discord Max role', [ + Log::error("Failed to assign Discord {$roleName} role", [ 'discord_user_id' => $discordUserId, 'status' => $response->status(), 'response' => $response->json(), @@ -93,7 +125,7 @@ public function assignMaxRole(string $discordUserId): bool return true; } - public function removeMaxRole(string $discordUserId): bool + private function removeRole(string $discordUserId, ?string $roleId, string $roleName): bool { $response = Http::withToken($this->botToken, 'Bot') ->delete(sprintf( @@ -101,11 +133,11 @@ public function removeMaxRole(string $discordUserId): bool self::BASE_URL, $this->guildId, $discordUserId, - $this->maxRoleId + $roleId )); if ($response->failed()) { - Log::error('Failed to remove Discord Max role', [ + Log::error("Failed to remove Discord {$roleName} role", [ 'discord_user_id' => $discordUserId, 'status' => $response->status(), 'response' => $response->json(), @@ -117,7 +149,7 @@ public function removeMaxRole(string $discordUserId): bool return true; } - public function hasMaxRole(string $discordUserId): bool + private function hasRole(string $discordUserId, ?string $roleId): bool { $response = Http::withToken($this->botToken, 'Bot') ->get(sprintf( @@ -140,6 +172,6 @@ public function hasMaxRole(string $discordUserId): bool $member = $response->json(); $roles = $member['roles'] ?? []; - return in_array($this->maxRoleId, $roles, true); + return in_array($roleId, $roles, true); } } diff --git a/config/services.php b/config/services.php index b9a0164b..ad8ebc06 100644 --- a/config/services.php +++ b/config/services.php @@ -57,6 +57,7 @@ 'bot_token' => env('DISCORD_BOT_TOKEN'), 'guild_id' => env('DISCORD_GUILD_ID'), 'max_role_id' => env('DISCORD_MAX_ROLE_ID'), + 'early_adopter_role_id' => env('DISCORD_EARLY_ADOPTER_ROLE_ID'), ], 'turnstile' => [ diff --git a/database/migrations/2026_04_17_154933_add_discord_early_adopter_role_granted_at_to_users_table.php b/database/migrations/2026_04_17_154933_add_discord_early_adopter_role_granted_at_to_users_table.php new file mode 100644 index 00000000..9066fde2 --- /dev/null +++ b/database/migrations/2026_04_17_154933_add_discord_early_adopter_role_granted_at_to_users_table.php @@ -0,0 +1,25 @@ +timestamp('discord_early_adopter_role_granted_at')->nullable()->after('discord_role_granted_at'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('discord_early_adopter_role_granted_at'); + }); + } +}; diff --git a/resources/views/livewire/customer/integrations.blade.php b/resources/views/livewire/customer/integrations.blade.php index 59919566..4cb80b0f 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. @@ -47,7 +47,7 @@ @endif - @if(auth()->user()->hasMaxAccess()) + @if(auth()->user()->isEapCustomer()) @endif

diff --git a/resources/views/livewire/discord-access-banner.blade.php b/resources/views/livewire/discord-access-banner.blade.php index f542db93..e0467d3b 100644 --- a/resources/views/livewire/discord-access-banner.blade.php +++ b/resources/views/livewire/discord-access-banner.blade.php @@ -10,7 +10,7 @@

- Discord Max Role + Discord Roles

@if(auth()->user()->discord_username) @@ -22,32 +22,38 @@ Not in Server

- @elseif($hasMaxRole) -

- - Max Role Active - -

- @elseif(auth()->user()->hasMaxAccess()) -

- - Eligible - -

+ @else +
+ @if($hasMaxRole) + + Max Role Active + + @elseif(auth()->user()->hasMaxAccess()) + + Max Eligible + + @endif + + @if($hasEarlyAdopterRole) + + Early Adopter Active + + @elseif(auth()->user()->isEapCustomer()) + + Early Adopter Eligible + + @endif +
@endif @else -

Connect your Discord account to receive the Max role.

+

Connect your Discord account to receive your roles.

@endif
@if(auth()->user()->discord_username) - @if($hasMaxRole) - - Open Discord - - @elseif(!$isGuildMember) + @if(!$isGuildMember) Join Discord Server @@ -55,11 +61,24 @@ Check Status Checking... - @elseif(auth()->user()->hasMaxAccess()) - + @else + @if(!$hasMaxRole && auth()->user()->hasMaxAccess()) + + @endif + @if(!$hasEarlyAdopterRole && auth()->user()->isEapCustomer()) + + @endif + @if(($hasMaxRole || !auth()->user()->hasMaxAccess()) && ($hasEarlyAdopterRole || !auth()->user()->isEapCustomer())) + + Open Discord + + @endif @endif
@csrf diff --git a/tests/Feature/DiscordIntegrationTest.php b/tests/Feature/DiscordIntegrationTest.php new file mode 100644 index 00000000..05ecaef6 --- /dev/null +++ b/tests/Feature/DiscordIntegrationTest.php @@ -0,0 +1,191 @@ + 'test-client-id', + 'services.discord.client_secret' => 'test-client-secret', + 'services.discord.bot_token' => 'test-bot-token', + 'services.discord.guild_id' => 'test-guild-id', + 'services.discord.max_role_id' => 'max-role-id', + 'services.discord.early_adopter_role_id' => 'early-adopter-role-id', + ]); + } + + #[Test] + public function callback_assigns_early_adopter_role_for_eap_customer(): void + { + $user = User::factory()->create(); + + License::factory() + ->for($user) + ->eapEligible() + ->active() + ->withoutSubscriptionItem() + ->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' => 'eapuser', + ]), + '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/early-adopter-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->assertEquals('eapuser', $user->discord_username); + $this->assertNotNull($user->discord_early_adopter_role_granted_at); + } + + #[Test] + public function callback_assigns_both_roles_for_eap_customer_with_max_access(): void + { + $user = User::factory()->create(); + + License::factory() + ->for($user) + ->max() + ->eapEligible() + ->active() + ->withoutSubscriptionItem() + ->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' => 'maxeapuser', + ]), + '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/*' => 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->assertNotNull($user->discord_role_granted_at); + $this->assertNotNull($user->discord_early_adopter_role_granted_at); + } + + #[Test] + public function callback_does_not_assign_early_adopter_role_for_non_eap_customer(): void + { + $user = User::factory()->create(); + + License::factory() + ->for($user) + ->afterEap() + ->active() + ->withoutSubscriptionItem() + ->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')); + + $user->refresh(); + $this->assertNull($user->discord_early_adopter_role_granted_at); + } + + #[Test] + public function disconnect_removes_early_adopter_role(): void + { + $user = User::factory()->create([ + 'discord_id' => '999888777', + 'discord_username' => 'testuser', + 'discord_role_granted_at' => now(), + 'discord_early_adopter_role_granted_at' => now(), + ]); + + Http::fake([ + 'discord.com/api/v10/guilds/test-guild-id/members/999888777/roles/*' => 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_username); + $this->assertNull($user->discord_role_granted_at); + $this->assertNull($user->discord_early_adopter_role_granted_at); + } + + #[Test] + public function disconnect_only_removes_early_adopter_role_when_max_role_was_not_granted(): void + { + $user = User::factory()->create([ + 'discord_id' => '999888777', + 'discord_username' => 'testuser', + 'discord_role_granted_at' => null, + 'discord_early_adopter_role_granted_at' => now(), + ]); + + Http::fake([ + 'discord.com/api/v10/guilds/test-guild-id/members/999888777/roles/early-adopter-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_early_adopter_role_granted_at); + + Http::assertSentCount(1); + } +} diff --git a/tests/Feature/Livewire/DiscordAccessBannerTest.php b/tests/Feature/Livewire/DiscordAccessBannerTest.php new file mode 100644 index 00000000..772e96b7 --- /dev/null +++ b/tests/Feature/Livewire/DiscordAccessBannerTest.php @@ -0,0 +1,251 @@ + 'test-bot-token', + 'services.discord.guild_id' => 'test-guild-id', + 'services.discord.max_role_id' => 'max-role-id', + 'services.discord.early_adopter_role_id' => 'early-adopter-role-id', + ]); + } + + #[Test] + public function it_shows_not_connected_state_when_discord_is_not_linked(): void + { + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(DiscordAccessBanner::class) + ->assertSee('Connect your Discord account to receive your roles.'); + } + + #[Test] + public function it_shows_connected_username_when_discord_is_linked(): void + { + $user = User::factory()->create([ + 'discord_id' => '123456789', + 'discord_username' => 'testuser', + ]); + + $this->fakeDiscordApi(isGuildMember: true); + + Livewire::actingAs($user) + ->test(DiscordAccessBanner::class) + ->assertSee('testuser'); + } + + #[Test] + public function it_shows_not_in_server_when_user_is_not_a_guild_member(): void + { + $user = User::factory()->create([ + 'discord_id' => '123456789', + 'discord_username' => 'testuser', + ]); + + $this->fakeDiscordApi(isGuildMember: false); + + Livewire::actingAs($user) + ->test(DiscordAccessBanner::class) + ->assertSee('Not in Server'); + } + + #[Test] + public function it_shows_max_role_active_when_user_has_max_role(): void + { + $user = User::factory()->create([ + 'discord_id' => '123456789', + 'discord_username' => 'testuser', + ]); + + $this->fakeDiscordApi(isGuildMember: true, roles: ['max-role-id']); + + Livewire::actingAs($user) + ->test(DiscordAccessBanner::class) + ->assertSee('Max Role Active'); + } + + #[Test] + public function it_shows_early_adopter_active_when_user_has_early_adopter_role(): void + { + $user = User::factory()->create([ + 'discord_id' => '123456789', + 'discord_username' => 'testuser', + ]); + + $this->fakeDiscordApi(isGuildMember: true, roles: ['early-adopter-role-id']); + + Livewire::actingAs($user) + ->test(DiscordAccessBanner::class) + ->assertSee('Early Adopter Active'); + } + + #[Test] + public function it_shows_early_adopter_eligible_for_eap_customer_without_role(): void + { + $user = User::factory()->create([ + 'discord_id' => '123456789', + 'discord_username' => 'testuser', + ]); + + License::factory() + ->for($user) + ->eapEligible() + ->active() + ->withoutSubscriptionItem() + ->create(); + + $this->fakeDiscordApi(isGuildMember: true); + + Livewire::actingAs($user) + ->test(DiscordAccessBanner::class) + ->assertSee('Early Adopter Eligible'); + } + + #[Test] + public function it_shows_request_early_adopter_role_button_for_eligible_user(): void + { + $user = User::factory()->create([ + 'discord_id' => '123456789', + 'discord_username' => 'testuser', + ]); + + License::factory() + ->for($user) + ->eapEligible() + ->active() + ->withoutSubscriptionItem() + ->create(); + + $this->fakeDiscordApi(isGuildMember: true); + + Livewire::actingAs($user) + ->test(DiscordAccessBanner::class) + ->assertSee('Request Early Adopter Role'); + } + + #[Test] + public function it_assigns_early_adopter_role_on_request(): void + { + $user = User::factory()->create([ + 'discord_id' => '123456789', + 'discord_username' => 'testuser', + ]); + + License::factory() + ->for($user) + ->eapEligible() + ->active() + ->withoutSubscriptionItem() + ->create(); + + $this->fakeDiscordApi(isGuildMember: true); + + Livewire::actingAs($user) + ->test(DiscordAccessBanner::class) + ->call('requestEarlyAdopterRole') + ->assertHasNoErrors(); + + $this->assertNotNull($user->fresh()->discord_early_adopter_role_granted_at); + } + + #[Test] + public function it_does_not_assign_early_adopter_role_for_non_eap_customer(): void + { + $user = User::factory()->create([ + 'discord_id' => '123456789', + 'discord_username' => 'testuser', + ]); + + // License created after EAP cutoff + License::factory() + ->for($user) + ->afterEap() + ->active() + ->withoutSubscriptionItem() + ->create(); + + $this->fakeDiscordApi(isGuildMember: true); + + Livewire::actingAs($user) + ->test(DiscordAccessBanner::class) + ->call('requestEarlyAdopterRole'); + + $this->assertNull($user->fresh()->discord_early_adopter_role_granted_at); + } + + #[Test] + public function it_does_not_assign_early_adopter_role_when_not_guild_member(): void + { + $user = User::factory()->create([ + 'discord_id' => '123456789', + 'discord_username' => 'testuser', + ]); + + License::factory() + ->for($user) + ->eapEligible() + ->active() + ->withoutSubscriptionItem() + ->create(); + + $this->fakeDiscordApi(isGuildMember: false); + + Livewire::actingAs($user) + ->test(DiscordAccessBanner::class) + ->call('requestEarlyAdopterRole'); + + $this->assertNull($user->fresh()->discord_early_adopter_role_granted_at); + } + + #[Test] + public function it_shows_both_roles_active_when_user_has_both(): void + { + $user = User::factory()->create([ + 'discord_id' => '123456789', + 'discord_username' => 'testuser', + ]); + + $this->fakeDiscordApi(isGuildMember: true, roles: ['max-role-id', 'early-adopter-role-id']); + + Livewire::actingAs($user) + ->test(DiscordAccessBanner::class) + ->assertSee('Max Role Active') + ->assertSee('Early Adopter Active'); + } + + /** + * @param array $roles + */ + private function fakeDiscordApi(bool $isGuildMember, array $roles = []): void + { + Cache::flush(); + + $memberResponse = $isGuildMember + ? Http::response(['roles' => $roles], 200) + : Http::response([], 404); + + Http::fake([ + 'discord.com/api/v10/guilds/test-guild-id/members/*/roles/*' => Http::response([], 204), + 'discord.com/api/v10/guilds/test-guild-id/members/*' => $memberResponse, + ]); + } +}