diff --git a/app/Http/Controllers/API/EventsController.php b/app/Http/Controllers/API/EventsController.php new file mode 100644 index 0000000000..c9c62cf473 --- /dev/null +++ b/app/Http/Controllers/API/EventsController.php @@ -0,0 +1,125 @@ +filled('search')) { + $search = $request->input('search'); + $query->where(function ($q) use ($search) { + $q->where('venue', 'like', "%{$search}%") + ->orWhere('location', 'like', "%{$search}%"); + }); + } + + // Apply deleted filter + if ($request->filled('deleted')) { + $deletedFilter = $request->input('deleted'); + if ($deletedFilter === 'only') { + $query->onlyTrashed(); + } elseif ($deletedFilter === 'all') { + $query->withTrashed(); + } + // Default shows only non-deleted + } + + // Handle sorting + $sortBy = $request->input('sort_by', 'event_start_utc'); + $sortDirection = $request->input('sort_direction', 'desc'); + + if (!in_array(strtolower($sortDirection), ['asc', 'desc'])) { + $sortDirection = 'desc'; + } + + $sortableColumns = [ + 'event_start_utc' => 'event_start_utc', + 'venue' => 'venue', + 'location' => 'location', + 'created_at' => 'created_at', + ]; + + $sortColumn = $sortableColumns[$sortBy] ?? 'event_start_utc'; + $query->orderBy($sortColumn, $sortDirection); + + $perPage = min($request->input('per_page', 100), 500); + $events = $query->paginate($perPage); + + return response()->json([ + 'success' => true, + 'data' => $events->getCollection(), + 'current_page' => $events->currentPage(), + 'last_page' => $events->lastPage(), + 'per_page' => $events->perPage(), + 'total' => $events->total(), + 'from' => $events->firstItem(), + 'to' => $events->lastItem(), + ]); + } catch (\Exception $e) { + Log::error('Error fetching events: ' . $e->getMessage()); + return response()->json([ + 'success' => false, + 'message' => 'Failed to fetch events', + ], 500); + } + } + + public static function performSingleAction(int $event_id, string $action): JsonResponse + { + try { + $event = Party::withTrashed()->findOrFail($event_id); + $result = self::performAction($event, $action); + + return response()->json([ + 'success' => true, + 'message' => $result['message'], + 'event' => $result, + ]); + } catch (\Exception $e) { + Log::error('Error performing event action: ' . $e->getMessage()); + + $statusCode = $e->getCode() === 409 ? 409 : 500; + + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], $statusCode); + } + } + + private static function performAction(Party $event, string $action): array + { + switch ($action) { + case 'restore': + // Check if parent group is soft-deleted + $group = Group::withTrashed()->find($event->group); + if ($group && $group->trashed()) { + throw new \Exception("Cannot restore event: the parent group '{$group->name}' is deleted. Restore the group first.", 409); + } + + $event->restore(); + break; + + default: + throw new \Exception("Invalid action: {$action}"); + } + + return [ + 'id' => $event->idevents, + 'venue' => $event->venue, + 'message' => "Event has been {$action}d successfully.", + ]; + } +} diff --git a/app/Http/Controllers/API/GroupsController.php b/app/Http/Controllers/API/GroupsController.php index 2f62ee5179..05a59131f0 100644 --- a/app/Http/Controllers/API/GroupsController.php +++ b/app/Http/Controllers/API/GroupsController.php @@ -29,6 +29,17 @@ public function index(Request $request): JsonResponse }); } + // Apply deleted filter + if ($request->filled('deleted')) { + $deletedFilter = $request->input('deleted'); + if ($deletedFilter === 'only') { + $query->onlyTrashed(); + } elseif ($deletedFilter === 'all') { + $query->withTrashed(); + } + // Default (no filter or 'active') shows only non-deleted + } + // Handle sorting $sortBy = $request->input('sort_by', 'name'); $sortDirection = $request->input('sort_direction', 'asc'); @@ -85,7 +96,7 @@ public function index(Request $request): JsonResponse public static function performSingleAction(int $group_id, string $action): JsonResponse { try { - $group = Group::findOrFail($group_id); + $group = Group::withTrashed()->findOrFail($group_id); $result = self::performAction($group, $action); @@ -98,8 +109,8 @@ public static function performSingleAction(int $group_id, string $action): JsonR Log::error('Error performing action: ' . $e->getMessage()); return response()->json([ 'success' => false, - 'message' => 'Failed to perform action' - ], 500); + 'message' => $e->getMessage(), + ], 422); } } @@ -107,7 +118,7 @@ public static function performBulkActions(Request $request, string $action): Jso { try { $group_ids = $request->input('group_ids'); - $groups = Group::whereIn('idgroups', $group_ids)->get(); + $groups = Group::withTrashed()->whereIn('idgroups', $group_ids)->get(); $failedGroups = []; @@ -161,13 +172,27 @@ private static function performAction(Group $group, string $action): array break; case 'delete': - $groupName = $group->name; - if (!$group->canDelete()) { - throw new \Exception("Group '{$groupName}' cannot be deleted because it has events with devices."); - } + // Soft-delete all group events (preserving devices and volunteer data) + \App\Models\Party::where('events.group', $group->idgroups)->each(function ($event) { + $event->delete(); + }); $group->delete(); break; + case 'restore': + if (!$group->trashed()) { + break; // Skip non-deleted groups silently (relevant for bulk actions) + } + $group->restore(); + // Also restore soft-deleted events for this group + \App\Models\Party::withTrashed() + ->where('events.group', $group->idgroups) + ->whereNotNull('events.deleted_at') + ->each(function ($event) { + $event->restore(); + }); + break; + default: throw new \Exception("Invalid action: {$action}"); } @@ -428,6 +453,7 @@ private function transformGroup($group): array 'country_display' => $countryDisplay, 'approved' => (bool) $group->approved, 'archived_at' => $group->archived_at, + 'deleted_at' => $group->deleted_at, 'created_at' => $group->created_at, 'networks' => $group->networks, 'group_tags' => $group->group_tags, diff --git a/app/Http/Controllers/GroupController.php b/app/Http/Controllers/GroupController.php index ac3b7d1f38..fe69022718 100644 --- a/app/Http/Controllers/GroupController.php +++ b/app/Http/Controllers/GroupController.php @@ -440,34 +440,23 @@ public function delete($id): RedirectResponse { $group = Group::where('idgroups', $id)->first(); - $name = $group->name; - - if (Auth::user()->hasRole('Administrator') && $group->canDelete()) { - // We know we can delete the group; if it has any past events they must be empty, so delete all - // events (including future). - $allEvents = Party::withTrashed()->where('events.group', $id)->get(); + if (!$group) { + return redirect('/group')->with('warning', 'Group not found.'); + } - foreach ($allEvents as $event) { - // Delete any users - these are not cascaded in the DB. - $users = EventsUsers::where('event', $event->idevents)->get(); + $name = $group->name; - foreach ($users as $user) { - // Need to force delete to get rid of the row and avoid constraint violations. - $user->forceDelete(); - } + if (Auth::user()->hasRole('Administrator')) { + // Soft-delete all group events (preserving devices and volunteer data). + Party::where('events.group', $id)->each(function ($event) { + $event->delete(); + }); - $event->forceDelete(); - } + $group->delete(); - $r = $group->delete($id); - - if (! $r) { - return redirect('/user/forbidden'); - } else { - return redirect('/group')->with('success', __('groups.delete_succeeded', [ - 'name' => $name, - ])); - } + return redirect('/group')->with('success', __('groups.delete_succeeded', [ + 'name' => $name, + ])); } else { return redirect('/user/forbidden'); } diff --git a/app/Http/Controllers/PartyController.php b/app/Http/Controllers/PartyController.php index 400482fee4..3dce9bd2bd 100644 --- a/app/Http/Controllers/PartyController.php +++ b/app/Http/Controllers/PartyController.php @@ -834,16 +834,6 @@ public function deleteEvent($id): RedirectResponse return redirect()->back()->with('warning', __('events.delete_permission')); } - $event = Party::findOrFail($id); - - Audits::where('auditable_type', \App\Models\Party::class)->where('auditable_id', $id)->delete(); - Device::where('event', $id)->delete(); - - // We have to do a loop to avoid the gotcha where bulk delete operations don't invoke observers. - foreach (EventsUsers::where('event', $id)->get() as $delete) { - $delete->delete(); - }; - $event->delete(); event(new EventDeleted($event)); diff --git a/app/Models/Group.php b/app/Models/Group.php index 71caf81ecf..b949d3d555 100644 --- a/app/Models/Group.php +++ b/app/Models/Group.php @@ -13,12 +13,14 @@ use Illuminate\Support\Facades\Lang; use Illuminate\Support\Facades\Log; use OwenIt\Auditing\Contracts\Auditable; +use Illuminate\Database\Eloquent\SoftDeletes; class Group extends Model implements Auditable { use HasFactory; use \OwenIt\Auditing\Auditable; + use SoftDeletes; protected $table = 'groups'; protected $primaryKey = 'idgroups'; @@ -129,6 +131,7 @@ public function findAll() FROM `'.$this->table.'` AS `g` LEFT JOIN `users_groups` AS `ug` ON `g`.`idgroups` = `ug`.`group` LEFT JOIN `users` AS `u` ON `ug`.`user` = `u`.`id` + WHERE `g`.`deleted_at` IS NULL GROUP BY `g`.`idgroups` ORDER BY `g`.`name` ASC'); } catch (\Illuminate\Database\QueryException $e) { @@ -158,6 +161,7 @@ public function findList() ) AS `xi` ON `xi`.`reference` = `g`.`idgroups` + WHERE `g`.`deleted_at` IS NULL GROUP BY `g`.`idgroups` ORDER BY `g`.`name` ASC'); @@ -182,6 +186,7 @@ public function ofThisUser($id) ON `xi`.`reference` = `g`.`idgroups` WHERE `ug`.`user` = :id + AND `g`.`deleted_at` IS NULL ORDER BY `g`.`name` ASC', ['id' => $id]); } diff --git a/database/migrations/2026_02_18_000000_add_soft_deletes_to_groups_table.php b/database/migrations/2026_02_18_000000_add_soft_deletes_to_groups_table.php new file mode 100644 index 0000000000..34b07f54a0 --- /dev/null +++ b/database/migrations/2026_02_18_000000_add_soft_deletes_to_groups_table.php @@ -0,0 +1,22 @@ +softDeletes()->index(); + }); + } + + public function down(): void + { + Schema::table('groups', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + } +}; diff --git a/resources/js/components/StatsImpact.vue b/resources/js/components/StatsImpact.vue index 8bfdccfbbd..c15c06d453 100644 --- a/resources/js/components/StatsImpact.vue +++ b/resources/js/components/StatsImpact.vue @@ -8,7 +8,7 @@
Unarchive - +
@@ -59,6 +63,10 @@ export default { type: Boolean, default: false, }, + deletedFilter: { + type: String, + default: "active", + }, }, methods: { diff --git a/resources/js/components/admin/GroupsManagement.vue b/resources/js/components/admin/GroupsManagement.vue index 2cacd69fce..8f7dc75559 100644 --- a/resources/js/components/admin/GroupsManagement.vue +++ b/resources/js/components/admin/GroupsManagement.vue @@ -11,7 +11,7 @@
- +
@@ -27,6 +27,13 @@ {{ searchResultsText }}
+
+ +
@@ -39,13 +46,13 @@ - + + :sort-field="sortField" :sort-direction="sortDirection" :deleted-filter="deletedFilter" @action="handleAction" + @select="handleGroupSelect" @select-all="handleSelectAll" @page-change="handlePageChange" + @page-size-change="handlePageSizeChange" @sort-change="handleSortChange" />
- {{ group.name }} + {{ group.name }} + {{ group.name }} Archived + Deleted
{{group.networks.map(n => n.name).join(', ')}}
@@ -94,7 +97,7 @@
@@ -214,6 +222,10 @@ export default { type: String, default: "asc", }, + deletedFilter: { + type: String, + default: "active", + }, }, computed: { diff --git a/resources/views/events/view.blade.php b/resources/views/events/view.blade.php index c1d2e9ed59..82d8d9d8e6 100644 --- a/resources/views/events/view.blade.php +++ b/resources/views/events/view.blade.php @@ -64,7 +64,7 @@ idevents); - $can_delete_event = App\Helpers\Fixometer::userHasDeletePartyPermission($event->idevents) && $event->canDelete(); + $can_delete_event = App\Helpers\Fixometer::userHasDeletePartyPermission($event->idevents); $is_admin = Auth::check() && App\Helpers\Fixometer::hasRole(Auth::user(), 'Administrator'); $is_attending = is_object($is_attending) && $is_attending->status == 1; diff --git a/resources/views/group/view.blade.php b/resources/views/group/view.blade.php index 6e7360d7e6..32e57b4c05 100644 --- a/resources/views/group/view.blade.php +++ b/resources/views/group/view.blade.php @@ -45,7 +45,7 @@ $can_edit_group = App\Helpers\Fixometer::hasRole($user, 'Administrator') || $isCoordinatorForGroup || $is_host_of_group; $can_demote = App\Helpers\Fixometer::hasRole($user, 'Administrator') || $isCoordinatorForGroup; $can_see_delete = App\Helpers\Fixometer::hasRole($user, 'Administrator'); - $can_perform_delete = $can_see_delete && $group->canDelete(); + $can_perform_delete = $can_see_delete; $can_perform_archive = App\Helpers\Fixometer::hasRole($user, 'Administrator') || $isCoordinatorForGroup; $showCalendar = Auth::check() && (($group && $group->isVolunteer()) || App\Helpers\Fixometer::hasRole(Auth::user(), 'Administrator')); diff --git a/routes/api.php b/routes/api.php index 83f6d2de20..12ee7df2bf 100644 --- a/routes/api.php +++ b/routes/api.php @@ -162,5 +162,10 @@ Route::post('bulk/{action}', [App\Http\Controllers\API\GroupsController::class, 'performBulkActions']); Route::post('{id}/{action}', [App\Http\Controllers\API\GroupsController::class, 'performSingleAction']); }); + + Route::prefix('events')->group(function () { + Route::get('/', [App\Http\Controllers\API\EventsController::class, 'index']); + Route::post('{id}/{action}', [App\Http\Controllers\API\EventsController::class, 'performSingleAction']); + }); }); }); \ No newline at end of file diff --git a/tests/Feature/Groups/GroupDeleteTest.php b/tests/Feature/Groups/GroupDeleteTest.php index 40e4c302c2..5b710b7a7f 100644 --- a/tests/Feature/Groups/GroupDeleteTest.php +++ b/tests/Feature/Groups/GroupDeleteTest.php @@ -33,6 +33,9 @@ public function testDelete(): void $this->assertStringContainsString(__('groups.delete_succeeded', [ 'name' => $name, ]), $response->getContent()); + + // Verify soft-delete + $this->assertSoftDeleted('groups', ['idgroups' => $id]); } public function testCanDeleteWithEmptyEvent(): void @@ -55,13 +58,15 @@ public function testCanDeleteWithEmptyEvent(): void ]), $response->getContent()); } - public function testCantDeleteWithDevice(): void + public function testCanDeleteWithDevice(): void { $this->loginAsTestUser(Role::ADMINISTRATOR); $id = $this->createGroup(); $this->assertNotNull($id); + $group = Group::where('idgroups', $id)->first(); + $name = $group->name; - // Add an event with a device - should not be able to delete. + // Add an event with a device - soft-delete should work regardless. $idevents = $this->createEvent($id, 'yesterday'); $iddevices = $this->createDevice($idevents, 'misc'); @@ -69,13 +74,18 @@ public function testCantDeleteWithDevice(): void $this->actingAs($user); $this->followingRedirects(); $response = $this->get("/group/delete/$id"); - $this->assertStringContainsString('Sorry, but you do not have the permissions to perform that action.', $response->getContent()); + $this->assertStringContainsString(__('groups.delete_succeeded', [ + 'name' => $name, + ]), $response->getContent()); - // Delete the event - still shouldn't be deletable as a device exists. - Party::find($idevents)->delete(); + // Group should be soft-deleted + $this->assertSoftDeleted('groups', ['idgroups' => $id]); - $response = $this->get("/group/delete/$id"); - $response->assertRedirect('/user/forbidden'); + // Event should also be soft-deleted + $this->assertSoftDeleted('events', ['idevents' => $idevents]); + + // Device should still exist + $this->assertGreaterThan(0, \App\Models\Device::where('event', $idevents)->count()); } public function testCanDeleteWithDeletedEvent(): void @@ -87,7 +97,7 @@ public function testCanDeleteWithDeletedEvent(): void // Create a past event $event = Party::factory()->moderated()->create([ 'event_start_utc' => '2000-01-01T10:15:05+05:00', - 'event_end_utc' => '2000-01-0113:45:05+05:00', + 'event_end_utc' => '2000-01-01 13:45:05+05:00', 'group' => $id, ]); diff --git a/tests/Feature/Groups/GroupSoftDeleteTest.php b/tests/Feature/Groups/GroupSoftDeleteTest.php new file mode 100644 index 0000000000..7606a067e1 --- /dev/null +++ b/tests/Feature/Groups/GroupSoftDeleteTest.php @@ -0,0 +1,211 @@ +loginAsTestUser(Role::ADMINISTRATOR); + $groupId = $this->createGroup(); + $eventId = $this->createEvent($groupId, 'yesterday'); + $deviceId = $this->createDevice($eventId, 'misc'); + + $deviceCountBefore = Device::where('event', $eventId)->count(); + $this->assertGreaterThan(0, $deviceCountBefore); + + // Delete the event via the web route + $admin = User::factory()->administrator()->create(); + $this->actingAs($admin); + $response = $this->post("/party/delete/{$eventId}"); + + // Event should be soft-deleted + $this->assertSoftDeleted('events', ['idevents' => $eventId]); + + // Devices should still exist + $deviceCountAfter = Device::where('event', $eventId)->count(); + $this->assertEquals($deviceCountBefore, $deviceCountAfter); + + // Event should be hidden from default queries + $this->assertNull(Party::find($eventId)); + + // But accessible with withTrashed + $this->assertNotNull(Party::withTrashed()->find($eventId)); + } + + public function testGroupSoftDeleteCascadesToEvents(): void + { + $this->loginAsTestUser(Role::ADMINISTRATOR); + $groupId = $this->createGroup(); + $eventId1 = $this->createEvent($groupId, 'yesterday'); + $eventId2 = $this->createEvent($groupId, 'tomorrow'); + $deviceId = $this->createDevice($eventId1, 'misc'); + + $admin = User::factory()->administrator()->create(); + $this->actingAs($admin); + + // Delete the group + $response = $this->get("/group/delete/{$groupId}"); + $response->assertRedirect(); + + // Group should be soft-deleted + $this->assertSoftDeleted('groups', ['idgroups' => $groupId]); + + // All events should be soft-deleted + $this->assertSoftDeleted('events', ['idevents' => $eventId1]); + $this->assertSoftDeleted('events', ['idevents' => $eventId2]); + + // Devices should be unchanged + $this->assertGreaterThan(0, Device::where('event', $eventId1)->count()); + } + + public function testAdminApiGroupSoftDelete(): void + { + $admin = User::factory()->administrator()->create(); + $this->actingAs($admin); + + $group = Group::factory()->create(); + $event = Party::factory()->create(['group' => $group->idgroups]); + $deviceId = $this->createDevice($event->idevents, 'misc'); + + // Soft-delete via admin API - should work even with devices + $response = $this->post("/api/v2/admin/groups/{$group->idgroups}/delete"); + $response->assertJson(['success' => true]); + + // Group should be soft-deleted + $this->assertSoftDeleted('groups', ['idgroups' => $group->idgroups]); + + // Event should be soft-deleted + $this->assertSoftDeleted('events', ['idevents' => $event->idevents]); + + // Devices should still exist + $this->assertGreaterThan(0, Device::where('event', $event->idevents)->count()); + } + + public function testAdminApiGroupRestore(): void + { + $admin = User::factory()->administrator()->create(); + $this->actingAs($admin); + + $group = Group::factory()->create(); + $event1 = Party::factory()->create(['group' => $group->idgroups]); + $event2 = Party::factory()->create(['group' => $group->idgroups]); + + // Soft-delete the group + $this->post("/api/v2/admin/groups/{$group->idgroups}/delete"); + $this->assertSoftDeleted('groups', ['idgroups' => $group->idgroups]); + + // Restore the group + $response = $this->post("/api/v2/admin/groups/{$group->idgroups}/restore"); + $response->assertJson(['success' => true]); + + // Group should be restored + $group->refresh(); + $this->assertNull($group->deleted_at); + + // Events should be restored + $event1->refresh(); + $event2->refresh(); + $this->assertNull($event1->deleted_at); + $this->assertNull($event2->deleted_at); + } + + public function testAdminApiEventRestoreWithDeletedGroupReturns409(): void + { + $admin = User::factory()->administrator()->create(); + $this->actingAs($admin); + + $group = Group::factory()->create(); + $event = Party::factory()->create(['group' => $group->idgroups]); + + // Soft-delete the group (cascades to events) + $this->post("/api/v2/admin/groups/{$group->idgroups}/delete"); + + // Try to restore the event while group is deleted + $response = $this->post("/api/v2/admin/events/{$event->idevents}/restore"); + $response->assertStatus(409); + $response->assertJson(['success' => false]); + } + + public function testAdminApiEventRestoreAfterGroupRestore(): void + { + $admin = User::factory()->administrator()->create(); + $this->actingAs($admin); + + $group = Group::factory()->create(); + $event = Party::factory()->create(['group' => $group->idgroups]); + + // Soft-delete the group (cascades to events) + $this->post("/api/v2/admin/groups/{$group->idgroups}/delete"); + + // Restore the group first + $this->post("/api/v2/admin/groups/{$group->idgroups}/restore"); + + // Now event should already be restored (group restore cascades) + $event->refresh(); + $this->assertNull($event->deleted_at); + } + + public function testDeletedGroupsHiddenFromDefaultQueries(): void + { + $admin = User::factory()->administrator()->create(); + $this->actingAs($admin); + + $group = Group::factory()->create(); + $groupId = $group->idgroups; + + // Visible before deletion + $this->assertNotNull(Group::find($groupId)); + + // Soft-delete + $this->post("/api/v2/admin/groups/{$groupId}/delete"); + + // Hidden from default queries + $this->assertNull(Group::find($groupId)); + + // Visible with withTrashed + $this->assertNotNull(Group::withTrashed()->find($groupId)); + } + + public function testAdminApiDeletedFilter(): void + { + $admin = User::factory()->administrator()->create(); + $this->actingAs($admin); + + $activeGroup = Group::factory()->create(['name' => 'Active Group']); + $deletedGroup = Group::factory()->create(['name' => 'Deleted Group']); + + // Soft-delete one group + $this->post("/api/v2/admin/groups/{$deletedGroup->idgroups}/delete"); + + // Default (active) - should only show active group + $response = $this->get('/api/v2/admin/groups'); + $response->assertJson(['success' => true]); + $data = $response->json('data'); + $names = collect($data)->pluck('name')->toArray(); + $this->assertContains('Active Group', $names); + $this->assertNotContains('Deleted Group', $names); + + // Only deleted - should only show deleted group + $response = $this->get('/api/v2/admin/groups?deleted=only'); + $data = $response->json('data'); + $names = collect($data)->pluck('name')->toArray(); + $this->assertNotContains('Active Group', $names); + $this->assertContains('Deleted Group', $names); + + // All - should show both + $response = $this->get('/api/v2/admin/groups?deleted=all'); + $data = $response->json('data'); + $names = collect($data)->pluck('name')->toArray(); + $this->assertContains('Active Group', $names); + $this->assertContains('Deleted Group', $names); + } +}