diff --git a/app/Policies/BrandPolicy.php b/app/Policies/BrandPolicy.php index 28b01c5e..ec488134 100644 --- a/app/Policies/BrandPolicy.php +++ b/app/Policies/BrandPolicy.php @@ -5,9 +5,11 @@ namespace App\Policies; use App\Enums\UserWorkspace\Role; +use App\Features\BrandLimit; use App\Models\Brand; use App\Models\User; use App\Models\Workspace; +use Laravel\Pennant\Feature; class BrandPolicy { @@ -26,13 +28,9 @@ public function create(User $user, Workspace $workspace): bool return true; } - $plan = $workspace->plan; + $limit = Feature::for($workspace)->value(BrandLimit::class); - if (! $plan) { - return false; - } - - return $workspace->brands()->count() < $plan->brand_limit; + return $workspace->brands()->count() < $limit; } public function update(User $user, Brand $brand): bool diff --git a/app/Policies/WorkspacePolicy.php b/app/Policies/WorkspacePolicy.php index 89ef7d7d..cb9f5031 100644 --- a/app/Policies/WorkspacePolicy.php +++ b/app/Policies/WorkspacePolicy.php @@ -5,8 +5,10 @@ namespace App\Policies; use App\Enums\UserWorkspace\Role; +use App\Features\MemberLimit; use App\Models\User; use App\Models\Workspace; +use Laravel\Pennant\Feature; class WorkspacePolicy { @@ -65,6 +67,21 @@ public function manageBilling(User $user, Workspace $workspace): bool return $this->hasRole($user, $workspace, [Role::Owner]); } + public function inviteMember(User $user, Workspace $workspace): bool + { + if (! $this->hasRole($user, $workspace, [Role::Owner, Role::Admin])) { + return false; + } + + if (config('trypost.self_hosted')) { + return true; + } + + $limit = Feature::for($workspace)->value(MemberLimit::class); + + return $workspace->members()->count() < $limit; + } + /** * @param Role[] $roles */ diff --git a/database/migrations/2026_04_14_213055_create_features_table.php b/database/migrations/2026_04_14_213055_create_features_table.php new file mode 100644 index 00000000..a64eea2f --- /dev/null +++ b/database/migrations/2026_04_14_213055_create_features_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('name'); + $table->string('scope'); + $table->text('value'); + $table->timestamps(); + + $table->unique(['name', 'scope']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('features'); + } +}; diff --git a/tests/Feature/LimitEnforcementTest.php b/tests/Feature/LimitEnforcementTest.php new file mode 100644 index 00000000..d39ef486 --- /dev/null +++ b/tests/Feature/LimitEnforcementTest.php @@ -0,0 +1,74 @@ + false]); + + $this->plan = Plan::first(); + $this->plan->update([ + 'brand_limit' => 5, + 'member_limit' => 5, + ]); + + $this->user = User::factory()->create(); + $this->workspace = Workspace::factory()->create([ + 'user_id' => $this->user->id, + 'plan_id' => $this->plan->id, + ]); + $this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]); + $this->user->update(['current_workspace_id' => $this->workspace->id]); +}); + +test('can create brand within limit', function () { + Brand::factory()->count(3)->create(['workspace_id' => $this->workspace->id]); + + expect($this->user->can('create', [Brand::class, $this->workspace]))->toBeTrue(); +}); + +test('cannot create brand beyond limit', function () { + Brand::factory()->count(5)->create(['workspace_id' => $this->workspace->id]); + + expect($this->user->can('create', [Brand::class, $this->workspace]))->toBeFalse(); +}); + +test('can invite member within limit', function () { + expect($this->user->can('inviteMember', $this->workspace))->toBeTrue(); +}); + +test('cannot invite member beyond limit', function () { + $members = User::factory()->count(4)->create(); + + foreach ($members as $member) { + $this->workspace->members()->attach($member->id, ['role' => Role::Member->value]); + } + + // 1 owner + 4 members = 5, which is the limit + expect($this->user->can('inviteMember', $this->workspace))->toBeFalse(); +}); + +test('self hosted mode bypasses brand limit', function () { + config(['trypost.self_hosted' => true]); + + Brand::factory()->count(10)->create(['workspace_id' => $this->workspace->id]); + + expect($this->user->can('create', [Brand::class, $this->workspace]))->toBeTrue(); +}); + +test('self hosted mode bypasses member limit', function () { + config(['trypost.self_hosted' => true]); + + $members = User::factory()->count(10)->create(); + + foreach ($members as $member) { + $this->workspace->members()->attach($member->id, ['role' => Role::Member->value]); + } + + expect($this->user->can('inviteMember', $this->workspace))->toBeTrue(); +});