From 671e0c98892e319d18c9e1c2c361bf133f468d3e Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 17 Apr 2026 10:27:37 +0200 Subject: [PATCH] feat(organisation): add dashboard-stats endpoint GET /organisations/{organisation}/dashboard-stats returns members, events (with status breakdown + active count), persons, the first five members sorted by join date, and the five most recent activity log entries. Business logic lives in OrganisationDashboardService; access follows OrganisationPolicy@view. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Api/V1/OrganisationController.php | 15 ++ .../V1/OrganisationDashboardStatsResource.php | 27 +++ .../Services/OrganisationDashboardService.php | 138 +++++++++++++++ api/routes/api.php | 1 + .../OrganisationDashboardStatsTest.php | 160 ++++++++++++++++++ 5 files changed, 341 insertions(+) create mode 100644 api/app/Http/Resources/Api/V1/OrganisationDashboardStatsResource.php create mode 100644 api/app/Services/OrganisationDashboardService.php create mode 100644 api/tests/Feature/Organisation/OrganisationDashboardStatsTest.php diff --git a/api/app/Http/Controllers/Api/V1/OrganisationController.php b/api/app/Http/Controllers/Api/V1/OrganisationController.php index b1e85ddf..19285c54 100644 --- a/api/app/Http/Controllers/Api/V1/OrganisationController.php +++ b/api/app/Http/Controllers/Api/V1/OrganisationController.php @@ -7,14 +7,20 @@ namespace App\Http\Controllers\Api\V1; use App\Http\Controllers\Controller; use App\Http\Requests\Api\V1\StoreOrganisationRequest; use App\Http\Requests\Api\V1\UpdateOrganisationRequest; +use App\Http\Resources\Api\V1\OrganisationDashboardStatsResource; use App\Http\Resources\Api\V1\OrganisationResource; use App\Models\Organisation; +use App\Services\OrganisationDashboardService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Support\Facades\Gate; final class OrganisationController extends Controller { + public function __construct( + private readonly OrganisationDashboardService $dashboardService, + ) {} + public function index(): AnonymousResourceCollection { Gate::authorize('viewAny', Organisation::class); @@ -54,4 +60,13 @@ final class OrganisationController extends Controller return $this->success(new OrganisationResource($organisation->fresh())); } + + public function dashboardStats(Organisation $organisation): JsonResponse + { + Gate::authorize('view', $organisation); + + return $this->success(new OrganisationDashboardStatsResource( + $this->dashboardService->statsFor($organisation), + )); + } } diff --git a/api/app/Http/Resources/Api/V1/OrganisationDashboardStatsResource.php b/api/app/Http/Resources/Api/V1/OrganisationDashboardStatsResource.php new file mode 100644 index 00000000..2384bbd7 --- /dev/null +++ b/api/app/Http/Resources/Api/V1/OrganisationDashboardStatsResource.php @@ -0,0 +1,27 @@ + $resource + */ +final class OrganisationDashboardStatsResource extends JsonResource +{ + public function toArray(Request $request): array + { + return [ + 'members_count' => $this->resource['members_count'], + 'events_count' => $this->resource['events_count'], + 'events_by_status' => $this->resource['events_by_status'], + 'active_events_count' => $this->resource['active_events_count'], + 'persons_count' => $this->resource['persons_count'], + 'top_members' => $this->resource['top_members'], + 'recent_activity' => $this->resource['recent_activity'], + ]; + } +} diff --git a/api/app/Services/OrganisationDashboardService.php b/api/app/Services/OrganisationDashboardService.php new file mode 100644 index 00000000..35491007 --- /dev/null +++ b/api/app/Services/OrganisationDashboardService.php @@ -0,0 +1,138 @@ + */ + public function statsFor(Organisation $organisation): array + { + return [ + 'members_count' => $this->membersCount($organisation), + 'events_count' => $this->eventsCount($organisation), + 'events_by_status' => $this->eventsByStatus($organisation), + 'active_events_count' => $this->activeEventsCount($organisation), + 'persons_count' => $this->personsCount($organisation), + 'top_members' => $this->topMembers($organisation), + 'recent_activity' => $this->recentActivity($organisation), + ]; + } + + private function membersCount(Organisation $organisation): int + { + return $organisation->users()->count(); + } + + private function eventsCount(Organisation $organisation): int + { + return Event::withoutGlobalScope(OrganisationScope::class) + ->where('organisation_id', $organisation->id) + ->count(); + } + + /** @return array */ + private function eventsByStatus(Organisation $organisation): array + { + $counts = Event::withoutGlobalScope(OrganisationScope::class) + ->where('organisation_id', $organisation->id) + ->selectRaw('status, COUNT(*) as total') + ->groupBy('status') + ->pluck('total', 'status') + ->map(fn ($count) => (int) $count) + ->toArray(); + + $result = []; + foreach (self::STATUSES as $status) { + $result[$status] = $counts[$status] ?? 0; + } + + return $result; + } + + private function activeEventsCount(Organisation $organisation): int + { + return Event::withoutGlobalScope(OrganisationScope::class) + ->where('organisation_id', $organisation->id) + ->whereIn('status', self::ACTIVE_STATUSES) + ->count(); + } + + private function personsCount(Organisation $organisation): int + { + return Person::withoutGlobalScope(OrganisationScope::class) + ->whereIn( + 'event_id', + Event::withoutGlobalScope(OrganisationScope::class) + ->where('organisation_id', $organisation->id) + ->select('id') + ) + ->count(); + } + + /** @return array> */ + private function topMembers(Organisation $organisation): array + { + return $organisation->users() + ->orderBy('organisation_user.created_at', 'asc') + ->limit(5) + ->get() + ->map(fn ($user) => [ + 'id' => $user->id, + 'name' => $user->full_name, + 'email' => $user->email, + 'avatar_url' => $user->avatar, + 'role' => $user->pivot?->role, + 'joined_at' => $user->pivot?->created_at?->toIso8601String(), + ]) + ->all(); + } + + /** @return array> */ + private function recentActivity(Organisation $organisation): array + { + return Activity::query() + ->where('subject_type', Organisation::class) + ->where('subject_id', $organisation->id) + ->with('causer') + ->latest() + ->limit(5) + ->get() + ->map(function (Activity $activity): array { + /** @var \App\Models\User|null $causer */ + $causer = $activity->causer; + + return [ + 'id' => $activity->id, + 'description' => $activity->description, + 'causer_name' => $causer?->full_name, + 'causer_avatar_url' => $causer?->avatar, + 'subject_type' => $activity->subject_type ? class_basename($activity->subject_type) : null, + 'subject_id' => $activity->subject_id, + 'properties' => $activity->attribute_changes?->toArray() ?? [], + 'created_at' => $activity->created_at?->toIso8601String(), + ]; + }) + ->all(); + } +} diff --git a/api/routes/api.php b/api/routes/api.php index a00ce9e6..1e28bb8e 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -168,6 +168,7 @@ Route::middleware(['auth:sanctum', 'impersonation'])->group(function () { // Organisations Route::apiResource('organisations', OrganisationController::class) ->only(['index', 'show', 'store', 'update']); + Route::get('organisations/{organisation}/dashboard-stats', [OrganisationController::class, 'dashboardStats']); // Events (nested under organisations) Route::apiResource('organisations.events', EventController::class) diff --git a/api/tests/Feature/Organisation/OrganisationDashboardStatsTest.php b/api/tests/Feature/Organisation/OrganisationDashboardStatsTest.php new file mode 100644 index 00000000..fc6eb411 --- /dev/null +++ b/api/tests/Feature/Organisation/OrganisationDashboardStatsTest.php @@ -0,0 +1,160 @@ +seed(RoleSeeder::class); + } + + public function test_unauthenticated_request_returns_401(): void + { + $org = Organisation::factory()->create(); + + $response = $this->getJson("/api/v1/organisations/{$org->id}/dashboard-stats"); + + $response->assertUnauthorized(); + } + + public function test_member_from_other_org_returns_403(): void + { + $user = User::factory()->create(); + $otherOrg = Organisation::factory()->create(); + + Sanctum::actingAs($user); + + $response = $this->getJson("/api/v1/organisations/{$otherOrg->id}/dashboard-stats"); + + $response->assertForbidden(); + } + + public function test_authenticated_member_can_fetch_dashboard_stats(): void + { + $user = User::factory()->create(); + $org = Organisation::factory()->create(); + $org->users()->attach($user, ['role' => 'org_member']); + + Sanctum::actingAs($user); + + $response = $this->getJson("/api/v1/organisations/{$org->id}/dashboard-stats"); + + $response->assertOk() + ->assertJsonStructure([ + 'data' => [ + 'members_count', + 'events_count', + 'events_by_status', + 'active_events_count', + 'persons_count', + 'top_members', + 'recent_activity', + ], + ]); + } + + public function test_stats_return_correct_counts(): void + { + $user = User::factory()->create(); + $org = Organisation::factory()->create(); + $org->users()->attach($user, ['role' => 'org_admin']); + + // 2 extra members -> 3 total + $org->users()->attach(User::factory()->create(), ['role' => 'org_member']); + $org->users()->attach(User::factory()->create(), ['role' => 'org_member']); + + // Events: 2 draft, 1 published, 1 buildup, 1 closed = 5 total, 2 active (published + buildup) + Event::factory()->count(2)->create(['organisation_id' => $org->id, 'status' => 'draft']); + $published = Event::factory()->create(['organisation_id' => $org->id, 'status' => 'published']); + Event::factory()->create(['organisation_id' => $org->id, 'status' => 'buildup']); + Event::factory()->create(['organisation_id' => $org->id, 'status' => 'closed']); + + // 3 persons in the published event + Person::factory()->count(3)->create(['event_id' => $published->id]); + + // Noise: another org with its own data — should be filtered out + $otherOrg = Organisation::factory()->create(); + $otherOrg->users()->attach(User::factory()->create(), ['role' => 'org_admin']); + $otherEvent = Event::factory()->create(['organisation_id' => $otherOrg->id, 'status' => 'published']); + Person::factory()->create(['event_id' => $otherEvent->id]); + + Sanctum::actingAs($user); + + $response = $this->getJson("/api/v1/organisations/{$org->id}/dashboard-stats"); + + $response->assertOk(); + $data = $response->json('data'); + + $this->assertSame(3, $data['members_count']); + $this->assertSame(5, $data['events_count']); + $this->assertSame(2, $data['active_events_count']); + $this->assertSame(3, $data['persons_count']); + $this->assertSame(2, $data['events_by_status']['draft']); + $this->assertSame(1, $data['events_by_status']['published']); + $this->assertSame(1, $data['events_by_status']['buildup']); + $this->assertSame(1, $data['events_by_status']['closed']); + $this->assertSame(0, $data['events_by_status']['registration_open']); + } + + public function test_top_members_limited_to_five_and_ordered_by_joined_at(): void + { + $user = User::factory()->create(); + $org = Organisation::factory()->create(); + + // Attach user first with the earliest join timestamp + $org->users()->attach($user, ['role' => 'org_admin']); + + // Attach 6 more members (total 7) + for ($i = 0; $i < 6; $i++) { + $org->users()->attach(User::factory()->create(), ['role' => 'org_member']); + } + + Sanctum::actingAs($user); + + $response = $this->getJson("/api/v1/organisations/{$org->id}/dashboard-stats"); + + $response->assertOk(); + $members = $response->json('data.top_members'); + + $this->assertCount(5, $members); + $this->assertSame($user->id, $members[0]['id']); + $this->assertSame('org_admin', $members[0]['role']); + } + + public function test_recent_activity_limited_to_five_entries(): void + { + $user = User::factory()->create(); + $org = Organisation::factory()->create(); + $org->users()->attach($user, ['role' => 'org_admin']); + + Activity::query()->delete(); + + // Trigger 7 activity log entries on the organisation + for ($i = 0; $i < 7; $i++) { + $org->update(['name' => "Changed {$i}"]); + } + + Sanctum::actingAs($user); + + $response = $this->getJson("/api/v1/organisations/{$org->id}/dashboard-stats"); + + $response->assertOk(); + $this->assertCount(5, $response->json('data.recent_activity')); + } +}