From 5dca04dbba61c095e40bd36024ae55581969652c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vi=CC=81ctor=20Falco=CC=81n?= Date: Mon, 6 Jul 2026 18:10:25 +0200 Subject: [PATCH] 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. --- .../Settings/CategoryController.php | 3 +- ...130000_rescope_unique_indexes_to_space.php | 67 +++++++++++++++++++ tests/Feature/Spaces/SpaceManagementTest.php | 9 ++- 3 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 database/migrations/2026_07_06_130000_rescope_unique_indexes_to_space.php diff --git a/app/Http/Controllers/Settings/CategoryController.php b/app/Http/Controllers/Settings/CategoryController.php index 8b92ea00..aa96248a 100644 --- a/app/Http/Controllers/Settings/CategoryController.php +++ b/app/Http/Controllers/Settings/CategoryController.php @@ -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; } diff --git a/database/migrations/2026_07_06_130000_rescope_unique_indexes_to_space.php b/database/migrations/2026_07_06_130000_rescope_unique_indexes_to_space.php new file mode 100644 index 00000000..81f72a1a --- /dev/null +++ b/database/migrations/2026_07_06_130000_rescope_unique_indexes_to_space.php @@ -0,0 +1,67 @@ +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'); + }); + } +}; diff --git a/tests/Feature/Spaces/SpaceManagementTest.php b/tests/Feature/Spaces/SpaceManagementTest.php index b114d63f..b9749fd3 100644 --- a/tests/Feature/Spaces/SpaceManagementTest.php +++ b/tests/Feature/Spaces/SpaceManagementTest.php @@ -1,5 +1,6 @@ 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 () {