diff --git a/api/app/Http/Controllers/Api/V1/MemberController.php b/api/app/Http/Controllers/Api/V1/MemberController.php index 127bd1ba..f3b6d3b6 100644 --- a/api/app/Http/Controllers/Api/V1/MemberController.php +++ b/api/app/Http/Controllers/Api/V1/MemberController.php @@ -8,7 +8,9 @@ use App\Http\Controllers\Controller; use App\Http\Requests\Api\V1\UpdateMemberRequest; use App\Http\Resources\Api\V1\MemberCollection; use App\Http\Resources\Api\V1\MemberResource; +use App\Models\Event; use App\Models\Organisation; +use App\Models\Person; use App\Models\User; use App\Services\EmailChangeService; use Illuminate\Http\JsonResponse; @@ -84,6 +86,35 @@ final class MemberController extends Controller return response()->json(null, 204); } + public function availableForEvent(Organisation $organisation, Event $event): JsonResponse + { + if ($event->organisation_id !== $organisation->id) { + abort(404); + } + + Gate::authorize('viewAny', [Person::class, $event]); + + $existingUserIds = Person::withoutGlobalScopes() + ->where('event_id', $event->id) + ->whereNotNull('user_id') + ->pluck('user_id'); + + $members = $organisation->users() + ->whereNotIn('users.id', $existingUserIds) + ->select('users.id', 'users.first_name', 'users.last_name', 'users.email') + ->orderBy('users.first_name') + ->get() + ->map(fn (User $user) => [ + 'id' => $user->id, + 'first_name' => $user->first_name, + 'last_name' => $user->last_name, + 'full_name' => $user->full_name, + 'email' => $user->email, + ]); + + return response()->json(['data' => $members]); + } + /** * POST /api/v1/organisations/{organisation}/members/{user}/change-email * Admin changes a member's email (sends verification to new address). diff --git a/api/app/Http/Controllers/Api/V1/PersonController.php b/api/app/Http/Controllers/Api/V1/PersonController.php index 2c7085f0..abfb0a35 100644 --- a/api/app/Http/Controllers/Api/V1/PersonController.php +++ b/api/app/Http/Controllers/Api/V1/PersonController.php @@ -4,8 +4,10 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\V1; +use App\Enums\PersonStatus; use App\Http\Controllers\Controller; use App\Http\Controllers\Api\V1\Traits\VerifiesOrganisationEvent; +use App\Http\Requests\Api\V1\StorePersonFromMemberRequest; use App\Http\Requests\Api\V1\StorePersonRequest; use App\Http\Requests\Api\V1\UpdatePersonRequest; use App\Http\Resources\Api\V1\PersonCollection; @@ -15,12 +17,14 @@ use App\Mail\RegistrationRejectedMail; use App\Models\Event; use App\Models\Organisation; use App\Models\Person; +use App\Models\User; use App\Services\PersonIdentityService; use App\Services\TagSyncService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Mail; +use Illuminate\Validation\ValidationException; final class PersonController extends Controller { @@ -115,6 +119,46 @@ final class PersonController extends Controller return response()->json(null, 204); } + public function createFromMember(StorePersonFromMemberRequest $request, Organisation $organisation, Event $event): JsonResponse + { + $this->verifyEventBelongsToOrganisation($organisation, $event); + Gate::authorize('create', [Person::class, $event]); + + $user = User::findOrFail($request->validated('user_id')); + + if (Person::withoutGlobalScopes()->where('event_id', $event->id)->where('user_id', $user->id)->exists()) { + throw ValidationException::withMessages([ + 'user_id' => ['Dit lid is al toegevoegd aan dit evenement.'], + ]); + } + + if (!$user->organisations()->where('organisations.id', $event->organisation_id)->exists()) { + throw ValidationException::withMessages([ + 'user_id' => ['Dit lid behoort niet tot deze organisatie.'], + ]); + } + + $person = Person::create([ + 'event_id' => $event->id, + 'crowd_type_id' => $request->validated('crowd_type_id'), + 'first_name' => $user->first_name, + 'last_name' => $user->last_name, + 'email' => $user->email, + 'status' => PersonStatus::APPROVED->value, + ]); + + $person->user_id = $user->id; + $person->save(); + + activity() + ->causedBy(auth()->user()) + ->performedOn($person) + ->withProperties(['source' => 'from_member', 'user_name' => $user->full_name]) + ->log('person.created_from_member'); + + return $this->created(new PersonResource($person->load('crowdType'))); + } + public function approve(Organisation $organisation, Event $event, Person $person): JsonResponse { $this->verifyEventBelongsToOrganisation($organisation, $event); diff --git a/api/app/Http/Requests/Api/V1/StorePersonFromMemberRequest.php b/api/app/Http/Requests/Api/V1/StorePersonFromMemberRequest.php new file mode 100644 index 00000000..7dc896ec --- /dev/null +++ b/api/app/Http/Requests/Api/V1/StorePersonFromMemberRequest.php @@ -0,0 +1,27 @@ + */ + public function rules(): array + { + $orgId = $this->route('event')->organisation_id; + + return [ + 'user_id' => ['required', 'ulid', 'exists:users,id'], + 'crowd_type_id' => ['required', 'ulid', Rule::exists('crowd_types', 'id')->where('organisation_id', $orgId)], + ]; + } +} diff --git a/api/routes/api.php b/api/routes/api.php index 19d384df..edc7eaac 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -160,6 +160,7 @@ Route::middleware('auth:sanctum')->group(function () { Route::put('members/{user}', [MemberController::class, 'update']); Route::delete('members/{user}', [MemberController::class, 'destroy']); Route::post('members/{user}/change-email', [MemberController::class, 'changeEmail']); + Route::get('members/available-for-event/{event}', [MemberController::class, 'availableForEvent']); // Event sub-resources (all nested under organisation prefix — A01-13) Route::prefix('events/{event}')->group(function () { @@ -199,6 +200,7 @@ Route::middleware('auth:sanctum')->group(function () { // Persons Route::apiResource('persons', PersonController::class); + Route::post('persons/from-member', [PersonController::class, 'createFromMember']); Route::post('persons/{person}/approve', [PersonController::class, 'approve']); Route::post('persons/{person}/reject', [PersonController::class, 'reject']); Route::post('persons/{person}/manual-link', [PersonIdentityMatchController::class, 'manualLink']); diff --git a/api/tests/Feature/Person/CreatePersonFromMemberTest.php b/api/tests/Feature/Person/CreatePersonFromMemberTest.php new file mode 100644 index 00000000..89685489 --- /dev/null +++ b/api/tests/Feature/Person/CreatePersonFromMemberTest.php @@ -0,0 +1,253 @@ +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->member = User::factory()->create([ + 'first_name' => 'Jan', + 'last_name' => 'de Vries', + 'email' => 'jan@test.nl', + ]); + $this->organisation->users()->attach($this->member, ['role' => 'org_member']); + + $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->crowdType = CrowdType::factory()->systemType('CREW')->create([ + 'organisation_id' => $this->organisation->id, + ]); + } + + // --- Available for event --- + + public function test_available_for_event_returns_members_not_yet_person(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson( + "/api/v1/organisations/{$this->organisation->id}/members/available-for-event/{$this->event->id}" + ); + + $response->assertOk(); + + $data = $response->json('data'); + + // Both orgAdmin and member should be available (neither is a person yet) + $this->assertCount(2, $data); + + $ids = collect($data)->pluck('id')->all(); + $this->assertContains($this->orgAdmin->id, $ids); + $this->assertContains($this->member->id, $ids); + } + + public function test_available_for_event_excludes_already_added_members(): void + { + // Add member as a person + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ]); + $person->user_id = $this->member->id; + $person->save(); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson( + "/api/v1/organisations/{$this->organisation->id}/members/available-for-event/{$this->event->id}" + ); + + $response->assertOk(); + + $ids = collect($response->json('data'))->pluck('id')->all(); + $this->assertNotContains($this->member->id, $ids); + $this->assertContains($this->orgAdmin->id, $ids); + } + + public function test_available_for_event_returns_correct_fields(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson( + "/api/v1/organisations/{$this->organisation->id}/members/available-for-event/{$this->event->id}" + ); + + $response->assertOk() + ->assertJsonStructure([ + 'data' => [ + '*' => ['id', 'first_name', 'last_name', 'full_name', 'email'], + ], + ]); + } + + public function test_available_for_event_unauthenticated_returns_401(): void + { + $response = $this->getJson( + "/api/v1/organisations/{$this->organisation->id}/members/available-for-event/{$this->event->id}" + ); + + $response->assertUnauthorized(); + } + + public function test_available_for_event_wrong_org_returns_403(): void + { + Sanctum::actingAs($this->outsider); + + $response = $this->getJson( + "/api/v1/organisations/{$this->organisation->id}/members/available-for-event/{$this->event->id}" + ); + + $response->assertForbidden(); + } + + // --- Create person from member --- + + public function test_create_from_member_creates_person_with_user_id(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/from-member", + [ + 'user_id' => $this->member->id, + 'crowd_type_id' => $this->crowdType->id, + ], + ); + + $response->assertCreated() + ->assertJsonPath('data.first_name', 'Jan') + ->assertJsonPath('data.last_name', 'de Vries') + ->assertJsonPath('data.email', 'jan@test.nl') + ->assertJsonPath('data.status', 'approved') + ->assertJsonPath('data.has_user_account', true); + + $this->assertDatabaseHas('persons', [ + 'event_id' => $this->event->id, + 'user_id' => $this->member->id, + 'first_name' => 'Jan', + 'last_name' => 'de Vries', + 'status' => 'approved', + ]); + } + + public function test_create_from_member_duplicate_returns_422(): void + { + // Add member as a person first + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ]); + $person->user_id = $this->member->id; + $person->save(); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/from-member", + [ + 'user_id' => $this->member->id, + 'crowd_type_id' => $this->crowdType->id, + ], + ); + + $response->assertUnprocessable() + ->assertJsonValidationErrors('user_id'); + } + + public function test_create_from_member_user_not_in_org_returns_422(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/from-member", + [ + 'user_id' => $this->outsider->id, + 'crowd_type_id' => $this->crowdType->id, + ], + ); + + $response->assertUnprocessable() + ->assertJsonValidationErrors('user_id'); + } + + public function test_create_from_member_unauthenticated_returns_401(): void + { + $response = $this->postJson( + "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/from-member", + [ + 'user_id' => $this->member->id, + 'crowd_type_id' => $this->crowdType->id, + ], + ); + + $response->assertUnauthorized(); + } + + public function test_create_from_member_wrong_org_returns_403(): void + { + Sanctum::actingAs($this->outsider); + + $response = $this->postJson( + "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/from-member", + [ + 'user_id' => $this->member->id, + 'crowd_type_id' => $this->crowdType->id, + ], + ); + + $response->assertForbidden(); + } + + public function test_create_from_member_logs_activity(): void + { + Sanctum::actingAs($this->orgAdmin); + + $this->postJson( + "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/from-member", + [ + 'user_id' => $this->member->id, + 'crowd_type_id' => $this->crowdType->id, + ], + )->assertCreated(); + + $this->assertDatabaseHas('activity_log', [ + 'description' => 'person.created_from_member', + 'causer_id' => $this->orgAdmin->id, + ]); + } +} diff --git a/apps/app/components.d.ts b/apps/app/components.d.ts index bd624a63..0e0e1649 100644 --- a/apps/app/components.d.ts +++ b/apps/app/components.d.ts @@ -10,6 +10,7 @@ declare module 'vue' { AddEditAddressDialog: typeof import('./src/components/dialogs/AddEditAddressDialog.vue')['default'] AddEditPermissionDialog: typeof import('./src/components/dialogs/AddEditPermissionDialog.vue')['default'] AddEditRoleDialog: typeof import('./src/components/dialogs/AddEditRoleDialog.vue')['default'] + AddMemberAsPersonDialog: typeof import('./src/components/persons/AddMemberAsPersonDialog.vue')['default'] AddPersonToCrowdListDialog: typeof import('./src/components/crowd-lists/AddPersonToCrowdListDialog.vue')['default'] AppAutocomplete: typeof import('./src/@core/components/app-form-elements/AppAutocomplete.vue')['default'] AppBarSearch: typeof import('./src/@core/components/AppBarSearch.vue')['default'] diff --git a/apps/app/src/components/persons/AddMemberAsPersonDialog.vue b/apps/app/src/components/persons/AddMemberAsPersonDialog.vue new file mode 100644 index 00000000..1769baaf --- /dev/null +++ b/apps/app/src/components/persons/AddMemberAsPersonDialog.vue @@ -0,0 +1,231 @@ + + + + + + + + + + + + + + + + + {{ errorMessage }} + + + + + + + + Kon beschikbare leden niet laden. + + + Opnieuw proberen + + + + + + + + + {{ searchQuery + ? 'Geen leden gevonden voor deze zoekopdracht' + : 'Alle organisatieleden zijn al toegevoegd aan dit evenement' + }} + + + + + + + + + + {{ member.first_name[0] }}{{ member.last_name[0] }} + + + + {{ member.full_name }} + {{ member.email }} + + + + + + + + + + + Sluiten + + + + + + + {{ successName }} toegevoegd als deelnemer + + diff --git a/apps/app/src/composables/api/usePersons.ts b/apps/app/src/composables/api/usePersons.ts index 533413ba..d6c5f709 100644 --- a/apps/app/src/composables/api/usePersons.ts +++ b/apps/app/src/composables/api/usePersons.ts @@ -1,6 +1,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' import type { Ref } from 'vue' import { apiClient } from '@/lib/axios' +import type { AvailableMember } from '@/types/member' import type { CreatePersonPayload, Person, UpdatePersonPayload } from '@/types/person' interface ApiResponse { @@ -128,3 +129,36 @@ export function useDeletePerson(orgId: Ref, eventId: Ref) { }, }) } + +export function useAvailableMembers(orgId: Ref, eventId: Ref, enabled: Ref) { + return useQuery({ + queryKey: ['available-members', orgId, eventId], + queryFn: async () => { + const { data } = await apiClient.get<{ data: AvailableMember[] }>( + `/organisations/${orgId.value}/members/available-for-event/${eventId.value}`, + ) + + return data.data + }, + enabled: () => !!orgId.value && !!eventId.value && enabled.value, + }) +} + +export function useCreatePersonFromMember(orgId: Ref, eventId: Ref) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (payload: { user_id: string; crowd_type_id: string }) => { + const { data } = await apiClient.post>( + `/organisations/${orgId.value}/events/${eventId.value}/persons/from-member`, + payload, + ) + + return data.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['persons', eventId.value] }) + queryClient.invalidateQueries({ queryKey: ['available-members', orgId.value, eventId.value] }) + }, + }) +} diff --git a/apps/app/src/pages/events/[id]/persons/index.vue b/apps/app/src/pages/events/[id]/persons/index.vue index 9886d994..1f62584c 100644 --- a/apps/app/src/pages/events/[id]/persons/index.vue +++ b/apps/app/src/pages/events/[id]/persons/index.vue @@ -3,6 +3,7 @@ import { usePersonList, useApprovePerson, useDeletePerson } from '@/composables/ import { useCrowdTypeList } from '@/composables/api/useCrowdTypes' import { useAuthStore } from '@/stores/useAuthStore' import EventTabsNav from '@/components/events/EventTabsNav.vue' +import AddMemberAsPersonDialog from '@/components/persons/AddMemberAsPersonDialog.vue' import CreatePersonDialog from '@/components/persons/CreatePersonDialog.vue' import EditPersonDialog from '@/components/persons/EditPersonDialog.vue' import PersonDetailPanel from '@/components/persons/PersonDetailPanel.vue' @@ -112,6 +113,7 @@ function getInitials(name: string) { } // Dialogs & panel +const isAddMemberDialogOpen = ref(false) const isCreateDialogOpen = ref(false) const isEditDialogOpen = ref(false) const editingPerson = ref(null) @@ -194,12 +196,21 @@ const crowdTypeOptions = computed(() => [ style="min-inline-size: 180px;" /> - - Persoon toevoegen - + + + Lid toevoegen + + + Persoon toevoegen + + @@ -378,6 +389,13 @@ const crowdTypeOptions = computed(() => [ + + +
+ {{ searchQuery + ? 'Geen leden gevonden voor deze zoekopdracht' + : 'Alle organisatieleden zijn al toegevoegd aan dit evenement' + }} +