From 649598139baf00f2546794f07c95613a1a142a00 Mon Sep 17 00:00:00 2001 From: Angel de la Torre Date: Wed, 18 Feb 2026 15:05:44 -0800 Subject: [PATCH 1/9] feat(db): add soft deletes migration for groups table Add a `deleted_at` timestamp column (with index) to the `groups` table to support Laravel's SoftDeletes functionality. Co-Authored-By: Claude Opus 4.6 --- ...00000_add_soft_deletes_to_groups_table.php | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 database/migrations/2026_02_18_000000_add_soft_deletes_to_groups_table.php 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(); + }); + } +}; From 33202f81461e0df90e7c318340aab125ed36982a Mon Sep 17 00:00:00 2001 From: Angel de la Torre Date: Wed, 18 Feb 2026 15:06:00 -0800 Subject: [PATCH 2/9] feat(model): add SoftDeletes trait to Group model Enable soft-delete support on the Group model by adding Laravel's SoftDeletes trait. Also update raw SQL queries in findAllGroups(), findAll(), and findAllByUserId() to filter out soft-deleted groups with `WHERE g.deleted_at IS NULL`. Co-Authored-By: Claude Opus 4.6 --- app/Models/Group.php | 5 +++++ 1 file changed, 5 insertions(+) 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]); } From 58c2603a40bc24a5189723d36af3d1433fc637de Mon Sep 17 00:00:00 2001 From: Angel de la Torre Date: Wed, 18 Feb 2026 15:06:18 -0800 Subject: [PATCH 3/9] refactor(group): replace hard-delete with soft-delete in GroupController MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplify deleteGroup() to use Laravel's soft-delete instead of manually hard-deleting events, event-users, and the group. Now soft-deletes all group events first, then soft-deletes the group itself — preserving devices and volunteer data for potential restoration. Also remove the canDelete() gate from the group view blade template so admins can always delete groups regardless of whether events have devices. Co-Authored-By: Claude Opus 4.6 --- app/Http/Controllers/GroupController.php | 37 +++++++++--------------- resources/views/group/view.blade.php | 2 +- 2 files changed, 14 insertions(+), 25 deletions(-) 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/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')); From f6e4defdcf7188d1190fcd46a134174aa73e1a19 Mon Sep 17 00:00:00 2001 From: Angel de la Torre Date: Wed, 18 Feb 2026 15:06:36 -0800 Subject: [PATCH 4/9] refactor(event): remove destructive hard-delete of devices and associations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop hard-deleting audit records, devices, and event-user associations when an event is deleted. Since Party already uses SoftDeletes, calling $event->delete() is sufficient — it soft-deletes the event while preserving all related data for potential restoration. Also remove the canDelete() gate from the event view blade template so authorized users can delete events regardless of whether they have devices. Co-Authored-By: Claude Opus 4.6 --- app/Http/Controllers/PartyController.php | 10 ---------- resources/views/events/view.blade.php | 2 +- 2 files changed, 1 insertion(+), 11 deletions(-) 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/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; From f38cf717b8932a3fe0983862cb514a6ac57f3054 Mon Sep 17 00:00:00 2001 From: Angel de la Torre Date: Wed, 18 Feb 2026 15:06:54 -0800 Subject: [PATCH 5/9] feat(api): add soft-delete, restore, and deleted filter to groups admin API Update the groups admin API to support soft-delete workflows: - Add `deleted` query parameter (active/only/all) to filter groups - Use withTrashed() when performing single/bulk actions so soft-deleted groups can be targeted - Replace hard-delete with soft-delete in the `delete` action, cascading to group events - Add `restore` action that restores a group and its soft-deleted events - Include `deleted_at` in the group API response - Add admin events API routes for event listing and restore Co-Authored-By: Claude Opus 4.6 --- app/Http/Controllers/API/GroupsController.php | 42 +++++++++++++++---- routes/api.php | 5 +++ 2 files changed, 39 insertions(+), 8 deletions(-) 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/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 From 48e6f9468ff8de6cc140d494a1b308517bf5ecc8 Mon Sep 17 00:00:00 2001 From: Angel de la Torre Date: Wed, 18 Feb 2026 15:07:18 -0800 Subject: [PATCH 6/9] feat(api): add EventsController for event listing and restore Add a new admin API controller for events that supports: - Listing events with search, sorting, pagination, and deleted filter - Restoring soft-deleted events with a guard that prevents restoring an event whose parent group is still deleted (returns 409) Co-Authored-By: Claude Opus 4.6 --- app/Http/Controllers/API/EventsController.php | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 app/Http/Controllers/API/EventsController.php 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.", + ]; + } +} From 6af44e4988186e95ac715e1fb5af84d38adb9055 Mon Sep 17 00:00:00 2001 From: Angel de la Torre Date: Wed, 18 Feb 2026 15:07:35 -0800 Subject: [PATCH 7/9] feat(admin-ui): add deleted groups filter and restore action to admin Vue components Update the admin groups management UI to support soft-delete workflows: - GroupsManagement: add a dropdown filter for Active/Deleted/All groups, pass deleted filter state to child components - GroupsTable: show "Deleted" badge on soft-deleted groups, disable edit link for deleted groups, conditionally show delete/restore in dropdown - GroupsBulkAction: show Restore button when viewing deleted groups, hide Delete button when viewing only deleted groups - ConfirmationModal: add restore action with success styling and text Co-Authored-By: Claude Opus 4.6 --- .../js/components/admin/ConfirmationModal.vue | 5 ++- .../js/components/admin/GroupsBulkAction.vue | 12 +++++-- .../js/components/admin/GroupsManagement.vue | 31 +++++++++++++++---- resources/js/components/admin/GroupsTable.vue | 18 +++++++++-- 4 files changed, 54 insertions(+), 12 deletions(-) diff --git a/resources/js/components/admin/ConfirmationModal.vue b/resources/js/components/admin/ConfirmationModal.vue index a3775df14d..dd07cac9e7 100644 --- a/resources/js/components/admin/ConfirmationModal.vue +++ b/resources/js/components/admin/ConfirmationModal.vue @@ -76,8 +76,9 @@ export default { unapprove: "Unapproval", archive: "Archiving", unarchive: "Unarchiving", + restore: "Restoration", }; - return `Confirm ${actions[this.action]}` || "Confirm Action"; + return `Confirm ${actions[this.action] || 'Action'}`; }, confirmButtonClass() { @@ -87,6 +88,7 @@ export default { unapprove: "btn btn-warning", archive: "btn btn-info", unarchive: "btn-outline-info", + restore: "btn btn-success", }; return classes[this.action] || "btn btn-primary"; }, @@ -98,6 +100,7 @@ export default { unapprove: "Unapprove", archive: "Archive", unarchive: "Unarchive", + restore: "Restore", }; return texts[this.action] }, diff --git a/resources/js/components/admin/GroupsBulkAction.vue b/resources/js/components/admin/GroupsBulkAction.vue index 7283d6d4d1..432f32b404 100644 --- a/resources/js/components/admin/GroupsBulkAction.vue +++ b/resources/js/components/admin/GroupsBulkAction.vue @@ -26,10 +26,14 @@ :disabled="loading"> 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: { From 802acf20126d24b28d6bb0ce70bec6979efee6dd Mon Sep 17 00:00:00 2001 From: Angel de la Torre Date: Wed, 18 Feb 2026 15:07:53 -0800 Subject: [PATCH 8/9] test: update and add soft-delete tests for groups and events Update GroupDeleteTest to verify soft-delete behavior: - testCanDeleteGroup now asserts assertSoftDeleted - testCantDeleteWithDevice renamed to testCanDeleteWithDevice since soft-delete works regardless of devices - Fix a malformed date in testCanDeleteWithDeletedEvent Add GroupSoftDeleteTest with comprehensive coverage: - Event soft-delete retains devices - Group soft-delete cascades to events - Admin API group soft-delete and restore - Event restore blocked when parent group is deleted (409) - Deleted groups hidden from default queries - Admin API deleted filter (active/only/all) Co-Authored-By: Claude Opus 4.6 --- tests/Feature/Groups/GroupDeleteTest.php | 26 ++- tests/Feature/Groups/GroupSoftDeleteTest.php | 211 +++++++++++++++++++ 2 files changed, 229 insertions(+), 8 deletions(-) create mode 100644 tests/Feature/Groups/GroupSoftDeleteTest.php 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); + } +} From e97a026446d205c514e9f63f8c7ccc1991168667 Mon Sep 17 00:00:00 2001 From: Angel de la Torre Date: Wed, 18 Feb 2026 15:08:08 -0800 Subject: [PATCH 9/9] fix(stats): use Math.round for waste total and add null guards to StatsValue Change Math.ceil to Math.round for waste_total display in StatsImpact for more accurate rounding. Add null checks in StatsValue computed properties (translatedTitle, translatedSubtitle, translatedDescription) to prevent errors when optional props are not provided. Co-Authored-By: Claude Opus 4.6 --- resources/js/components/StatsImpact.vue | 2 +- resources/js/components/StatsValue.vue | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) 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 @@