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:
parent
d470ee9646
commit
5dca04dbba
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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 () {
|
||||
|
|
|
|||
Loading…
Reference in New Issue