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); + } +}