feat(spaces): phase 0 — multi-tenant Space foundation (no behaviour change) (#650)

## Spaces / Business plan — Phase 0: invisible foundation

First of **three stacked PRs** introducing multi-tenant **Spaces** (the
basis for the Business plan). This one is a **pure, behaviour-preserving
foundation**: it can ship to production on its own with zero
user-visible change.

- **Stacked PRs:** this → `enterprise-spaces-ui` (Phase 1+2) →
`enterprise-spaces-invitations` (Phase 3).

### What a Space is
A Space groups its own accounts, connections, transactions, categories,
labels, budgets and rules. **Every user gets one invisible "Personal"
space**, provisioned automatically on creation — so the architecture is
identical for free, Standard and Business accounts, even though only
Business will ever see more than one.

### What this PR does (no behaviour change)
- `spaces`, `space_user`, `space_invitations` tables;
`users.current_space_id`; a nullable, indexed `space_id` on the 8 owned
tables (plain column, **no FK** — avoids a validating table-scan/lock on
`transactions` during a phased rollout).
- `Space` model + `BelongsToSpace` trait that stamps `space_id` on
create (from the row's user's current space; a transaction inherits its
account's space, so bank-sync lands rows correctly).
- Idempotent, chunked `spaces:backfill` command (run from a migration)
that gives every existing user a personal space and stamps their rows —
so the read switch in the next PR is safe.
- **Reads are untouched here** (still user-scoped); every user has
exactly one space, so behaviour is identical.

### Testing
- New `tests/Feature/Spaces/SpaceFoundationTest.php` (provisioning,
default-space stamping, account-anchored transaction space,
stale-pointer self-heal, backfill).
- Full suite green.

### Notes / deliberate simplifications
- `space_id` stays **nullable** (populated by backfill + on every
write); the NOT NULL constraint is deferred until prod is confirmed
fully backfilled.
- For very large `transactions` tables, `spaces:backfill` can be run
out-of-band before deploy so the migration's call is a no-op.
This commit is contained in:
Víctor Falcón 2026-07-09 14:26:07 +02:00 committed by GitHub
parent abe31ff967
commit 6f72c43cce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 822 additions and 11 deletions

View File

@ -5,6 +5,7 @@ namespace App\Actions;
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategoryType;
use App\Models\Category;
use App\Models\Space;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
@ -13,14 +14,16 @@ class CreateDefaultCategories
{
/**
* Create default categories for a newly registered user, nesting child
* categories under their configured parent.
* categories under their configured parent. Categories live in a space, so
* seeding targets the given space (defaulting to the user's active one).
*/
public function handle(User $user): void
public function handle(User $user, ?Space $space = null): void
{
$space ??= $user->activeSpace();
$locale = $user->locale ?? app()->getLocale();
$defaultCategories = self::getDefaultCategories($locale);
$existingCategories = $user->categories()
$existingCategories = $space->categories()
->whereIn('name', array_column($defaultCategories, 'name'))
->pluck('id', 'name');
@ -32,6 +35,7 @@ class CreateDefaultCategories
'cashflow_direction' => $category['cashflow_direction'] ?? CategoryCashflowDirection::Hidden->value,
'id' => (string) Str::uuid(),
'user_id' => $user->id,
'space_id' => $space->id,
'created_at' => $now,
'updated_at' => $now,
]);

View File

@ -0,0 +1,82 @@
<?php
namespace App\Console\Commands;
use App\Models\Account;
use App\Models\AutomationRule;
use App\Models\BankingConnection;
use App\Models\Budget;
use App\Models\Category;
use App\Models\Label;
use App\Models\SavedFilter;
use App\Models\Space;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
class BackfillSpaces extends Command
{
protected $signature = 'spaces:backfill {--chunk=500 : Users processed per batch}';
protected $description = 'Provision a personal space for every user and stamp their existing rows with it';
/**
* Owned tables to backfill. Each row inherits the personal space of its
* user_id. Idempotent: only rows with a null space_id are touched.
*
* @var list<class-string<Model>>
*/
private array $models = [
Account::class,
BankingConnection::class,
Transaction::class,
Category::class,
Label::class,
Budget::class,
AutomationRule::class,
SavedFilter::class,
];
public function handle(): int
{
$chunk = (int) $this->option('chunk');
// Soft-deleted users are included: their accounts/transactions still
// exist and must be stamped too, so a restored account keeps its data
// and the column can eventually go NOT NULL.
$this->info('Provisioning personal spaces…');
User::withTrashed()->whereNull('current_space_id')->chunkById($chunk, function ($users): void {
foreach ($users as $user) {
$user->provisionPersonalSpace();
}
});
$this->info('Backfilling space_id on owned rows…');
Space::query()->where('personal', true)->chunkById($chunk, function ($spaces): void {
foreach ($spaces as $space) {
foreach ($this->models as $model) {
$this->stamp($model, $space->owner_id, $space->id);
}
}
});
$this->info('Done.');
return self::SUCCESS;
}
/**
* @param class-string<Model> $model
*/
private function stamp(string $model, string $ownerId, string $spaceId): void
{
// Go through the query builder rather than Eloquent so soft-deleted rows
// are stamped too (no soft-delete global scope to work around).
DB::table((new $model)->getTable())
->whereNull('space_id')
->where('user_id', $ownerId)
->update(['space_id' => $spaceId]);
}
}

View File

@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\AccountType;
use App\Models\Concerns\BelongsToSpace;
use Database\Factories\AccountFactory;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
@ -19,10 +20,11 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class Account extends Model
{
/** @use HasFactory<AccountFactory> */
use HasFactory, HasUuids, SoftDeletes;
use BelongsToSpace, HasFactory, HasUuids, SoftDeletes;
protected $fillable = [
'user_id',
'space_id',
'name',
'name_iv',
'bank_id',
@ -40,6 +42,7 @@ class Account extends Model
/** @var list<string> */
protected $hidden = [
'user_id',
'space_id',
'bank_id',
'iban',
'position',

View File

@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\RuleOrigin;
use App\Models\Concerns\BelongsToSpace;
use Database\Factories\AutomationRuleFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
@ -19,10 +20,11 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class AutomationRule extends Model
{
/** @use HasFactory<AutomationRuleFactory> */
use HasFactory, HasUuids, SoftDeletes;
use BelongsToSpace, HasFactory, HasUuids, SoftDeletes;
protected $fillable = [
'user_id',
'space_id',
'title',
'priority',
'origin',
@ -32,6 +34,11 @@ class AutomationRule extends Model
'action_note_iv',
];
/** @var list<string> */
protected $hidden = [
'space_id',
];
protected function casts(): array
{
return [

View File

@ -4,6 +4,7 @@ namespace App\Models;
use App\Enums\BankingConnectionStatus;
use App\Enums\BankingProvider;
use App\Models\Concerns\BelongsToSpace;
use Carbon\Carbon;
use Database\Factories\BankingConnectionFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
@ -28,10 +29,11 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class BankingConnection extends Model
{
/** @use HasFactory<BankingConnectionFactory> */
use HasFactory, HasUuids, SoftDeletes;
use BelongsToSpace, HasFactory, HasUuids, SoftDeletes;
protected $fillable = [
'user_id',
'space_id',
'provider',
'authorization_id',
'state_token',
@ -52,6 +54,7 @@ class BankingConnection extends Model
];
protected $hidden = [
'space_id',
'api_token',
'api_secret',
'pending_accounts_data',

View File

@ -4,6 +4,7 @@ namespace App\Models;
use App\Enums\BudgetPeriodType;
use App\Enums\RolloverType;
use App\Models\Concerns\BelongsToSpace;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -18,10 +19,11 @@ use Illuminate\Database\Eloquent\SoftDeletes;
*/
class Budget extends Model
{
use HasFactory, HasUuids, SoftDeletes;
use BelongsToSpace, HasFactory, HasUuids, SoftDeletes;
protected $fillable = [
'user_id',
'space_id',
'name',
'period_type',
'period_start_day',
@ -32,6 +34,7 @@ class Budget extends Model
/** @var list<string> */
protected $hidden = [
'period_duration',
'space_id',
];
protected function casts(): array

View File

@ -4,6 +4,7 @@ namespace App\Models;
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategoryType;
use App\Models\Concerns\BelongsToSpace;
use Database\Factories\CategoryFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
@ -26,7 +27,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class Category extends Model
{
/** @use HasFactory<CategoryFactory> */
use HasFactory, HasUuids, SoftDeletes;
use BelongsToSpace, HasFactory, HasUuids, SoftDeletes;
/**
* Maximum allowed nesting depth (a root counts as level 1).
@ -40,12 +41,14 @@ class Category extends Model
'type',
'cashflow_direction',
'user_id',
'space_id',
'parent_id',
];
/** @var list<string> */
protected $hidden = [
'user_id',
'space_id',
'created_at',
'updated_at',
'deleted_at',

View File

@ -0,0 +1,69 @@
<?php
namespace App\Models\Concerns;
use App\Models\Space;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Marks a model as belonging to a Space (the tenant). On create, `space_id` is
* filled from the row's own `user_id` (its owner's current space) unless it was
* set explicitly so factories and existing single-owner creation paths keep
* working untouched, while multi-space callers pass `space_id` themselves.
*
* @property ?string $space_id
*/
trait BelongsToSpace
{
public static function bootBelongsToSpace(): void
{
static::creating(function ($model): void {
if ($model->space_id === null) {
$model->space_id = $model->resolveDefaultSpaceId();
}
});
}
/** @return BelongsTo<Space, $this> */
public function space(): BelongsTo
{
return $this->belongsTo(Space::class);
}
/**
* Restrict a query to a single space.
*
* @param Builder<static> $query
* @return Builder<static>
*/
public function scopeForSpace(Builder $query, Space|string $space): Builder
{
return $query->where($this->getTable().'.space_id', $space instanceof Space ? $space->id : $space);
}
/**
* The space a new row defaults to when none was set explicitly: the current
* space of the user that owns the row. Models with a stronger anchor (e.g. a
* transaction inheriting its account's space) override this.
*/
protected function resolveDefaultSpaceId(): ?string
{
return $this->spaceIdFromUser();
}
/**
* The current space of the user that owns this row, if any.
*/
protected function spaceIdFromUser(): ?string
{
$userId = $this->getAttribute('user_id');
if ($userId === null) {
return null;
}
return User::query()->whereKey($userId)->value('current_space_id');
}
}

View File

@ -2,6 +2,7 @@
namespace App\Models;
use App\Models\Concerns\BelongsToSpace;
use Database\Factories\LabelFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -13,12 +14,13 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class Label extends Model
{
/** @use HasFactory<LabelFactory> */
use HasFactory, HasUuids, SoftDeletes;
use BelongsToSpace, HasFactory, HasUuids, SoftDeletes;
protected $fillable = [
'name',
'color',
'user_id',
'space_id',
];
/**
@ -29,6 +31,7 @@ class Label extends Model
*/
protected $hidden = [
'pivot',
'space_id',
];
/** @return BelongsTo<User, $this> */

View File

@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\AnalysisMode;
use App\Models\Concerns\BelongsToSpace;
use Database\Factories\SavedFilterFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -12,10 +13,11 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SavedFilter extends Model
{
/** @use HasFactory<SavedFilterFactory> */
use HasFactory, HasUuids;
use BelongsToSpace, HasFactory, HasUuids;
protected $fillable = [
'user_id',
'space_id',
'name',
'filters',
'analysis_days',
@ -25,6 +27,7 @@ class SavedFilter extends Model
/** @var list<string> */
protected $hidden = [
'user_id',
'space_id',
'created_at',
'updated_at',
];

134
app/Models/Space.php Normal file
View File

@ -0,0 +1,134 @@
<?php
namespace App\Models;
use Database\Factories\SpaceFactory;
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\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property string $id
* @property string $owner_id
* @property string $name
* @property bool $personal
*/
class Space extends Model
{
/** @use HasFactory<SpaceFactory> */
use HasFactory, HasUuids;
protected $fillable = [
'owner_id',
'name',
'personal',
];
protected function casts(): array
{
return [
'personal' => 'boolean',
];
}
/** @return BelongsTo<User, $this> */
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_id');
}
/**
* Members invited into the space (excludes the owner, who is implicit).
*
* @return BelongsToMany<User, $this>
*/
public function members(): BelongsToMany
{
return $this->belongsToMany(User::class, 'space_user')
->withPivot('role')
->withTimestamps();
}
/** @return HasMany<SpaceInvitation, $this> */
public function invitations(): HasMany
{
return $this->hasMany(SpaceInvitation::class);
}
/** @return HasMany<Account, $this> */
public function accounts(): HasMany
{
return $this->hasMany(Account::class);
}
/** @return HasMany<BankingConnection, $this> */
public function bankingConnections(): HasMany
{
return $this->hasMany(BankingConnection::class);
}
/** @return HasMany<Transaction, $this> */
public function transactions(): HasMany
{
return $this->hasMany(Transaction::class);
}
/** @return HasMany<Category, $this> */
public function categories(): HasMany
{
return $this->hasMany(Category::class);
}
/** @return HasMany<Label, $this> */
public function labels(): HasMany
{
return $this->hasMany(Label::class);
}
/** @return HasMany<Budget, $this> */
public function budgets(): HasMany
{
return $this->hasMany(Budget::class);
}
/** @return HasMany<AutomationRule, $this> */
public function automationRules(): HasMany
{
return $this->hasMany(AutomationRule::class);
}
/** @return HasMany<SavedFilter, $this> */
public function savedFilters(): HasMany
{
return $this->hasMany(SavedFilter::class);
}
/**
* Whether the given user owns or is a member of this space. The owner check
* short-circuits without a query for the common (personal-space) case.
*/
public function hasMember(User $user): bool
{
if ($this->owner_id === $user->id) {
return true;
}
return $this->members()->whereKey($user->id)->exists();
}
/**
* The role the user holds in this space: owner, the pivot role, or null when
* they have no access.
*/
public function roleFor(User $user): ?string
{
if ($this->owner_id === $user->id) {
return 'owner';
}
return $this->members()->whereKey($user->id)->value('role');
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property string $id
* @property string $space_id
* @property ?string $invited_by_id
* @property string $email
* @property string $role
* @property string $token
* @property ?Carbon $expires_at
* @property ?Carbon $accepted_at
*/
class SpaceInvitation extends Model
{
use HasUuids;
protected $fillable = [
'space_id',
'invited_by_id',
'email',
'role',
'token',
'expires_at',
'accepted_at',
];
protected function casts(): array
{
return [
'expires_at' => 'datetime',
'accepted_at' => 'datetime',
];
}
/** @return BelongsTo<Space, $this> */
public function space(): BelongsTo
{
return $this->belongsTo(Space::class);
}
/** @return BelongsTo<User, $this> */
public function invitedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'invited_by_id');
}
public function isExpired(): bool
{
return $this->expires_at !== null && $this->expires_at->isPast();
}
public function isAccepted(): bool
{
return $this->accepted_at !== null;
}
}

View File

@ -9,6 +9,7 @@ use App\Enums\TransactionSource;
use App\Events\TransactionCreated;
use App\Events\TransactionDeleted;
use App\Events\TransactionUpdated;
use App\Models\Concerns\BelongsToSpace;
use App\Services\CategoryTree;
use Carbon\Carbon;
use Database\Factories\TransactionFactory;
@ -35,7 +36,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class Transaction extends Model
{
/** @use HasFactory<TransactionFactory> */
use HasFactory, HasUuids, SoftDeletes;
use BelongsToSpace, HasFactory, HasUuids, SoftDeletes;
/** @var array<string, class-string> */
protected $dispatchesEvents = [
@ -46,6 +47,7 @@ class Transaction extends Model
protected $fillable = [
'user_id',
'space_id',
'account_id',
'category_id',
'category_source',
@ -77,6 +79,7 @@ class Transaction extends Model
* @var list<string>
*/
protected $hidden = [
'space_id',
'original_description',
'external_transaction_id',
'dedup_fingerprint',
@ -111,6 +114,26 @@ class Transaction extends Model
return $this->belongsTo(Account::class);
}
/**
* A transaction always lives in its account's space (the account is the
* tenant anchor), so bank-sync inserts land in the right space regardless of
* whichever space the syncing user is currently viewing.
*/
protected function resolveDefaultSpaceId(): ?string
{
$accountId = $this->getAttribute('account_id');
if ($accountId !== null) {
$spaceId = Account::query()->whereKey($accountId)->value('space_id');
if ($spaceId !== null) {
return $spaceId;
}
}
return $this->spaceIdFromUser();
}
/** @return BelongsTo<Category, $this> */
public function category(): BelongsTo
{

View File

@ -10,8 +10,11 @@ use Database\Factories\UserFactory;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Contracts\Translation\HasLocalePreference;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
@ -50,6 +53,7 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma
'currency_code',
'locale',
'timezone',
'current_space_id',
];
/**
@ -89,6 +93,18 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma
];
}
/**
* Memoized active space for the current request lifecycle.
*/
protected ?Space $resolvedActiveSpace = null;
protected static function booted(): void
{
static::created(function (User $user): void {
$user->provisionPersonalSpace();
});
}
public function isOnboarded(): bool
{
return $this->onboarded_at !== null;
@ -139,6 +155,97 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma
});
}
/** @return BelongsTo<Space, $this> */
public function currentSpace(): BelongsTo
{
return $this->belongsTo(Space::class, 'current_space_id');
}
/** @return HasMany<Space, $this> */
public function ownedSpaces(): HasMany
{
return $this->hasMany(Space::class, 'owner_id');
}
/**
* The one personal space every user owns (created on registration).
*
* @return HasOne<Space, $this>
*/
public function personalSpace(): HasOne
{
return $this->hasOne(Space::class, 'owner_id')->where('personal', true);
}
/**
* Spaces the user was invited into (excludes the ones they own).
*
* @return BelongsToMany<Space, $this>
*/
public function memberSpaces(): BelongsToMany
{
return $this->belongsToMany(Space::class, 'space_user')
->withPivot('role')
->withTimestamps();
}
/**
* Every space the user can access: the ones they own plus the ones they were
* invited into, ordered with the personal space first.
*
* @return Collection<int, Space>
*/
public function accessibleSpaces(): Collection
{
return Space::query()
->where('owner_id', $this->id)
->orWhereHas('members', fn (Builder $query) => $query->whereKey($this->id))
->orderByDesc('personal')
->orderBy('name')
->get();
}
/**
* Idempotently ensure the user has a personal space and points at it.
*/
public function provisionPersonalSpace(): Space
{
$space = $this->ownedSpaces()->firstOrCreate(
['personal' => true],
['name' => 'Personal'],
);
if ($this->current_space_id === null) {
$this->forceFill(['current_space_id' => $space->id])->saveQuietly();
}
return $space;
}
/**
* The space the user is currently working in. Falls back to (and repairs
* towards) the personal space when the pointer is missing or points at a
* space the user can no longer access e.g. after a membership is revoked or
* a Business subscription lapses.
*/
public function activeSpace(): Space
{
if ($this->resolvedActiveSpace !== null) {
return $this->resolvedActiveSpace;
}
$space = $this->current_space_id !== null
? Space::query()->find($this->current_space_id)
: null;
if ($space === null || ! $space->hasMember($this)) {
$space = $this->provisionPersonalSpace();
$this->forceFill(['current_space_id' => $space->id])->saveQuietly();
}
return $this->resolvedActiveSpace = $space;
}
/** @return HasMany<AutomationRule, $this> */
public function automationRules(): HasMany
{

View File

@ -181,6 +181,7 @@ class TransactionSyncService
try {
$account->transactions()->create([
'user_id' => $account->user_id,
'space_id' => $account->space_id,
'description' => $formatted['description'],
'description_iv' => null,
'original_description' => $formatted['original_description'],

View File

@ -166,6 +166,7 @@ class WiseTransactionSyncService
try {
$account->transactions()->create([
'user_id' => $account->user_id,
'space_id' => $account->space_id,
'description' => $parsed['description'],
'description_iv' => null,
'original_description' => $parsed['description'],

View File

@ -0,0 +1,33 @@
<?php
namespace Database\Factories;
use App\Models\Space;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Space>
*/
class SpaceFactory extends Factory
{
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'owner_id' => User::factory(),
'name' => fake()->company(),
'personal' => false,
];
}
public function personal(): static
{
return $this->state(fn (array $attributes): array => [
'personal' => true,
'name' => 'Personal',
]);
}
}

View File

@ -0,0 +1,26 @@
<?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('spaces', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('owner_id')->constrained('users')->cascadeOnDelete();
$table->string('name');
$table->boolean('personal')->default(false);
$table->timestamps();
$table->index('owner_id');
});
}
public function down(): void
{
Schema::dropIfExists('spaces');
}
};

View File

@ -0,0 +1,26 @@
<?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('space_user', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('space_id')->constrained('spaces')->cascadeOnDelete();
$table->foreignUuid('user_id')->constrained('users')->cascadeOnDelete();
$table->string('role')->default('member');
$table->timestamps();
$table->unique(['space_id', 'user_id']);
});
}
public function down(): void
{
Schema::dropIfExists('space_user');
}
};

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('space_invitations', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('space_id')->constrained('spaces')->cascadeOnDelete();
$table->foreignUuid('invited_by_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('email');
$table->string('role')->default('member');
$table->string('token', 64)->unique();
$table->timestamp('expires_at')->nullable();
$table->timestamp('accepted_at')->nullable();
$table->timestamps();
$table->index(['space_id', 'email']);
});
}
public function down(): void
{
Schema::dropIfExists('space_invitations');
}
};

View File

@ -0,0 +1,22 @@
<?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) {
$table->foreignUuid('current_space_id')->nullable()->constrained('spaces')->nullOnDelete();
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropConstrainedForeignId('current_space_id');
});
}
};

View File

@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Tables whose rows belong to a space (the tenant). We add a plain indexed
* column rather than a foreign key: adding an FK to large tables such as
* `transactions` triggers a validating table scan/lock on Postgres, which is
* exactly what a phased, zero-downtime backfill needs to avoid. Referential
* integrity for space deletion is enforced in application code instead.
*
* ponytail: indexed column, no FK. Add the FK + NOT NULL once prod has been
* backfilled and space deletion reassigns rows (see spaces:backfill command).
*
* @var list<string>
*/
private array $tables = [
'accounts',
'banking_connections',
'transactions',
'categories',
'labels',
'budgets',
'automation_rules',
'saved_filters',
];
public function up(): void
{
foreach ($this->tables as $table) {
Schema::table($table, function (Blueprint $blueprint) {
$blueprint->uuid('space_id')->nullable()->index();
});
}
}
public function down(): void
{
foreach ($this->tables as $table) {
Schema::table($table, function (Blueprint $blueprint) {
$blueprint->dropColumn('space_id');
});
}
}
};

View File

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Artisan;
return new class extends Migration
{
/**
* Stamp every existing user and their rows with a personal space before the
* space-scoped reads go live in the same release, so no data disappears in
* the window between adding the columns and switching the queries. Reuses the
* idempotent, chunked `spaces:backfill` command (a no-op on a fresh install).
*/
public function up(): void
{
Artisan::call('spaces:backfill');
}
public function down(): void
{
// ponytail: irreversible — spaces stay; dropping the columns (previous
// migration) is what unwinds this.
}
};

View File

@ -0,0 +1,92 @@
<?php
use App\Actions\CreateDefaultCategories;
use App\Models\Account;
use App\Models\Category;
use App\Models\Space;
use App\Models\Transaction;
use App\Models\User;
it('provisions a personal space when a user is created', function () {
$user = User::factory()->create();
expect($user->personalSpace)->not->toBeNull()
->and($user->personalSpace->personal)->toBeTrue()
->and($user->current_space_id)->toBe($user->personalSpace->id)
->and($user->activeSpace()->id)->toBe($user->personalSpace->id);
});
it('stamps owned rows with the owner\'s current space by default', function () {
$user = User::factory()->create();
$account = Account::factory()->for($user)->create();
expect($account->space_id)->toBe($user->current_space_id);
});
it('inherits a transaction\'s space from its account, not the acting user', function () {
$owner = User::factory()->create();
$account = Account::factory()->for($owner)->create();
// A transaction created with a different acting user still lands in the
// account's space (the account is the tenant anchor).
$other = User::factory()->create();
$transaction = Transaction::factory()->for($account)->create(['user_id' => $other->id]);
expect($transaction->space_id)->toBe($account->space_id);
});
it('seeds default categories into the given space', function () {
$user = User::factory()->create();
app(CreateDefaultCategories::class)->handle($user);
expect(Category::where('space_id', $user->current_space_id)->count())->toBeGreaterThan(0)
->and(Category::whereNull('space_id')->count())->toBe(0);
});
it('resolves active space back to personal when the pointer is stale', function () {
$user = User::factory()->create();
$foreign = Space::factory()->create();
// Point the user at a space they cannot access.
$user->forceFill(['current_space_id' => $foreign->id])->saveQuietly();
$user->refresh();
expect($user->activeSpace()->personal)->toBeTrue()
->and($user->fresh()->current_space_id)->toBe($user->personalSpace->id);
});
it('backfills legacy rows that predate spaces', function () {
$user = User::factory()->create();
$account = Account::factory()->for($user)->create();
// Simulate a pre-migration state: no space, dangling pointers.
Account::query()->where('id', $account->id)->update(['space_id' => null]);
User::query()->where('id', $user->id)->update(['current_space_id' => null]);
Space::query()->where('owner_id', $user->id)->delete();
$this->artisan('spaces:backfill')->assertSuccessful();
$user->refresh();
expect($user->current_space_id)->not->toBeNull()
->and($account->fresh()->space_id)->toBe($user->current_space_id);
});
it('backfills soft-deleted users and their rows', function () {
$user = User::factory()->create();
$account = Account::factory()->for($user)->create();
// Simulate a pre-migration, soft-deleted user with dangling data.
Account::query()->where('id', $account->id)->update(['space_id' => null]);
User::query()->where('id', $user->id)->update(['current_space_id' => null]);
Space::query()->where('owner_id', $user->id)->delete();
$user->delete();
$this->artisan('spaces:backfill')->assertSuccessful();
$trashed = User::withTrashed()->find($user->id);
expect($trashed->trashed())->toBeTrue()
->and($trashed->current_space_id)->not->toBeNull()
->and($account->fresh()->space_id)->toBe($trashed->current_space_id);
});