diff --git a/api/app/Http/Controllers/Api/V1/CrowdListController.php b/api/app/Http/Controllers/Api/V1/CrowdListController.php index 17153df4..00124ebe 100644 --- a/api/app/Http/Controllers/Api/V1/CrowdListController.php +++ b/api/app/Http/Controllers/Api/V1/CrowdListController.php @@ -13,6 +13,7 @@ use App\Models\CrowdList; use App\Models\Event; use App\Models\Person; use App\Services\CrowdListService; +use App\Http\Resources\Api\V1\PersonResource; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Support\Facades\Gate; @@ -62,6 +63,17 @@ final class CrowdListController extends Controller return response()->json(null, 204); } + public function persons(Event $event, CrowdList $crowdList): AnonymousResourceCollection + { + Gate::authorize('viewPersons', [$crowdList, $event]); + + $persons = $crowdList->persons() + ->with(['crowdType', 'company', 'pendingIdentityMatch.matchedUser']) + ->paginate(50); + + return PersonResource::collection($persons); + } + public function addPerson(AddPersonToCrowdListRequest $request, Event $event, CrowdList $crowdList): JsonResponse { Gate::authorize('managePerson', [$crowdList, $event]); diff --git a/api/app/Http/Resources/Api/V1/PersonResource.php b/api/app/Http/Resources/Api/V1/PersonResource.php index 54eeacfb..848ed5cb 100644 --- a/api/app/Http/Resources/Api/V1/PersonResource.php +++ b/api/app/Http/Resources/Api/V1/PersonResource.php @@ -41,6 +41,13 @@ final class PersonResource extends JsonResource ]; } ), + 'crowd_list_pivot' => $this->when( + $this->pivot && $this->pivot->added_at, + fn () => [ + 'added_at' => $this->pivot->added_at, + 'added_by_user_id' => $this->pivot->added_by_user_id, + ] + ), 'tags' => $this->when( $this->user_id && $this->relationLoaded('user'), function () { diff --git a/api/app/Policies/CrowdListPolicy.php b/api/app/Policies/CrowdListPolicy.php index 46d38e02..6335b011 100644 --- a/api/app/Policies/CrowdListPolicy.php +++ b/api/app/Policies/CrowdListPolicy.php @@ -39,6 +39,16 @@ final class CrowdListPolicy return $this->canManageEvent($user, $event); } + public function viewPersons(User $user, CrowdList $crowdList, Event $event): bool + { + if ($crowdList->event_id !== $event->id) { + return false; + } + + return $user->hasRole('super_admin') + || $event->organisation->users()->where('user_id', $user->id)->exists(); + } + public function managePerson(User $user, CrowdList $crowdList, Event $event): bool { if ($crowdList->event_id !== $event->id) { diff --git a/api/routes/api.php b/api/routes/api.php index b4cbb1be..3d861f06 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -132,6 +132,7 @@ Route::middleware('auth:sanctum')->group(function () { Route::post('persons/{person}/approve', [PersonController::class, 'approve']); Route::apiResource('crowd-lists', CrowdListController::class) ->except(['show']); + Route::get('crowd-lists/{crowdList}/persons', [CrowdListController::class, 'persons']); Route::post('crowd-lists/{crowdList}/persons', [CrowdListController::class, 'addPerson']); Route::delete('crowd-lists/{crowdList}/persons/{person}', [CrowdListController::class, 'removePerson']); }); diff --git a/api/tests/Feature/CrowdList/CrowdListTest.php b/api/tests/Feature/CrowdList/CrowdListTest.php index 1641a0c5..32ad6265 100644 --- a/api/tests/Feature/CrowdList/CrowdListTest.php +++ b/api/tests/Feature/CrowdList/CrowdListTest.php @@ -377,6 +377,88 @@ class CrowdListTest extends TestCase $this->assertEquals(3, $listData['persons_count']); } + // ---- Persons Listing Tests ---- + + public function test_can_list_persons_in_crowd_list(): void + { + $crowdList = CrowdList::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ]); + + $persons = Person::factory()->count(3)->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ]); + + foreach ($persons as $person) { + $crowdList->persons()->attach($person->id, [ + 'added_at' => now()->toDateTimeString(), + 'added_by_user_id' => $this->orgAdmin->id, + ]); + } + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/events/{$this->event->id}/crowd-lists/{$crowdList->id}/persons"); + + $response->assertOk(); + $this->assertCount(3, $response->json('data')); + + // Verify pivot data is present + $firstPerson = $response->json('data.0'); + $this->assertArrayHasKey('crowd_list_pivot', $firstPerson); + $this->assertNotNull($firstPerson['crowd_list_pivot']['added_at']); + $this->assertEquals($this->orgAdmin->id, $firstPerson['crowd_list_pivot']['added_by_user_id']); + } + + public function test_persons_list_is_paginated(): void + { + $crowdList = CrowdList::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ]); + + $persons = Person::factory()->count(3)->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ]); + + foreach ($persons as $person) { + $crowdList->persons()->attach($person->id, [ + 'added_at' => now()->toDateTimeString(), + 'added_by_user_id' => $this->orgAdmin->id, + ]); + } + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/events/{$this->event->id}/crowd-lists/{$crowdList->id}/persons"); + + $response->assertOk(); + $response->assertJsonStructure([ + 'data', + 'links', + 'meta' => ['current_page', 'per_page', 'total', 'last_page'], + ]); + $this->assertEquals(3, $response->json('meta.total')); + $this->assertEquals(50, $response->json('meta.per_page')); + } + + public function test_cross_org_cannot_list_crowd_list_persons(): void + { + $crowdList = CrowdList::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ]); + + Sanctum::actingAs($this->outsider); + + $response = $this->getJson("/api/v1/events/{$this->event->id}/crowd-lists/{$crowdList->id}/persons"); + + $response->assertForbidden(); + } + // ---- Authorization Tests ---- public function test_cross_org_cannot_access_crowd_lists(): void diff --git a/dev-docs/API.md b/dev-docs/API.md index 0c7863a2..ae275097 100644 --- a/dev-docs/API.md +++ b/dev-docs/API.md @@ -167,6 +167,7 @@ Response: `{ "confirmed": 2, "errors": [{ "match_id": "ulid3", "error": "User al - `POST /events/{event}/crowd-lists` — create crowd list - `PUT /events/{event}/crowd-lists/{list}` — update crowd list - `DELETE /events/{event}/crowd-lists/{list}` — delete crowd list +- `GET /events/{event}/crowd-lists/{list}/persons` — list persons on a crowd list (paginated, 50/page, includes `crowd_list_pivot`) - `POST /events/{event}/crowd-lists/{list}/persons` — add person to list - `DELETE /events/{event}/crowd-lists/{list}/persons/{person}` — remove person from list