From 3d5a049c2acf5c4d4a5e37676966ec85f77f57b1 Mon Sep 17 00:00:00 2001 From: Morris Jencen Chavez Date: Fri, 4 Sep 2026 19:17:13 +0000 Subject: [PATCH] fix: allow reusing a soft-deleted event's name Event names were permanently unreusable once soft-deleted: the DB-level unique index on (user_id, name) and the validation rules backing it both ignored deleted_at, so recreating an event with a previously deleted name was rejected forever even though the app has no restore path and the old event is invisible everywhere. - Scope the events unique index to non-deleted rows via a partial index (portable across SQLite and PostgreSQL). - Add withoutTrashed() to the name uniqueness validation in both the web and API EventControllers so validation matches the new constraint. Fixes #83 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01E1dCNHSzn1JyxZbZ147TJw --- app/Http/Controllers/Api/EventController.php | 4 +- app/Http/Controllers/EventController.php | 4 +- ...vents_unique_index_to_non_deleted_rows.php | 40 ++++++++ .../Feature/SoftDeletedEventNameReuseTest.php | 91 +++++++++++++++++++ 4 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 database/migrations/2026_09_04_191547_scope_events_unique_index_to_non_deleted_rows.php create mode 100644 tests/Feature/SoftDeletedEventNameReuseTest.php diff --git a/app/Http/Controllers/Api/EventController.php b/app/Http/Controllers/Api/EventController.php index 00b5b2d..9ac1619 100644 --- a/app/Http/Controllers/Api/EventController.php +++ b/app/Http/Controllers/Api/EventController.php @@ -34,7 +34,7 @@ public function store(Request $request): JsonResponse $validator = Validator::make($request->all(), [ 'name' => [ 'required', 'string', 'max:255', - Rule::unique('events', 'name')->where('user_id', $request->user()->id), + Rule::unique('events', 'name')->where('user_id', $request->user()->id)->withoutTrashed(), ], 'description' => 'nullable|string|max:1000', 'schema' => ['nullable', 'array', new ValidEventSchema()], @@ -95,7 +95,7 @@ public function update(Request $request, Event $event): JsonResponse $validator = Validator::make($request->all(), [ 'name' => [ 'string', 'max:255', - Rule::unique('events', 'name')->where('user_id', $request->user()->id)->ignore($event->id), + Rule::unique('events', 'name')->where('user_id', $request->user()->id)->ignore($event->id)->withoutTrashed(), ], 'description' => 'nullable|string|max:1000', 'schema' => ['nullable', 'array', new ValidEventSchema()], diff --git a/app/Http/Controllers/EventController.php b/app/Http/Controllers/EventController.php index 03564ab..b8a6f47 100644 --- a/app/Http/Controllers/EventController.php +++ b/app/Http/Controllers/EventController.php @@ -20,7 +20,7 @@ public function store(Request $request): RedirectResponse $validated = $request->validate([ 'name' => [ 'required', 'string', 'max:255', - Rule::unique('events', 'name')->where('user_id', $request->user()->id), + Rule::unique('events', 'name')->where('user_id', $request->user()->id)->withoutTrashed(), ], 'event_type' => 'nullable|string|max:255', 'description' => 'nullable|string|max:1000', @@ -40,7 +40,7 @@ public function update(Request $request, Event $event): RedirectResponse $validated = $request->validate([ 'name' => [ 'required', 'string', 'max:255', - Rule::unique('events', 'name')->where('user_id', $request->user()->id)->ignore($event->id), + Rule::unique('events', 'name')->where('user_id', $request->user()->id)->ignore($event->id)->withoutTrashed(), ], 'event_type' => 'nullable|string|max:255', 'description' => 'nullable|string|max:1000', diff --git a/database/migrations/2026_09_04_191547_scope_events_unique_index_to_non_deleted_rows.php b/database/migrations/2026_09_04_191547_scope_events_unique_index_to_non_deleted_rows.php new file mode 100644 index 0000000..a43f068 --- /dev/null +++ b/database/migrations/2026_09_04_191547_scope_events_unique_index_to_non_deleted_rows.php @@ -0,0 +1,40 @@ +dropUnique(['user_id', 'name']); + }); + + // A plain unique index on (user_id, name) also matches soft-deleted + // rows, permanently blocking reuse of a deleted event's name since + // there is no restore path in the app. Scope the constraint to + // non-deleted rows only via a partial index; this syntax is + // supported identically by SQLite (tests) and PostgreSQL + // (production), the two drivers this app runs on. + DB::statement( + 'CREATE UNIQUE INDEX events_user_id_name_unique ON events (user_id, name) WHERE deleted_at IS NULL' + ); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + DB::statement('DROP INDEX events_user_id_name_unique'); + + Schema::table('events', function (Blueprint $table) { + $table->unique(['user_id', 'name']); + }); + } +}; diff --git a/tests/Feature/SoftDeletedEventNameReuseTest.php b/tests/Feature/SoftDeletedEventNameReuseTest.php new file mode 100644 index 0000000..10a5554 --- /dev/null +++ b/tests/Feature/SoftDeletedEventNameReuseTest.php @@ -0,0 +1,91 @@ +withPersonalTeam()->create(); + $original = Event::factory()->for($user)->create(['name' => 'order.created']); + $original->delete(); + + $this->assertSoftDeleted($original); + + $this->actingAs($user) + ->post('/events', ['name' => 'order.created']) + ->assertRedirect(route('events')); + + $this->assertDatabaseHas('events', [ + 'user_id' => $user->id, + 'name' => 'order.created', + 'deleted_at' => null, + ]); + } + + public function test_user_can_rename_an_event_to_the_name_of_a_soft_deleted_event_via_web(): void + { + $user = User::factory()->withPersonalTeam()->create(); + $deleted = Event::factory()->for($user)->create(['name' => 'order.created']); + $deleted->delete(); + $active = Event::factory()->for($user)->create(['name' => 'order.updated']); + + $this->actingAs($user) + ->put("/events/{$active->id}", ['name' => 'order.created']) + ->assertRedirect(route('events')); + + $this->assertDatabaseHas('events', [ + 'id' => $active->id, + 'name' => 'order.created', + ]); + } + + public function test_user_still_cannot_reuse_the_name_of_an_active_event_via_web(): void + { + $user = User::factory()->withPersonalTeam()->create(); + Event::factory()->for($user)->create(['name' => 'order.created']); + + $this->actingAs($user) + ->post('/events', ['name' => 'order.created']) + ->assertSessionHasErrors('name'); + + $this->assertDatabaseCount('events', 1); + } + + public function test_user_can_reuse_the_name_of_a_soft_deleted_event_via_api(): void + { + $user = User::factory()->withPersonalTeam()->create(); + $original = Event::factory()->for($user)->create(['name' => 'order.created']); + $original->delete(); + + $this->actingAs($user) + ->postJson('/api/v1/events', ['name' => 'order.created']) + ->assertStatus(201); + + $this->assertDatabaseHas('events', [ + 'user_id' => $user->id, + 'name' => 'order.created', + 'deleted_at' => null, + ]); + } + + public function test_user_still_cannot_reuse_the_name_of_an_active_event_via_api(): void + { + $user = User::factory()->withPersonalTeam()->create(); + Event::factory()->for($user)->create(['name' => 'order.created']); + + $this->actingAs($user) + ->postJson('/api/v1/events', ['name' => 'order.created']) + ->assertStatus(422) + ->assertJsonValidationErrors('name'); + + $this->assertDatabaseCount('events', 1); + } +}