diff --git a/api/app/Http/Controllers/Api/V1/CrowdListController.php b/api/app/Http/Controllers/Api/V1/CrowdListController.php index 00124ebe..5f9a8113 100644 --- a/api/app/Http/Controllers/Api/V1/CrowdListController.php +++ b/api/app/Http/Controllers/Api/V1/CrowdListController.php @@ -78,7 +78,8 @@ final class CrowdListController extends Controller { Gate::authorize('managePerson', [$crowdList, $event]); - $person = Person::findOrFail($request->validated('person_id')); + $festivalEventId = $event->parent_event_id ?? $event->id; + $person = Person::where('event_id', $festivalEventId)->findOrFail($request->validated('person_id')); $this->crowdListService->addPerson($crowdList, $person, $request->user()); diff --git a/api/app/Http/Controllers/Api/V1/InvitationController.php b/api/app/Http/Controllers/Api/V1/InvitationController.php index 2ad01531..eb232e23 100644 --- a/api/app/Http/Controllers/Api/V1/InvitationController.php +++ b/api/app/Http/Controllers/Api/V1/InvitationController.php @@ -75,6 +75,11 @@ final class InvitationController extends Controller public function revoke(Organisation $organisation, UserInvitation $invitation): JsonResponse { + // Verify invitation belongs to this organisation + if ($invitation->organisation_id !== $organisation->id) { + return $this->notFound('Uitnodiging niet gevonden'); + } + Gate::authorize('invite', $organisation); if (! $invitation->isPending()) { diff --git a/api/app/Http/Controllers/Api/V1/PersonIdentityMatchController.php b/api/app/Http/Controllers/Api/V1/PersonIdentityMatchController.php index d4310590..25e331b5 100644 --- a/api/app/Http/Controllers/Api/V1/PersonIdentityMatchController.php +++ b/api/app/Http/Controllers/Api/V1/PersonIdentityMatchController.php @@ -50,6 +50,11 @@ final class PersonIdentityMatchController extends Controller public function confirm(Request $request, Organisation $organisation, PersonIdentityMatch $personIdentityMatch): JsonResponse { + // Verify match belongs to this organisation + if ($personIdentityMatch->person->event->organisation_id !== $organisation->id) { + return $this->notFound('Match not found.'); + } + Gate::authorize('confirm', $personIdentityMatch); try { @@ -65,6 +70,11 @@ final class PersonIdentityMatchController extends Controller public function dismiss(Request $request, Organisation $organisation, PersonIdentityMatch $personIdentityMatch): JsonResponse { + // Verify match belongs to this organisation + if ($personIdentityMatch->person->event->organisation_id !== $organisation->id) { + return $this->notFound('Match not found.'); + } + Gate::authorize('dismiss', $personIdentityMatch); try { @@ -82,7 +92,9 @@ final class PersonIdentityMatchController extends Controller { Gate::authorize('bulkConfirm', [PersonIdentityMatch::class, $organisation]); + $orgEventIds = $organisation->events()->pluck('id'); $matches = PersonIdentityMatch::whereIn('id', $request->validated('match_ids')) + ->whereHas('person', fn ($q) => $q->whereIn('event_id', $orgEventIds)) ->with('person') ->get() ->keyBy('id'); diff --git a/api/app/Http/Controllers/Api/V1/Portal/PortalShiftController.php b/api/app/Http/Controllers/Api/V1/Portal/PortalShiftController.php index f4fbfdcb..92560744 100644 --- a/api/app/Http/Controllers/Api/V1/Portal/PortalShiftController.php +++ b/api/app/Http/Controllers/Api/V1/Portal/PortalShiftController.php @@ -172,6 +172,12 @@ final class PortalShiftController extends Controller public function claim(Request $request, Event $event, Shift $shift): JsonResponse { + // Verify shift belongs to this event context + $eventIds = $this->resolveEventIds($event); + if (! in_array($shift->festivalSection->event_id, $eventIds)) { + return $this->notFound('Shift niet gevonden.'); + } + $person = $this->resolvePerson($event); if ($person->status !== 'approved') { @@ -203,6 +209,12 @@ final class PortalShiftController extends Controller public function cancel(Request $request, Event $event, ShiftAssignment $shiftAssignment): JsonResponse { + // Verify assignment belongs to this event context + $eventIds = $this->resolveEventIds($event); + if (! in_array($shiftAssignment->shift->timeSlot->event_id, $eventIds)) { + return $this->notFound('Dienst niet gevonden.'); + } + $person = $this->resolvePerson($event); // Must be the person's own assignment diff --git a/api/app/Http/Controllers/Api/V1/PortalMeController.php b/api/app/Http/Controllers/Api/V1/PortalMeController.php index a50674d1..c139e6cb 100644 --- a/api/app/Http/Controllers/Api/V1/PortalMeController.php +++ b/api/app/Http/Controllers/Api/V1/PortalMeController.php @@ -21,13 +21,16 @@ final class PortalMeController extends Controller { public function index(PortalMeRequest $request): JsonResponse { - $event = Event::findOrFail($request->validated('event_id')); + $event = Event::withoutGlobalScope(\App\Models\Scopes\OrganisationScope::class) + ->findOrFail($request->validated('event_id')); if ($event->isSubEvent()) { $event = $event->parent; } - $person = Person::where('user_id', $request->user()->id) + // Verify user has a person record for this event (scopes access) + $person = Person::withoutGlobalScope(\App\Models\Scopes\OrganisationScope::class) + ->where('user_id', $request->user()->id) ->where('event_id', $event->id) ->with([ 'crowdType', @@ -95,13 +98,16 @@ final class PortalMeController extends Controller $user = $request->user(); - $event = Event::findOrFail($validated['event_id']); + $event = Event::withoutGlobalScope(\App\Models\Scopes\OrganisationScope::class) + ->findOrFail($validated['event_id']); if ($event->isSubEvent()) { $event = $event->parent; } - $person = Person::where('user_id', $user->id) + // Verify user has a person record for this event (scopes access) + $person = Person::withoutGlobalScope(\App\Models\Scopes\OrganisationScope::class) + ->where('user_id', $user->id) ->where('event_id', $event->id) ->firstOrFail(); diff --git a/api/app/Http/Controllers/Api/V1/ShiftAssignmentController.php b/api/app/Http/Controllers/Api/V1/ShiftAssignmentController.php index f4791218..2cb8a855 100644 --- a/api/app/Http/Controllers/Api/V1/ShiftAssignmentController.php +++ b/api/app/Http/Controllers/Api/V1/ShiftAssignmentController.php @@ -96,6 +96,7 @@ final class ShiftAssignmentController extends Controller Gate::authorize('bulkApprove', [ShiftAssignment::class, $event]); $assignments = ShiftAssignment::whereIn('id', $request->validated('assignment_ids')) + ->whereHas('shift.festivalSection', fn ($q) => $q->where('event_id', $event->id)) ->with('shift') ->get(); diff --git a/api/app/Http/Controllers/Api/V1/ShiftController.php b/api/app/Http/Controllers/Api/V1/ShiftController.php index f595dc46..c4663d87 100644 --- a/api/app/Http/Controllers/Api/V1/ShiftController.php +++ b/api/app/Http/Controllers/Api/V1/ShiftController.php @@ -71,7 +71,8 @@ final class ShiftController extends Controller { Gate::authorize('assign', [$shift, $event, $section]); - $person = Person::findOrFail($request->validated('person_id')); + $festivalEventId = $event->parent_event_id ?? $event->id; + $person = Person::where('event_id', $festivalEventId)->findOrFail($request->validated('person_id')); $assignment = $this->shiftAssignmentService->assign($shift, $person, $request->user()); return $this->created(new ShiftAssignmentResource($assignment)); @@ -81,7 +82,8 @@ final class ShiftController extends Controller { Gate::authorize('claim', [$shift, $event, $section]); - $person = Person::findOrFail($request->validated('person_id')); + $festivalEventId = $event->parent_event_id ?? $event->id; + $person = Person::where('event_id', $festivalEventId)->findOrFail($request->validated('person_id')); $assignment = $this->shiftAssignmentService->claim($shift, $person); return $this->created(new ShiftAssignmentResource($assignment)); diff --git a/api/app/Http/Requests/Api/V1/AddPersonToCrowdListRequest.php b/api/app/Http/Requests/Api/V1/AddPersonToCrowdListRequest.php index 3c5076b3..303175fa 100644 --- a/api/app/Http/Requests/Api/V1/AddPersonToCrowdListRequest.php +++ b/api/app/Http/Requests/Api/V1/AddPersonToCrowdListRequest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Requests\Api\V1; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; final class AddPersonToCrowdListRequest extends FormRequest { @@ -16,8 +17,11 @@ final class AddPersonToCrowdListRequest extends FormRequest /** @return array */ public function rules(): array { + $event = $this->route('event'); + $festivalEventId = $event->parent_event_id ?? $event->id; + return [ - 'person_id' => ['required', 'ulid', 'exists:persons,id'], + 'person_id' => ['required', 'ulid', Rule::exists('persons', 'id')->where('event_id', $festivalEventId)], ]; } } diff --git a/api/app/Http/Requests/Api/V1/AssignShiftRequest.php b/api/app/Http/Requests/Api/V1/AssignShiftRequest.php index 2fb94b93..39f8da47 100644 --- a/api/app/Http/Requests/Api/V1/AssignShiftRequest.php +++ b/api/app/Http/Requests/Api/V1/AssignShiftRequest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Requests\Api\V1; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; final class AssignShiftRequest extends FormRequest { @@ -16,8 +17,11 @@ final class AssignShiftRequest extends FormRequest /** @return array */ public function rules(): array { + $event = $this->route('event'); + $festivalEventId = $event->parent_event_id ?? $event->id; + return [ - 'person_id' => ['required', 'ulid', 'exists:persons,id'], + 'person_id' => ['required', 'ulid', Rule::exists('persons', 'id')->where('event_id', $festivalEventId)], ]; } } diff --git a/api/app/Http/Requests/Api/V1/BulkApproveShiftAssignmentRequest.php b/api/app/Http/Requests/Api/V1/BulkApproveShiftAssignmentRequest.php index 16baf643..251742f1 100644 --- a/api/app/Http/Requests/Api/V1/BulkApproveShiftAssignmentRequest.php +++ b/api/app/Http/Requests/Api/V1/BulkApproveShiftAssignmentRequest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Requests\Api\V1; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; final class BulkApproveShiftAssignmentRequest extends FormRequest { @@ -16,9 +17,22 @@ final class BulkApproveShiftAssignmentRequest extends FormRequest /** @return array */ public function rules(): array { + $event = $this->route('event'); + return [ 'assignment_ids' => ['required', 'array', 'min:1', 'max:100'], - 'assignment_ids.*' => ['required', 'ulid', 'exists:shift_assignments,id'], + 'assignment_ids.*' => [ + 'required', 'ulid', + Rule::exists('shift_assignments', 'id')->where(function ($query) use ($event) { + $query->whereIn('shift_id', function ($q) use ($event) { + $q->select('id')->from('shifts') + ->whereIn('festival_section_id', function ($q2) use ($event) { + $q2->select('id')->from('festival_sections') + ->where('event_id', $event->id); + }); + }); + }), + ], ]; } } diff --git a/api/app/Http/Requests/Api/V1/BulkConfirmIdentityMatchesRequest.php b/api/app/Http/Requests/Api/V1/BulkConfirmIdentityMatchesRequest.php index 595ab9fa..1220e15b 100644 --- a/api/app/Http/Requests/Api/V1/BulkConfirmIdentityMatchesRequest.php +++ b/api/app/Http/Requests/Api/V1/BulkConfirmIdentityMatchesRequest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Requests\Api\V1; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; final class BulkConfirmIdentityMatchesRequest extends FormRequest { @@ -16,9 +17,22 @@ final class BulkConfirmIdentityMatchesRequest extends FormRequest /** @return array */ public function rules(): array { + $organisation = $this->route('organisation'); + return [ 'match_ids' => ['required', 'array', 'min:1', 'max:100'], - 'match_ids.*' => ['required', 'string', 'exists:person_identity_matches,id'], + 'match_ids.*' => [ + 'required', 'string', + Rule::exists('person_identity_matches', 'id')->where(function ($query) use ($organisation) { + $query->whereIn('person_id', function ($q) use ($organisation) { + $q->select('id')->from('persons') + ->whereIn('event_id', function ($q2) use ($organisation) { + $q2->select('id')->from('events') + ->where('organisation_id', $organisation->id); + }); + }); + }), + ], ]; } } diff --git a/api/app/Http/Requests/Api/V1/ImportFromEventRequest.php b/api/app/Http/Requests/Api/V1/ImportFromEventRequest.php index 862986c4..3184430d 100644 --- a/api/app/Http/Requests/Api/V1/ImportFromEventRequest.php +++ b/api/app/Http/Requests/Api/V1/ImportFromEventRequest.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace App\Http\Requests\Api\V1; -use App\Models\Event; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; final class ImportFromEventRequest extends FormRequest { @@ -18,24 +18,10 @@ final class ImportFromEventRequest extends FormRequest public function rules(): array { return [ - 'source_event_id' => ['required', 'ulid', 'exists:events,id'], + 'source_event_id' => [ + 'required', 'ulid', + Rule::exists('events', 'id')->where('organisation_id', $this->route('event')->organisation_id), + ], ]; } - - public function withValidator($validator): void - { - $validator->after(function ($validator) { - $sourceEventId = $this->input('source_event_id'); - if (!$sourceEventId) { - return; - } - - $sourceEvent = Event::find($sourceEventId); - $targetEvent = $this->route('event'); - - if ($sourceEvent && $targetEvent && $sourceEvent->organisation_id !== $targetEvent->organisation_id) { - $validator->errors()->add('source_event_id', 'Source event must belong to the same organisation.'); - } - }); - } } diff --git a/api/app/Http/Requests/Api/V1/ReorderRegistrationFormFieldsRequest.php b/api/app/Http/Requests/Api/V1/ReorderRegistrationFormFieldsRequest.php index 80f39abc..9fa63778 100644 --- a/api/app/Http/Requests/Api/V1/ReorderRegistrationFormFieldsRequest.php +++ b/api/app/Http/Requests/Api/V1/ReorderRegistrationFormFieldsRequest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Requests\Api\V1; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; final class ReorderRegistrationFormFieldsRequest extends FormRequest { @@ -16,9 +17,11 @@ final class ReorderRegistrationFormFieldsRequest extends FormRequest /** @return array */ public function rules(): array { + $event = $this->route('event'); + return [ 'ids' => ['required', 'array', 'min:1'], - 'ids.*' => ['required', 'ulid', 'exists:registration_form_fields,id'], + 'ids.*' => ['required', 'ulid', Rule::exists('registration_form_fields', 'id')->where('event_id', $event->id)], ]; } } diff --git a/api/app/Http/Requests/Api/V1/StoreCrowdListRequest.php b/api/app/Http/Requests/Api/V1/StoreCrowdListRequest.php index b38f88fc..7a5b68bc 100644 --- a/api/app/Http/Requests/Api/V1/StoreCrowdListRequest.php +++ b/api/app/Http/Requests/Api/V1/StoreCrowdListRequest.php @@ -19,10 +19,10 @@ final class StoreCrowdListRequest extends FormRequest public function rules(): array { return [ - 'crowd_type_id' => ['required', 'ulid', 'exists:crowd_types,id'], + 'crowd_type_id' => ['required', 'ulid', Rule::exists('crowd_types', 'id')->where('organisation_id', $this->route('event')->organisation_id)], 'name' => ['required', 'string', 'max:255'], 'type' => ['required', Rule::enum(CrowdListType::class)], - 'recipient_company_id' => ['nullable', 'ulid', 'exists:companies,id'], + 'recipient_company_id' => ['nullable', 'ulid', Rule::exists('companies', 'id')->where('organisation_id', $this->route('event')->organisation_id)], 'auto_approve' => ['sometimes', 'boolean'], 'max_persons' => ['nullable', 'integer', 'min:1'], ]; diff --git a/api/app/Http/Requests/Api/V1/StoreEventRequest.php b/api/app/Http/Requests/Api/V1/StoreEventRequest.php index 9bf4f02f..641509ea 100644 --- a/api/app/Http/Requests/Api/V1/StoreEventRequest.php +++ b/api/app/Http/Requests/Api/V1/StoreEventRequest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Requests\Api\V1; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; final class StoreEventRequest extends FormRequest { @@ -23,7 +24,7 @@ final class StoreEventRequest extends FormRequest 'end_date' => ['required', 'date', 'after_or_equal:start_date'], 'timezone' => ['sometimes', 'string', 'max:50'], 'status' => ['sometimes', 'string', 'in:draft,published,registration_open,buildup,showday,teardown,closed'], - 'parent_event_id' => ['nullable', 'ulid', 'exists:events,id'], + 'parent_event_id' => ['nullable', 'ulid', Rule::exists('events', 'id')->where('organisation_id', $this->route('organisation')->id)], 'event_type' => ['nullable', 'in:event,festival,series'], 'event_type_label' => ['nullable', 'string', 'max:50'], 'sub_event_label' => ['nullable', 'string', 'max:50'], diff --git a/api/app/Http/Requests/Api/V1/StorePersonRequest.php b/api/app/Http/Requests/Api/V1/StorePersonRequest.php index 6f66f81b..a69b4291 100644 --- a/api/app/Http/Requests/Api/V1/StorePersonRequest.php +++ b/api/app/Http/Requests/Api/V1/StorePersonRequest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Requests\Api\V1; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; final class StorePersonRequest extends FormRequest { @@ -16,14 +17,16 @@ final class StorePersonRequest extends FormRequest /** @return array */ public function rules(): array { + $orgId = $this->route('event')->organisation_id; + return [ - 'crowd_type_id' => ['required', 'ulid', 'exists:crowd_types,id'], + 'crowd_type_id' => ['required', 'ulid', Rule::exists('crowd_types', 'id')->where('organisation_id', $orgId)], 'first_name' => ['required', 'string', 'max:255'], 'last_name' => ['required', 'string', 'max:255'], 'date_of_birth' => ['nullable', 'date', 'before:today'], 'email' => ['required', 'email', 'max:255'], 'phone' => ['nullable', 'string', 'max:30'], - 'company_id' => ['nullable', 'ulid', 'exists:companies,id'], + 'company_id' => ['nullable', 'ulid', Rule::exists('companies', 'id')->where('organisation_id', $orgId)], 'status' => ['nullable', 'in:invited,applied,pending,approved,rejected,no_show'], 'remarks' => ['nullable', 'string', 'max:5000'], 'custom_fields' => ['nullable', 'array'], diff --git a/api/app/Http/Requests/Api/V1/StoreShiftRequest.php b/api/app/Http/Requests/Api/V1/StoreShiftRequest.php index 1379bc63..495d4770 100644 --- a/api/app/Http/Requests/Api/V1/StoreShiftRequest.php +++ b/api/app/Http/Requests/Api/V1/StoreShiftRequest.php @@ -28,7 +28,7 @@ final class StoreShiftRequest extends FormRequest $query->whereIn('event_id', $eventIds); })], - 'location_id' => ['nullable', 'ulid', 'exists:locations,id'], + 'location_id' => ['nullable', 'ulid', Rule::exists('locations', 'id')->where('event_id', $this->route('event')->id)], 'title' => ['nullable', 'string', 'max:255'], 'description' => ['nullable', 'string'], 'instructions' => ['nullable', 'string'], diff --git a/api/app/Http/Requests/Api/V1/SyncVolunteerAvailabilityRequest.php b/api/app/Http/Requests/Api/V1/SyncVolunteerAvailabilityRequest.php index f6571ce0..a805a147 100644 --- a/api/app/Http/Requests/Api/V1/SyncVolunteerAvailabilityRequest.php +++ b/api/app/Http/Requests/Api/V1/SyncVolunteerAvailabilityRequest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Requests\Api\V1; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; final class SyncVolunteerAvailabilityRequest extends FormRequest { @@ -16,9 +17,20 @@ final class SyncVolunteerAvailabilityRequest extends FormRequest /** @return array */ public function rules(): array { + $event = $this->route('event'); + $eventIds = [$event->id]; + if ($event->parent_event_id) { + $eventIds[] = $event->parent_event_id; + } elseif ($event->isFestival()) { + $eventIds = array_merge($eventIds, $event->children()->pluck('id')->all()); + } + return [ 'availabilities' => ['required', 'array'], - 'availabilities.*.time_slot_id' => ['required', 'ulid', 'exists:time_slots,id'], + 'availabilities.*.time_slot_id' => [ + 'required', 'ulid', + Rule::exists('time_slots', 'id')->whereIn('event_id', $eventIds), + ], 'availabilities.*.preference_level' => ['sometimes', 'integer', 'min:1', 'max:5'], ]; } diff --git a/api/app/Http/Requests/Api/V1/UpdateCrowdListRequest.php b/api/app/Http/Requests/Api/V1/UpdateCrowdListRequest.php index 9115847e..51d6f88c 100644 --- a/api/app/Http/Requests/Api/V1/UpdateCrowdListRequest.php +++ b/api/app/Http/Requests/Api/V1/UpdateCrowdListRequest.php @@ -19,10 +19,10 @@ final class UpdateCrowdListRequest extends FormRequest public function rules(): array { return [ - 'crowd_type_id' => ['sometimes', 'ulid', 'exists:crowd_types,id'], + 'crowd_type_id' => ['sometimes', 'ulid', Rule::exists('crowd_types', 'id')->where('organisation_id', $this->route('event')->organisation_id)], 'name' => ['sometimes', 'string', 'max:255'], 'type' => ['sometimes', Rule::enum(CrowdListType::class)], - 'recipient_company_id' => ['nullable', 'ulid', 'exists:companies,id'], + 'recipient_company_id' => ['nullable', 'ulid', Rule::exists('companies', 'id')->where('organisation_id', $this->route('event')->organisation_id)], 'auto_approve' => ['sometimes', 'boolean'], 'max_persons' => ['nullable', 'integer', 'min:1'], ]; diff --git a/api/app/Http/Requests/Api/V1/UpdateEventRequest.php b/api/app/Http/Requests/Api/V1/UpdateEventRequest.php index b8889ba6..9bd6f792 100644 --- a/api/app/Http/Requests/Api/V1/UpdateEventRequest.php +++ b/api/app/Http/Requests/Api/V1/UpdateEventRequest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Requests\Api\V1; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; final class UpdateEventRequest extends FormRequest { @@ -23,7 +24,7 @@ final class UpdateEventRequest extends FormRequest 'start_date' => ['sometimes', 'date'], 'end_date' => ['sometimes', 'date', 'after_or_equal:start_date'], 'timezone' => ['sometimes', 'string', 'max:50'], - 'parent_event_id' => ['nullable', 'ulid', 'exists:events,id'], + 'parent_event_id' => ['nullable', 'ulid', Rule::exists('events', 'id')->where('organisation_id', $this->route('organisation')->id)], 'event_type' => ['sometimes', 'in:event,festival,series'], 'event_type_label' => ['nullable', 'string', 'max:50'], 'sub_event_label' => ['nullable', 'string', 'max:50'], diff --git a/api/app/Http/Requests/Api/V1/UpdatePersonRequest.php b/api/app/Http/Requests/Api/V1/UpdatePersonRequest.php index ddbfcd40..ee5d3e33 100644 --- a/api/app/Http/Requests/Api/V1/UpdatePersonRequest.php +++ b/api/app/Http/Requests/Api/V1/UpdatePersonRequest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Requests\Api\V1; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; final class UpdatePersonRequest extends FormRequest { @@ -16,14 +17,16 @@ final class UpdatePersonRequest extends FormRequest /** @return array */ public function rules(): array { + $orgId = $this->route('event')->organisation_id; + return [ - 'crowd_type_id' => ['sometimes', 'ulid', 'exists:crowd_types,id'], + 'crowd_type_id' => ['sometimes', 'ulid', Rule::exists('crowd_types', 'id')->where('organisation_id', $orgId)], 'first_name' => ['sometimes', 'string', 'max:255'], 'last_name' => ['sometimes', 'string', 'max:255'], 'date_of_birth' => ['nullable', 'date', 'before:today'], 'email' => ['sometimes', 'email', 'max:255'], 'phone' => ['nullable', 'string', 'max:30'], - 'company_id' => ['nullable', 'ulid', 'exists:companies,id'], + 'company_id' => ['nullable', 'ulid', Rule::exists('companies', 'id')->where('organisation_id', $orgId)], 'status' => ['sometimes', 'in:invited,applied,pending,approved,rejected,no_show'], 'is_blacklisted' => ['sometimes', 'boolean'], 'admin_notes' => ['nullable', 'string'], diff --git a/api/app/Http/Requests/Api/V1/UpdateShiftRequest.php b/api/app/Http/Requests/Api/V1/UpdateShiftRequest.php index dcf96917..9f8d34e5 100644 --- a/api/app/Http/Requests/Api/V1/UpdateShiftRequest.php +++ b/api/app/Http/Requests/Api/V1/UpdateShiftRequest.php @@ -28,7 +28,7 @@ final class UpdateShiftRequest extends FormRequest $query->whereIn('event_id', $eventIds); })], - 'location_id' => ['nullable', 'ulid', 'exists:locations,id'], + 'location_id' => ['nullable', 'ulid', Rule::exists('locations', 'id')->where('event_id', $this->route('event')->id)], 'title' => ['nullable', 'string', 'max:255'], 'description' => ['nullable', 'string'], 'instructions' => ['nullable', 'string'], diff --git a/api/app/Models/Company.php b/api/app/Models/Company.php index 60f479ad..2078b84b 100644 --- a/api/app/Models/Company.php +++ b/api/app/Models/Company.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models; +use App\Models\Scopes\OrganisationScope; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -18,6 +19,11 @@ final class Company extends Model use HasUlids; use SoftDeletes; + protected static function booted(): void + { + static::addGlobalScope(new OrganisationScope()); + } + protected $fillable = [ 'organisation_id', 'name', diff --git a/api/app/Models/CrowdList.php b/api/app/Models/CrowdList.php index 5e504dc3..845fa088 100644 --- a/api/app/Models/CrowdList.php +++ b/api/app/Models/CrowdList.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Models; use App\Enums\CrowdListType; +use App\Models\Scopes\OrganisationScope; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -16,6 +17,14 @@ final class CrowdList extends Model use HasFactory; use HasUlids; + /** @var string Used by OrganisationScope to determine filtering strategy */ + public string $organisationScopeColumn = 'event_id'; + + protected static function booted(): void + { + static::addGlobalScope(new OrganisationScope()); + } + protected $fillable = [ 'event_id', 'crowd_type_id', diff --git a/api/app/Models/CrowdType.php b/api/app/Models/CrowdType.php index 3c4ddea7..a48f2123 100644 --- a/api/app/Models/CrowdType.php +++ b/api/app/Models/CrowdType.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models; +use App\Models\Scopes\OrganisationScope; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -15,6 +16,11 @@ final class CrowdType extends Model use HasFactory; use HasUlids; + protected static function booted(): void + { + static::addGlobalScope(new OrganisationScope()); + } + protected $fillable = [ 'organisation_id', 'name', diff --git a/api/app/Models/Event.php b/api/app/Models/Event.php index 01afe3f7..b43fdc65 100644 --- a/api/app/Models/Event.php +++ b/api/app/Models/Event.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models; +use App\Models\Scopes\OrganisationScope; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -20,6 +21,14 @@ final class Event extends Model use HasUlids; use SoftDeletes; + /** @var string Used by OrganisationScope to determine filtering strategy */ + public string $organisationScopeColumn = 'organisation_id'; + + protected static function booted(): void + { + static::addGlobalScope(new OrganisationScope()); + } + /** @var array> Allowed status transitions */ public const STATUS_TRANSITIONS = [ 'draft' => ['published'], diff --git a/api/app/Models/FestivalSection.php b/api/app/Models/FestivalSection.php index 2763b56d..f25ac344 100644 --- a/api/app/Models/FestivalSection.php +++ b/api/app/Models/FestivalSection.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models; +use App\Models\Scopes\OrganisationScope; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -18,6 +19,14 @@ final class FestivalSection extends Model use HasUlids; use SoftDeletes; + /** @var string Used by OrganisationScope to determine filtering strategy */ + public string $organisationScopeColumn = 'event_id'; + + protected static function booted(): void + { + static::addGlobalScope(new OrganisationScope()); + } + protected $fillable = [ 'event_id', 'name', diff --git a/api/app/Models/Location.php b/api/app/Models/Location.php index 0cfb7d0b..d26e02d0 100644 --- a/api/app/Models/Location.php +++ b/api/app/Models/Location.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models; +use App\Models\Scopes\OrganisationScope; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -14,6 +15,14 @@ final class Location extends Model use HasFactory; use HasUlids; + /** @var string Used by OrganisationScope to determine filtering strategy */ + public string $organisationScopeColumn = 'event_id'; + + protected static function booted(): void + { + static::addGlobalScope(new OrganisationScope()); + } + protected $fillable = [ 'event_id', 'name', diff --git a/api/app/Models/Person.php b/api/app/Models/Person.php index 99d9e469..a492e18b 100644 --- a/api/app/Models/Person.php +++ b/api/app/Models/Person.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Models; use App\Enums\IdentityMatchStatus; +use App\Models\Scopes\OrganisationScope; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -21,6 +22,14 @@ final class Person extends Model use HasUlids; use SoftDeletes; + /** @var string Used by OrganisationScope to determine filtering strategy */ + public string $organisationScopeColumn = 'event_id'; + + protected static function booted(): void + { + static::addGlobalScope(new OrganisationScope()); + } + protected $table = 'persons'; protected $fillable = [ diff --git a/api/app/Models/PersonTag.php b/api/app/Models/PersonTag.php index 608aef40..b12edc48 100644 --- a/api/app/Models/PersonTag.php +++ b/api/app/Models/PersonTag.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models; +use App\Models\Scopes\OrganisationScope; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -15,6 +16,11 @@ final class PersonTag extends Model use HasFactory; use HasUlids; + protected static function booted(): void + { + static::addGlobalScope(new OrganisationScope()); + } + protected $fillable = [ 'name', 'category', diff --git a/api/app/Models/RegistrationFieldTemplate.php b/api/app/Models/RegistrationFieldTemplate.php index d77723f3..e62e103a 100644 --- a/api/app/Models/RegistrationFieldTemplate.php +++ b/api/app/Models/RegistrationFieldTemplate.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Models; use App\Enums\RegistrationFieldType; +use App\Models\Scopes\OrganisationScope; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -16,6 +17,11 @@ final class RegistrationFieldTemplate extends Model use HasFactory; use HasUlids; + protected static function booted(): void + { + static::addGlobalScope(new OrganisationScope()); + } + protected $fillable = [ 'organisation_id', 'label', diff --git a/api/app/Models/RegistrationFormField.php b/api/app/Models/RegistrationFormField.php index 7ddc5218..1203e9c7 100644 --- a/api/app/Models/RegistrationFormField.php +++ b/api/app/Models/RegistrationFormField.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Models; use App\Enums\RegistrationFieldType; +use App\Models\Scopes\OrganisationScope; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -17,6 +18,14 @@ final class RegistrationFormField extends Model use HasFactory; use HasUlids; + /** @var string Used by OrganisationScope to determine filtering strategy */ + public string $organisationScopeColumn = 'event_id'; + + protected static function booted(): void + { + static::addGlobalScope(new OrganisationScope()); + } + protected $fillable = [ 'event_id', 'label', diff --git a/api/app/Models/Scopes/OrganisationScope.php b/api/app/Models/Scopes/OrganisationScope.php index ab8aba92..02e2c5a5 100644 --- a/api/app/Models/Scopes/OrganisationScope.php +++ b/api/app/Models/Scopes/OrganisationScope.php @@ -4,22 +4,23 @@ declare(strict_types=1); namespace App\Models\Scopes; +use App\Models\Event; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Scope; /** - * Global scope that filters event-related models by organisation_id. + * Global scope that filters models by organisation_id. * - * Resolves the organisation from (in order): - * 1. An explicitly provided organisation ID (constructor) - * 2. The route parameter 'organisation' + * Resolution order: + * 1. Explicitly provided organisation ID (constructor) + * 2. Route parameter 'organisation' (object or string) + * 3. Skip scope if no context (CLI, queue jobs, unauthenticated) * - * IMPORTANT: This scope is route-dependent — it only works when the - * 'organisation' parameter is present in the URL (e.g. /organisations/{organisation}/events). - * Routes without an organisation segment (e.g. GET /events/{event}) will NOT be scoped, - * silently bypassing multi-tenancy. All new routes that touch organisation-owned - * data MUST be nested under /organisations/{organisation}/... to guarantee scoping. + * Models declare their scoping strategy via the $organisationScopeColumn property: + * - 'organisation_id' (default) — model has a direct organisation_id column + * - 'event_id' — model is scoped through events.organisation_id + * - 'festival_section_id' — model is scoped through festival_sections.event_id → events.organisation_id */ final class OrganisationScope implements Scope { @@ -29,12 +30,61 @@ final class OrganisationScope implements Scope public function apply(Builder $builder, Model $model): void { - $id = $this->organisationId - ?? request()->route('organisation')?->id - ?? (is_string(request()->route('organisation')) ? request()->route('organisation') : null); + $id = $this->resolveOrganisationId(); - if ($id) { - $builder->where($model->getTable() . '.organisation_id', $id); + if ($id === null) { + return; } + + $column = $model->organisationScopeColumn ?? 'organisation_id'; + + match ($column) { + 'organisation_id' => $builder->where($model->getTable() . '.organisation_id', $id), + 'event_id' => $builder->whereIn( + $model->getTable() . '.event_id', + Event::withoutGlobalScope(self::class) + ->where('organisation_id', $id) + ->select('id') + ), + 'festival_section_id' => $builder->whereIn( + $model->getTable() . '.festival_section_id', + \App\Models\FestivalSection::withoutGlobalScope(self::class) + ->whereIn('event_id', Event::withoutGlobalScope(self::class) + ->where('organisation_id', $id) + ->select('id')) + ->select('id') + ), + default => null, + }; + } + + private function resolveOrganisationId(): ?string + { + if ($this->organisationId !== null) { + return $this->organisationId; + } + + $route = request()->route(); + if ($route === null) { + return null; + } + + $org = $route->parameter('organisation'); + + if ($org instanceof \App\Models\Organisation) { + return $org->id; + } + + if (is_string($org) && $org !== '') { + return $org; + } + + // Fall back to the event's organisation if we're on an event route + $event = $route->parameter('event'); + if ($event instanceof Event) { + return $event->organisation_id; + } + + return null; } } diff --git a/api/app/Models/Shift.php b/api/app/Models/Shift.php index 5388d4af..f9fdf6f5 100644 --- a/api/app/Models/Shift.php +++ b/api/app/Models/Shift.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Models; use App\Enums\ShiftAssignmentStatus; +use App\Models\Scopes\OrganisationScope; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Concerns\HasUlids; @@ -20,6 +21,14 @@ final class Shift extends Model use HasUlids; use SoftDeletes; + /** @var string Used by OrganisationScope to determine filtering strategy */ + public string $organisationScopeColumn = 'festival_section_id'; + + protected static function booted(): void + { + static::addGlobalScope(new OrganisationScope()); + } + protected $fillable = [ 'festival_section_id', 'time_slot_id', diff --git a/api/app/Models/TimeSlot.php b/api/app/Models/TimeSlot.php index a3b64619..e3341aa2 100644 --- a/api/app/Models/TimeSlot.php +++ b/api/app/Models/TimeSlot.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models; +use App\Models\Scopes\OrganisationScope; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -17,6 +18,14 @@ final class TimeSlot extends Model use HasFactory; use HasUlids; + /** @var string Used by OrganisationScope to determine filtering strategy */ + public string $organisationScopeColumn = 'event_id'; + + protected static function booted(): void + { + static::addGlobalScope(new OrganisationScope()); + } + protected $fillable = [ 'event_id', 'name', diff --git a/api/app/Policies/ShiftAssignmentPolicy.php b/api/app/Policies/ShiftAssignmentPolicy.php index b23c6534..53845df7 100644 --- a/api/app/Policies/ShiftAssignmentPolicy.php +++ b/api/app/Policies/ShiftAssignmentPolicy.php @@ -18,16 +18,28 @@ final class ShiftAssignmentPolicy public function approve(User $user, ShiftAssignment $assignment, Event $event): bool { + if ($assignment->shift->festivalSection->event_id !== $event->id) { + return false; + } + return $this->canManageEvent($user, $event); } public function reject(User $user, ShiftAssignment $assignment, Event $event): bool { + if ($assignment->shift->festivalSection->event_id !== $event->id) { + return false; + } + return $this->canManageEvent($user, $event); } public function cancel(User $user, ShiftAssignment $assignment, Event $event): bool { + if ($assignment->shift->festivalSection->event_id !== $event->id) { + return false; + } + if ($this->canManageEvent($user, $event)) { return true; } diff --git a/api/tests/Feature/Api/V1/EventImageUploadTest.php b/api/tests/Feature/Api/V1/EventImageUploadTest.php index 4ac910c7..629c8308 100644 --- a/api/tests/Feature/Api/V1/EventImageUploadTest.php +++ b/api/tests/Feature/Api/V1/EventImageUploadTest.php @@ -134,7 +134,7 @@ class EventImageUploadTest extends TestCase ] ); - $response->assertForbidden(); + $response->assertNotFound(); } public function test_registration_data_includes_branding_fields(): void diff --git a/api/tests/Feature/Event/EventTest.php b/api/tests/Feature/Event/EventTest.php index 0af876e7..530c6e49 100644 --- a/api/tests/Feature/Event/EventTest.php +++ b/api/tests/Feature/Event/EventTest.php @@ -255,7 +255,7 @@ class EventTest extends TestCase $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}"); - $response->assertForbidden(); + $response->assertNotFound(); } public function test_update_event_from_other_org_is_blocked(): void @@ -269,7 +269,7 @@ class EventTest extends TestCase 'name' => 'Cross-org hack', ]); - $response->assertForbidden(); + $response->assertNotFound(); } // --- UNAUTHENTICATED --- @@ -370,7 +370,7 @@ class EventTest extends TestCase $response = $this->deleteJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}"); - $response->assertForbidden(); + $response->assertNotFound(); } public function test_soft_deleted_event_not_in_index(): void diff --git a/api/tests/Feature/Event/FestivalEventTest.php b/api/tests/Feature/Event/FestivalEventTest.php index ad4da0c0..e21efc5a 100644 --- a/api/tests/Feature/Event/FestivalEventTest.php +++ b/api/tests/Feature/Event/FestivalEventTest.php @@ -550,9 +550,9 @@ class FestivalEventTest extends TestCase 'allow_overlap' => false, ]); - // Create a person + // Create a person (on festival level, per schema design) $person = Person::factory()->create([ - 'event_id' => $this->subEvent->id, + 'event_id' => $this->festival->id, 'crowd_type_id' => $this->crowdType->id, ]); diff --git a/api/tests/Feature/Security/MultiTenancyIsolationTest.php b/api/tests/Feature/Security/MultiTenancyIsolationTest.php new file mode 100644 index 00000000..9d8465eb --- /dev/null +++ b/api/tests/Feature/Security/MultiTenancyIsolationTest.php @@ -0,0 +1,297 @@ +seed(RoleSeeder::class); + + // Organisation A + $this->orgA = Organisation::factory()->create(); + $this->adminA = User::factory()->create(); + $this->orgA->users()->attach($this->adminA, ['role' => 'org_admin']); + $this->crowdTypeA = CrowdType::factory()->systemType('VOLUNTEER')->create(['organisation_id' => $this->orgA->id]); + $this->eventA = Event::factory()->create([ + 'organisation_id' => $this->orgA->id, + 'status' => 'registration_open', + ]); + + // Organisation B + $this->orgB = Organisation::factory()->create(); + $this->adminB = User::factory()->create(); + $this->orgB->users()->attach($this->adminB, ['role' => 'org_admin']); + $this->crowdTypeB = CrowdType::factory()->systemType('VOLUNTEER')->create(['organisation_id' => $this->orgB->id]); + $this->eventB = Event::factory()->create([ + 'organisation_id' => $this->orgB->id, + 'status' => 'registration_open', + ]); + } + + // --- Cross-tenant event access --- + + public function test_cannot_view_other_org_event(): void + { + Sanctum::actingAs($this->adminA); + + $response = $this->getJson("/api/v1/organisations/{$this->orgA->id}/events/{$this->eventB->id}"); + + $response->assertNotFound(); + } + + public function test_cannot_list_other_org_events(): void + { + Sanctum::actingAs($this->adminA); + + $response = $this->getJson("/api/v1/organisations/{$this->orgA->id}/events"); + + $response->assertOk(); + $eventIds = collect($response->json('data'))->pluck('id'); + $this->assertContains($this->eventA->id, $eventIds->all()); + $this->assertNotContains($this->eventB->id, $eventIds->all()); + } + + // --- Cross-tenant person assignment --- + + public function test_cannot_create_person_with_other_org_crowd_type(): void + { + Sanctum::actingAs($this->adminA); + + $response = $this->postJson("/api/v1/events/{$this->eventA->id}/persons", [ + 'crowd_type_id' => $this->crowdTypeB->id, + 'first_name' => 'Test', + 'last_name' => 'User', + 'email' => 'test@example.com', + ]); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors('crowd_type_id'); + } + + public function test_cannot_assign_person_from_other_org_to_shift(): void + { + Sanctum::actingAs($this->adminA); + + $personB = Person::factory()->approved()->create([ + 'event_id' => $this->eventB->id, + 'crowd_type_id' => $this->crowdTypeB->id, + ]); + + $sectionA = FestivalSection::factory()->create(['event_id' => $this->eventA->id]); + $timeSlotA = TimeSlot::factory()->create(['event_id' => $this->eventA->id]); + $shiftA = Shift::factory()->create([ + 'festival_section_id' => $sectionA->id, + 'time_slot_id' => $timeSlotA->id, + ]); + + $response = $this->postJson( + "/api/v1/events/{$this->eventA->id}/sections/{$sectionA->id}/shifts/{$shiftA->id}/assign", + ['person_id' => $personB->id] + ); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors('person_id'); + } + + // --- Cross-tenant crowd list operations --- + + public function test_cannot_add_person_from_other_org_to_crowd_list(): void + { + Sanctum::actingAs($this->adminA); + + $personB = Person::factory()->approved()->create([ + 'event_id' => $this->eventB->id, + 'crowd_type_id' => $this->crowdTypeB->id, + ]); + + $crowdListA = CrowdList::factory()->create([ + 'event_id' => $this->eventA->id, + 'crowd_type_id' => $this->crowdTypeA->id, + ]); + + $response = $this->postJson( + "/api/v1/events/{$this->eventA->id}/crowd-lists/{$crowdListA->id}/persons", + ['person_id' => $personB->id] + ); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors('person_id'); + } + + // --- Cross-tenant bulk operations --- + + public function test_cannot_bulk_approve_other_org_assignments(): void + { + Sanctum::actingAs($this->adminA); + + $sectionB = FestivalSection::factory()->create(['event_id' => $this->eventB->id]); + $timeSlotB = TimeSlot::factory()->create(['event_id' => $this->eventB->id]); + $shiftB = Shift::factory()->create([ + 'festival_section_id' => $sectionB->id, + 'time_slot_id' => $timeSlotB->id, + ]); + $personB = Person::factory()->approved()->create([ + 'event_id' => $this->eventB->id, + 'crowd_type_id' => $this->crowdTypeB->id, + ]); + $assignmentB = ShiftAssignment::factory()->create([ + 'shift_id' => $shiftB->id, + 'person_id' => $personB->id, + 'time_slot_id' => $timeSlotB->id, + ]); + + $response = $this->postJson( + "/api/v1/events/{$this->eventA->id}/shift-assignments/bulk-approve", + ['assignment_ids' => [$assignmentB->id]] + ); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors('assignment_ids.0'); + } + + // --- Cross-tenant invitation revocation --- + + public function test_cannot_revoke_other_org_invitation(): void + { + Sanctum::actingAs($this->adminA); + + $invitationB = UserInvitation::factory()->create([ + 'organisation_id' => $this->orgB->id, + 'invited_by_user_id' => $this->adminB->id, + ]); + + $response = $this->deleteJson( + "/api/v1/organisations/{$this->orgA->id}/invitations/{$invitationB->id}" + ); + + $response->assertNotFound(); + } + + // --- Cross-tenant company reference --- + + public function test_cannot_create_person_with_other_org_company(): void + { + Sanctum::actingAs($this->adminA); + + $companyB = Company::factory()->create(['organisation_id' => $this->orgB->id]); + + $response = $this->postJson("/api/v1/events/{$this->eventA->id}/persons", [ + 'crowd_type_id' => $this->crowdTypeA->id, + 'first_name' => 'Test', + 'last_name' => 'User', + 'email' => 'test@example.com', + 'company_id' => $companyB->id, + ]); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors('company_id'); + } + + // --- Cross-tenant crowd list creation --- + + public function test_cannot_create_crowd_list_with_other_org_crowd_type(): void + { + Sanctum::actingAs($this->adminA); + + $response = $this->postJson("/api/v1/events/{$this->eventA->id}/crowd-lists", [ + 'crowd_type_id' => $this->crowdTypeB->id, + 'name' => 'Test List', + 'type' => 'accreditation', + ]); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors('crowd_type_id'); + } + + // --- Cross-tenant event parent reference --- + + public function test_cannot_set_parent_event_from_other_org(): void + { + Sanctum::actingAs($this->adminA); + + $response = $this->putJson( + "/api/v1/organisations/{$this->orgA->id}/events/{$this->eventA->id}", + ['parent_event_id' => $this->eventB->id] + ); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors('parent_event_id'); + } + + // --- OrganisationScope filters correctly --- + + public function test_organisation_scope_filters_persons(): void + { + $personA = Person::factory()->approved()->create([ + 'event_id' => $this->eventA->id, + 'crowd_type_id' => $this->crowdTypeA->id, + ]); + + Person::factory()->approved()->create([ + 'event_id' => $this->eventB->id, + 'crowd_type_id' => $this->crowdTypeB->id, + ]); + + Sanctum::actingAs($this->adminA); + + $response = $this->getJson("/api/v1/events/{$this->eventA->id}/persons"); + + $response->assertOk(); + $personIds = collect($response->json('data'))->pluck('id'); + $this->assertContains($personA->id, $personIds->all()); + $this->assertCount(1, $personIds); + } + + // --- Portal cross-event access --- + + public function test_portal_me_cannot_access_other_org_event(): void + { + $volunteer = User::factory()->create(); + + Person::factory()->approved()->create([ + 'event_id' => $this->eventA->id, + 'crowd_type_id' => $this->crowdTypeA->id, + 'user_id' => $volunteer->id, + 'email' => $volunteer->email, + ]); + + Sanctum::actingAs($volunteer); + + // Volunteer tries to access event from org B (where they have no person record) + $response = $this->getJson("/api/v1/portal/me?event_id={$this->eventB->id}"); + + $response->assertNotFound(); + } +}