diff --git a/api/app/Enums/CrowdListType.php b/api/app/Enums/CrowdListType.php new file mode 100644 index 00000000..ac3c1c34 --- /dev/null +++ b/api/app/Enums/CrowdListType.php @@ -0,0 +1,11 @@ +crowdLists()->withCount('persons')->get(); + $crowdLists = $event->crowdLists() + ->with(['crowdType', 'recipientCompany']) + ->withCount('persons') + ->get(); return CrowdListResource::collection($crowdLists); } @@ -31,43 +39,36 @@ final class CrowdListController extends Controller { Gate::authorize('create', [CrowdList::class, $event]); - $crowdList = $event->crowdLists()->create($request->validated()); + $crowdList = $this->crowdListService->create($event, $request->validated(), $request->user()); - return $this->created(new CrowdListResource($crowdList)); + return $this->created(new CrowdListResource($crowdList->loadCount('persons'))); } public function update(UpdateCrowdListRequest $request, Event $event, CrowdList $crowdList): JsonResponse { Gate::authorize('update', [$crowdList, $event]); - $crowdList->update($request->validated()); + $crowdList = $this->crowdListService->update($crowdList, $request->validated(), $request->user()); - return $this->success(new CrowdListResource($crowdList->fresh())); + return $this->success(new CrowdListResource($crowdList->loadCount('persons'))); } public function destroy(Event $event, CrowdList $crowdList): JsonResponse { Gate::authorize('delete', [$crowdList, $event]); - $crowdList->delete(); + $this->crowdListService->delete($crowdList, request()->user()); return response()->json(null, 204); } - public function addPerson(Request $request, Event $event, CrowdList $crowdList): JsonResponse + public function addPerson(AddPersonToCrowdListRequest $request, Event $event, CrowdList $crowdList): JsonResponse { Gate::authorize('managePerson', [$crowdList, $event]); - $validated = $request->validate([ - 'person_id' => ['required', 'ulid', 'exists:persons,id'], - ]); + $person = Person::findOrFail($request->validated('person_id')); - $crowdList->persons()->syncWithoutDetaching([ - $validated['person_id'] => [ - 'added_at' => now(), - 'added_by_user_id' => $request->user()->id, - ], - ]); + $this->crowdListService->addPerson($crowdList, $person, $request->user()); return $this->success(new CrowdListResource($crowdList->fresh()->loadCount('persons'))); } @@ -76,7 +77,7 @@ final class CrowdListController extends Controller { Gate::authorize('managePerson', [$crowdList, $event]); - $crowdList->persons()->detach($person->id); + $this->crowdListService->removePerson($crowdList, $person, request()->user()); return response()->json(null, 204); } diff --git a/api/app/Http/Requests/Api/V1/AddPersonToCrowdListRequest.php b/api/app/Http/Requests/Api/V1/AddPersonToCrowdListRequest.php new file mode 100644 index 00000000..3c5076b3 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/AddPersonToCrowdListRequest.php @@ -0,0 +1,23 @@ + */ + public function rules(): array + { + return [ + 'person_id' => ['required', 'ulid', 'exists:persons,id'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/StoreCrowdListRequest.php b/api/app/Http/Requests/Api/V1/StoreCrowdListRequest.php index 7da7e6d1..b38f88fc 100644 --- a/api/app/Http/Requests/Api/V1/StoreCrowdListRequest.php +++ b/api/app/Http/Requests/Api/V1/StoreCrowdListRequest.php @@ -4,7 +4,9 @@ declare(strict_types=1); namespace App\Http\Requests\Api\V1; +use App\Enums\CrowdListType; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; final class StoreCrowdListRequest extends FormRequest { @@ -19,7 +21,7 @@ final class StoreCrowdListRequest extends FormRequest return [ 'crowd_type_id' => ['required', 'ulid', 'exists:crowd_types,id'], 'name' => ['required', 'string', 'max:255'], - 'type' => ['required', 'in:internal,external'], + 'type' => ['required', Rule::enum(CrowdListType::class)], 'recipient_company_id' => ['nullable', 'ulid', 'exists:companies,id'], 'auto_approve' => ['sometimes', 'boolean'], 'max_persons' => ['nullable', 'integer', 'min:1'], diff --git a/api/app/Http/Requests/Api/V1/UpdateCrowdListRequest.php b/api/app/Http/Requests/Api/V1/UpdateCrowdListRequest.php index acf2ecdc..9115847e 100644 --- a/api/app/Http/Requests/Api/V1/UpdateCrowdListRequest.php +++ b/api/app/Http/Requests/Api/V1/UpdateCrowdListRequest.php @@ -4,7 +4,9 @@ declare(strict_types=1); namespace App\Http\Requests\Api\V1; +use App\Enums\CrowdListType; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; final class UpdateCrowdListRequest extends FormRequest { @@ -19,7 +21,7 @@ final class UpdateCrowdListRequest extends FormRequest return [ 'crowd_type_id' => ['sometimes', 'ulid', 'exists:crowd_types,id'], 'name' => ['sometimes', 'string', 'max:255'], - 'type' => ['sometimes', 'in:internal,external'], + 'type' => ['sometimes', Rule::enum(CrowdListType::class)], 'recipient_company_id' => ['nullable', 'ulid', 'exists:companies,id'], 'auto_approve' => ['sometimes', 'boolean'], 'max_persons' => ['nullable', 'integer', 'min:1'], diff --git a/api/app/Http/Resources/Api/V1/CrowdListResource.php b/api/app/Http/Resources/Api/V1/CrowdListResource.php index 00df9c6a..3c7ad07a 100644 --- a/api/app/Http/Resources/Api/V1/CrowdListResource.php +++ b/api/app/Http/Resources/Api/V1/CrowdListResource.php @@ -11,17 +11,22 @@ final class CrowdListResource extends JsonResource { public function toArray(Request $request): array { + $personsCount = $this->whenCounted('persons'); + return [ 'id' => $this->id, 'event_id' => $this->event_id, 'crowd_type_id' => $this->crowd_type_id, 'name' => $this->name, - 'type' => $this->type, + 'type' => $this->type->value, 'recipient_company_id' => $this->recipient_company_id, 'auto_approve' => $this->auto_approve, 'max_persons' => $this->max_persons, + 'is_full' => $this->max_persons !== null && isset($this->persons_count) + ? $this->persons_count >= $this->max_persons + : false, 'created_at' => $this->created_at->toIso8601String(), - 'persons_count' => $this->whenCounted('persons'), + 'persons_count' => $personsCount, ]; } } diff --git a/api/app/Models/CrowdList.php b/api/app/Models/CrowdList.php index 4dbc8072..5e504dc3 100644 --- a/api/app/Models/CrowdList.php +++ b/api/app/Models/CrowdList.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models; +use App\Enums\CrowdListType; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -28,6 +29,7 @@ final class CrowdList extends Model protected function casts(): array { return [ + 'type' => CrowdListType::class, 'auto_approve' => 'boolean', 'max_persons' => 'integer', ]; diff --git a/api/app/Services/CrowdListService.php b/api/app/Services/CrowdListService.php new file mode 100644 index 00000000..2252fd95 --- /dev/null +++ b/api/app/Services/CrowdListService.php @@ -0,0 +1,114 @@ +crowdLists()->create($data); + + activity('crowd_list') + ->causedBy($createdBy) + ->performedOn($crowdList) + ->withProperties(['attributes' => $crowdList->only($crowdList->getFillable())]) + ->log('crowd_list.created'); + + return $crowdList; + } + + public function update(CrowdList $crowdList, array $data, User $updatedBy): CrowdList + { + $oldValues = $crowdList->only(array_keys($data)); + + $crowdList->update($data); + + activity('crowd_list') + ->causedBy($updatedBy) + ->performedOn($crowdList) + ->withProperties([ + 'old' => $oldValues, + 'new' => $crowdList->only(array_keys($data)), + ]) + ->log('crowd_list.updated'); + + return $crowdList->fresh(); + } + + public function delete(CrowdList $crowdList, User $deletedBy): void + { + activity('crowd_list') + ->causedBy($deletedBy) + ->performedOn($crowdList) + ->withProperties(['attributes' => $crowdList->only($crowdList->getFillable())]) + ->log('crowd_list.deleted'); + + $crowdList->delete(); + } + + /** + * @throws ValidationException + */ + public function addPerson(CrowdList $crowdList, Person $person, User $addedBy): void + { + if ($crowdList->persons()->where('person_id', $person->id)->exists()) { + throw ValidationException::withMessages([ + 'person_id' => ['This person is already on this crowd list.'], + ]); + } + + if ($crowdList->max_persons !== null) { + $currentCount = $crowdList->persons()->count(); + + if ($currentCount >= $crowdList->max_persons) { + throw ValidationException::withMessages([ + 'person_id' => ['This crowd list has reached its maximum capacity of ' . $crowdList->max_persons . ' persons.'], + ]); + } + } + + $crowdList->persons()->attach($person->id, [ + 'added_at' => now(), + 'added_by_user_id' => $addedBy->id, + ]); + + $wasAutoApproved = false; + + if ($crowdList->auto_approve && $person->status !== 'approved') { + $person->update(['status' => 'approved']); + $wasAutoApproved = true; + } + + activity('crowd_list') + ->causedBy($addedBy) + ->performedOn($crowdList) + ->withProperties([ + 'person_id' => $person->id, + 'person_name' => $person->name, + 'auto_approved' => $wasAutoApproved, + ]) + ->log('crowd_list.person_added'); + } + + public function removePerson(CrowdList $crowdList, Person $person, User $removedBy): void + { + $crowdList->persons()->detach($person->id); + + activity('crowd_list') + ->causedBy($removedBy) + ->performedOn($crowdList) + ->withProperties([ + 'person_id' => $person->id, + 'person_name' => $person->name, + ]) + ->log('crowd_list.person_removed'); + } +} diff --git a/api/database/factories/CrowdListFactory.php b/api/database/factories/CrowdListFactory.php new file mode 100644 index 00000000..099d88cc --- /dev/null +++ b/api/database/factories/CrowdListFactory.php @@ -0,0 +1,74 @@ + */ +final class CrowdListFactory extends Factory +{ + private const INTERNAL_NAMES = [ + 'VIP Gastenlijst', + 'Backstage Crew', + 'Barpersoneel', + 'Opbouw Team', + 'Afbouw Team', + 'Vrijwilligers Hoofdpodium', + 'Nachtploeg', + 'EHBO Team', + 'Parkeerteam', + 'Kassa Medewerkers', + ]; + + private const EXTERNAL_NAMES = [ + 'Catering Medewerkers', + 'Beveiligingspersoneel', + 'Technische Crew', + 'Schoonmaakploeg', + 'Leveranciers Personeel', + ]; + + /** @return array */ + public function definition(): array + { + return [ + 'event_id' => Event::factory(), + 'crowd_type_id' => CrowdType::factory(), + 'name' => fake()->randomElement(self::INTERNAL_NAMES), + 'type' => CrowdListType::INTERNAL, + 'recipient_company_id' => null, + 'auto_approve' => false, + 'max_persons' => null, + ]; + } + + public function external(): static + { + return $this->state(fn () => [ + 'name' => fake()->randomElement(self::EXTERNAL_NAMES), + 'type' => CrowdListType::EXTERNAL, + 'recipient_company_id' => Company::factory(), + ]); + } + + public function withAutoApprove(): static + { + return $this->state(fn () => [ + 'auto_approve' => true, + ]); + } + + public function withMaxPersons(int $max): static + { + return $this->state(fn () => [ + 'max_persons' => $max, + ]); + } +} diff --git a/api/tests/Feature/CrowdList/CrowdListTest.php b/api/tests/Feature/CrowdList/CrowdListTest.php new file mode 100644 index 00000000..1641a0c5 --- /dev/null +++ b/api/tests/Feature/CrowdList/CrowdListTest.php @@ -0,0 +1,480 @@ +seed(RoleSeeder::class); + + $this->organisation = Organisation::factory()->create(); + $this->otherOrganisation = Organisation::factory()->create(); + + $this->orgAdmin = User::factory()->create(); + $this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']); + + $this->outsider = User::factory()->create(); + $this->otherOrganisation->users()->attach($this->outsider, ['role' => 'org_admin']); + + $this->event = Event::factory()->create(['organisation_id' => $this->organisation->id]); + $this->otherEvent = Event::factory()->create(['organisation_id' => $this->otherOrganisation->id]); + + $this->crowdType = CrowdType::factory()->systemType('VOLUNTEER')->create([ + 'organisation_id' => $this->organisation->id, + ]); + } + + // ---- CRUD Tests ---- + + public function test_can_list_crowd_lists_for_event(): void + { + CrowdList::factory()->count(3)->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ]); + + // Add persons to one list to verify persons_count + $list = $this->event->crowdLists()->first(); + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ]); + $list->persons()->attach($person->id, [ + 'added_at' => now(), + 'added_by_user_id' => $this->orgAdmin->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/events/{$this->event->id}/crowd-lists"); + + $response->assertOk(); + $this->assertCount(3, $response->json('data')); + + // Verify persons_count is present + $listData = collect($response->json('data'))->firstWhere('id', $list->id); + $this->assertEquals(1, $listData['persons_count']); + } + + public function test_can_create_internal_crowd_list(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/crowd-lists", [ + 'crowd_type_id' => $this->crowdType->id, + 'name' => 'VIP Gastenlijst', + 'type' => 'internal', + 'auto_approve' => true, + 'max_persons' => 50, + ]); + + $response->assertCreated(); + $this->assertEquals('VIP Gastenlijst', $response->json('data.name')); + $this->assertEquals('internal', $response->json('data.type')); + $this->assertNull($response->json('data.recipient_company_id')); + $this->assertTrue($response->json('data.auto_approve')); + $this->assertEquals(50, $response->json('data.max_persons')); + + $this->assertDatabaseHas('crowd_lists', [ + 'event_id' => $this->event->id, + 'name' => 'VIP Gastenlijst', + 'type' => 'internal', + ]); + } + + public function test_can_create_external_crowd_list_with_company(): void + { + $company = Company::factory()->create([ + 'organisation_id' => $this->organisation->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/crowd-lists", [ + 'crowd_type_id' => $this->crowdType->id, + 'name' => 'Catering Medewerkers', + 'type' => 'external', + 'recipient_company_id' => $company->id, + ]); + + $response->assertCreated(); + $this->assertEquals('external', $response->json('data.type')); + $this->assertEquals($company->id, $response->json('data.recipient_company_id')); + } + + public function test_store_validates_invalid_type(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/crowd-lists", [ + 'crowd_type_id' => $this->crowdType->id, + 'name' => 'Test List', + 'type' => 'invalid_type', + ]); + + $response->assertUnprocessable() + ->assertJsonValidationErrors(['type']); + } + + public function test_can_update_crowd_list(): void + { + $crowdList = CrowdList::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'name' => 'Oude Naam', + 'auto_approve' => false, + 'max_persons' => null, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->putJson("/api/v1/events/{$this->event->id}/crowd-lists/{$crowdList->id}", [ + 'name' => 'Nieuwe Naam', + 'auto_approve' => true, + 'max_persons' => 25, + ]); + + $response->assertOk(); + $this->assertEquals('Nieuwe Naam', $response->json('data.name')); + $this->assertTrue($response->json('data.auto_approve')); + $this->assertEquals(25, $response->json('data.max_persons')); + } + + public function test_can_delete_crowd_list(): void + { + $crowdList = CrowdList::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->deleteJson("/api/v1/events/{$this->event->id}/crowd-lists/{$crowdList->id}"); + + $response->assertNoContent(); + $this->assertDatabaseMissing('crowd_lists', ['id' => $crowdList->id]); + } + + // ---- Person Management Tests ---- + + public function test_can_add_person_to_crowd_list(): void + { + $crowdList = CrowdList::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ]); + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/crowd-lists/{$crowdList->id}/persons", [ + 'person_id' => $person->id, + ]); + + $response->assertOk(); + + $this->assertDatabaseHas('crowd_list_persons', [ + 'crowd_list_id' => $crowdList->id, + 'person_id' => $person->id, + 'added_by_user_id' => $this->orgAdmin->id, + ]); + } + + public function test_cannot_add_duplicate_person_to_crowd_list(): void + { + $crowdList = CrowdList::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ]); + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ]); + + $crowdList->persons()->attach($person->id, [ + 'added_at' => now(), + 'added_by_user_id' => $this->orgAdmin->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/crowd-lists/{$crowdList->id}/persons", [ + 'person_id' => $person->id, + ]); + + $response->assertUnprocessable() + ->assertJsonValidationErrors(['person_id']); + } + + public function test_cannot_add_person_beyond_max_persons(): void + { + $crowdList = CrowdList::factory()->withMaxPersons(2)->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, + ]); + + // Add two persons (fill the list) + foreach ($persons->take(2) as $person) { + $crowdList->persons()->attach($person->id, [ + 'added_at' => now(), + 'added_by_user_id' => $this->orgAdmin->id, + ]); + } + + Sanctum::actingAs($this->orgAdmin); + + // Try to add 3rd person + $response = $this->postJson("/api/v1/events/{$this->event->id}/crowd-lists/{$crowdList->id}/persons", [ + 'person_id' => $persons->last()->id, + ]); + + $response->assertUnprocessable() + ->assertJsonValidationErrors(['person_id']); + } + + public function test_can_add_person_when_max_persons_is_null(): void + { + $crowdList = CrowdList::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'max_persons' => null, + ]); + + $persons = Person::factory()->count(5)->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + // Add all 5 persons — no limit + foreach ($persons as $person) { + $response = $this->postJson("/api/v1/events/{$this->event->id}/crowd-lists/{$crowdList->id}/persons", [ + 'person_id' => $person->id, + ]); + $response->assertOk(); + } + + $this->assertEquals(5, $crowdList->persons()->count()); + } + + public function test_auto_approve_sets_person_status_to_approved(): void + { + $crowdList = CrowdList::factory()->withAutoApprove()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ]); + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'status' => 'pending', + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/crowd-lists/{$crowdList->id}/persons", [ + 'person_id' => $person->id, + ]); + + $response->assertOk(); + + $this->assertDatabaseHas('persons', [ + 'id' => $person->id, + 'status' => 'approved', + ]); + } + + public function test_can_remove_person_from_crowd_list(): void + { + $crowdList = CrowdList::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ]); + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ]); + + $crowdList->persons()->attach($person->id, [ + 'added_at' => now(), + 'added_by_user_id' => $this->orgAdmin->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->deleteJson("/api/v1/events/{$this->event->id}/crowd-lists/{$crowdList->id}/persons/{$person->id}"); + + $response->assertNoContent(); + + $this->assertDatabaseMissing('crowd_list_persons', [ + 'crowd_list_id' => $crowdList->id, + 'person_id' => $person->id, + ]); + + // Person still exists + $this->assertDatabaseHas('persons', ['id' => $person->id]); + } + + public function test_crowd_list_resource_includes_persons_count(): 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(), + 'added_by_user_id' => $this->orgAdmin->id, + ]); + } + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/events/{$this->event->id}/crowd-lists"); + + $response->assertOk(); + $listData = collect($response->json('data'))->firstWhere('id', $crowdList->id); + $this->assertEquals(3, $listData['persons_count']); + } + + // ---- Authorization Tests ---- + + public function test_cross_org_cannot_access_crowd_lists(): void + { + Sanctum::actingAs($this->outsider); + + $response = $this->getJson("/api/v1/events/{$this->event->id}/crowd-lists"); + + $response->assertForbidden(); + } + + public function test_unauthenticated_cannot_access_crowd_lists(): void + { + $response = $this->getJson("/api/v1/events/{$this->event->id}/crowd-lists"); + + $response->assertUnauthorized(); + } + + public function test_cross_event_cannot_access_crowd_list(): void + { + $crowdList = CrowdList::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ]); + + // Create a second event in the same org so the user has access to it + $eventB = Event::factory()->create(['organisation_id' => $this->organisation->id]); + + Sanctum::actingAs($this->orgAdmin); + + // Try to update a list from event A via event B's URL + $response = $this->putJson("/api/v1/events/{$eventB->id}/crowd-lists/{$crowdList->id}", [ + 'name' => 'Hacked', + ]); + + $response->assertForbidden(); + } + + // ---- Edge Case Tests ---- + + public function test_deleting_crowd_list_does_not_delete_persons(): void + { + $crowdList = CrowdList::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ]); + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ]); + + $crowdList->persons()->attach($person->id, [ + 'added_at' => now(), + 'added_by_user_id' => $this->orgAdmin->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $this->deleteJson("/api/v1/events/{$this->event->id}/crowd-lists/{$crowdList->id}") + ->assertNoContent(); + + // Pivot removed (cascade) + $this->assertDatabaseMissing('crowd_list_persons', [ + 'crowd_list_id' => $crowdList->id, + ]); + + // Person still exists + $this->assertDatabaseHas('persons', ['id' => $person->id]); + } + + public function test_persons_count_reflects_current_state(): 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(), + 'added_by_user_id' => $this->orgAdmin->id, + ]); + } + + // Remove one + $crowdList->persons()->detach($persons->first()->id); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/events/{$this->event->id}/crowd-lists"); + + $response->assertOk(); + $listData = collect($response->json('data'))->firstWhere('id', $crowdList->id); + $this->assertEquals(2, $listData['persons_count']); + } +} diff --git a/dev-docs/API.md b/dev-docs/API.md index 106d4a79..0c7863a2 100644 --- a/dev-docs/API.md +++ b/dev-docs/API.md @@ -163,12 +163,56 @@ Response: `{ "confirmed": 2, "errors": [{ "match_id": "ulid3", "error": "User al ## Crowd Lists -- `GET /events/{event}/crowd-lists` -- `POST /events/{event}/crowd-lists` -- `PUT /events/{event}/crowd-lists/{list}` -- `DELETE /events/{event}/crowd-lists/{list}` -- `POST /events/{event}/crowd-lists/{list}/persons` -- `DELETE /events/{event}/crowd-lists/{list}/persons/{person}` +- `GET /events/{event}/crowd-lists` — list all crowd lists for event (includes `persons_count`) +- `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 +- `POST /events/{event}/crowd-lists/{list}/persons` — add person to list +- `DELETE /events/{event}/crowd-lists/{list}/persons/{person}` — remove person from list + +### Create/Update Body + +```json +{ + "crowd_type_id": "01JXYZ...", + "name": "VIP Gastenlijst", + "type": "internal|external", + "recipient_company_id": "01JXYZ... (nullable, for external lists)", + "auto_approve": false, + "max_persons": 50 +} +``` + +### Add Person Body + +```json +{ + "person_id": "01JXYZ..." +} +``` + +**Business rules:** +- `max_persons`: when set, adding a person beyond the limit returns 422 +- `auto_approve`: when true, adding a person with status `pending` automatically sets their status to `approved` +- Duplicate person on same list returns 422 + +### CrowdListResource + +```json +{ + "id": "01JXYZ...", + "event_id": "01JXYZ...", + "crowd_type_id": "01JXYZ...", + "name": "VIP Gastenlijst", + "type": "internal", + "recipient_company_id": null, + "auto_approve": false, + "max_persons": 50, + "is_full": false, + "created_at": "2026-04-10T12:00:00+00:00", + "persons_count": 12 +} +``` ## Locations