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:
parent
abe31ff967
commit
6f72c43cce
|
|
@ -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,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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> */
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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.
|
||||
}
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue