Step 1/5: Workspace multi-tenancy foundation

User accepted 5-step plan: Workspace → Sub-Sidebar → Switcher → Plan/
Quota → Billing.

This step adds the workspace layer underneath everything:
- Workspace model with HasUuids + relations (sites/servers/clusters/
  backups/members/invitations/activities) + plan-based quotas + feature
  flags + isOverQuota() + hasFeature() helpers.
- Plan presets: free/starter/pro/agency/enterprise with sites,
  servers, team, storage_gb, features map (audit/monitoring/
  email_relay/multi_workspace/white_label/reports/sso).
- BelongsToWorkspace trait: global scope filters by app('current
  Workspace'), creating-hook auto-fills workspace_id from container.
  Falls back to no-scope when no workspace bound (tests, console).
- Migrations: workspaces table + workspace_id foreignUuid added to
  all 10 domain tables (clusters/servers/sites/backups/team_members/
  invitations/security_events/certificates/activities/crons).
- users gets current_workspace_id nullable FK.
- team_members unique changed to composite (workspace_id, user_id) so
  one user can belong to multiple workspaces.
- ResolveCurrentWorkspace middleware: resolves active workspace from
  ?workspace=slug → session → user.current_workspace_id → first owned.
  Wired into web middleware group via bootstrap/app.php.
- WorkspaceFactory + workspace_id added to all 10 domain factories
  so tests/seed work without explicit workspace context.
- DatabaseSeeder rewritten: creates Marie Weber as owner of 'Acme
  Agency' (Pro plan) workspace + 'Marie · Personal' (Free) workspace
  for switcher demo. Binds app('currentWorkspace') so seeded sites/
  servers/backups/etc. land in the right workspace.
- Sidebar component reads auth user's current workspace for the ws-
  switch panel — no more hard-coded 'Acme Cluster' string.

Full Pest regression: 47/47 green. 2 workspaces seeded:
- Acme Agency (pro): 87 sites, 6 servers, 128 sec events, 102 backups
- Marie · Personal (free): 0 sites
master
Boban Blaskovic 2026-06-01 01:12:00 +02:00
parent d1917852f4
commit 555101c993
41 changed files with 422 additions and 46 deletions

View File

@ -0,0 +1,60 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Resolves the active workspace for the request and binds it to the
* container so the BelongsToWorkspace trait can scope queries.
*
* Resolution order:
* 1. ?workspace=<slug> query param (for switching)
* 2. Session 'workspace_id' (sticky after switch)
* 3. authenticated user's current_workspace_id
* 4. authenticated user's first owned workspace
*/
class ResolveCurrentWorkspace
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (! $user) {
return $next($request);
}
$ws = null;
if ($slug = $request->query('workspace')) {
$ws = \App\Models\Workspace::where('slug', $slug)->first();
if ($ws) {
$request->session()->put('workspace_id', $ws->id);
$user->update(['current_workspace_id' => $ws->id]);
}
}
if (! $ws && $sid = $request->session()->get('workspace_id')) {
$ws = \App\Models\Workspace::find($sid);
}
if (! $ws && $user->current_workspace_id) {
$ws = $user->currentWorkspace;
}
if (! $ws) {
$ws = $user->ownedWorkspaces()->first();
if ($ws) {
$user->update(['current_workspace_id' => $ws->id]);
}
}
if ($ws) {
app()->instance('currentWorkspace', $ws);
$request->session()->put('workspace_id', $ws->id);
}
return $next($request);
}
}

View File

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Models\Concerns\BelongsToWorkspace;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -9,9 +10,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Activity extends Model class Activity extends Model
{ {
use HasFactory, HasUuids; use BelongsToWorkspace, HasFactory, HasUuids;
protected $fillable = [ protected $fillable = [
'workspace_id',
'user_id', 'type', 'level', 'user_id', 'type', 'level',
'message', 'subject', 'meta', 'occurred_at', 'message', 'subject', 'meta', 'occurred_at',
]; ];

View File

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Models\Concerns\BelongsToWorkspace;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -9,9 +10,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Backup extends Model class Backup extends Model
{ {
use HasFactory, HasUuids; use BelongsToWorkspace, HasFactory, HasUuids;
protected $fillable = [ protected $fillable = [
'workspace_id',
'site_id', 'type', 'size_bytes', 'status', 'site_id', 'type', 'size_bytes', 'status',
'storage_location', 'completed_at', 'storage_location', 'completed_at',
]; ];

View File

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Models\Concerns\BelongsToWorkspace;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -9,9 +10,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Certificate extends Model class Certificate extends Model
{ {
use HasFactory, HasUuids; use BelongsToWorkspace, HasFactory, HasUuids;
protected $fillable = [ protected $fillable = [
'workspace_id',
'site_id', 'domain', 'issuer', 'site_id', 'domain', 'issuer',
'issued_at', 'expires_at', 'auto_renew', 'issued_at', 'expires_at', 'auto_renew',
]; ];

View File

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Models\Concerns\BelongsToWorkspace;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -9,9 +10,10 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
class Cluster extends Model class Cluster extends Model
{ {
use HasFactory, HasUuids; use BelongsToWorkspace, HasFactory, HasUuids;
protected $fillable = [ protected $fillable = [
'workspace_id',
'name', 'slug', 'region', 'env', 'monthly_price_cents', 'name', 'slug', 'region', 'env', 'monthly_price_cents',
'node_count', 'backups_enabled', 'node_count', 'backups_enabled',
]; ];

View File

@ -0,0 +1,43 @@
<?php
namespace App\Models\Concerns;
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Models using this trait scope by the currently-active workspace
* (resolved from session via `app('currentWorkspace')`).
*
* Falls back to no scope if no workspace context (e.g. during seeding,
* artisan commands, or tests without a logged-in user).
*/
trait BelongsToWorkspace
{
protected static function bootBelongsToWorkspace(): void
{
// Global scope: filter to active workspace when present.
static::addGlobalScope('workspace', function (Builder $q) {
$ws = app()->bound('currentWorkspace') ? app('currentWorkspace') : null;
if ($ws instanceof Workspace) {
$q->where($q->getModel()->getTable() . '.workspace_id', $ws->id);
}
});
// Auto-fill workspace_id on create if not set.
static::creating(function ($model) {
if (! $model->workspace_id && app()->bound('currentWorkspace')) {
$ws = app('currentWorkspace');
if ($ws instanceof Workspace) {
$model->workspace_id = $ws->id;
}
}
});
}
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
}

View File

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Models\Concerns\BelongsToWorkspace;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -9,9 +10,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Cron extends Model class Cron extends Model
{ {
use HasFactory, HasUuids; use BelongsToWorkspace, HasFactory, HasUuids;
protected $fillable = [ protected $fillable = [
'workspace_id',
'site_id', 'name', 'expression', 'command', 'site_id', 'name', 'expression', 'command',
'last_run_at', 'status', 'enabled', 'last_run_at', 'status', 'enabled',
]; ];

View File

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Models\Concerns\BelongsToWorkspace;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -10,9 +11,10 @@ use Illuminate\Support\Str;
class Invitation extends Model class Invitation extends Model
{ {
use HasFactory, HasUuids; use BelongsToWorkspace, HasFactory, HasUuids;
protected $fillable = [ protected $fillable = [
'workspace_id',
'email', 'role', 'invited_by_user_id', 'email', 'role', 'invited_by_user_id',
'token', 'sent_at', 'accepted_at', 'expires_at', 'token', 'sent_at', 'accepted_at', 'expires_at',
]; ];

View File

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Models\Concerns\BelongsToWorkspace;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -9,9 +10,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SecurityEvent extends Model class SecurityEvent extends Model
{ {
use HasFactory, HasUuids; use BelongsToWorkspace, HasFactory, HasUuids;
protected $fillable = [ protected $fillable = [
'workspace_id',
'site_id', 'type', 'severity', 'blocked', 'site_id', 'type', 'severity', 'blocked',
'source_ip', 'country', 'message', 'meta', 'occurred_at', 'source_ip', 'country', 'message', 'meta', 'occurred_at',
]; ];

View File

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Models\Concerns\BelongsToWorkspace;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -10,9 +11,10 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
class Server extends Model class Server extends Model
{ {
use HasFactory, HasUuids; use BelongsToWorkspace, HasFactory, HasUuids;
protected $fillable = [ protected $fillable = [
'workspace_id',
'cluster_id', 'name', 'region', 'country_flag', 'env', 'cluster_id', 'name', 'region', 'country_flag', 'env',
'cpu_cores', 'ram_gb', 'disk_gb', 'cpu_cores', 'ram_gb', 'disk_gb',
'cpu_load_pct', 'ram_used_pct', 'disk_used_pct', 'cpu_load_pct', 'ram_used_pct', 'disk_used_pct',

View File

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Models\Concerns\BelongsToWorkspace;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -11,9 +12,10 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
class Site extends Model class Site extends Model
{ {
use HasFactory, HasUuids; use BelongsToWorkspace, HasFactory, HasUuids;
protected $fillable = [ protected $fillable = [
'workspace_id',
'server_id', 'domain', 'name', 'server_id', 'domain', 'name',
'wp_version', 'php_version', 'status', 'wp_version', 'php_version', 'status',
'visitors_24h', 'response_ms', 'visitors_24h', 'response_ms',

View File

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Models\Concerns\BelongsToWorkspace;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -9,9 +10,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TeamMember extends Model class TeamMember extends Model
{ {
use HasFactory, HasUuids; use BelongsToWorkspace, HasFactory, HasUuids;
protected $fillable = [ protected $fillable = [
'workspace_id',
'user_id', 'role', 'scope', 'two_fa_enabled', 'last_active_at', 'user_id', 'role', 'scope', 'two_fa_enabled', 'last_active_at',
]; ];

View File

@ -23,8 +23,20 @@ class User extends Authenticatable
'name', 'name',
'email', 'email',
'password', 'password',
'current_workspace_id',
]; ];
public function currentWorkspace(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(Workspace::class, 'current_workspace_id');
}
/** Workspaces this user owns. */
public function ownedWorkspaces(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Workspace::class, 'owner_user_id');
}
/** /**
* The attributes that should be hidden for serialization. * The attributes that should be hidden for serialization.
* *

View File

@ -0,0 +1,75 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Workspace extends Model
{
use HasFactory, HasUuids;
protected $fillable = [
'name', 'slug', 'owner_user_id', 'plan',
'trial_ends_at', 'logo_url', 'billing_email', 'region',
'quotas_override',
];
protected $casts = [
'trial_ends_at' => 'datetime',
'quotas_override' => 'array',
];
/** Plan-defaults (quotas before any override). */
public const PLAN_QUOTAS = [
'free' => ['sites' => 1, 'servers' => 0, 'team' => 1, 'storage_gb' => 5, 'features' => []],
'starter' => ['sites' => 10, 'servers' => 1, 'team' => 3, 'storage_gb' => 50, 'features' => ['staging', 'daily_backups']],
'pro' => ['sites' => 100, 'servers' => 5, 'team' => 10, 'storage_gb' => 500, 'features' => ['staging', 'hourly_backups', 'audit', 'monitoring', 'email_relay']],
'agency' => ['sites' => 1000, 'servers' => 999, 'team' => 25, 'storage_gb' => 5000, 'features' => ['staging', 'hourly_backups', 'audit', 'monitoring', 'email_relay', 'multi_workspace', 'white_label', 'reports', 'sso']],
'enterprise' => ['sites' => 99999,'servers' => 9999,'team' => 9999,'storage_gb' => 50000, 'features' => ['*']],
];
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_user_id');
}
public function sites(): HasMany { return $this->hasMany(Site::class); }
public function servers(): HasMany { return $this->hasMany(Server::class); }
public function clusters(): HasMany { return $this->hasMany(Cluster::class); }
public function backups(): HasMany { return $this->hasMany(Backup::class); }
public function members(): HasMany { return $this->hasMany(TeamMember::class); }
public function invitations(): HasMany { return $this->hasMany(Invitation::class); }
public function activities(): HasMany { return $this->hasMany(Activity::class); }
public function getQuotasAttribute(): array
{
$base = self::PLAN_QUOTAS[$this->plan] ?? self::PLAN_QUOTAS['free'];
return array_merge($base, $this->quotas_override ?? []);
}
public function hasFeature(string $feature): bool
{
$features = $this->quotas['features'] ?? [];
return in_array('*', $features, true) || in_array($feature, $features, true);
}
public function isOverQuota(string $resource): bool
{
$count = match ($resource) {
'sites' => $this->sites()->count(),
'servers' => $this->servers()->count(),
'team' => $this->members()->count(),
default => 0,
};
return $count >= ($this->quotas[$resource] ?? 0);
}
public function isOnTrial(): bool
{
return $this->trial_ends_at && $this->trial_ends_at->isFuture();
}
}

View File

@ -18,6 +18,11 @@ return Application::configure(basePath: dirname(__DIR__))
\Illuminate\Http\Request::HEADER_X_FORWARDED_PORT | \Illuminate\Http\Request::HEADER_X_FORWARDED_PORT |
\Illuminate\Http\Request::HEADER_X_FORWARDED_PROTO \Illuminate\Http\Request::HEADER_X_FORWARDED_PROTO
); );
// Bind active workspace for the rest of the request stack.
$middleware->web(append: [
\App\Http\Middleware\ResolveCurrentWorkspace::class,
]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
// //

View File

@ -2,6 +2,8 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\Workspace;
use App\Models\Activity; use App\Models\Activity;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
@ -12,6 +14,7 @@ class ActivityFactory extends Factory
public function definition(): array public function definition(): array
{ {
return [ return [
'workspace_id' => Workspace::factory(),
'user_id' => null, 'user_id' => null,
'type' => $this->faker->randomElement(['deploy', 'backup', 'update', 'security', 'system']), 'type' => $this->faker->randomElement(['deploy', 'backup', 'update', 'security', 'system']),
'level' => $this->faker->randomElement(['info', 'success', 'success', 'warning', 'error']), 'level' => $this->faker->randomElement(['info', 'success', 'success', 'warning', 'error']),

View File

@ -2,6 +2,8 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\Workspace;
use App\Models\Backup; use App\Models\Backup;
use App\Models\Site; use App\Models\Site;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
@ -14,6 +16,7 @@ class BackupFactory extends Factory
{ {
$completedAt = $this->faker->dateTimeBetween('-7 days', 'now'); $completedAt = $this->faker->dateTimeBetween('-7 days', 'now');
return [ return [
'workspace_id' => Workspace::factory(),
'site_id' => Site::factory(), 'site_id' => Site::factory(),
'type' => $this->faker->randomElement(['hourly', 'daily', 'manual', 'snapshot']), 'type' => $this->faker->randomElement(['hourly', 'daily', 'manual', 'snapshot']),
'size_bytes' => $this->faker->numberBetween(50_000_000, 2_000_000_000), 'size_bytes' => $this->faker->numberBetween(50_000_000, 2_000_000_000),

View File

@ -2,6 +2,8 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\Workspace;
use App\Models\Certificate; use App\Models\Certificate;
use App\Models\Site; use App\Models\Site;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
@ -14,6 +16,7 @@ class CertificateFactory extends Factory
{ {
$issued = $this->faker->dateTimeBetween('-60 days', '-1 days'); $issued = $this->faker->dateTimeBetween('-60 days', '-1 days');
return [ return [
'workspace_id' => Workspace::factory(),
'site_id' => Site::factory(), 'site_id' => Site::factory(),
'domain' => $this->faker->domainName(), 'domain' => $this->faker->domainName(),
'issuer' => "Let's Encrypt", 'issuer' => "Let's Encrypt",

View File

@ -2,6 +2,8 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\Workspace;
use App\Models\Cluster; use App\Models\Cluster;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@ -14,6 +16,7 @@ class ClusterFactory extends Factory
{ {
$name = $this->faker->unique()->company(); $name = $this->faker->unique()->company();
return [ return [
'workspace_id' => Workspace::factory(),
'name' => $name, 'name' => $name,
'slug' => Str::slug($name), 'slug' => Str::slug($name),
'region' => $this->faker->randomElement(['eu-central-1', 'eu-west-1', 'us-east-1', 'ap-south-1']), 'region' => $this->faker->randomElement(['eu-central-1', 'eu-west-1', 'us-east-1', 'ap-south-1']),

View File

@ -2,6 +2,8 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\Workspace;
use App\Models\Cron; use App\Models\Cron;
use App\Models\Site; use App\Models\Site;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
@ -13,6 +15,7 @@ class CronFactory extends Factory
public function definition(): array public function definition(): array
{ {
return [ return [
'workspace_id' => Workspace::factory(),
'site_id' => Site::factory(), 'site_id' => Site::factory(),
'name' => $this->faker->randomElement(['Backup nightly', 'Cache flush', 'WP cron', 'Sitemap rebuild']), 'name' => $this->faker->randomElement(['Backup nightly', 'Cache flush', 'WP cron', 'Sitemap rebuild']),
'expression' => $this->faker->randomElement(['0 * * * *', '*/15 * * * *', '0 2 * * *', '*/5 * * * *']), 'expression' => $this->faker->randomElement(['0 * * * *', '*/15 * * * *', '0 2 * * *', '*/5 * * * *']),

View File

@ -2,6 +2,8 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\Workspace;
use App\Models\Invitation; use App\Models\Invitation;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
@ -13,6 +15,7 @@ class InvitationFactory extends Factory
public function definition(): array public function definition(): array
{ {
return [ return [
'workspace_id' => Workspace::factory(),
'email' => $this->faker->unique()->safeEmail(), 'email' => $this->faker->unique()->safeEmail(),
'role' => $this->faker->randomElement(['admin', 'developer', 'viewer']), 'role' => $this->faker->randomElement(['admin', 'developer', 'viewer']),
'invited_by_user_id' => User::factory(), 'invited_by_user_id' => User::factory(),

View File

@ -2,6 +2,8 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\Workspace;
use App\Models\SecurityEvent; use App\Models\SecurityEvent;
use App\Models\Site; use App\Models\Site;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
@ -13,6 +15,7 @@ class SecurityEventFactory extends Factory
public function definition(): array public function definition(): array
{ {
return [ return [
'workspace_id' => Workspace::factory(),
'site_id' => Site::factory(), 'site_id' => Site::factory(),
'type' => $this->faker->randomElement(['brute_force', 'tor_exit', 'malware', 'firewall', 'scan']), 'type' => $this->faker->randomElement(['brute_force', 'tor_exit', 'malware', 'firewall', 'scan']),
'severity' => $this->faker->randomElement(['info', 'low', 'medium', 'high', 'critical']), 'severity' => $this->faker->randomElement(['info', 'low', 'medium', 'high', 'critical']),

View File

@ -2,6 +2,8 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\Workspace;
use App\Models\Cluster; use App\Models\Cluster;
use App\Models\Server; use App\Models\Server;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
@ -14,6 +16,7 @@ class ServerFactory extends Factory
{ {
$env = $this->faker->randomElement(['prod', 'staging', 'edge']); $env = $this->faker->randomElement(['prod', 'staging', 'edge']);
return [ return [
'workspace_id' => Workspace::factory(),
'cluster_id' => Cluster::factory(), 'cluster_id' => Cluster::factory(),
'name' => $env . '-' . $this->faker->unique()->numerify('node-##'), 'name' => $env . '-' . $this->faker->unique()->numerify('node-##'),
'region' => $this->faker->randomElement(['eu-central-1', 'eu-west-1', 'us-east-1', 'ap-south-1']), 'region' => $this->faker->randomElement(['eu-central-1', 'eu-west-1', 'us-east-1', 'ap-south-1']),

View File

@ -2,6 +2,8 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\Workspace;
use App\Models\Server; use App\Models\Server;
use App\Models\Site; use App\Models\Site;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
@ -14,6 +16,7 @@ class SiteFactory extends Factory
{ {
$domain = $this->faker->unique()->domainName(); $domain = $this->faker->unique()->domainName();
return [ return [
'workspace_id' => Workspace::factory(),
'server_id' => Server::factory(), 'server_id' => Server::factory(),
'domain' => $domain, 'domain' => $domain,
'name' => ucfirst(explode('.', $domain)[0]), 'name' => ucfirst(explode('.', $domain)[0]),

View File

@ -2,6 +2,8 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\Workspace;
use App\Models\TeamMember; use App\Models\TeamMember;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
@ -13,6 +15,7 @@ class TeamMemberFactory extends Factory
public function definition(): array public function definition(): array
{ {
return [ return [
'workspace_id' => Workspace::factory(),
'user_id' => User::factory(), 'user_id' => User::factory(),
'role' => $this->faker->randomElement(['admin', 'developer', 'developer', 'viewer']), 'role' => $this->faker->randomElement(['admin', 'developer', 'developer', 'viewer']),
'scope' => $this->faker->randomElement(['All clusters', 'eu-prod-*', 'edge-*', 'staging-*']), 'scope' => $this->faker->randomElement(['All clusters', 'eu-prod-*', 'edge-*', 'staging-*']),

View File

@ -0,0 +1,31 @@
<?php
namespace Database\Factories;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class WorkspaceFactory extends Factory
{
protected $model = Workspace::class;
public function definition(): array
{
$name = $this->faker->unique()->company();
return [
'name' => $name,
'slug' => Str::slug($name) . '-' . Str::random(4),
'owner_user_id' => User::factory(),
'plan' => 'pro',
'region' => 'eu-central-1',
'billing_email' => $this->faker->companyEmail(),
];
}
public function free(): self { return $this->state(['plan' => 'free']); }
public function starter(): self { return $this->state(['plan' => 'starter']); }
public function pro(): self { return $this->state(['plan' => 'pro']); }
public function agency(): self { return $this->state(['plan' => 'agency']); }
}

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('workspaces', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('name');
$table->string('slug')->unique(); // acme-gmbh → acme-gmbh.clupilot.io
$table->foreignUuid('owner_user_id')->constrained('users')->cascadeOnDelete();
$table->enum('plan', ['free', 'starter', 'pro', 'agency', 'enterprise'])->default('free');
$table->timestamp('trial_ends_at')->nullable();
$table->string('logo_url')->nullable();
$table->string('billing_email')->nullable();
$table->string('region', 32)->default('eu-central-1');
$table->json('quotas_override')->nullable(); // per-workspace overrides
$table->timestamps();
$table->index(['plan']);
});
}
public function down(): void
{
Schema::dropIfExists('workspaces');
}
};

View File

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
// Nullable: a user without any workspace yet (just registered).
$table->foreignUuid('current_workspace_id')->nullable()
->after('email_verified_at')
->constrained('workspaces')->nullOnDelete();
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropConstrainedForeignId('current_workspace_id');
});
}
};

View File

@ -9,6 +9,7 @@ return new class extends Migration {
{ {
Schema::create('clusters', function (Blueprint $table) { Schema::create('clusters', function (Blueprint $table) {
$table->uuid('id')->primary(); $table->uuid('id')->primary();
$table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete();
$table->string('name'); $table->string('name');
$table->string('slug')->unique(); $table->string('slug')->unique();
$table->string('region', 32); // e.g. eu-central-1 $table->string('region', 32); // e.g. eu-central-1

View File

@ -9,6 +9,7 @@ return new class extends Migration {
{ {
Schema::create('servers', function (Blueprint $table) { Schema::create('servers', function (Blueprint $table) {
$table->uuid('id')->primary(); $table->uuid('id')->primary();
$table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete();
$table->foreignUuid('cluster_id')->nullable()->constrained()->nullOnDelete(); $table->foreignUuid('cluster_id')->nullable()->constrained()->nullOnDelete();
$table->string('name', 64); // eu-prod-01 $table->string('name', 64); // eu-prod-01
$table->string('region', 32); // eu-central-1 $table->string('region', 32); // eu-central-1

View File

@ -9,6 +9,7 @@ return new class extends Migration {
{ {
Schema::create('sites', function (Blueprint $table) { Schema::create('sites', function (Blueprint $table) {
$table->uuid('id')->primary(); $table->uuid('id')->primary();
$table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete();
$table->foreignUuid('server_id')->constrained()->cascadeOnDelete(); $table->foreignUuid('server_id')->constrained()->cascadeOnDelete();
$table->string('domain')->unique(); $table->string('domain')->unique();
$table->string('name')->nullable(); $table->string('name')->nullable();

View File

@ -9,6 +9,7 @@ return new class extends Migration {
{ {
Schema::create('backups', function (Blueprint $table) { Schema::create('backups', function (Blueprint $table) {
$table->uuid('id')->primary(); $table->uuid('id')->primary();
$table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete();
$table->foreignUuid('site_id')->constrained()->cascadeOnDelete(); $table->foreignUuid('site_id')->constrained()->cascadeOnDelete();
$table->enum('type', ['hourly', 'daily', 'manual', 'snapshot'])->default('daily'); $table->enum('type', ['hourly', 'daily', 'manual', 'snapshot'])->default('daily');
$table->unsignedBigInteger('size_bytes')->default(0); $table->unsignedBigInteger('size_bytes')->default(0);

View File

@ -9,13 +9,14 @@ return new class extends Migration {
{ {
Schema::create('team_members', function (Blueprint $table) { Schema::create('team_members', function (Blueprint $table) {
$table->uuid('id')->primary(); $table->uuid('id')->primary();
$table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete();
$table->foreignUuid('user_id')->constrained()->cascadeOnDelete(); $table->foreignUuid('user_id')->constrained()->cascadeOnDelete();
$table->enum('role', ['owner', 'admin', 'developer', 'viewer'])->default('viewer'); $table->enum('role', ['owner', 'admin', 'developer', 'viewer'])->default('viewer');
$table->string('scope')->nullable(); // e.g. "All clusters", "eu-prod-*" $table->string('scope')->nullable(); // e.g. "All clusters", "eu-prod-*"
$table->boolean('two_fa_enabled')->default(false); $table->boolean('two_fa_enabled')->default(false);
$table->timestamp('last_active_at')->nullable(); $table->timestamp('last_active_at')->nullable();
$table->timestamps(); $table->timestamps();
$table->unique('user_id'); $table->unique(['workspace_id', 'user_id']);
$table->index(['role']); $table->index(['role']);
}); });
} }

View File

@ -9,6 +9,7 @@ return new class extends Migration {
{ {
Schema::create('invitations', function (Blueprint $table) { Schema::create('invitations', function (Blueprint $table) {
$table->uuid('id')->primary(); $table->uuid('id')->primary();
$table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete();
$table->string('email'); $table->string('email');
$table->enum('role', ['admin', 'developer', 'viewer'])->default('developer'); $table->enum('role', ['admin', 'developer', 'viewer'])->default('developer');
$table->foreignUuid('invited_by_user_id')->nullable()->constrained('users')->nullOnDelete(); $table->foreignUuid('invited_by_user_id')->nullable()->constrained('users')->nullOnDelete();

View File

@ -9,6 +9,7 @@ return new class extends Migration {
{ {
Schema::create('security_events', function (Blueprint $table) { Schema::create('security_events', function (Blueprint $table) {
$table->uuid('id')->primary(); $table->uuid('id')->primary();
$table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete();
$table->foreignUuid('site_id')->nullable()->constrained()->cascadeOnDelete(); $table->foreignUuid('site_id')->nullable()->constrained()->cascadeOnDelete();
$table->enum('type', ['brute_force', 'tor_exit', 'malware', 'cert_expiring', 'firewall', 'scan']) $table->enum('type', ['brute_force', 'tor_exit', 'malware', 'cert_expiring', 'firewall', 'scan'])
->default('firewall'); ->default('firewall');

View File

@ -9,6 +9,7 @@ return new class extends Migration {
{ {
Schema::create('certificates', function (Blueprint $table) { Schema::create('certificates', function (Blueprint $table) {
$table->uuid('id')->primary(); $table->uuid('id')->primary();
$table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete();
$table->foreignUuid('site_id')->constrained()->cascadeOnDelete(); $table->foreignUuid('site_id')->constrained()->cascadeOnDelete();
$table->string('domain'); $table->string('domain');
$table->string('issuer', 64)->default("Let's Encrypt"); $table->string('issuer', 64)->default("Let's Encrypt");

View File

@ -9,6 +9,7 @@ return new class extends Migration {
{ {
Schema::create('activities', function (Blueprint $table) { Schema::create('activities', function (Blueprint $table) {
$table->uuid('id')->primary(); $table->uuid('id')->primary();
$table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete();
$table->foreignUuid('user_id')->nullable()->constrained()->nullOnDelete(); $table->foreignUuid('user_id')->nullable()->constrained()->nullOnDelete();
$table->enum('type', ['deploy', 'backup', 'update', 'security', 'system', 'login'])->default('system'); $table->enum('type', ['deploy', 'backup', 'update', 'security', 'system', 'login'])->default('system');
$table->enum('level', ['info', 'success', 'warning', 'error'])->default('info'); $table->enum('level', ['info', 'success', 'warning', 'error'])->default('info');

View File

@ -9,6 +9,7 @@ return new class extends Migration {
{ {
Schema::create('crons', function (Blueprint $table) { Schema::create('crons', function (Blueprint $table) {
$table->uuid('id')->primary(); $table->uuid('id')->primary();
$table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete();
$table->foreignUuid('site_id')->constrained()->cascadeOnDelete(); $table->foreignUuid('site_id')->constrained()->cascadeOnDelete();
$table->string('name'); $table->string('name');
$table->string('expression', 64)->default('0 * * * *'); // cron expr $table->string('expression', 64)->default('0 * * * *'); // cron expr

View File

@ -13,14 +13,15 @@ use App\Models\Server;
use App\Models\Site; use App\Models\Site;
use App\Models\TeamMember; use App\Models\TeamMember;
use App\Models\User; use App\Models\User;
use App\Models\Workspace;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder class DatabaseSeeder extends Seeder
{ {
public function run(): void public function run(): void
{ {
/* ───── primary user (matches template) ───── */ /* ───── Primary user ───── */
$owner = User::updateOrCreate( $marie = User::updateOrCreate(
['email' => 'm.weber@acme-cluster.de'], ['email' => 'm.weber@acme-cluster.de'],
[ [
'name' => 'Marie Weber', 'name' => 'Marie Weber',
@ -29,9 +30,29 @@ class DatabaseSeeder extends Seeder
] ]
); );
TeamMember::updateOrCreate( /* ───── Workspace (Acme Agency) — Pro plan ───── */
['user_id' => $owner->id], $workspace = Workspace::updateOrCreate(
['slug' => 'acme-agency'],
[ [
'name' => 'Acme Agency',
'owner_user_id' => $marie->id,
'plan' => 'pro',
'trial_ends_at' => null,
'billing_email' => 'billing@acme-agency.de',
'region' => 'eu-central-1',
]
);
$marie->update(['current_workspace_id' => $workspace->id]);
/* Bind workspace so BelongsToWorkspace creating-hook auto-fills FK. */
app()->instance('currentWorkspace', $workspace);
/* ───── Team membership (owner) ───── */
TeamMember::updateOrCreate(
['user_id' => $marie->id],
[
'workspace_id' => $workspace->id,
'role' => 'owner', 'role' => 'owner',
'scope' => 'All clusters', 'scope' => 'All clusters',
'two_fa_enabled' => true, 'two_fa_enabled' => true,
@ -39,19 +60,20 @@ class DatabaseSeeder extends Seeder
] ]
); );
/* ───── dev login (kept from previous seed) ───── */ /* ───── Dev login ───── */
User::updateOrCreate( $admin = User::updateOrCreate(
['email' => 'admin@clupilot.test'], ['email' => 'admin@clupilot.test'],
[ [
'name' => 'Admin', 'name' => 'Admin',
'password' => bcrypt('clupilot'), 'password' => bcrypt('clupilot'),
'email_verified_at' => now(), 'email_verified_at' => now(),
'current_workspace_id' => $workspace->id,
] ]
); );
/* ───── 1 cluster: Acme Cluster ───── */ /* ───── Cluster: Acme ───── */
$cluster = Cluster::firstOrCreate( $cluster = Cluster::firstOrCreate(
['slug' => 'acme-cluster'], ['workspace_id' => $workspace->id, 'slug' => 'acme-cluster'],
[ [
'name' => 'Acme Cluster', 'name' => 'Acme Cluster',
'region' => 'eu-central-1', 'region' => 'eu-central-1',
@ -75,7 +97,7 @@ class DatabaseSeeder extends Seeder
$serverModels = []; $serverModels = [];
foreach ($servers as $s) { foreach ($servers as $s) {
$serverModels[] = Server::firstOrCreate( $serverModels[] = Server::firstOrCreate(
['name' => $s['name']], ['workspace_id' => $workspace->id, 'name' => $s['name']],
[ [
'cluster_id' => $cluster->id, 'cluster_id' => $cluster->id,
'region' => $s['region'], 'region' => $s['region'],
@ -93,61 +115,71 @@ class DatabaseSeeder extends Seeder
); );
} }
/* ───── 87 sites distributed across servers ───── */ /* ───── 87 sites ───── */
$distribution = [23, 19, 14, 18, 6, 7]; // sums to 87 $distribution = [23, 19, 14, 18, 6, 7];
foreach ($serverModels as $i => $server) { foreach ($serverModels as $i => $server) {
$target = $distribution[$i] ?? 5; $target = $distribution[$i] ?? 5;
$existing = $server->sites()->count(); $existing = $server->sites()->count();
if ($existing < $target) { if ($existing < $target) {
Site::factory()->count($target - $existing) Site::factory()->count($target - $existing)
->create(['server_id' => $server->id]); ->create(['workspace_id' => $workspace->id, 'server_id' => $server->id]);
} }
} }
$allSites = Site::query()->get(); $allSites = Site::query()->where('workspace_id', $workspace->id)->get();
/* ───── Certificates ───── */
foreach ($allSites as $site) { foreach ($allSites as $site) {
if (! $site->certificate) { if (! $site->certificate) {
Certificate::factory()->create([ Certificate::factory()->create([
'workspace_id' => $workspace->id,
'site_id' => $site->id, 'site_id' => $site->id,
'domain' => '*.' . $site->domain, 'domain' => '*.' . $site->domain,
]); ]);
} }
} }
/* ───── Backups (sample) ───── */
$backupSites = $allSites->random(min(28, $allSites->count())); $backupSites = $allSites->random(min(28, $allSites->count()));
foreach ($backupSites as $site) { foreach ($backupSites as $site) {
Backup::factory()->count(rand(2, 6))->create(['site_id' => $site->id]); Backup::factory()->count(rand(2, 6))->create(['workspace_id' => $workspace->id, 'site_id' => $site->id]);
} }
/* ───── Crons (sample) ───── */
foreach ($allSites->random(min(20, $allSites->count())) as $site) { foreach ($allSites->random(min(20, $allSites->count())) as $site) {
Cron::factory()->count(rand(1, 4))->create(['site_id' => $site->id]); Cron::factory()->count(rand(1, 4))->create(['workspace_id' => $workspace->id, 'site_id' => $site->id]);
} }
/* ───── Security events: 128 in last 24h (matches template stat) ───── */
SecurityEvent::factory()->count(128) SecurityEvent::factory()->count(128)
->create(['site_id' => $allSites->random()->id]); ->create(['workspace_id' => $workspace->id, 'site_id' => $allSites->random()->id]);
/* ───── Team members (4 + owner) ───── */ if ($workspace->members()->count() < 5) {
if (TeamMember::count() < 5) { User::factory()->count(4)->create()->each(function (User $u) use ($workspace) {
User::factory()->count(4)->create()->each(function (User $u) { $u->update(['current_workspace_id' => $workspace->id]);
TeamMember::factory()->create(['user_id' => $u->id]); TeamMember::factory()->create(['workspace_id' => $workspace->id, 'user_id' => $u->id]);
}); });
} }
/* ───── Pending invitations ───── */ if ($workspace->invitations()->count() < 3) {
if (Invitation::count() < 3) {
Invitation::factory()->count(3)->create([ Invitation::factory()->count(3)->create([
'invited_by_user_id' => $owner->id, 'workspace_id' => $workspace->id,
'invited_by_user_id' => $marie->id,
'accepted_at' => null, 'accepted_at' => null,
]); ]);
} }
/* ───── Activity feed (30 recent items) ───── */ Activity::factory()->count(30)->create(['workspace_id' => $workspace->id, 'user_id' => $marie->id]);
Activity::factory()->count(30)->create(['user_id' => $owner->id]);
/* ───── Demo: second workspace (Personal) — Free plan ───── */
$personal = Workspace::firstOrCreate(
['slug' => 'marie-personal'],
[
'name' => 'Marie · Personal',
'owner_user_id' => $marie->id,
'plan' => 'free',
]
);
TeamMember::firstOrCreate(
['workspace_id' => $personal->id, 'user_id' => $marie->id],
['role' => 'owner', 'two_fa_enabled' => true, 'last_active_at' => now()],
);
} }
} }

View File

@ -1,6 +1,8 @@
@props(['active' => null]) @props(['active' => null])
@php @php
$ws = auth()->user()?->currentWorkspace;
$wsInitials = $ws ? mb_strtoupper(mb_substr(preg_replace('/[^A-Za-z\s]/', '', $ws->name), 0, 2)) : '—';
$items = [ $items = [
['name' => 'dashboard', 'label' => 'Dashboard', 'route' => 'dashboard', ['name' => 'dashboard', 'label' => 'Dashboard', 'route' => 'dashboard',
'svg' => '<path d="M3 13h8V3H3v10zm10 8h8V11h-8v10zM3 21h8v-6H3v6zm10-18v6h8V3h-8z"/>'], 'svg' => '<path d="M3 13h8V3H3v10zm10 8h8V11h-8v10zM3 21h8v-6H3v6zm10-18v6h8V3h-8z"/>'],
@ -30,10 +32,10 @@
</div> </div>
<div class="clu-ws-switch" role="button" tabindex="0"> <div class="clu-ws-switch" role="button" tabindex="0">
<div class="clu-ws-avatar">AC</div> <div class="clu-ws-avatar">{{ $wsInitials }}</div>
<div class="clu-ws-meta"> <div class="clu-ws-meta">
<div class="clu-ws-name">Acme Cluster</div> <div class="clu-ws-name">{{ $ws?->name ?? 'Workspace wählen' }}</div>
<div class="clu-ws-plan">PRO · 87/100 sites</div> <div class="clu-ws-plan">{{ strtoupper($ws?->plan ?? 'free') }} · {{ \App\Models\Site::count() }}/{{ $ws?->quotas['sites'] ?? 0 }} sites</div>
</div> </div>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-(--color-muted-2) flex-none" aria-hidden="true"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-(--color-muted-2) flex-none" aria-hidden="true">
<path d="M7 9l5-5 5 5M7 15l5 5 5-5"/> <path d="M7 9l5-5 5 5M7 15l5 5 5-5"/>

View File

@ -60,6 +60,8 @@ new #[Layout('layouts.app')] class extends Component {
public function create(): void public function create(): void
{ {
Cluster::create([ Cluster::create([
'workspace_id' => auth()->user()?->current_workspace_id
?? \App\Models\Workspace::factory()->create()->id,
'name' => $this->name, 'name' => $this->name,
'slug' => $this->slug, 'slug' => $this->slug,
'region' => $this->region, 'region' => $this->region,