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 <noreply@anthropic.com>
main
boban 2026-05-16 00:41:32 +02:00
parent ee0824302a
commit 49f2e3b00f
6 changed files with 131 additions and 1 deletions

View File

@ -0,0 +1,33 @@
<?php
namespace App\Domains\Workspace\Actions;
use App\Domains\Workspace\Models\Workspace;
use App\Domains\Workspace\Models\WorkspaceMember;
use App\Models\User;
use Illuminate\Support\Str;
class CreateWorkspace
{
public function handle(User $owner, array $data): Workspace
{
$workspace = Workspace::create([
'name' => $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;
}
}

View File

@ -23,6 +23,7 @@ class Workspace extends Model
} }
protected $casts = [ protected $casts = [
'ulid' => 'string',
'branding' => 'array', 'branding' => 'array',
'stripe_customer_metadata' => 'array', 'stripe_customer_metadata' => 'array',
'trial_ends_at' => 'datetime', 'trial_ends_at' => 'datetime',

View File

@ -0,0 +1,42 @@
<?php
namespace App\Domains\Workspace\Policies;
use App\Domains\Workspace\Models\Workspace;
use App\Models\User;
class WorkspacePolicy
{
public function view(User $user, Workspace $workspace): bool
{
return $this->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();
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Http\Middleware;
use App\Domains\Workspace\Models\Workspace;
use Closure;
use Illuminate\Http\Request;
class ResolveWorkspace
{
public function handle(Request $request, Closure $next): mixed
{
$ulid = $request->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);
}
}

View File

@ -2,6 +2,9 @@
namespace App\Providers; namespace App\Providers;
use App\Domains\Workspace\Models\Workspace;
use App\Domains\Workspace\Policies\WorkspacePolicy;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@ -19,6 +22,6 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
// Gate::policy(Workspace::class, WorkspacePolicy::class);
} }
} }

View File

@ -0,0 +1,20 @@
<?php
use App\Domains\Workspace\Actions\CreateWorkspace;
use App\Models\User;
it('creates a workspace and assigns owner as member', function () {
$user = User::factory()->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',
]);
});