Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions app/Http/Controllers/API/EventsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
<?php

namespace App\Http\Controllers\API;

use App\Http\Controllers\Controller;
use App\Models\Group;
use App\Models\Party;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;

class EventsController extends Controller
{
public function index(Request $request): JsonResponse
{
try {
$query = Party::with(['theGroup']);

// Apply search filter
if ($request->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.",
];
}
}
42 changes: 34 additions & 8 deletions app/Http/Controllers/API/GroupsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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);

Expand All @@ -98,16 +109,16 @@ 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);
}
}

public static function performBulkActions(Request $request, string $action): JsonResponse
{
try {
$group_ids = $request->input('group_ids');
$groups = Group::whereIn('idgroups', $group_ids)->get();
$groups = Group::withTrashed()->whereIn('idgroups', $group_ids)->get();

$failedGroups = [];

Expand Down Expand Up @@ -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}");
}
Expand Down Expand Up @@ -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,
Expand Down
37 changes: 13 additions & 24 deletions app/Http/Controllers/GroupController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down
10 changes: 0 additions & 10 deletions app/Http/Controllers/PartyController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
5 changes: 5 additions & 0 deletions app/Models/Group.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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');
Expand All @@ -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]);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::table('groups', function (Blueprint $table) {
$table->softDeletes()->index();
});
}

public function down(): void
{
Schema::table('groups', function (Blueprint $table) {
$table->dropSoftDeletes();
});
}
};
2 changes: 1 addition & 1 deletion resources/js/components/StatsImpact.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
</h2>
<div class="impact-container">
<StatsValue
:count="Math.ceil(stats.waste_total)"
:count="Math.round(stats.waste_total)"
icon="trash"
size="md"
title="partials.waste_prevented"
Expand Down
12 changes: 12 additions & 0 deletions resources/js/components/StatsValue.vue
Original file line number Diff line number Diff line change
Expand Up @@ -130,12 +130,24 @@ export default {
return 'impact-stat impact-stat-' + this.size + ' impact-stat-' + this.variant + (this.border ? ' hasBorder' : '')
},
translatedTitle() {
if (!this.title) {
return ''
}

return this.translate ? this.$lang.choice(this.title, this.roundedCount) : this.title
},
translatedSubtitle() {
if (!this.subtitle) {
return ''
}

return this.translate ? this.$lang.get(this.subtitle) : this.subtitle
},
translatedDescription() {
if (!this.description) {
return ''
}

return this.translate ? this.$lang.get(this.description) : this.description
},
roundedCount() {
Expand Down
Loading