From 49f2e3b00f27f339229b6c02cd33bab376eaf6c4 Mon Sep 17 00:00:00 2001 From: boban Date: Sat, 16 May 2026 00:41:32 +0200 Subject: [PATCH] feat(workspace): create action, middleware, policies - CreateWorkspace action: creates workspace + owner member + sets default_workspace_id - ResolveWorkspace middleware: resolves workspace by ULID, 404s for non-members - WorkspacePolicy: owner/admin/editor/viewer role-based gates - Register WorkspacePolicy in AppServiceProvider - Add ulid string cast to Workspace model to ensure scalar after create() - Feature test: CreateWorkspaceTest (4 assertions, passing) Co-Authored-By: Claude Sonnet 4.6 --- .../Workspace/Actions/CreateWorkspace.php | 33 +++++++++++++++ app/Domains/Workspace/Models/Workspace.php | 1 + .../Workspace/Policies/WorkspacePolicy.php | 42 +++++++++++++++++++ app/Http/Middleware/ResolveWorkspace.php | 31 ++++++++++++++ app/Providers/AppServiceProvider.php | 5 ++- .../Feature/Workspace/CreateWorkspaceTest.php | 20 +++++++++ 6 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 app/Domains/Workspace/Actions/CreateWorkspace.php create mode 100644 app/Domains/Workspace/Policies/WorkspacePolicy.php create mode 100644 app/Http/Middleware/ResolveWorkspace.php create mode 100644 tests/Feature/Workspace/CreateWorkspaceTest.php diff --git a/app/Domains/Workspace/Actions/CreateWorkspace.php b/app/Domains/Workspace/Actions/CreateWorkspace.php new file mode 100644 index 0000000..944cbf6 --- /dev/null +++ b/app/Domains/Workspace/Actions/CreateWorkspace.php @@ -0,0 +1,33 @@ + $data['name'], + 'slug' => Str::slug($data['name']), + 'owner_id' => $owner->id, + ]); + + WorkspaceMember::create([ + 'workspace_id' => $workspace->id, + 'user_id' => $owner->id, + 'role' => 'owner', + 'joined_at' => now(), + ]); + + if (! $owner->default_workspace_id) { + $owner->update(['default_workspace_id' => $workspace->id]); + } + + return $workspace; + } +} diff --git a/app/Domains/Workspace/Models/Workspace.php b/app/Domains/Workspace/Models/Workspace.php index a451e49..fb456a9 100644 --- a/app/Domains/Workspace/Models/Workspace.php +++ b/app/Domains/Workspace/Models/Workspace.php @@ -23,6 +23,7 @@ class Workspace extends Model } protected $casts = [ + 'ulid' => 'string', 'branding' => 'array', 'stripe_customer_metadata' => 'array', 'trial_ends_at' => 'datetime', diff --git a/app/Domains/Workspace/Policies/WorkspacePolicy.php b/app/Domains/Workspace/Policies/WorkspacePolicy.php new file mode 100644 index 0000000..a52bc37 --- /dev/null +++ b/app/Domains/Workspace/Policies/WorkspacePolicy.php @@ -0,0 +1,42 @@ +hasRole($user, $workspace, ['owner', 'admin', 'editor', 'viewer']); + } + + public function create(User $user): bool + { + return true; + } + + public function update(User $user, Workspace $workspace): bool + { + return $this->hasRole($user, $workspace, ['owner', 'admin']); + } + + public function delete(User $user, Workspace $workspace): bool + { + return $this->hasRole($user, $workspace, ['owner']); + } + + public function manageBilling(User $user, Workspace $workspace): bool + { + return $this->hasRole($user, $workspace, ['owner']); + } + + private function hasRole(User $user, Workspace $workspace, array $roles): bool + { + return $workspace->members() + ->where('user_id', $user->id) + ->whereIn('role', $roles) + ->exists(); + } +} diff --git a/app/Http/Middleware/ResolveWorkspace.php b/app/Http/Middleware/ResolveWorkspace.php new file mode 100644 index 0000000..dcf6b21 --- /dev/null +++ b/app/Http/Middleware/ResolveWorkspace.php @@ -0,0 +1,31 @@ +route('workspace'); + + if (! $ulid) { + return $next($request); + } + + $workspace = Workspace::where('ulid', $ulid)->firstOrFail(); + + // 404 instead of 403 — don't leak existence across workspace boundaries + if (! $request->user()?->workspaceMemberships()->where('workspace_id', $workspace->id)->exists()) { + abort(404); + } + + $request->merge(['current_workspace' => $workspace]); + app()->instance('current_workspace', $workspace); + + return $next($request); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..3951a1c 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,9 @@ namespace App\Providers; +use App\Domains\Workspace\Models\Workspace; +use App\Domains\Workspace\Policies\WorkspacePolicy; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -19,6 +22,6 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { - // + Gate::policy(Workspace::class, WorkspacePolicy::class); } } diff --git a/tests/Feature/Workspace/CreateWorkspaceTest.php b/tests/Feature/Workspace/CreateWorkspaceTest.php new file mode 100644 index 0000000..8ec445b --- /dev/null +++ b/tests/Feature/Workspace/CreateWorkspaceTest.php @@ -0,0 +1,20 @@ +create(); + + $workspace = (new CreateWorkspace)->handle($user, ['name' => 'Acme Corp']); + + expect($workspace->name)->toBe('Acme Corp') + ->and($workspace->owner_id)->toBe($user->id) + ->and($workspace->ulid)->toHaveLength(26); + + $this->assertDatabaseHas('workspace_members', [ + 'workspace_id' => $workspace->id, + 'user_id' => $user->id, + 'role' => 'owner', + ]); +});