fix(spaces): scope category/label/saved-filter uniqueness per space

A user's spaces are each seeded with the same default category names, so the
per-user unique indexes on categories, labels and saved_filters collided the
moment a second space was created (500 on space creation). Rescope those
uniques to space_id (adding a plain user_id index first so the FK keeps its
index), teach the category duplicate-name handler the new index name, and
harden the creation test to seed the personal space first so it reproduces the
collision.
This commit is contained in:
Víctor Falcón 2026-07-06 18:10:25 +02:00
parent d470ee9646
commit 5dca04dbba
3 changed files with 77 additions and 2 deletions

View File

@ -107,7 +107,8 @@ class CategoryController extends Controller
{
if (! str_contains($exception->getMessage(), 'categories_user_id_name_unique')
&& ! str_contains($exception->getMessage(), 'categories_user_id_name_active_unique')
&& ! str_contains($exception->getMessage(), 'categories_user_id_parent_name_active_unique')) {
&& ! str_contains($exception->getMessage(), 'categories_user_id_parent_name_active_unique')
&& ! str_contains($exception->getMessage(), 'categories_space_id_parent_name_active_unique')) {
throw $exception;
}

View File

@ -0,0 +1,67 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Category, label and saved-filter names were unique per user. Now that a
* user can own several spaces (each seeded with the same default category
* names), those constraints must be per space instead otherwise creating a
* second space collides with the first space's categories. Runs after the
* backfill, so space_id is already populated on every existing row.
*
* The old composite uniques also served as the index MySQL requires for the
* `user_id` foreign key, so we add a plain `user_id` index before dropping
* them (and drop it again on rollback once the composite is restored).
*/
public function up(): void
{
Schema::table('categories', function (Blueprint $table) {
$table->index('user_id', 'categories_user_id_index');
$table->dropUnique('categories_user_id_parent_name_active_unique');
$table->unique(
['space_id', 'parent_unique_marker', 'name', 'active_unique_marker'],
'categories_space_id_parent_name_active_unique',
);
});
Schema::table('labels', function (Blueprint $table) {
$table->index('user_id', 'labels_user_id_index');
$table->dropUnique('labels_user_id_name_deleted_at_unique');
$table->unique(['space_id', 'name', 'deleted_at'], 'labels_space_id_name_deleted_at_unique');
});
Schema::table('saved_filters', function (Blueprint $table) {
$table->index('user_id', 'saved_filters_user_id_index');
$table->dropUnique('saved_filters_user_id_name_unique');
$table->unique(['space_id', 'name'], 'saved_filters_space_id_name_unique');
});
}
public function down(): void
{
Schema::table('categories', function (Blueprint $table) {
$table->dropUnique('categories_space_id_parent_name_active_unique');
$table->unique(
['user_id', 'parent_unique_marker', 'name', 'active_unique_marker'],
'categories_user_id_parent_name_active_unique',
);
$table->dropIndex('categories_user_id_index');
});
Schema::table('labels', function (Blueprint $table) {
$table->dropUnique('labels_space_id_name_deleted_at_unique');
$table->unique(['user_id', 'name', 'deleted_at'], 'labels_user_id_name_deleted_at_unique');
$table->dropIndex('labels_user_id_index');
});
Schema::table('saved_filters', function (Blueprint $table) {
$table->dropUnique('saved_filters_space_id_name_unique');
$table->unique(['user_id', 'name'], 'saved_filters_user_id_name_unique');
$table->dropIndex('saved_filters_user_id_index');
});
}
};

View File

@ -1,5 +1,6 @@
<?php
use App\Actions\CreateDefaultCategories;
use App\Features\Spaces;
use App\Models\Account;
use App\Models\Category;
@ -11,6 +12,11 @@ it('creates a space, seeds its categories and switches to it', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate(Spaces::class);
// The personal space already carries the default categories (as it would
// after registration), so seeding the same names into a second space must
// NOT collide — category name uniqueness is per space, not per user.
app(CreateDefaultCategories::class)->handle($user, $user->personalSpace);
$this->actingAs($user)->post('/settings/spaces', ['name' => 'Acme'])
->assertRedirect();
@ -18,7 +24,8 @@ it('creates a space, seeds its categories and switches to it', function () {
expect($space)->not->toBeNull()
->and($user->fresh()->current_space_id)->toBe($space->id)
->and(Category::where('space_id', $space->id)->count())->toBeGreaterThan(0);
->and(Category::where('space_id', $space->id)->count())->toBeGreaterThan(0)
->and(Category::where('space_id', $user->personalSpace->id)->count())->toBeGreaterThan(0);
});
it('forbids creating a space without the spaces feature', function () {