From adf270ae9c2289fdaf34197fefddfe4333a003ac Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Mon, 18 May 2026 21:28:41 +0100 Subject: [PATCH] Show Discord integration to Ultra subscribers and rename role to "Ultra" Widens the Discord role gate from `hasMaxAccess()` to also include `hasUltraAccess()` so Ultra subscribers see the integration banner and can claim the role. Renames the role end-to-end (config key, env var, DiscordApi methods, jobs, Livewire state, user-facing copy) from "Max" to "Ultra" to match the new branding. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Commands/RemoveExpiredDiscordRoles.php | 6 +- .../DiscordIntegrationController.php | 12 +- ...eJob.php => AssignDiscordUltraRoleJob.php} | 12 +- ...eJob.php => RemoveDiscordUltraRoleJob.php} | 8 +- app/Jobs/RevokeMaxAccessJob.php | 6 +- .../StripeWebhookReceivedListener.php | 12 +- app/Livewire/DiscordAccessBanner.php | 22 +-- app/Support/DiscordApi.php | 20 +-- config/services.php | 2 +- .../livewire/customer/integrations.blade.php | 4 +- .../livewire/discord-access-banner.blade.php | 20 +-- tests/Feature/DiscordUltraAccessTest.php | 145 ++++++++++++++++++ 12 files changed, 207 insertions(+), 62 deletions(-) rename app/Jobs/{AssignDiscordMaxRoleJob.php => AssignDiscordUltraRoleJob.php} (81%) rename app/Jobs/{RemoveDiscordMaxRoleJob.php => RemoveDiscordUltraRoleJob.php} (80%) create mode 100644 tests/Feature/DiscordUltraAccessTest.php diff --git a/app/Console/Commands/RemoveExpiredDiscordRoles.php b/app/Console/Commands/RemoveExpiredDiscordRoles.php index c1dbf304..e9fb3993 100644 --- a/app/Console/Commands/RemoveExpiredDiscordRoles.php +++ b/app/Console/Commands/RemoveExpiredDiscordRoles.php @@ -10,7 +10,7 @@ class RemoveExpiredDiscordRoles extends Command { protected $signature = 'discord:remove-expired-roles'; - protected $description = 'Remove Discord Max role for users whose Max licenses have expired'; + protected $description = 'Remove Discord Ultra role for users whose Max licenses or Ultra subscriptions have ended'; public function handle(): int { @@ -23,8 +23,8 @@ public function handle(): int ->get(); foreach ($users as $user) { - if (! $user->hasMaxAccess()) { - $success = $discord->removeMaxRole($user->discord_id); + if (! $user->hasMaxAccess() && ! $user->hasUltraAccess()) { + $success = $discord->removeUltraRole($user->discord_id); if ($success) { $user->update([ diff --git a/app/Http/Controllers/DiscordIntegrationController.php b/app/Http/Controllers/DiscordIntegrationController.php index 9abad300..6f070534 100644 --- a/app/Http/Controllers/DiscordIntegrationController.php +++ b/app/Http/Controllers/DiscordIntegrationController.php @@ -70,11 +70,11 @@ 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 the Ultra role.'); } - if ($user->hasMaxAccess()) { - $success = $discord->assignMaxRole($discordUser['id']); + if ($user->hasMaxAccess() || $user->hasUltraAccess()) { + $success = $discord->assignUltraRole($discordUser['id']); if ($success) { $user->update([ @@ -82,11 +82,11 @@ public function handleCallback(): RedirectResponse ]); return to_route('customer.integrations') - ->with('success', 'Discord account connected and Max role assigned!'); + ->with('success', 'Discord account connected and Ultra role assigned!'); } return to_route('customer.integrations') - ->with('warning', 'Discord account connected, but we could not assign the Max role. Please try again later.'); + ->with('warning', 'Discord account connected, but we could not assign the Ultra role. Please try again later.'); } return to_route('customer.integrations') @@ -108,7 +108,7 @@ public function disconnect(): RedirectResponse if ($user->discord_role_granted_at && $user->discord_id) { $discord = DiscordApi::make(); - $discord->removeMaxRole($user->discord_id); + $discord->removeUltraRole($user->discord_id); } $user->update([ diff --git a/app/Jobs/AssignDiscordMaxRoleJob.php b/app/Jobs/AssignDiscordUltraRoleJob.php similarity index 81% rename from app/Jobs/AssignDiscordMaxRoleJob.php rename to app/Jobs/AssignDiscordUltraRoleJob.php index 139dcc25..f58ac532 100644 --- a/app/Jobs/AssignDiscordMaxRoleJob.php +++ b/app/Jobs/AssignDiscordUltraRoleJob.php @@ -11,7 +11,7 @@ use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Log; -class AssignDiscordMaxRoleJob implements ShouldQueue +class AssignDiscordUltraRoleJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; @@ -31,8 +31,8 @@ public function handle(): void return; } - if (! $this->user->hasMaxAccess()) { - Log::info('Skipping Discord role assignment - user has no Max access', [ + if (! $this->user->hasMaxAccess() && ! $this->user->hasUltraAccess()) { + Log::info('Skipping Discord role assignment - user has no Max or Ultra access', [ 'user_id' => $this->user->id, ]); @@ -50,17 +50,17 @@ public function handle(): void return; } - $success = $discord->assignMaxRole($this->user->discord_id); + $success = $discord->assignUltraRole($this->user->discord_id); if ($success) { $this->user->update(['discord_role_granted_at' => now()]); - Log::info('Discord Max role assigned successfully', [ + Log::info('Discord Ultra role assigned successfully', [ 'user_id' => $this->user->id, 'discord_id' => $this->user->discord_id, ]); } else { - Log::error('Failed to assign Discord Max role', [ + Log::error('Failed to assign Discord Ultra role', [ 'user_id' => $this->user->id, 'discord_id' => $this->user->discord_id, ]); diff --git a/app/Jobs/RemoveDiscordMaxRoleJob.php b/app/Jobs/RemoveDiscordUltraRoleJob.php similarity index 80% rename from app/Jobs/RemoveDiscordMaxRoleJob.php rename to app/Jobs/RemoveDiscordUltraRoleJob.php index 1cce367d..92b708b6 100644 --- a/app/Jobs/RemoveDiscordMaxRoleJob.php +++ b/app/Jobs/RemoveDiscordUltraRoleJob.php @@ -11,7 +11,7 @@ use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Log; -class RemoveDiscordMaxRoleJob implements ShouldQueue +class RemoveDiscordUltraRoleJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; @@ -33,17 +33,17 @@ public function handle(): void $discord = DiscordApi::make(); - $success = $discord->removeMaxRole($this->user->discord_id); + $success = $discord->removeUltraRole($this->user->discord_id); if ($success) { $this->user->update(['discord_role_granted_at' => null]); - Log::info('Discord Max role removed successfully', [ + Log::info('Discord Ultra role removed successfully', [ 'user_id' => $this->user->id, 'discord_id' => $this->user->discord_id, ]); } else { - Log::warning('Failed to remove Discord Max role (user may not have role)', [ + Log::warning('Failed to remove Discord Ultra role (user may not have role)', [ 'user_id' => $this->user->id, 'discord_id' => $this->user->discord_id, ]); diff --git a/app/Jobs/RevokeMaxAccessJob.php b/app/Jobs/RevokeMaxAccessJob.php index b08d0686..0a77469f 100644 --- a/app/Jobs/RevokeMaxAccessJob.php +++ b/app/Jobs/RevokeMaxAccessJob.php @@ -82,17 +82,17 @@ private function revokeDiscordRole(User $user): void } $discord = DiscordApi::make(); - $success = $discord->removeMaxRole($user->discord_id); + $success = $discord->removeUltraRole($user->discord_id); if ($success) { $user->update(['discord_role_granted_at' => null]); - Log::info('Discord Max role revoked for user', [ + Log::info('Discord Ultra role revoked for user', [ 'user_id' => $user->id, 'discord_id' => $user->discord_id, ]); } else { - Log::warning('Failed to revoke Discord Max role for user', [ + Log::warning('Failed to revoke Discord Ultra role for user', [ 'user_id' => $user->id, 'discord_id' => $user->discord_id, ]); diff --git a/app/Listeners/StripeWebhookReceivedListener.php b/app/Listeners/StripeWebhookReceivedListener.php index c293fb54..4f114d11 100644 --- a/app/Listeners/StripeWebhookReceivedListener.php +++ b/app/Listeners/StripeWebhookReceivedListener.php @@ -4,7 +4,7 @@ use App\Jobs\CreateUserFromStripeCustomer; use App\Jobs\HandleInvoicePaidJob; -use App\Jobs\RemoveDiscordMaxRoleJob; +use App\Jobs\RemoveDiscordUltraRoleJob; use App\Jobs\RevokeTeamUserAccessJob; use App\Jobs\SuspendTeamJob; use App\Jobs\UnsuspendTeamJob; @@ -75,7 +75,7 @@ private function handleSubscriptionDeleted(WebhookReceived $event): void return; } - $this->removeDiscordRoleIfNoMaxLicense($user); + $this->removeDiscordRoleIfNoAccess($user); dispatch(new SuspendTeamJob($user->id)); dispatch(new RevokeTeamUserAccessJob($user->id)); @@ -101,7 +101,7 @@ private function handleSubscriptionUpdated(WebhookReceived $event): void } if (in_array($status, ['canceled', 'unpaid', 'past_due', 'incomplete_expired'])) { - $this->removeDiscordRoleIfNoMaxLicense($user); + $this->removeDiscordRoleIfNoAccess($user); dispatch(new SuspendTeamJob($user->id)); dispatch(new RevokeTeamUserAccessJob($user->id)); } @@ -112,16 +112,16 @@ private function handleSubscriptionUpdated(WebhookReceived $event): void } } - private function removeDiscordRoleIfNoMaxLicense(User $user): void + private function removeDiscordRoleIfNoAccess(User $user): void { if (! $user->discord_id) { return; } - if ($user->hasMaxAccess()) { + if ($user->hasMaxAccess() || $user->hasUltraAccess()) { return; } - dispatch(new RemoveDiscordMaxRoleJob($user)); + dispatch(new RemoveDiscordUltraRoleJob($user)); } } diff --git a/app/Livewire/DiscordAccessBanner.php b/app/Livewire/DiscordAccessBanner.php index dd882880..919fd877 100644 --- a/app/Livewire/DiscordAccessBanner.php +++ b/app/Livewire/DiscordAccessBanner.php @@ -10,7 +10,7 @@ class DiscordAccessBanner extends Component { public bool $inline = false; - public bool $hasMaxRole = false; + public bool $hasUltraRole = false; public bool $isGuildMember = false; @@ -25,7 +25,7 @@ public function checkRoleStatus(): void $user = auth()->user(); if (! $user || ! $user->discord_id) { - $this->hasMaxRole = false; + $this->hasUltraRole = false; $this->isGuildMember = false; return; @@ -38,14 +38,14 @@ public function checkRoleStatus(): void return [ 'isGuildMember' => $discord->isGuildMember($user->discord_id), - 'hasMaxRole' => $discord->hasMaxRole($user->discord_id), + 'hasUltraRole' => $discord->hasUltraRole($user->discord_id), ]; }); $this->isGuildMember = $status['isGuildMember']; - $this->hasMaxRole = $status['hasMaxRole']; + $this->hasUltraRole = $status['hasUltraRole']; - if ($this->hasMaxRole && ! $user->discord_role_granted_at) { + if ($this->hasUltraRole && ! $user->discord_role_granted_at) { $user->update(['discord_role_granted_at' => now()]); } } @@ -61,7 +61,7 @@ public function refreshStatus(): void $this->checkRoleStatus(); } - public function requestMaxRole(): void + public function requestUltraRole(): void { $user = auth()->user(); @@ -71,8 +71,8 @@ public function requestMaxRole(): void return; } - if (! $user->hasMaxAccess()) { - session()->flash('error', 'You need an active Max license to receive the Max role.'); + if (! $user->hasMaxAccess() && ! $user->hasUltraAccess()) { + session()->flash('error', 'You need an active Max license or Ultra subscription to receive the Ultra role.'); return; } @@ -85,15 +85,15 @@ public function requestMaxRole(): void return; } - $success = $discord->assignMaxRole($user->discord_id); + $success = $discord->assignUltraRole($user->discord_id); if ($success) { $user->update(['discord_role_granted_at' => now()]); Cache::forget("discord_role_status_{$user->id}"); $this->checkRoleStatus(); - session()->flash('success', 'Max role assigned successfully!'); + session()->flash('success', 'Ultra role assigned successfully!'); } else { - session()->flash('error', 'Failed to assign Max role. Please try again later.'); + session()->flash('error', 'Failed to assign Ultra role. Please try again later.'); } } diff --git a/app/Support/DiscordApi.php b/app/Support/DiscordApi.php index 07dabe38..d8c8eac1 100644 --- a/app/Support/DiscordApi.php +++ b/app/Support/DiscordApi.php @@ -12,7 +12,7 @@ class DiscordApi public function __construct( private ?string $botToken, private ?string $guildId, - private ?string $maxRoleId + private ?string $ultraRoleId ) {} public static function make(): static @@ -20,7 +20,7 @@ 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.ultra_role_id', '') ); } @@ -69,7 +69,7 @@ public function isGuildMember(string $discordUserId): bool return true; } - public function assignMaxRole(string $discordUserId): bool + public function assignUltraRole(string $discordUserId): bool { $response = Http::withToken($this->botToken, 'Bot') ->put(sprintf( @@ -77,11 +77,11 @@ public function assignMaxRole(string $discordUserId): bool self::BASE_URL, $this->guildId, $discordUserId, - $this->maxRoleId + $this->ultraRoleId )); if ($response->failed()) { - Log::error('Failed to assign Discord Max role', [ + Log::error('Failed to assign Discord Ultra role', [ 'discord_user_id' => $discordUserId, 'status' => $response->status(), 'response' => $response->json(), @@ -93,7 +93,7 @@ public function assignMaxRole(string $discordUserId): bool return true; } - public function removeMaxRole(string $discordUserId): bool + public function removeUltraRole(string $discordUserId): bool { $response = Http::withToken($this->botToken, 'Bot') ->delete(sprintf( @@ -101,11 +101,11 @@ public function removeMaxRole(string $discordUserId): bool self::BASE_URL, $this->guildId, $discordUserId, - $this->maxRoleId + $this->ultraRoleId )); if ($response->failed()) { - Log::error('Failed to remove Discord Max role', [ + Log::error('Failed to remove Discord Ultra role', [ 'discord_user_id' => $discordUserId, 'status' => $response->status(), 'response' => $response->json(), @@ -117,7 +117,7 @@ public function removeMaxRole(string $discordUserId): bool return true; } - public function hasMaxRole(string $discordUserId): bool + public function hasUltraRole(string $discordUserId): bool { $response = Http::withToken($this->botToken, 'Bot') ->get(sprintf( @@ -140,6 +140,6 @@ public function hasMaxRole(string $discordUserId): bool $member = $response->json(); $roles = $member['roles'] ?? []; - return in_array($this->maxRoleId, $roles, true); + return in_array($this->ultraRoleId, $roles, true); } } diff --git a/config/services.php b/config/services.php index b9a0164b..b26d03cd 100644 --- a/config/services.php +++ b/config/services.php @@ -56,7 +56,7 @@ 'redirect' => env('APP_URL').'/auth/discord/callback', 'bot_token' => env('DISCORD_BOT_TOKEN'), 'guild_id' => env('DISCORD_GUILD_ID'), - 'max_role_id' => env('DISCORD_MAX_ROLE_ID'), + 'ultra_role_id' => env('DISCORD_ULTRA_ROLE_ID'), ], 'turnstile' => [ diff --git a/resources/views/livewire/customer/integrations.blade.php b/resources/views/livewire/customer/integrations.blade.php index 59919566..32fb2445 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 receive a special "Max" role in the NativePHP Discord server.
  • +
  • Discord: Max license holders and Ultra subscribers receive a special "Ultra" role in the NativePHP Discord server.

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

diff --git a/resources/views/livewire/discord-access-banner.blade.php b/resources/views/livewire/discord-access-banner.blade.php index f542db93..ae4d0975 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 Ultra Role

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

- @elseif($hasMaxRole) + @elseif($hasUltraRole)

- Max Role Active + Ultra Role Active

- @elseif(auth()->user()->hasMaxAccess()) + @elseif(auth()->user()->hasMaxAccess() || auth()->user()->hasUltraAccess())

Eligible @@ -36,14 +36,14 @@

@endif @else -

Connect your Discord account to receive the Max role.

+

Connect your Discord account to receive the Ultra role.

@endif
@if(auth()->user()->discord_username) - @if($hasMaxRole) + @if($hasUltraRole) Open Discord @@ -55,10 +55,10 @@ Check Status Checking... - @elseif(auth()->user()->hasMaxAccess()) - @endif
diff --git a/tests/Feature/DiscordUltraAccessTest.php b/tests/Feature/DiscordUltraAccessTest.php new file mode 100644 index 00000000..cd6420f3 --- /dev/null +++ b/tests/Feature/DiscordUltraAccessTest.php @@ -0,0 +1,145 @@ + self::MAX_PRICE_ID, + 'services.discord.bot_token' => 'test_token', + 'services.discord.guild_id' => self::GUILD_ID, + 'services.discord.ultra_role_id' => self::ULTRA_ROLE_ID, + ]); + + Cache::flush(); + } + + private function ultraSubscriber(array $userAttributes = []): User + { + $user = User::factory()->create(array_merge([ + 'stripe_id' => 'cus_'.uniqid(), + ], $userAttributes)); + + CashierSubscription::factory()->for($user)->active()->create([ + 'stripe_price' => self::MAX_PRICE_ID, + ]); + + return $user; + } + + public function test_ultra_subscriber_sees_discord_access_banner(): void + { + Http::fake([ + 'discord.com/api/v10/guilds/*/members/*' => Http::response([], 404), + ]); + + $user = $this->ultraSubscriber(); + + $response = $this->actingAs($user)->get('/dashboard/integrations'); + + $response->assertStatus(200); + $response->assertSeeLivewire('discord-access-banner'); + } + + public function test_user_without_max_or_ultra_does_not_see_discord_banner(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->get('/dashboard/integrations'); + + $response->assertStatus(200); + $response->assertDontSeeLivewire('discord-access-banner'); + } + + public function test_ultra_subscriber_can_request_ultra_role(): void + { + Http::fake([ + 'discord.com/api/v10/guilds/*/members/discord-123/roles/*' => Http::response([], 204), + 'discord.com/api/v10/guilds/*/members/discord-123' => Http::response(['roles' => []], 200), + ]); + + $user = $this->ultraSubscriber([ + 'discord_id' => 'discord-123', + 'discord_username' => 'ultra-user', + ]); + + Livewire::actingAs($user) + ->test(DiscordAccessBanner::class) + ->call('requestUltraRole'); + + $this->assertNotNull($user->fresh()->discord_role_granted_at); + Http::assertSent(fn ($request) => str_contains($request->url(), '/members/discord-123/roles/'.self::ULTRA_ROLE_ID) + && $request->method() === 'PUT'); + } + + public function test_request_ultra_role_rejected_without_max_or_ultra(): void + { + Http::fake(); + + $user = User::factory()->create([ + 'discord_id' => 'discord-999', + 'discord_username' => 'free-user', + ]); + + Livewire::actingAs($user) + ->test(DiscordAccessBanner::class) + ->call('requestUltraRole'); + + $this->assertNull($user->fresh()->discord_role_granted_at); + Http::assertNotSent(fn ($request) => $request->method() === 'PUT' + && str_contains($request->url(), '/roles/'.self::ULTRA_ROLE_ID)); + } + + public function test_cleanup_command_retains_role_for_ultra_subscriber(): void + { + $user = $this->ultraSubscriber([ + 'discord_id' => 'discord-456', + 'discord_username' => 'ultra-user', + 'discord_role_granted_at' => now()->subDays(10), + ]); + + $this->artisan('discord:remove-expired-roles') + ->assertExitCode(0); + + $this->assertNotNull($user->fresh()->discord_role_granted_at); + } + + public function test_cleanup_command_removes_role_when_no_access(): void + { + Http::fake([ + 'discord.com/api/v10/guilds/*/members/*/roles/*' => Http::response([], 204), + ]); + + $user = User::factory()->create([ + 'discord_id' => 'discord-789', + 'discord_username' => 'former-user', + 'discord_role_granted_at' => now()->subDays(10), + ]); + + $this->artisan('discord:remove-expired-roles') + ->assertExitCode(0); + + $this->assertNull($user->fresh()->discord_role_granted_at); + } +}