From 9acb27af3ae9b7e66933f75f185e0c5ad25781f0 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Wed, 8 Apr 2026 01:34:46 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20fase=202=20backend=20=E2=80=94=20crowd?= =?UTF-8?q?=20types,=20persons,=20sections,=20shifts,=20invite=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Crowd Types + Persons CRUD (73 tests) - Festival Sections + Time Slots + Shifts CRUD met assign/claim flow (84 tests) - Invite Flow + Member Management met InvitationService (109 tests) - Schema v1.6 migraties volledig uitgevoerd - DevSeeder bijgewerkt met crowd types voor testorganisatie --- .gitignore | 3 + .../Console/Commands/ExpireInvitations.php | 24 + .../Controllers/Api/V1/CompanyController.php | 54 + .../Api/V1/CrowdListController.php | 83 + .../Api/V1/CrowdTypeController.php | 58 + .../Api/V1/FestivalSectionController.php | 70 + .../Api/V1/InvitationController.php | 86 + .../Controllers/Api/V1/LocationController.php | 54 + .../Controllers/Api/V1/MemberController.php | 84 + .../Controllers/Api/V1/PersonController.php | 82 + .../Controllers/Api/V1/ShiftController.php | 148 ++ .../Controllers/Api/V1/TimeSlotController.php | 54 + .../Api/V1/AcceptInvitationRequest.php | 29 + .../Requests/Api/V1/AssignShiftRequest.php | 23 + .../Api/V1/ReorderFestivalSectionsRequest.php | 25 + .../Requests/Api/V1/StoreCompanyRequest.php | 27 + .../Requests/Api/V1/StoreCrowdListRequest.php | 28 + .../Requests/Api/V1/StoreCrowdTypeRequest.php | 26 + .../Api/V1/StoreFestivalSectionRequest.php | 27 + .../Api/V1/StoreInvitationRequest.php | 24 + .../Requests/Api/V1/StoreLocationRequest.php | 28 + .../Requests/Api/V1/StorePersonRequest.php | 29 + .../Requests/Api/V1/StoreShiftRequest.php | 36 + .../Requests/Api/V1/StoreTimeSlotRequest.php | 28 + .../Requests/Api/V1/UpdateCompanyRequest.php | 27 + .../Api/V1/UpdateCrowdListRequest.php | 28 + .../Api/V1/UpdateCrowdTypeRequest.php | 26 + .../Api/V1/UpdateFestivalSectionRequest.php | 33 + .../Requests/Api/V1/UpdateLocationRequest.php | 28 + .../Requests/Api/V1/UpdateMemberRequest.php | 23 + .../Requests/Api/V1/UpdatePersonRequest.php | 31 + .../Requests/Api/V1/UpdateShiftRequest.php | 36 + .../Requests/Api/V1/UpdateTimeSlotRequest.php | 28 + .../Http/Resources/Api/V1/CompanyResource.php | 25 + .../Resources/Api/V1/CrowdListResource.php | 27 + .../Resources/Api/V1/CrowdTypeResource.php | 24 + .../Api/V1/FestivalSectionResource.php | 29 + .../Resources/Api/V1/InvitationResource.php | 29 + .../Resources/Api/V1/LocationResource.php | 26 + .../Resources/Api/V1/MemberCollection.php | 37 + .../Http/Resources/Api/V1/MemberResource.php | 29 + .../Resources/Api/V1/PersonCollection.php | 44 + .../Http/Resources/Api/V1/PersonResource.php | 29 + .../Api/V1/ShiftAssignmentResource.php | 24 + .../Http/Resources/Api/V1/ShiftResource.php | 71 + .../Resources/Api/V1/TimeSlotResource.php | 26 + api/app/Mail/InvitationMail.php | 44 + api/app/Models/Company.php | 38 + api/app/Models/CrowdList.php | 56 + api/app/Models/CrowdType.php | 43 + api/app/Models/Event.php | 25 + api/app/Models/FestivalSection.php | 61 + api/app/Models/Location.php | 31 + api/app/Models/Organisation.php | 10 + api/app/Models/Person.php | 91 + api/app/Models/Shift.php | 124 ++ api/app/Models/ShiftAssignment.php | 61 + api/app/Models/ShiftWaitlist.php | 46 + api/app/Models/TimeSlot.php | 57 + api/app/Models/UserInvitation.php | 20 + api/app/Policies/CompanyPolicy.php | 53 + api/app/Policies/CrowdListPolicy.php | 71 + api/app/Policies/CrowdTypePolicy.php | 53 + api/app/Policies/FestivalSectionPolicy.php | 67 + api/app/Policies/LocationPolicy.php | 62 + api/app/Policies/OrganisationPolicy.php | 12 + api/app/Policies/PersonPolicy.php | 81 + api/app/Policies/ShiftPolicy.php | 82 + api/app/Policies/TimeSlotPolicy.php | 62 + api/app/Services/InvitationService.php | 109 ++ api/config/app.php | 2 + api/database/factories/CompanyFactory.php | 26 + api/database/factories/CrowdTypeFactory.php | 50 + .../factories/FestivalSectionFactory.php | 43 + api/database/factories/LocationFactory.php | 34 + api/database/factories/PersonFactory.php | 34 + api/database/factories/ShiftFactory.php | 52 + api/database/factories/TimeSlotFactory.php | 33 + ...26_04_07_260000_create_locations_table.php | 32 + ..._270000_create_festival_sections_table.php | 29 + ...6_04_07_280000_create_time_slots_table.php | 32 + ...remove_volunteer_min_hours_from_events.php | 28 + ...00_remove_route_geojson_from_locations.php | 28 + ..._section_settings_to_festival_sections.php | 42 + .../2026_04_08_130000_create_shifts_table.php | 44 + ..._140000_create_shift_assignments_table.php | 42 + ...08_150000_create_shift_check_ins_table.php | 33 + ..._create_volunteer_availabilities_table.php | 29 + ...2026_04_08_170000_create_artists_table.php | 43 + ...8_180000_create_advance_sections_table.php | 37 + ..._04_08_200000_create_crowd_types_table.php | 31 + ...26_04_08_210000_create_companies_table.php | 32 + ...2026_04_08_220000_create_persons_table.php | 44 + ..._04_08_230000_create_crowd_lists_table.php | 32 + ...240000_create_crowd_list_persons_table.php | 29 + ...person_foreign_keys_to_existing_tables.php | 41 + ..._add_shifts_extras_and_waitlist_tables.php | 72 + ...e_person_timeslot_on_shift_assignments.php | 36 + api/database/seeders/DevSeeder.php | 23 + .../views/emails/invitation.blade.php | 16 + api/routes/api.php | 55 + api/routes/console.php | 3 + api/tests/Feature/CrowdType/CrowdTypeTest.php | 139 ++ .../FestivalSection/FestivalSectionTest.php | 168 ++ .../Feature/Invitation/InvitationTest.php | 284 ++++ api/tests/Feature/Member/MemberTest.php | 261 +++ api/tests/Feature/Person/PersonTest.php | 197 +++ api/tests/Feature/Shift/ShiftTest.php | 312 ++++ api/tests/Feature/TimeSlot/TimeSlotTest.php | 156 ++ apps/app/vite.config.ts | 1 + docs/API.md | 42 +- docs/SCHEMA.md | 567 ++++--- docs/TEST_SCENARIO.md | 10 + resources/corporate-identity/Logo.ai | 1487 ++++++++--------- 114 files changed, 6916 insertions(+), 984 deletions(-) create mode 100644 api/app/Console/Commands/ExpireInvitations.php create mode 100644 api/app/Http/Controllers/Api/V1/CompanyController.php create mode 100644 api/app/Http/Controllers/Api/V1/CrowdListController.php create mode 100644 api/app/Http/Controllers/Api/V1/CrowdTypeController.php create mode 100644 api/app/Http/Controllers/Api/V1/FestivalSectionController.php create mode 100644 api/app/Http/Controllers/Api/V1/InvitationController.php create mode 100644 api/app/Http/Controllers/Api/V1/LocationController.php create mode 100644 api/app/Http/Controllers/Api/V1/MemberController.php create mode 100644 api/app/Http/Controllers/Api/V1/PersonController.php create mode 100644 api/app/Http/Controllers/Api/V1/ShiftController.php create mode 100644 api/app/Http/Controllers/Api/V1/TimeSlotController.php create mode 100644 api/app/Http/Requests/Api/V1/AcceptInvitationRequest.php create mode 100644 api/app/Http/Requests/Api/V1/AssignShiftRequest.php create mode 100644 api/app/Http/Requests/Api/V1/ReorderFestivalSectionsRequest.php create mode 100644 api/app/Http/Requests/Api/V1/StoreCompanyRequest.php create mode 100644 api/app/Http/Requests/Api/V1/StoreCrowdListRequest.php create mode 100644 api/app/Http/Requests/Api/V1/StoreCrowdTypeRequest.php create mode 100644 api/app/Http/Requests/Api/V1/StoreFestivalSectionRequest.php create mode 100644 api/app/Http/Requests/Api/V1/StoreInvitationRequest.php create mode 100644 api/app/Http/Requests/Api/V1/StoreLocationRequest.php create mode 100644 api/app/Http/Requests/Api/V1/StorePersonRequest.php create mode 100644 api/app/Http/Requests/Api/V1/StoreShiftRequest.php create mode 100644 api/app/Http/Requests/Api/V1/StoreTimeSlotRequest.php create mode 100644 api/app/Http/Requests/Api/V1/UpdateCompanyRequest.php create mode 100644 api/app/Http/Requests/Api/V1/UpdateCrowdListRequest.php create mode 100644 api/app/Http/Requests/Api/V1/UpdateCrowdTypeRequest.php create mode 100644 api/app/Http/Requests/Api/V1/UpdateFestivalSectionRequest.php create mode 100644 api/app/Http/Requests/Api/V1/UpdateLocationRequest.php create mode 100644 api/app/Http/Requests/Api/V1/UpdateMemberRequest.php create mode 100644 api/app/Http/Requests/Api/V1/UpdatePersonRequest.php create mode 100644 api/app/Http/Requests/Api/V1/UpdateShiftRequest.php create mode 100644 api/app/Http/Requests/Api/V1/UpdateTimeSlotRequest.php create mode 100644 api/app/Http/Resources/Api/V1/CompanyResource.php create mode 100644 api/app/Http/Resources/Api/V1/CrowdListResource.php create mode 100644 api/app/Http/Resources/Api/V1/CrowdTypeResource.php create mode 100644 api/app/Http/Resources/Api/V1/FestivalSectionResource.php create mode 100644 api/app/Http/Resources/Api/V1/InvitationResource.php create mode 100644 api/app/Http/Resources/Api/V1/LocationResource.php create mode 100644 api/app/Http/Resources/Api/V1/MemberCollection.php create mode 100644 api/app/Http/Resources/Api/V1/MemberResource.php create mode 100644 api/app/Http/Resources/Api/V1/PersonCollection.php create mode 100644 api/app/Http/Resources/Api/V1/PersonResource.php create mode 100644 api/app/Http/Resources/Api/V1/ShiftAssignmentResource.php create mode 100644 api/app/Http/Resources/Api/V1/ShiftResource.php create mode 100644 api/app/Http/Resources/Api/V1/TimeSlotResource.php create mode 100644 api/app/Mail/InvitationMail.php create mode 100644 api/app/Models/Company.php create mode 100644 api/app/Models/CrowdList.php create mode 100644 api/app/Models/CrowdType.php create mode 100644 api/app/Models/FestivalSection.php create mode 100644 api/app/Models/Location.php create mode 100644 api/app/Models/Person.php create mode 100644 api/app/Models/Shift.php create mode 100644 api/app/Models/ShiftAssignment.php create mode 100644 api/app/Models/ShiftWaitlist.php create mode 100644 api/app/Models/TimeSlot.php create mode 100644 api/app/Policies/CompanyPolicy.php create mode 100644 api/app/Policies/CrowdListPolicy.php create mode 100644 api/app/Policies/CrowdTypePolicy.php create mode 100644 api/app/Policies/FestivalSectionPolicy.php create mode 100644 api/app/Policies/LocationPolicy.php create mode 100644 api/app/Policies/PersonPolicy.php create mode 100644 api/app/Policies/ShiftPolicy.php create mode 100644 api/app/Policies/TimeSlotPolicy.php create mode 100644 api/app/Services/InvitationService.php create mode 100644 api/database/factories/CompanyFactory.php create mode 100644 api/database/factories/CrowdTypeFactory.php create mode 100644 api/database/factories/FestivalSectionFactory.php create mode 100644 api/database/factories/LocationFactory.php create mode 100644 api/database/factories/PersonFactory.php create mode 100644 api/database/factories/ShiftFactory.php create mode 100644 api/database/factories/TimeSlotFactory.php create mode 100644 api/database/migrations/2026_04_07_260000_create_locations_table.php create mode 100644 api/database/migrations/2026_04_07_270000_create_festival_sections_table.php create mode 100644 api/database/migrations/2026_04_07_280000_create_time_slots_table.php create mode 100644 api/database/migrations/2026_04_08_100000_remove_volunteer_min_hours_from_events.php create mode 100644 api/database/migrations/2026_04_08_110000_remove_route_geojson_from_locations.php create mode 100644 api/database/migrations/2026_04_08_120000_add_section_settings_to_festival_sections.php create mode 100644 api/database/migrations/2026_04_08_130000_create_shifts_table.php create mode 100644 api/database/migrations/2026_04_08_140000_create_shift_assignments_table.php create mode 100644 api/database/migrations/2026_04_08_150000_create_shift_check_ins_table.php create mode 100644 api/database/migrations/2026_04_08_160000_create_volunteer_availabilities_table.php create mode 100644 api/database/migrations/2026_04_08_170000_create_artists_table.php create mode 100644 api/database/migrations/2026_04_08_180000_create_advance_sections_table.php create mode 100644 api/database/migrations/2026_04_08_200000_create_crowd_types_table.php create mode 100644 api/database/migrations/2026_04_08_210000_create_companies_table.php create mode 100644 api/database/migrations/2026_04_08_220000_create_persons_table.php create mode 100644 api/database/migrations/2026_04_08_230000_create_crowd_lists_table.php create mode 100644 api/database/migrations/2026_04_08_240000_create_crowd_list_persons_table.php create mode 100644 api/database/migrations/2026_04_08_250000_add_person_foreign_keys_to_existing_tables.php create mode 100644 api/database/migrations/2026_04_08_300000_add_shifts_extras_and_waitlist_tables.php create mode 100644 api/database/migrations/2026_04_08_310000_drop_unique_person_timeslot_on_shift_assignments.php create mode 100644 api/resources/views/emails/invitation.blade.php create mode 100644 api/tests/Feature/CrowdType/CrowdTypeTest.php create mode 100644 api/tests/Feature/FestivalSection/FestivalSectionTest.php create mode 100644 api/tests/Feature/Invitation/InvitationTest.php create mode 100644 api/tests/Feature/Member/MemberTest.php create mode 100644 api/tests/Feature/Person/PersonTest.php create mode 100644 api/tests/Feature/Shift/ShiftTest.php create mode 100644 api/tests/Feature/TimeSlot/TimeSlotTest.php diff --git a/.gitignore b/.gitignore index 272064a..0ec42e1 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ coverage/ # Misc *.pem .cache/ + +# Design / assets temp files (e.g. Illustrator) +resources/**/*.tmp diff --git a/api/app/Console/Commands/ExpireInvitations.php b/api/app/Console/Commands/ExpireInvitations.php new file mode 100644 index 0000000..95e312f --- /dev/null +++ b/api/app/Console/Commands/ExpireInvitations.php @@ -0,0 +1,24 @@ +expireOldInvitations(); + + $this->info("Marked {$count} invitation(s) as expired."); + + return self::SUCCESS; + } +} diff --git a/api/app/Http/Controllers/Api/V1/CompanyController.php b/api/app/Http/Controllers/Api/V1/CompanyController.php new file mode 100644 index 0000000..fc4a5ae --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/CompanyController.php @@ -0,0 +1,54 @@ +companies()->get(); + + return CompanyResource::collection($companies); + } + + public function store(StoreCompanyRequest $request, Organisation $organisation): JsonResponse + { + Gate::authorize('create', [Company::class, $organisation]); + + $company = $organisation->companies()->create($request->validated()); + + return $this->created(new CompanyResource($company)); + } + + public function update(UpdateCompanyRequest $request, Organisation $organisation, Company $company): JsonResponse + { + Gate::authorize('update', [$company, $organisation]); + + $company->update($request->validated()); + + return $this->success(new CompanyResource($company->fresh())); + } + + public function destroy(Organisation $organisation, Company $company): JsonResponse + { + Gate::authorize('delete', [$company, $organisation]); + + $company->delete(); + + return response()->json(null, 204); + } +} diff --git a/api/app/Http/Controllers/Api/V1/CrowdListController.php b/api/app/Http/Controllers/Api/V1/CrowdListController.php new file mode 100644 index 0000000..483ba74 --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/CrowdListController.php @@ -0,0 +1,83 @@ +crowdLists()->withCount('persons')->get(); + + return CrowdListResource::collection($crowdLists); + } + + public function store(StoreCrowdListRequest $request, Event $event): JsonResponse + { + Gate::authorize('create', [CrowdList::class, $event]); + + $crowdList = $event->crowdLists()->create($request->validated()); + + return $this->created(new CrowdListResource($crowdList)); + } + + public function update(UpdateCrowdListRequest $request, Event $event, CrowdList $crowdList): JsonResponse + { + Gate::authorize('update', [$crowdList, $event]); + + $crowdList->update($request->validated()); + + return $this->success(new CrowdListResource($crowdList->fresh())); + } + + public function destroy(Event $event, CrowdList $crowdList): JsonResponse + { + Gate::authorize('delete', [$crowdList, $event]); + + $crowdList->delete(); + + return response()->json(null, 204); + } + + public function addPerson(Request $request, Event $event, CrowdList $crowdList): JsonResponse + { + Gate::authorize('managePerson', [$crowdList, $event]); + + $validated = $request->validate([ + 'person_id' => ['required', 'ulid', 'exists:persons,id'], + ]); + + $crowdList->persons()->syncWithoutDetaching([ + $validated['person_id'] => [ + 'added_at' => now(), + 'added_by_user_id' => $request->user()->id, + ], + ]); + + return $this->success(new CrowdListResource($crowdList->fresh()->loadCount('persons'))); + } + + public function removePerson(Event $event, CrowdList $crowdList, Person $person): JsonResponse + { + Gate::authorize('managePerson', [$crowdList, $event]); + + $crowdList->persons()->detach($person->id); + + return response()->json(null, 204); + } +} diff --git a/api/app/Http/Controllers/Api/V1/CrowdTypeController.php b/api/app/Http/Controllers/Api/V1/CrowdTypeController.php new file mode 100644 index 0000000..3045299 --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/CrowdTypeController.php @@ -0,0 +1,58 @@ +crowdTypes()->where('is_active', true)->get(); + + return CrowdTypeResource::collection($crowdTypes); + } + + public function store(StoreCrowdTypeRequest $request, Organisation $organisation): JsonResponse + { + Gate::authorize('create', [CrowdType::class, $organisation]); + + $crowdType = $organisation->crowdTypes()->create($request->validated()); + + return $this->created(new CrowdTypeResource($crowdType)); + } + + public function update(UpdateCrowdTypeRequest $request, Organisation $organisation, CrowdType $crowdType): JsonResponse + { + Gate::authorize('update', [$crowdType, $organisation]); + + $crowdType->update($request->validated()); + + return $this->success(new CrowdTypeResource($crowdType->fresh())); + } + + public function destroy(Organisation $organisation, CrowdType $crowdType): JsonResponse + { + Gate::authorize('delete', [$crowdType, $organisation]); + + if ($crowdType->persons()->exists()) { + $crowdType->update(['is_active' => false]); + } else { + $crowdType->delete(); + } + + return response()->json(null, 204); + } +} diff --git a/api/app/Http/Controllers/Api/V1/FestivalSectionController.php b/api/app/Http/Controllers/Api/V1/FestivalSectionController.php new file mode 100644 index 0000000..1896df8 --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/FestivalSectionController.php @@ -0,0 +1,70 @@ +festivalSections()->ordered()->get(); + + return FestivalSectionResource::collection($sections); + } + + public function store(StoreFestivalSectionRequest $request, Event $event): JsonResponse + { + Gate::authorize('create', [FestivalSection::class, $event]); + + $section = $event->festivalSections()->create($request->validated()); + + return $this->created(new FestivalSectionResource($section)); + } + + public function update(UpdateFestivalSectionRequest $request, Event $event, FestivalSection $section): JsonResponse + { + Gate::authorize('update', [$section, $event]); + + $section->update($request->validated()); + + return $this->success(new FestivalSectionResource($section->fresh())); + } + + public function destroy(Event $event, FestivalSection $section): JsonResponse + { + Gate::authorize('delete', [$section, $event]); + + $section->delete(); + + return response()->json(null, 204); + } + + public function reorder(ReorderFestivalSectionsRequest $request, Event $event): JsonResponse + { + Gate::authorize('reorder', [FestivalSection::class, $event]); + + foreach ($request->validated('sections') as $item) { + $event->festivalSections() + ->where('id', $item['id']) + ->update(['sort_order' => $item['sort_order']]); + } + + $sections = $event->festivalSections()->ordered()->get(); + + return $this->success(FestivalSectionResource::collection($sections)); + } +} diff --git a/api/app/Http/Controllers/Api/V1/InvitationController.php b/api/app/Http/Controllers/Api/V1/InvitationController.php new file mode 100644 index 0000000..d9ebe6c --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/InvitationController.php @@ -0,0 +1,86 @@ +invitationService->invite( + $organisation, + $request->validated('email'), + $request->validated('role'), + $request->user(), + ); + + return $this->created( + new InvitationResource($invitation->load(['organisation', 'invitedBy'])), + 'Uitnodiging verstuurd', + ); + } + + public function show(string $token): JsonResponse + { + $invitation = UserInvitation::where('token', $token) + ->with(['organisation', 'invitedBy']) + ->first(); + + if (! $invitation) { + return $this->notFound('Uitnodiging niet gevonden'); + } + + return $this->success(new InvitationResource($invitation)); + } + + public function accept(AcceptInvitationRequest $request, string $token): JsonResponse + { + $invitation = UserInvitation::where('token', $token)->firstOrFail(); + + $user = $this->invitationService->accept( + $invitation, + $request->validated('password'), + ); + + $sanctumToken = $user->createToken('auth-token')->plainTextToken; + + return $this->success([ + 'user' => [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + ], + 'token' => $sanctumToken, + ], 'Uitnodiging geaccepteerd'); + } + + public function revoke(Organisation $organisation, UserInvitation $invitation): JsonResponse + { + Gate::authorize('invite', $organisation); + + if (! $invitation->isPending()) { + return $this->error('Alleen openstaande uitnodigingen kunnen worden ingetrokken.', 422); + } + + $invitation->markAsExpired(); + + return response()->json(null, 204); + } +} diff --git a/api/app/Http/Controllers/Api/V1/LocationController.php b/api/app/Http/Controllers/Api/V1/LocationController.php new file mode 100644 index 0000000..45a506b --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/LocationController.php @@ -0,0 +1,54 @@ +locations()->orderBy('name')->get(); + + return LocationResource::collection($locations); + } + + public function store(StoreLocationRequest $request, Event $event): JsonResponse + { + Gate::authorize('create', [Location::class, $event]); + + $location = $event->locations()->create($request->validated()); + + return $this->created(new LocationResource($location)); + } + + public function update(UpdateLocationRequest $request, Event $event, Location $location): JsonResponse + { + Gate::authorize('update', [$location, $event]); + + $location->update($request->validated()); + + return $this->success(new LocationResource($location->fresh())); + } + + public function destroy(Event $event, Location $location): JsonResponse + { + Gate::authorize('delete', [$location, $event]); + + $location->delete(); + + return response()->json(null, 204); + } +} diff --git a/api/app/Http/Controllers/Api/V1/MemberController.php b/api/app/Http/Controllers/Api/V1/MemberController.php new file mode 100644 index 0000000..62789c5 --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/MemberController.php @@ -0,0 +1,84 @@ +users()->get(); + + return new MemberCollection($members); + } + + public function update(UpdateMemberRequest $request, Organisation $organisation, User $user): JsonResponse + { + Gate::authorize('invite', $organisation); + + if ($request->user()->id === $user->id) { + return $this->error('Je kunt je eigen rol niet wijzigen.', 422); + } + + $currentRole = $organisation->users() + ->where('user_id', $user->id) + ->first()?->pivot?->role; + + if ($currentRole === 'org_admin' && $request->validated('role') !== 'org_admin') { + $adminCount = $organisation->users() + ->wherePivot('role', 'org_admin') + ->count(); + + if ($adminCount <= 1) { + return $this->error('De laatste org_admin kan niet worden gedegradeerd.', 422); + } + } + + $organisation->users()->updateExistingPivot($user->id, [ + 'role' => $request->validated('role'), + ]); + + return $this->success( + new MemberResource($organisation->users()->where('user_id', $user->id)->first()), + ); + } + + public function destroy(Organisation $organisation, User $user): JsonResponse + { + Gate::authorize('invite', $organisation); + + if (request()->user()->id === $user->id) { + return $this->error('Je kunt je eigen account niet verwijderen uit de organisatie.', 422); + } + + $currentRole = $organisation->users() + ->where('user_id', $user->id) + ->first()?->pivot?->role; + + if ($currentRole === 'org_admin') { + $adminCount = $organisation->users() + ->wherePivot('role', 'org_admin') + ->count(); + + if ($adminCount <= 1) { + return $this->error('De laatste org_admin kan niet worden verwijderd.', 422); + } + } + + $organisation->users()->detach($user->id); + + return response()->json(null, 204); + } +} diff --git a/api/app/Http/Controllers/Api/V1/PersonController.php b/api/app/Http/Controllers/Api/V1/PersonController.php new file mode 100644 index 0000000..9f64b4b --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/PersonController.php @@ -0,0 +1,82 @@ +persons()->with('crowdType'); + + if ($request->filled('crowd_type_id')) { + $query->where('crowd_type_id', $request->input('crowd_type_id')); + } + + if ($request->filled('status')) { + $query->where('status', $request->input('status')); + } + + return new PersonCollection($query->get()); + } + + public function show(Event $event, Person $person): JsonResponse + { + Gate::authorize('view', [$person, $event]); + + $person->load(['crowdType', 'company']); + + return $this->success(new PersonResource($person)); + } + + public function store(StorePersonRequest $request, Event $event): JsonResponse + { + Gate::authorize('create', [Person::class, $event]); + + $person = $event->persons()->create($request->validated()); + + return $this->created(new PersonResource($person->fresh()->load('crowdType'))); + } + + public function update(UpdatePersonRequest $request, Event $event, Person $person): JsonResponse + { + Gate::authorize('update', [$person, $event]); + + $person->update($request->validated()); + $person->load(['crowdType', 'company']); + + return $this->success(new PersonResource($person->fresh()->load(['crowdType', 'company']))); + } + + public function destroy(Event $event, Person $person): JsonResponse + { + Gate::authorize('delete', [$person, $event]); + + $person->delete(); + + return response()->json(null, 204); + } + + public function approve(Event $event, Person $person): JsonResponse + { + Gate::authorize('approve', [$person, $event]); + + $person->update(['status' => 'approved']); + + return $this->success(new PersonResource($person->fresh()->load('crowdType'))); + } +} diff --git a/api/app/Http/Controllers/Api/V1/ShiftController.php b/api/app/Http/Controllers/Api/V1/ShiftController.php new file mode 100644 index 0000000..4a89156 --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/ShiftController.php @@ -0,0 +1,148 @@ +shifts() + ->with(['timeSlot', 'location']) + ->get(); + + return ShiftResource::collection($shifts); + } + + public function store(StoreShiftRequest $request, Event $event, FestivalSection $section): JsonResponse + { + Gate::authorize('create', [Shift::class, $event]); + + $shift = $section->shifts()->create($request->validated()); + $shift->load(['timeSlot', 'location']); + + return $this->created(new ShiftResource($shift)); + } + + public function update(UpdateShiftRequest $request, Event $event, FestivalSection $section, Shift $shift): JsonResponse + { + Gate::authorize('update', [$shift, $event, $section]); + + $shift->update($request->validated()); + $shift->load(['timeSlot', 'location']); + + return $this->success(new ShiftResource($shift->fresh()->load(['timeSlot', 'location']))); + } + + public function destroy(Event $event, FestivalSection $section, Shift $shift): JsonResponse + { + Gate::authorize('delete', [$shift, $event, $section]); + + $shift->delete(); + + return response()->json(null, 204); + } + + public function assign(AssignShiftRequest $request, Event $event, FestivalSection $section, Shift $shift): JsonResponse + { + Gate::authorize('assign', [$shift, $event, $section]); + + $personId = $request->validated('person_id'); + + // Check if shift is full + $approvedCount = $shift->shiftAssignments()->where('status', 'approved')->count(); + if ($approvedCount >= $shift->slots_total) { + return $this->error('Shift is vol — alle slots zijn bezet.', 422); + } + + // Check overlap conflict if allow_overlap is false + if (! $shift->allow_overlap) { + $conflict = ShiftAssignment::where('person_id', $personId) + ->where('time_slot_id', $shift->time_slot_id) + ->whereNotIn('status', ['rejected', 'cancelled']) + ->exists(); + + if ($conflict) { + return $this->error('Deze persoon is al ingepland voor dit tijdslot.', 422); + } + } + + $autoApprove = $section->crew_auto_accepts; + + $assignment = $shift->shiftAssignments()->create([ + 'person_id' => $personId, + 'time_slot_id' => $shift->time_slot_id, + 'status' => $autoApprove ? 'approved' : 'approved', + 'auto_approved' => $autoApprove, + 'assigned_by' => $request->user()->id, + 'assigned_at' => now(), + 'approved_at' => now(), + ]); + + // Update shift status if full + $newApprovedCount = $shift->shiftAssignments()->where('status', 'approved')->count(); + if ($newApprovedCount >= $shift->slots_total) { + $shift->update(['status' => 'full']); + } + + return $this->created(new ShiftAssignmentResource($assignment)); + } + + public function claim(AssignShiftRequest $request, Event $event, FestivalSection $section, Shift $shift): JsonResponse + { + Gate::authorize('claim', [$shift, $event, $section]); + + $personId = $request->validated('person_id'); + + // Check claiming slots available + $claimedCount = $shift->shiftAssignments() + ->whereNotIn('status', ['rejected', 'cancelled']) + ->count(); + + if ($shift->slots_open_for_claiming <= 0 || $claimedCount >= $shift->slots_open_for_claiming) { + return $this->error('Geen claimbare slots beschikbaar voor deze shift.', 422); + } + + // Check overlap conflict if allow_overlap is false + if (! $shift->allow_overlap) { + $conflict = ShiftAssignment::where('person_id', $personId) + ->where('time_slot_id', $shift->time_slot_id) + ->whereNotIn('status', ['rejected', 'cancelled']) + ->exists(); + + if ($conflict) { + return $this->error('Deze persoon is al ingepland voor dit tijdslot.', 422); + } + } + + $autoApprove = $section->crew_auto_accepts; + + $assignment = $shift->shiftAssignments()->create([ + 'person_id' => $personId, + 'time_slot_id' => $shift->time_slot_id, + 'status' => $autoApprove ? 'approved' : 'pending_approval', + 'auto_approved' => $autoApprove, + 'assigned_at' => now(), + 'approved_at' => $autoApprove ? now() : null, + ]); + + return $this->created(new ShiftAssignmentResource($assignment)); + } +} diff --git a/api/app/Http/Controllers/Api/V1/TimeSlotController.php b/api/app/Http/Controllers/Api/V1/TimeSlotController.php new file mode 100644 index 0000000..3582672 --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/TimeSlotController.php @@ -0,0 +1,54 @@ +timeSlots()->orderBy('date')->orderBy('start_time')->get(); + + return TimeSlotResource::collection($timeSlots); + } + + public function store(StoreTimeSlotRequest $request, Event $event): JsonResponse + { + Gate::authorize('create', [TimeSlot::class, $event]); + + $timeSlot = $event->timeSlots()->create($request->validated()); + + return $this->created(new TimeSlotResource($timeSlot)); + } + + public function update(UpdateTimeSlotRequest $request, Event $event, TimeSlot $timeSlot): JsonResponse + { + Gate::authorize('update', [$timeSlot, $event]); + + $timeSlot->update($request->validated()); + + return $this->success(new TimeSlotResource($timeSlot->fresh())); + } + + public function destroy(Event $event, TimeSlot $timeSlot): JsonResponse + { + Gate::authorize('delete', [$timeSlot, $event]); + + $timeSlot->delete(); + + return response()->json(null, 204); + } +} diff --git a/api/app/Http/Requests/Api/V1/AcceptInvitationRequest.php b/api/app/Http/Requests/Api/V1/AcceptInvitationRequest.php new file mode 100644 index 0000000..98d98b1 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/AcceptInvitationRequest.php @@ -0,0 +1,29 @@ + */ + public function rules(): array + { + $invitation = UserInvitation::where('token', $this->route('token'))->first(); + $userExists = $invitation && User::where('email', $invitation->email)->exists(); + + return [ + 'name' => [$userExists ? 'nullable' : 'required', 'string', 'max:255'], + 'password' => [$userExists ? 'nullable' : 'required', 'string', 'min:8', 'confirmed'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/AssignShiftRequest.php b/api/app/Http/Requests/Api/V1/AssignShiftRequest.php new file mode 100644 index 0000000..2fb94b9 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/AssignShiftRequest.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/ReorderFestivalSectionsRequest.php b/api/app/Http/Requests/Api/V1/ReorderFestivalSectionsRequest.php new file mode 100644 index 0000000..f997a61 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/ReorderFestivalSectionsRequest.php @@ -0,0 +1,25 @@ + */ + public function rules(): array + { + return [ + 'sections' => ['required', 'array'], + 'sections.*.id' => ['required', 'ulid'], + 'sections.*.sort_order' => ['required', 'integer', 'min:0'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/StoreCompanyRequest.php b/api/app/Http/Requests/Api/V1/StoreCompanyRequest.php new file mode 100644 index 0000000..4f43970 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/StoreCompanyRequest.php @@ -0,0 +1,27 @@ + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'type' => ['required', 'in:supplier,partner,agency,venue,other'], + 'contact_name' => ['nullable', 'string', 'max:255'], + 'contact_email' => ['nullable', 'email', 'max:255'], + 'contact_phone' => ['nullable', 'string', 'max:30'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/StoreCrowdListRequest.php b/api/app/Http/Requests/Api/V1/StoreCrowdListRequest.php new file mode 100644 index 0000000..7da7e6d --- /dev/null +++ b/api/app/Http/Requests/Api/V1/StoreCrowdListRequest.php @@ -0,0 +1,28 @@ + */ + public function rules(): array + { + return [ + 'crowd_type_id' => ['required', 'ulid', 'exists:crowd_types,id'], + 'name' => ['required', 'string', 'max:255'], + 'type' => ['required', 'in:internal,external'], + '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/StoreCrowdTypeRequest.php b/api/app/Http/Requests/Api/V1/StoreCrowdTypeRequest.php new file mode 100644 index 0000000..4ddfce6 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/StoreCrowdTypeRequest.php @@ -0,0 +1,26 @@ + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:100'], + 'system_type' => ['required', 'in:CREW,GUEST,ARTIST,VOLUNTEER,PRESS,PARTNER,SUPPLIER'], + 'color' => ['required', 'regex:/^#[0-9A-Fa-f]{6}$/'], + 'icon' => ['nullable', 'string', 'max:50'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/StoreFestivalSectionRequest.php b/api/app/Http/Requests/Api/V1/StoreFestivalSectionRequest.php new file mode 100644 index 0000000..063d63c --- /dev/null +++ b/api/app/Http/Requests/Api/V1/StoreFestivalSectionRequest.php @@ -0,0 +1,27 @@ + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'sort_order' => ['nullable', 'integer', 'min:0'], + 'type' => ['nullable', 'in:standard,cross_event'], + 'crew_auto_accepts' => ['nullable', 'boolean'], + 'responder_self_checkin' => ['nullable', 'boolean'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/StoreInvitationRequest.php b/api/app/Http/Requests/Api/V1/StoreInvitationRequest.php new file mode 100644 index 0000000..8a8a68a --- /dev/null +++ b/api/app/Http/Requests/Api/V1/StoreInvitationRequest.php @@ -0,0 +1,24 @@ + */ + public function rules(): array + { + return [ + 'email' => ['required', 'email', 'max:255'], + 'role' => ['required', 'in:org_admin,org_member,event_manager,staff_coordinator,volunteer_coordinator'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/StoreLocationRequest.php b/api/app/Http/Requests/Api/V1/StoreLocationRequest.php new file mode 100644 index 0000000..97d6d10 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/StoreLocationRequest.php @@ -0,0 +1,28 @@ + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'address' => ['nullable', 'string', 'max:255'], + 'lat' => ['nullable', 'numeric', 'between:-90,90'], + 'lng' => ['nullable', 'numeric', 'between:-180,180'], + 'description' => ['nullable', 'string'], + 'access_instructions' => ['nullable', 'string'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/StorePersonRequest.php b/api/app/Http/Requests/Api/V1/StorePersonRequest.php new file mode 100644 index 0000000..23c117b --- /dev/null +++ b/api/app/Http/Requests/Api/V1/StorePersonRequest.php @@ -0,0 +1,29 @@ + */ + public function rules(): array + { + return [ + 'crowd_type_id' => ['required', 'ulid', 'exists:crowd_types,id'], + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255'], + 'phone' => ['nullable', 'string', 'max:30'], + 'company_id' => ['nullable', 'ulid', 'exists:companies,id'], + 'status' => ['nullable', 'in:invited,applied,pending,approved,rejected,no_show'], + 'custom_fields' => ['nullable', 'array'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/StoreShiftRequest.php b/api/app/Http/Requests/Api/V1/StoreShiftRequest.php new file mode 100644 index 0000000..0fc9309 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/StoreShiftRequest.php @@ -0,0 +1,36 @@ + */ + public function rules(): array + { + return [ + 'time_slot_id' => ['required', 'ulid', 'exists:time_slots,id'], + 'location_id' => ['nullable', 'ulid', 'exists:locations,id'], + 'title' => ['nullable', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'instructions' => ['nullable', 'string'], + 'coordinator_notes' => ['nullable', 'string'], + 'slots_total' => ['required', 'integer', 'min:1'], + 'slots_open_for_claiming' => ['required', 'integer', 'min:0', 'lte:slots_total'], + 'report_time' => ['nullable', 'date_format:H:i'], + 'actual_start_time' => ['nullable', 'date_format:H:i'], + 'actual_end_time' => ['nullable', 'date_format:H:i'], + 'is_lead_role' => ['nullable', 'boolean'], + 'allow_overlap' => ['nullable', 'boolean'], + 'status' => ['nullable', 'in:draft,open,full,in_progress,completed,cancelled'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/StoreTimeSlotRequest.php b/api/app/Http/Requests/Api/V1/StoreTimeSlotRequest.php new file mode 100644 index 0000000..84b2332 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/StoreTimeSlotRequest.php @@ -0,0 +1,28 @@ + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'person_type' => ['required', 'in:CREW,VOLUNTEER,PRESS,PHOTO,PARTNER'], + 'date' => ['required', 'date'], + 'start_time' => ['required', 'date_format:H:i'], + 'end_time' => ['required', 'date_format:H:i'], + 'duration_hours' => ['nullable', 'numeric', 'min:0.5', 'max:24'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/UpdateCompanyRequest.php b/api/app/Http/Requests/Api/V1/UpdateCompanyRequest.php new file mode 100644 index 0000000..b62b4d3 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/UpdateCompanyRequest.php @@ -0,0 +1,27 @@ + */ + public function rules(): array + { + return [ + 'name' => ['sometimes', 'string', 'max:255'], + 'type' => ['sometimes', 'in:supplier,partner,agency,venue,other'], + 'contact_name' => ['nullable', 'string', 'max:255'], + 'contact_email' => ['nullable', 'email', 'max:255'], + 'contact_phone' => ['nullable', 'string', 'max:30'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/UpdateCrowdListRequest.php b/api/app/Http/Requests/Api/V1/UpdateCrowdListRequest.php new file mode 100644 index 0000000..acf2ecd --- /dev/null +++ b/api/app/Http/Requests/Api/V1/UpdateCrowdListRequest.php @@ -0,0 +1,28 @@ + */ + public function rules(): array + { + return [ + 'crowd_type_id' => ['sometimes', 'ulid', 'exists:crowd_types,id'], + 'name' => ['sometimes', 'string', 'max:255'], + 'type' => ['sometimes', 'in:internal,external'], + '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/UpdateCrowdTypeRequest.php b/api/app/Http/Requests/Api/V1/UpdateCrowdTypeRequest.php new file mode 100644 index 0000000..ab61005 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/UpdateCrowdTypeRequest.php @@ -0,0 +1,26 @@ + */ + public function rules(): array + { + return [ + 'name' => ['sometimes', 'string', 'max:100'], + 'color' => ['sometimes', 'regex:/^#[0-9A-Fa-f]{6}$/'], + 'icon' => ['nullable', 'string', 'max:50'], + 'is_active' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/UpdateFestivalSectionRequest.php b/api/app/Http/Requests/Api/V1/UpdateFestivalSectionRequest.php new file mode 100644 index 0000000..8c5c267 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/UpdateFestivalSectionRequest.php @@ -0,0 +1,33 @@ + */ + public function rules(): array + { + return [ + 'name' => ['sometimes', 'string', 'max:255'], + 'sort_order' => ['sometimes', 'integer', 'min:0'], + 'type' => ['sometimes', 'in:standard,cross_event'], + 'crew_auto_accepts' => ['sometimes', 'boolean'], + 'responder_self_checkin' => ['sometimes', 'boolean'], + 'crew_need' => ['nullable', 'integer', 'min:0'], + 'crew_invited_to_events' => ['sometimes', 'boolean'], + 'added_to_timeline' => ['sometimes', 'boolean'], + 'timed_accreditations' => ['sometimes', 'boolean'], + 'crew_accreditation_level' => ['nullable', 'string', 'max:50'], + 'public_form_accreditation_level' => ['nullable', 'string', 'max:50'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/UpdateLocationRequest.php b/api/app/Http/Requests/Api/V1/UpdateLocationRequest.php new file mode 100644 index 0000000..dff1177 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/UpdateLocationRequest.php @@ -0,0 +1,28 @@ + */ + public function rules(): array + { + return [ + 'name' => ['sometimes', 'string', 'max:255'], + 'address' => ['nullable', 'string', 'max:255'], + 'lat' => ['nullable', 'numeric', 'between:-90,90'], + 'lng' => ['nullable', 'numeric', 'between:-180,180'], + 'description' => ['nullable', 'string'], + 'access_instructions' => ['nullable', 'string'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/UpdateMemberRequest.php b/api/app/Http/Requests/Api/V1/UpdateMemberRequest.php new file mode 100644 index 0000000..8544b83 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/UpdateMemberRequest.php @@ -0,0 +1,23 @@ + */ + public function rules(): array + { + return [ + 'role' => ['required', 'in:org_admin,org_member,event_manager,staff_coordinator,volunteer_coordinator'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/UpdatePersonRequest.php b/api/app/Http/Requests/Api/V1/UpdatePersonRequest.php new file mode 100644 index 0000000..eb0bf9b --- /dev/null +++ b/api/app/Http/Requests/Api/V1/UpdatePersonRequest.php @@ -0,0 +1,31 @@ + */ + public function rules(): array + { + return [ + 'crowd_type_id' => ['sometimes', 'ulid', 'exists:crowd_types,id'], + 'name' => ['sometimes', 'string', 'max:255'], + 'email' => ['sometimes', 'email', 'max:255'], + 'phone' => ['nullable', 'string', 'max:30'], + 'company_id' => ['nullable', 'ulid', 'exists:companies,id'], + 'status' => ['sometimes', 'in:invited,applied,pending,approved,rejected,no_show'], + 'is_blacklisted' => ['sometimes', 'boolean'], + 'admin_notes' => ['nullable', 'string'], + 'custom_fields' => ['nullable', 'array'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/UpdateShiftRequest.php b/api/app/Http/Requests/Api/V1/UpdateShiftRequest.php new file mode 100644 index 0000000..1c1ffbe --- /dev/null +++ b/api/app/Http/Requests/Api/V1/UpdateShiftRequest.php @@ -0,0 +1,36 @@ + */ + public function rules(): array + { + return [ + 'time_slot_id' => ['sometimes', 'ulid', 'exists:time_slots,id'], + 'location_id' => ['nullable', 'ulid', 'exists:locations,id'], + 'title' => ['nullable', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'instructions' => ['nullable', 'string'], + 'coordinator_notes' => ['nullable', 'string'], + 'slots_total' => ['sometimes', 'integer', 'min:1'], + 'slots_open_for_claiming' => ['sometimes', 'integer', 'min:0'], + 'report_time' => ['nullable', 'date_format:H:i'], + 'actual_start_time' => ['nullable', 'date_format:H:i'], + 'actual_end_time' => ['nullable', 'date_format:H:i'], + 'is_lead_role' => ['nullable', 'boolean'], + 'allow_overlap' => ['nullable', 'boolean'], + 'status' => ['sometimes', 'in:draft,open,full,in_progress,completed,cancelled'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/UpdateTimeSlotRequest.php b/api/app/Http/Requests/Api/V1/UpdateTimeSlotRequest.php new file mode 100644 index 0000000..12f4c23 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/UpdateTimeSlotRequest.php @@ -0,0 +1,28 @@ + */ + public function rules(): array + { + return [ + 'name' => ['sometimes', 'string', 'max:255'], + 'person_type' => ['sometimes', 'in:CREW,VOLUNTEER,PRESS,PHOTO,PARTNER'], + 'date' => ['sometimes', 'date'], + 'start_time' => ['sometimes', 'date_format:H:i'], + 'end_time' => ['sometimes', 'date_format:H:i'], + 'duration_hours' => ['nullable', 'numeric', 'min:0.5', 'max:24'], + ]; + } +} diff --git a/api/app/Http/Resources/Api/V1/CompanyResource.php b/api/app/Http/Resources/Api/V1/CompanyResource.php new file mode 100644 index 0000000..07e4859 --- /dev/null +++ b/api/app/Http/Resources/Api/V1/CompanyResource.php @@ -0,0 +1,25 @@ + $this->id, + 'organisation_id' => $this->organisation_id, + 'name' => $this->name, + 'type' => $this->type, + 'contact_name' => $this->contact_name, + 'contact_email' => $this->contact_email, + 'contact_phone' => $this->contact_phone, + 'created_at' => $this->created_at->toIso8601String(), + ]; + } +} diff --git a/api/app/Http/Resources/Api/V1/CrowdListResource.php b/api/app/Http/Resources/Api/V1/CrowdListResource.php new file mode 100644 index 0000000..00df9c6 --- /dev/null +++ b/api/app/Http/Resources/Api/V1/CrowdListResource.php @@ -0,0 +1,27 @@ + $this->id, + 'event_id' => $this->event_id, + 'crowd_type_id' => $this->crowd_type_id, + 'name' => $this->name, + 'type' => $this->type, + 'recipient_company_id' => $this->recipient_company_id, + 'auto_approve' => $this->auto_approve, + 'max_persons' => $this->max_persons, + 'created_at' => $this->created_at->toIso8601String(), + 'persons_count' => $this->whenCounted('persons'), + ]; + } +} diff --git a/api/app/Http/Resources/Api/V1/CrowdTypeResource.php b/api/app/Http/Resources/Api/V1/CrowdTypeResource.php new file mode 100644 index 0000000..1f6d758 --- /dev/null +++ b/api/app/Http/Resources/Api/V1/CrowdTypeResource.php @@ -0,0 +1,24 @@ + $this->id, + 'organisation_id' => $this->organisation_id, + 'name' => $this->name, + 'system_type' => $this->system_type, + 'color' => $this->color, + 'icon' => $this->icon, + 'is_active' => $this->is_active, + ]; + } +} diff --git a/api/app/Http/Resources/Api/V1/FestivalSectionResource.php b/api/app/Http/Resources/Api/V1/FestivalSectionResource.php new file mode 100644 index 0000000..c2abe50 --- /dev/null +++ b/api/app/Http/Resources/Api/V1/FestivalSectionResource.php @@ -0,0 +1,29 @@ + $this->id, + 'event_id' => $this->event_id, + 'name' => $this->name, + 'type' => $this->type, + 'sort_order' => $this->sort_order, + 'crew_need' => $this->crew_need, + 'crew_auto_accepts' => $this->crew_auto_accepts, + 'responder_self_checkin' => $this->responder_self_checkin, + 'added_to_timeline' => $this->added_to_timeline, + 'crew_accreditation_level' => $this->crew_accreditation_level, + 'created_at' => $this->created_at->toIso8601String(), + 'shifts_count' => $this->whenCounted('shifts'), + ]; + } +} diff --git a/api/app/Http/Resources/Api/V1/InvitationResource.php b/api/app/Http/Resources/Api/V1/InvitationResource.php new file mode 100644 index 0000000..f9474e3 --- /dev/null +++ b/api/app/Http/Resources/Api/V1/InvitationResource.php @@ -0,0 +1,29 @@ + $this->id, + 'email' => $this->email, + 'role' => $this->role, + 'status' => $this->isExpired() && $this->isPending() ? 'expired' : $this->status, + 'expires_at' => $this->expires_at->toIso8601String(), + 'created_at' => $this->created_at->toIso8601String(), + 'organisation' => $this->whenLoaded('organisation', fn () => [ + 'name' => $this->organisation->name, + ]), + 'invited_by' => $this->whenLoaded('invitedBy', fn () => [ + 'name' => $this->invitedBy?->name, + ]), + ]; + } +} diff --git a/api/app/Http/Resources/Api/V1/LocationResource.php b/api/app/Http/Resources/Api/V1/LocationResource.php new file mode 100644 index 0000000..63dc242 --- /dev/null +++ b/api/app/Http/Resources/Api/V1/LocationResource.php @@ -0,0 +1,26 @@ + $this->id, + 'event_id' => $this->event_id, + 'name' => $this->name, + 'address' => $this->address, + 'lat' => $this->lat, + 'lng' => $this->lng, + 'description' => $this->description, + 'access_instructions' => $this->access_instructions, + 'created_at' => $this->created_at->toIso8601String(), + ]; + } +} diff --git a/api/app/Http/Resources/Api/V1/MemberCollection.php b/api/app/Http/Resources/Api/V1/MemberCollection.php new file mode 100644 index 0000000..a541505 --- /dev/null +++ b/api/app/Http/Resources/Api/V1/MemberCollection.php @@ -0,0 +1,37 @@ + $this->collection, + ]; + } + + /** @return array */ + public function with(Request $request): array + { + $organisation = $request->route('organisation'); + + return [ + 'meta' => [ + 'total_members' => $this->collection->count(), + 'pending_invitations_count' => UserInvitation::where('organisation_id', $organisation->id) + ->pending() + ->where('expires_at', '>', now()) + ->count(), + ], + ]; + } +} diff --git a/api/app/Http/Resources/Api/V1/MemberResource.php b/api/app/Http/Resources/Api/V1/MemberResource.php new file mode 100644 index 0000000..1130894 --- /dev/null +++ b/api/app/Http/Resources/Api/V1/MemberResource.php @@ -0,0 +1,29 @@ + $this->id, + 'name' => $this->name, + 'email' => $this->email, + 'role' => $this->pivot?->role, + 'avatar' => $this->avatar, + 'event_roles' => $this->whenLoaded('events', fn () => + $this->events->map(fn ($event) => [ + 'event_id' => $event->id, + 'event_name' => $event->name, + 'role' => $event->pivot->role, + ]) + ), + ]; + } +} diff --git a/api/app/Http/Resources/Api/V1/PersonCollection.php b/api/app/Http/Resources/Api/V1/PersonCollection.php new file mode 100644 index 0000000..e55fe5f --- /dev/null +++ b/api/app/Http/Resources/Api/V1/PersonCollection.php @@ -0,0 +1,44 @@ + $this->collection, + ]; + } + + public function with(Request $request): array + { + $persons = $this->collection; + + $byCrowdType = $persons->groupBy(fn ($person) => $person->crowd_type_id); + + $crowdTypeMeta = []; + foreach ($byCrowdType as $crowdTypeId => $group) { + $crowdTypeMeta[$crowdTypeId] = [ + 'approved_count' => $group->where('status', 'approved')->count(), + 'pending_count' => $group->where('status', 'pending')->count(), + ]; + } + + return [ + 'meta' => [ + 'total' => $persons->count(), + 'approved_count' => $persons->where('status', 'approved')->count(), + 'pending_count' => $persons->where('status', 'pending')->count(), + 'by_crowd_type' => $crowdTypeMeta, + ], + ]; + } +} diff --git a/api/app/Http/Resources/Api/V1/PersonResource.php b/api/app/Http/Resources/Api/V1/PersonResource.php new file mode 100644 index 0000000..513dd20 --- /dev/null +++ b/api/app/Http/Resources/Api/V1/PersonResource.php @@ -0,0 +1,29 @@ + $this->id, + 'event_id' => $this->event_id, + 'name' => $this->name, + 'email' => $this->email, + 'phone' => $this->phone, + 'status' => $this->status, + 'is_blacklisted' => $this->is_blacklisted, + 'admin_notes' => $this->admin_notes, + 'custom_fields' => $this->custom_fields, + 'created_at' => $this->created_at->toIso8601String(), + 'crowd_type' => new CrowdTypeResource($this->whenLoaded('crowdType')), + 'company' => new CompanyResource($this->whenLoaded('company')), + ]; + } +} diff --git a/api/app/Http/Resources/Api/V1/ShiftAssignmentResource.php b/api/app/Http/Resources/Api/V1/ShiftAssignmentResource.php new file mode 100644 index 0000000..a0eaf4d --- /dev/null +++ b/api/app/Http/Resources/Api/V1/ShiftAssignmentResource.php @@ -0,0 +1,24 @@ + $this->id, + 'shift_id' => $this->shift_id, + 'person_id' => $this->person_id, + 'status' => $this->status, + 'auto_approved' => $this->auto_approved, + 'assigned_at' => $this->assigned_at?->toIso8601String(), + 'approved_at' => $this->approved_at?->toIso8601String(), + ]; + } +} diff --git a/api/app/Http/Resources/Api/V1/ShiftResource.php b/api/app/Http/Resources/Api/V1/ShiftResource.php new file mode 100644 index 0000000..1426bfe --- /dev/null +++ b/api/app/Http/Resources/Api/V1/ShiftResource.php @@ -0,0 +1,71 @@ + $this->id, + 'festival_section_id' => $this->festival_section_id, + 'time_slot_id' => $this->time_slot_id, + 'location_id' => $this->location_id, + 'title' => $this->title, + 'description' => $this->description, + 'instructions' => $this->instructions, + 'coordinator_notes' => $this->when( + $this->shouldShowCoordinatorNotes($request), + $this->coordinator_notes, + ), + 'slots_total' => $this->slots_total, + 'slots_open_for_claiming' => $this->slots_open_for_claiming, + 'is_lead_role' => $this->is_lead_role, + 'report_time' => $this->report_time, + 'actual_start_time' => $this->actual_start_time, + 'actual_end_time' => $this->actual_end_time, + 'allow_overlap' => $this->allow_overlap, + 'status' => $this->status, + 'filled_slots' => $this->filled_slots, + 'fill_rate' => $this->fill_rate, + 'effective_start_time' => $this->effective_start_time, + 'effective_end_time' => $this->effective_end_time, + 'created_at' => $this->created_at->toIso8601String(), + 'time_slot' => new TimeSlotResource($this->whenLoaded('timeSlot')), + 'location' => new LocationResource($this->whenLoaded('location')), + 'festival_section' => new FestivalSectionResource($this->whenLoaded('festivalSection')), + ]; + } + + private function shouldShowCoordinatorNotes(Request $request): bool + { + $user = $request->user(); + if (! $user) { + return false; + } + + if ($user->hasRole('super_admin')) { + return true; + } + + $shift = $this->resource; + $event = $shift->festivalSection?->event; + if (! $event) { + return false; + } + + return $event->organisation->users() + ->where('user_id', $user->id) + ->wherePivot('role', 'org_admin') + ->exists() + || $event->users() + ->where('user_id', $user->id) + ->wherePivot('role', 'event_manager') + ->exists(); + } +} diff --git a/api/app/Http/Resources/Api/V1/TimeSlotResource.php b/api/app/Http/Resources/Api/V1/TimeSlotResource.php new file mode 100644 index 0000000..f832b02 --- /dev/null +++ b/api/app/Http/Resources/Api/V1/TimeSlotResource.php @@ -0,0 +1,26 @@ + $this->id, + 'event_id' => $this->event_id, + 'name' => $this->name, + 'person_type' => $this->person_type, + 'date' => $this->date->toDateString(), + 'start_time' => $this->start_time, + 'end_time' => $this->end_time, + 'duration_hours' => $this->duration_hours, + 'created_at' => $this->created_at->toIso8601String(), + ]; + } +} diff --git a/api/app/Mail/InvitationMail.php b/api/app/Mail/InvitationMail.php new file mode 100644 index 0000000..8efb19f --- /dev/null +++ b/api/app/Mail/InvitationMail.php @@ -0,0 +1,44 @@ +invitation->organisation->name}", + ); + } + + public function content(): Content + { + return new Content( + markdown: 'emails.invitation', + with: [ + 'acceptUrl' => config('app.frontend_app_url') . '/invitations/' . $this->invitation->token . '/accept', + 'organisationName' => $this->invitation->organisation->name, + 'inviterName' => $this->invitation->invitedBy?->name ?? 'Een beheerder', + 'role' => $this->invitation->role, + 'expiresAt' => $this->invitation->expires_at, + ], + ); + } +} diff --git a/api/app/Models/Company.php b/api/app/Models/Company.php new file mode 100644 index 0000000..9db1e89 --- /dev/null +++ b/api/app/Models/Company.php @@ -0,0 +1,38 @@ +belongsTo(Organisation::class); + } + + public function persons(): HasMany + { + return $this->hasMany(Person::class); + } +} diff --git a/api/app/Models/CrowdList.php b/api/app/Models/CrowdList.php new file mode 100644 index 0000000..4dbc807 --- /dev/null +++ b/api/app/Models/CrowdList.php @@ -0,0 +1,56 @@ + 'boolean', + 'max_persons' => 'integer', + ]; + } + + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } + + public function crowdType(): BelongsTo + { + return $this->belongsTo(CrowdType::class); + } + + public function recipientCompany(): BelongsTo + { + return $this->belongsTo(Company::class, 'recipient_company_id'); + } + + public function persons(): BelongsToMany + { + return $this->belongsToMany(Person::class, 'crowd_list_persons') + ->withPivot('added_at', 'added_by_user_id'); + } +} diff --git a/api/app/Models/CrowdType.php b/api/app/Models/CrowdType.php new file mode 100644 index 0000000..3c4ddea --- /dev/null +++ b/api/app/Models/CrowdType.php @@ -0,0 +1,43 @@ + 'boolean', + ]; + } + + public function organisation(): BelongsTo + { + return $this->belongsTo(Organisation::class); + } + + public function persons(): HasMany + { + return $this->hasMany(Person::class); + } +} diff --git a/api/app/Models/Event.php b/api/app/Models/Event.php index 7f70ec5..71227a7 100644 --- a/api/app/Models/Event.php +++ b/api/app/Models/Event.php @@ -54,6 +54,31 @@ final class Event extends Model return $this->hasMany(UserInvitation::class); } + public function locations(): HasMany + { + return $this->hasMany(Location::class); + } + + public function festivalSections(): HasMany + { + return $this->hasMany(FestivalSection::class); + } + + public function timeSlots(): HasMany + { + return $this->hasMany(TimeSlot::class); + } + + public function persons(): HasMany + { + return $this->hasMany(Person::class); + } + + public function crowdLists(): HasMany + { + return $this->hasMany(CrowdList::class); + } + public function scopeDraft(Builder $query): Builder { return $query->where('status', 'draft'); diff --git a/api/app/Models/FestivalSection.php b/api/app/Models/FestivalSection.php new file mode 100644 index 0000000..616f674 --- /dev/null +++ b/api/app/Models/FestivalSection.php @@ -0,0 +1,61 @@ + 'boolean', + 'crew_invited_to_events' => 'boolean', + 'added_to_timeline' => 'boolean', + 'responder_self_checkin' => 'boolean', + 'timed_accreditations' => 'boolean', + ]; + } + + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } + + public function shifts(): HasMany + { + return $this->hasMany(Shift::class); + } + + public function scopeOrdered(Builder $query): Builder + { + return $query->orderBy('sort_order'); + } +} diff --git a/api/app/Models/Location.php b/api/app/Models/Location.php new file mode 100644 index 0000000..0cfb7d0 --- /dev/null +++ b/api/app/Models/Location.php @@ -0,0 +1,31 @@ +belongsTo(Event::class); + } +} diff --git a/api/app/Models/Organisation.php b/api/app/Models/Organisation.php index 002fbc7..42916d8 100644 --- a/api/app/Models/Organisation.php +++ b/api/app/Models/Organisation.php @@ -47,4 +47,14 @@ final class Organisation extends Model { return $this->hasMany(UserInvitation::class); } + + public function crowdTypes(): HasMany + { + return $this->hasMany(CrowdType::class); + } + + public function companies(): HasMany + { + return $this->hasMany(Company::class); + } } diff --git a/api/app/Models/Person.php b/api/app/Models/Person.php new file mode 100644 index 0000000..2077ad5 --- /dev/null +++ b/api/app/Models/Person.php @@ -0,0 +1,91 @@ + 'boolean', + 'custom_fields' => 'array', + ]; + } + + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } + + public function crowdType(): BelongsTo + { + return $this->belongsTo(CrowdType::class); + } + + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function crowdLists(): BelongsToMany + { + return $this->belongsToMany(CrowdList::class, 'crowd_list_persons') + ->withPivot('added_at', 'added_by_user_id'); + } + + public function shiftAssignments(): HasMany + { + return $this->hasMany(ShiftAssignment::class); + } + + public function scopeApproved(Builder $query): Builder + { + return $query->where('status', 'approved'); + } + + public function scopePending(Builder $query): Builder + { + return $query->where('status', 'pending'); + } + + public function scopeForCrowdType(Builder $query, string $type): Builder + { + return $query->whereHas('crowdType', fn (Builder $q) => $q->where('system_type', $type)); + } +} diff --git a/api/app/Models/Shift.php b/api/app/Models/Shift.php new file mode 100644 index 0000000..edca084 --- /dev/null +++ b/api/app/Models/Shift.php @@ -0,0 +1,124 @@ + 'boolean', + 'allow_overlap' => 'boolean', + 'events_during_shift' => 'array', + 'slots_total' => 'integer', + 'slots_open_for_claiming' => 'integer', + ]; + } + + public function festivalSection(): BelongsTo + { + return $this->belongsTo(FestivalSection::class); + } + + public function timeSlot(): BelongsTo + { + return $this->belongsTo(TimeSlot::class); + } + + public function location(): BelongsTo + { + return $this->belongsTo(Location::class); + } + + public function assignedCrew(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_crew_id'); + } + + public function shiftAssignments(): HasMany + { + return $this->hasMany(ShiftAssignment::class); + } + + public function waitlist(): HasMany + { + return $this->hasMany(ShiftWaitlist::class); + } + + protected function effectiveStartTime(): Attribute + { + return Attribute::get(fn () => $this->actual_start_time ?? $this->timeSlot?->start_time); + } + + protected function effectiveEndTime(): Attribute + { + return Attribute::get(fn () => $this->actual_end_time ?? $this->timeSlot?->end_time); + } + + protected function filledSlots(): Attribute + { + return Attribute::get(fn () => $this->shiftAssignments()->where('status', 'approved')->count()); + } + + protected function fillRate(): Attribute + { + return Attribute::get(function () { + if ($this->slots_total === 0) { + return 0; + } + + return round($this->filled_slots / $this->slots_total, 2); + }); + } + + public function scopeOpen(Builder $query): Builder + { + return $query->where('status', 'open'); + } + + public function scopeDraft(Builder $query): Builder + { + return $query->where('status', 'draft'); + } + + public function scopeFull(Builder $query): Builder + { + return $query->where('status', 'full'); + } +} diff --git a/api/app/Models/ShiftAssignment.php b/api/app/Models/ShiftAssignment.php new file mode 100644 index 0000000..ed9b7d6 --- /dev/null +++ b/api/app/Models/ShiftAssignment.php @@ -0,0 +1,61 @@ + 'boolean', + 'assigned_at' => 'datetime', + 'approved_at' => 'datetime', + 'checked_in_at' => 'datetime', + 'checked_out_at' => 'datetime', + ]; + } + + public function shift(): BelongsTo + { + return $this->belongsTo(Shift::class); + } + + public function person(): BelongsTo + { + return $this->belongsTo(Person::class); + } + + public function timeSlot(): BelongsTo + { + return $this->belongsTo(TimeSlot::class); + } +} diff --git a/api/app/Models/ShiftWaitlist.php b/api/app/Models/ShiftWaitlist.php new file mode 100644 index 0000000..0c6fc67 --- /dev/null +++ b/api/app/Models/ShiftWaitlist.php @@ -0,0 +1,46 @@ + 'datetime', + 'notified_at' => 'datetime', + ]; + } + + public function shift(): BelongsTo + { + return $this->belongsTo(Shift::class); + } + + public function person(): BelongsTo + { + return $this->belongsTo(Person::class); + } +} diff --git a/api/app/Models/TimeSlot.php b/api/app/Models/TimeSlot.php new file mode 100644 index 0000000..a3b6461 --- /dev/null +++ b/api/app/Models/TimeSlot.php @@ -0,0 +1,57 @@ + 'date', + 'person_type' => 'string', + ]; + } + + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } + + public function shifts(): HasMany + { + return $this->hasMany(Shift::class); + } + + public function scopeForType(Builder $query, string $type): Builder + { + return $query->where('person_type', $type); + } + + public function scopeForDate(Builder $query, Carbon $date): Builder + { + return $query->whereDate('date', $date); + } +} diff --git a/api/app/Models/UserInvitation.php b/api/app/Models/UserInvitation.php index 5282773..1a19336 100644 --- a/api/app/Models/UserInvitation.php +++ b/api/app/Models/UserInvitation.php @@ -48,6 +48,26 @@ final class UserInvitation extends Model return $this->belongsTo(Event::class); } + public function isExpired(): bool + { + return $this->expires_at->isPast(); + } + + public function isPending(): bool + { + return $this->status === 'pending'; + } + + public function markAsAccepted(): void + { + $this->update(['status' => 'accepted']); + } + + public function markAsExpired(): void + { + $this->update(['status' => 'expired']); + } + public function scopePending(Builder $query): Builder { return $query->where('status', 'pending'); diff --git a/api/app/Policies/CompanyPolicy.php b/api/app/Policies/CompanyPolicy.php new file mode 100644 index 0000000..d929bf7 --- /dev/null +++ b/api/app/Policies/CompanyPolicy.php @@ -0,0 +1,53 @@ +hasRole('super_admin') + || $organisation->users()->where('user_id', $user->id)->exists(); + } + + public function create(User $user, Organisation $organisation): bool + { + return $this->canManageOrganisation($user, $organisation); + } + + public function update(User $user, Company $company, Organisation $organisation): bool + { + if ($company->organisation_id !== $organisation->id) { + return false; + } + + return $this->canManageOrganisation($user, $organisation); + } + + public function delete(User $user, Company $company, Organisation $organisation): bool + { + if ($company->organisation_id !== $organisation->id) { + return false; + } + + return $this->canManageOrganisation($user, $organisation); + } + + private function canManageOrganisation(User $user, Organisation $organisation): bool + { + if ($user->hasRole('super_admin')) { + return true; + } + + return $organisation->users() + ->where('user_id', $user->id) + ->wherePivot('role', 'org_admin') + ->exists(); + } +} diff --git a/api/app/Policies/CrowdListPolicy.php b/api/app/Policies/CrowdListPolicy.php new file mode 100644 index 0000000..46d38e0 --- /dev/null +++ b/api/app/Policies/CrowdListPolicy.php @@ -0,0 +1,71 @@ +hasRole('super_admin') + || $event->organisation->users()->where('user_id', $user->id)->exists(); + } + + public function create(User $user, Event $event): bool + { + return $this->canManageEvent($user, $event); + } + + public function update(User $user, CrowdList $crowdList, Event $event): bool + { + if ($crowdList->event_id !== $event->id) { + return false; + } + + return $this->canManageEvent($user, $event); + } + + public function delete(User $user, CrowdList $crowdList, Event $event): bool + { + if ($crowdList->event_id !== $event->id) { + return false; + } + + return $this->canManageEvent($user, $event); + } + + public function managePerson(User $user, CrowdList $crowdList, Event $event): bool + { + if ($crowdList->event_id !== $event->id) { + return false; + } + + return $this->canManageEvent($user, $event); + } + + private function canManageEvent(User $user, Event $event): bool + { + if ($user->hasRole('super_admin')) { + return true; + } + + $isOrgAdmin = $event->organisation->users() + ->where('user_id', $user->id) + ->wherePivot('role', 'org_admin') + ->exists(); + + if ($isOrgAdmin) { + return true; + } + + return $event->users() + ->where('user_id', $user->id) + ->wherePivot('role', 'event_manager') + ->exists(); + } +} diff --git a/api/app/Policies/CrowdTypePolicy.php b/api/app/Policies/CrowdTypePolicy.php new file mode 100644 index 0000000..0db3f15 --- /dev/null +++ b/api/app/Policies/CrowdTypePolicy.php @@ -0,0 +1,53 @@ +hasRole('super_admin') + || $organisation->users()->where('user_id', $user->id)->exists(); + } + + public function create(User $user, Organisation $organisation): bool + { + return $this->canManageOrganisation($user, $organisation); + } + + public function update(User $user, CrowdType $crowdType, Organisation $organisation): bool + { + if ($crowdType->organisation_id !== $organisation->id) { + return false; + } + + return $this->canManageOrganisation($user, $organisation); + } + + public function delete(User $user, CrowdType $crowdType, Organisation $organisation): bool + { + if ($crowdType->organisation_id !== $organisation->id) { + return false; + } + + return $this->canManageOrganisation($user, $organisation); + } + + private function canManageOrganisation(User $user, Organisation $organisation): bool + { + if ($user->hasRole('super_admin')) { + return true; + } + + return $organisation->users() + ->where('user_id', $user->id) + ->wherePivot('role', 'org_admin') + ->exists(); + } +} diff --git a/api/app/Policies/FestivalSectionPolicy.php b/api/app/Policies/FestivalSectionPolicy.php new file mode 100644 index 0000000..d409673 --- /dev/null +++ b/api/app/Policies/FestivalSectionPolicy.php @@ -0,0 +1,67 @@ +hasRole('super_admin') + || $event->organisation->users()->where('user_id', $user->id)->exists(); + } + + public function create(User $user, Event $event): bool + { + return $this->canManageEvent($user, $event); + } + + public function update(User $user, FestivalSection $section, Event $event): bool + { + if ($section->event_id !== $event->id) { + return false; + } + + return $this->canManageEvent($user, $event); + } + + public function delete(User $user, FestivalSection $section, Event $event): bool + { + if ($section->event_id !== $event->id) { + return false; + } + + return $this->canManageEvent($user, $event); + } + + public function reorder(User $user, Event $event): bool + { + return $this->canManageEvent($user, $event); + } + + private function canManageEvent(User $user, Event $event): bool + { + if ($user->hasRole('super_admin')) { + return true; + } + + $isOrgAdmin = $event->organisation->users() + ->where('user_id', $user->id) + ->wherePivot('role', 'org_admin') + ->exists(); + + if ($isOrgAdmin) { + return true; + } + + return $event->users() + ->where('user_id', $user->id) + ->wherePivot('role', 'event_manager') + ->exists(); + } +} diff --git a/api/app/Policies/LocationPolicy.php b/api/app/Policies/LocationPolicy.php new file mode 100644 index 0000000..d74f8e2 --- /dev/null +++ b/api/app/Policies/LocationPolicy.php @@ -0,0 +1,62 @@ +hasRole('super_admin') + || $event->organisation->users()->where('user_id', $user->id)->exists(); + } + + public function create(User $user, Event $event): bool + { + return $this->canManageEvent($user, $event); + } + + public function update(User $user, Location $location, Event $event): bool + { + if ($location->event_id !== $event->id) { + return false; + } + + return $this->canManageEvent($user, $event); + } + + public function delete(User $user, Location $location, Event $event): bool + { + if ($location->event_id !== $event->id) { + return false; + } + + return $this->canManageEvent($user, $event); + } + + private function canManageEvent(User $user, Event $event): bool + { + if ($user->hasRole('super_admin')) { + return true; + } + + $isOrgAdmin = $event->organisation->users() + ->where('user_id', $user->id) + ->wherePivot('role', 'org_admin') + ->exists(); + + if ($isOrgAdmin) { + return true; + } + + return $event->users() + ->where('user_id', $user->id) + ->wherePivot('role', 'event_manager') + ->exists(); + } +} diff --git a/api/app/Policies/OrganisationPolicy.php b/api/app/Policies/OrganisationPolicy.php index 0e8f220..552b69d 100644 --- a/api/app/Policies/OrganisationPolicy.php +++ b/api/app/Policies/OrganisationPolicy.php @@ -37,4 +37,16 @@ final class OrganisationPolicy ->wherePivot('role', 'org_admin') ->exists(); } + + public function invite(User $user, Organisation $organisation): bool + { + if ($user->hasRole('super_admin')) { + return true; + } + + return $organisation->users() + ->where('user_id', $user->id) + ->wherePivot('role', 'org_admin') + ->exists(); + } } diff --git a/api/app/Policies/PersonPolicy.php b/api/app/Policies/PersonPolicy.php new file mode 100644 index 0000000..218f3e4 --- /dev/null +++ b/api/app/Policies/PersonPolicy.php @@ -0,0 +1,81 @@ +hasRole('super_admin') + || $event->organisation->users()->where('user_id', $user->id)->exists(); + } + + public function view(User $user, Person $person, Event $event): bool + { + if ($person->event_id !== $event->id) { + return false; + } + + return $user->hasRole('super_admin') + || $event->organisation->users()->where('user_id', $user->id)->exists(); + } + + public function create(User $user, Event $event): bool + { + return $this->canManageEvent($user, $event); + } + + public function update(User $user, Person $person, Event $event): bool + { + if ($person->event_id !== $event->id) { + return false; + } + + return $this->canManageEvent($user, $event); + } + + public function delete(User $user, Person $person, Event $event): bool + { + if ($person->event_id !== $event->id) { + return false; + } + + return $this->canManageEvent($user, $event); + } + + public function approve(User $user, Person $person, Event $event): bool + { + if ($person->event_id !== $event->id) { + return false; + } + + return $this->canManageEvent($user, $event); + } + + private function canManageEvent(User $user, Event $event): bool + { + if ($user->hasRole('super_admin')) { + return true; + } + + $isOrgAdmin = $event->organisation->users() + ->where('user_id', $user->id) + ->wherePivot('role', 'org_admin') + ->exists(); + + if ($isOrgAdmin) { + return true; + } + + return $event->users() + ->where('user_id', $user->id) + ->wherePivot('role', 'event_manager') + ->exists(); + } +} diff --git a/api/app/Policies/ShiftPolicy.php b/api/app/Policies/ShiftPolicy.php new file mode 100644 index 0000000..6d04786 --- /dev/null +++ b/api/app/Policies/ShiftPolicy.php @@ -0,0 +1,82 @@ +hasRole('super_admin') + || $event->organisation->users()->where('user_id', $user->id)->exists(); + } + + public function create(User $user, Event $event): bool + { + return $this->canManageEvent($user, $event); + } + + public function update(User $user, Shift $shift, Event $event, FestivalSection $section): bool + { + if ($shift->festival_section_id !== $section->id || $section->event_id !== $event->id) { + return false; + } + + return $this->canManageEvent($user, $event); + } + + public function delete(User $user, Shift $shift, Event $event, FestivalSection $section): bool + { + if ($shift->festival_section_id !== $section->id || $section->event_id !== $event->id) { + return false; + } + + return $this->canManageEvent($user, $event); + } + + public function assign(User $user, Shift $shift, Event $event, FestivalSection $section): bool + { + if ($shift->festival_section_id !== $section->id || $section->event_id !== $event->id) { + return false; + } + + return $this->canManageEvent($user, $event); + } + + public function claim(User $user, Shift $shift, Event $event, FestivalSection $section): bool + { + if ($shift->festival_section_id !== $section->id || $section->event_id !== $event->id) { + return false; + } + + return $user->hasRole('super_admin') + || $event->organisation->users()->where('user_id', $user->id)->exists(); + } + + private function canManageEvent(User $user, Event $event): bool + { + if ($user->hasRole('super_admin')) { + return true; + } + + $isOrgAdmin = $event->organisation->users() + ->where('user_id', $user->id) + ->wherePivot('role', 'org_admin') + ->exists(); + + if ($isOrgAdmin) { + return true; + } + + return $event->users() + ->where('user_id', $user->id) + ->wherePivot('role', 'event_manager') + ->exists(); + } +} diff --git a/api/app/Policies/TimeSlotPolicy.php b/api/app/Policies/TimeSlotPolicy.php new file mode 100644 index 0000000..f02a1b4 --- /dev/null +++ b/api/app/Policies/TimeSlotPolicy.php @@ -0,0 +1,62 @@ +hasRole('super_admin') + || $event->organisation->users()->where('user_id', $user->id)->exists(); + } + + public function create(User $user, Event $event): bool + { + return $this->canManageEvent($user, $event); + } + + public function update(User $user, TimeSlot $timeSlot, Event $event): bool + { + if ($timeSlot->event_id !== $event->id) { + return false; + } + + return $this->canManageEvent($user, $event); + } + + public function delete(User $user, TimeSlot $timeSlot, Event $event): bool + { + if ($timeSlot->event_id !== $event->id) { + return false; + } + + return $this->canManageEvent($user, $event); + } + + private function canManageEvent(User $user, Event $event): bool + { + if ($user->hasRole('super_admin')) { + return true; + } + + $isOrgAdmin = $event->organisation->users() + ->where('user_id', $user->id) + ->wherePivot('role', 'org_admin') + ->exists(); + + if ($isOrgAdmin) { + return true; + } + + return $event->users() + ->where('user_id', $user->id) + ->wherePivot('role', 'event_manager') + ->exists(); + } +} diff --git a/api/app/Services/InvitationService.php b/api/app/Services/InvitationService.php new file mode 100644 index 0000000..1f5d47e --- /dev/null +++ b/api/app/Services/InvitationService.php @@ -0,0 +1,109 @@ +where('organisation_id', $org->id) + ->pending() + ->where('expires_at', '>', now()) + ->first(); + + if ($existingInvitation) { + throw ValidationException::withMessages([ + 'email' => ['Er is al een openstaande uitnodiging voor dit e-mailadres.'], + ]); + } + + $existingMember = $org->users()->where('email', $email)->first(); + + if ($existingMember) { + throw ValidationException::withMessages([ + 'email' => ['Gebruiker is al lid van deze organisatie.'], + ]); + } + + $invitation = UserInvitation::create([ + 'email' => $email, + 'invited_by_user_id' => $invitedBy->id, + 'organisation_id' => $org->id, + 'role' => $role, + 'token' => strtolower((string) Str::ulid()), + 'status' => 'pending', + 'expires_at' => now()->addDays(7), + ]); + + Mail::to($email)->queue(new InvitationMail($invitation)); + + activity('invitation') + ->performedOn($invitation) + ->causedBy($invitedBy) + ->withProperties(['email' => $email, 'role' => $role]) + ->log("Invited {$email} as {$role}"); + + return $invitation; + } + + public function accept(UserInvitation $invitation, ?string $password = null): User + { + if (! $invitation->isPending() || $invitation->isExpired()) { + throw ValidationException::withMessages([ + 'token' => ['Deze uitnodiging is niet meer geldig.'], + ]); + } + + $user = User::where('email', $invitation->email)->first(); + + if (! $user) { + if (! $password) { + throw ValidationException::withMessages([ + 'password' => ['Een wachtwoord is vereist om een nieuw account aan te maken.'], + ]); + } + + $user = User::create([ + 'name' => Str::before($invitation->email, '@'), + 'email' => $invitation->email, + 'password' => $password, + 'email_verified_at' => now(), + ]); + } + + $organisation = $invitation->organisation; + + if (! $organisation->users()->where('user_id', $user->id)->exists()) { + $organisation->users()->attach($user, ['role' => $invitation->role]); + } + + $invitation->markAsAccepted(); + + activity('invitation') + ->performedOn($invitation) + ->causedBy($user) + ->withProperties(['email' => $invitation->email]) + ->log("Accepted invitation for {$organisation->name}"); + + return $user; + } + + public function expireOldInvitations(): int + { + return UserInvitation::where('status', 'pending') + ->where('expires_at', '<', now()) + ->update(['status' => 'expired']); + } +} diff --git a/api/config/app.php b/api/config/app.php index cadd10b..8323588 100644 --- a/api/config/app.php +++ b/api/config/app.php @@ -123,4 +123,6 @@ return [ 'store' => env('APP_MAINTENANCE_STORE', 'database'), ], + 'frontend_app_url' => env('FRONTEND_APP_URL', 'http://localhost:5174'), + ]; diff --git a/api/database/factories/CompanyFactory.php b/api/database/factories/CompanyFactory.php new file mode 100644 index 0000000..e4cf7cb --- /dev/null +++ b/api/database/factories/CompanyFactory.php @@ -0,0 +1,26 @@ + */ +final class CompanyFactory extends Factory +{ + /** @return array */ + public function definition(): array + { + return [ + 'organisation_id' => Organisation::factory(), + 'name' => fake('nl_NL')->company(), + 'type' => fake()->randomElement(['supplier', 'partner', 'agency', 'venue', 'other']), + 'contact_name' => fake('nl_NL')->name(), + 'contact_email' => fake()->companyEmail(), + 'contact_phone' => fake('nl_NL')->phoneNumber(), + ]; + } +} diff --git a/api/database/factories/CrowdTypeFactory.php b/api/database/factories/CrowdTypeFactory.php new file mode 100644 index 0000000..3856963 --- /dev/null +++ b/api/database/factories/CrowdTypeFactory.php @@ -0,0 +1,50 @@ + */ +final class CrowdTypeFactory extends Factory +{ + private const TYPES = [ + 'CREW' => ['name' => 'Crew', 'color' => '#3b82f6'], + 'VOLUNTEER' => ['name' => 'Vrijwilliger', 'color' => '#10b981'], + 'ARTIST' => ['name' => 'Artiest', 'color' => '#8b5cf6'], + 'GUEST' => ['name' => 'Gast', 'color' => '#f59e0b'], + 'PRESS' => ['name' => 'Pers', 'color' => '#6366f1'], + 'PARTNER' => ['name' => 'Partner', 'color' => '#ec4899'], + 'SUPPLIER' => ['name' => 'Leverancier', 'color' => '#64748b'], + ]; + + /** @return array */ + public function definition(): array + { + $systemType = fake()->randomElement(array_keys(self::TYPES)); + $typeConfig = self::TYPES[$systemType]; + + return [ + 'organisation_id' => Organisation::factory(), + 'name' => $typeConfig['name'], + 'system_type' => $systemType, + 'color' => $typeConfig['color'], + 'icon' => null, + 'is_active' => true, + ]; + } + + public function systemType(string $type): static + { + $config = self::TYPES[$type]; + + return $this->state(fn () => [ + 'system_type' => $type, + 'name' => $config['name'], + 'color' => $config['color'], + ]); + } +} diff --git a/api/database/factories/FestivalSectionFactory.php b/api/database/factories/FestivalSectionFactory.php new file mode 100644 index 0000000..ed04546 --- /dev/null +++ b/api/database/factories/FestivalSectionFactory.php @@ -0,0 +1,43 @@ + */ +final class FestivalSectionFactory extends Factory +{ + /** @return array */ + public function definition(): array + { + return [ + 'event_id' => Event::factory(), + 'name' => fake()->randomElement([ + 'Horeca', + 'Backstage', + 'Overig', + 'Entertainment', + 'Security', + 'Techniek', + ]), + 'type' => 'standard', + 'sort_order' => fake()->numberBetween(1, 5), + 'responder_self_checkin' => true, + 'crew_auto_accepts' => false, + ]; + } + + public function withSortOrder(int $order): static + { + return $this->state(fn () => ['sort_order' => $order]); + } + + public function crossEvent(): static + { + return $this->state(fn () => ['type' => 'cross_event']); + } +} diff --git a/api/database/factories/LocationFactory.php b/api/database/factories/LocationFactory.php new file mode 100644 index 0000000..68fc511 --- /dev/null +++ b/api/database/factories/LocationFactory.php @@ -0,0 +1,34 @@ + */ +final class LocationFactory extends Factory +{ + /** @return array */ + public function definition(): array + { + return [ + 'event_id' => Event::factory(), + 'name' => fake()->randomElement([ + 'Hoofdpodium', + 'Bar Noord', + 'Bar Zuid', + 'Security Gate A', + 'Backstage', + 'Hospitality Tent', + ]), + 'address' => null, + 'lat' => null, + 'lng' => null, + 'description' => null, + 'access_instructions' => null, + ]; + } +} diff --git a/api/database/factories/PersonFactory.php b/api/database/factories/PersonFactory.php new file mode 100644 index 0000000..fc389e7 --- /dev/null +++ b/api/database/factories/PersonFactory.php @@ -0,0 +1,34 @@ + */ +final class PersonFactory extends Factory +{ + /** @return array */ + public function definition(): array + { + return [ + 'event_id' => Event::factory(), + 'crowd_type_id' => CrowdType::factory(), + 'name' => fake('nl_NL')->name(), + 'email' => fake()->unique()->safeEmail(), + 'phone' => fake('nl_NL')->phoneNumber(), + 'status' => 'pending', + 'is_blacklisted' => false, + 'custom_fields' => null, + ]; + } + + public function approved(): static + { + return $this->state(fn () => ['status' => 'approved']); + } +} diff --git a/api/database/factories/ShiftFactory.php b/api/database/factories/ShiftFactory.php new file mode 100644 index 0000000..2b5c9bd --- /dev/null +++ b/api/database/factories/ShiftFactory.php @@ -0,0 +1,52 @@ + */ +final class ShiftFactory extends Factory +{ + /** @return array */ + public function definition(): array + { + return [ + 'festival_section_id' => FestivalSection::factory(), + 'time_slot_id' => TimeSlot::factory(), + 'title' => fake()->randomElement([ + 'Tapper', + 'Tussenbuffet', + 'Barhoofd', + 'Stage Manager', + 'Stagehand', + 'Coördinator', + 'Runner', + ]), + 'slots_total' => fake()->numberBetween(1, 10), + 'slots_open_for_claiming' => 0, + 'status' => 'draft', + 'is_lead_role' => false, + 'allow_overlap' => false, + ]; + } + + public function open(): static + { + return $this->state(fn () => ['status' => 'open']); + } + + public function withClaiming(int $slots): static + { + return $this->state(fn () => ['slots_open_for_claiming' => $slots]); + } + + public function allowOverlap(): static + { + return $this->state(fn () => ['allow_overlap' => true]); + } +} diff --git a/api/database/factories/TimeSlotFactory.php b/api/database/factories/TimeSlotFactory.php new file mode 100644 index 0000000..57a8c86 --- /dev/null +++ b/api/database/factories/TimeSlotFactory.php @@ -0,0 +1,33 @@ + */ +final class TimeSlotFactory extends Factory +{ + /** @return array */ + public function definition(): array + { + return [ + 'event_id' => Event::factory(), + 'name' => fake()->randomElement([ + 'Vrijdag Avond', + 'Zaterdag Dag', + 'Zaterdag Avond', + 'Zondag', + 'Opbouw', + ]), + 'person_type' => 'VOLUNTEER', + 'date' => fake()->dateTimeBetween('+1 month', '+3 months'), + 'start_time' => '18:00:00', + 'end_time' => '02:00:00', + 'duration_hours' => 8.00, + ]; + } +} diff --git a/api/database/migrations/2026_04_07_260000_create_locations_table.php b/api/database/migrations/2026_04_07_260000_create_locations_table.php new file mode 100644 index 0000000..247e54d --- /dev/null +++ b/api/database/migrations/2026_04_07_260000_create_locations_table.php @@ -0,0 +1,32 @@ +ulid('id')->primary(); + $table->foreignUlid('event_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('address')->nullable(); + $table->decimal('lat', 10, 8)->nullable(); + $table->decimal('lng', 11, 8)->nullable(); + $table->text('description')->nullable(); + $table->text('access_instructions')->nullable(); + $table->timestamps(); + + $table->index('event_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('locations'); + } +}; diff --git a/api/database/migrations/2026_04_07_270000_create_festival_sections_table.php b/api/database/migrations/2026_04_07_270000_create_festival_sections_table.php new file mode 100644 index 0000000..0d43fd6 --- /dev/null +++ b/api/database/migrations/2026_04_07_270000_create_festival_sections_table.php @@ -0,0 +1,29 @@ +ulid('id')->primary(); + $table->foreignUlid('event_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->unsignedInteger('sort_order')->default(0); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['event_id', 'sort_order']); + }); + } + + public function down(): void + { + Schema::dropIfExists('festival_sections'); + } +}; diff --git a/api/database/migrations/2026_04_07_280000_create_time_slots_table.php b/api/database/migrations/2026_04_07_280000_create_time_slots_table.php new file mode 100644 index 0000000..8b370fe --- /dev/null +++ b/api/database/migrations/2026_04_07_280000_create_time_slots_table.php @@ -0,0 +1,32 @@ +ulid('id')->primary(); + $table->foreignUlid('event_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->enum('person_type', ['CREW', 'VOLUNTEER', 'PRESS', 'PHOTO', 'PARTNER'])->default('VOLUNTEER'); + $table->date('date'); + $table->time('start_time'); + $table->time('end_time'); + $table->decimal('duration_hours', 4, 2)->nullable(); + $table->timestamps(); + + $table->index(['event_id', 'person_type', 'date']); + }); + } + + public function down(): void + { + Schema::dropIfExists('time_slots'); + } +}; diff --git a/api/database/migrations/2026_04_08_100000_remove_volunteer_min_hours_from_events.php b/api/database/migrations/2026_04_08_100000_remove_volunteer_min_hours_from_events.php new file mode 100644 index 0000000..1c4f4b5 --- /dev/null +++ b/api/database/migrations/2026_04_08_100000_remove_volunteer_min_hours_from_events.php @@ -0,0 +1,28 @@ +dropColumn('volunteer_min_hours_for_pass'); + } + }); + } + + public function down(): void + { + Schema::table('events', function (Blueprint $table) { + if (! Schema::hasColumn('events', 'volunteer_min_hours_for_pass')) { + $table->decimal('volunteer_min_hours_for_pass', 4, 2)->nullable()->after('status'); + } + }); + } +}; diff --git a/api/database/migrations/2026_04_08_110000_remove_route_geojson_from_locations.php b/api/database/migrations/2026_04_08_110000_remove_route_geojson_from_locations.php new file mode 100644 index 0000000..f45ffde --- /dev/null +++ b/api/database/migrations/2026_04_08_110000_remove_route_geojson_from_locations.php @@ -0,0 +1,28 @@ +dropColumn('route_geojson'); + } + }); + } + + public function down(): void + { + Schema::table('locations', function (Blueprint $table) { + if (! Schema::hasColumn('locations', 'route_geojson')) { + $table->json('route_geojson')->nullable()->after('access_instructions'); + } + }); + } +}; diff --git a/api/database/migrations/2026_04_08_120000_add_section_settings_to_festival_sections.php b/api/database/migrations/2026_04_08_120000_add_section_settings_to_festival_sections.php new file mode 100644 index 0000000..adf6532 --- /dev/null +++ b/api/database/migrations/2026_04_08_120000_add_section_settings_to_festival_sections.php @@ -0,0 +1,42 @@ +enum('type', ['standard', 'cross_event'])->default('standard')->after('name'); + $table->unsignedInteger('crew_need')->nullable()->after('type'); + $table->boolean('crew_auto_accepts')->default(false)->after('sort_order'); + $table->boolean('crew_invited_to_events')->default(false)->after('crew_auto_accepts'); + $table->boolean('added_to_timeline')->default(false)->after('crew_invited_to_events'); + $table->boolean('responder_self_checkin')->default(true)->after('added_to_timeline'); + $table->string('crew_accreditation_level')->nullable()->after('responder_self_checkin'); + $table->string('public_form_accreditation_level')->nullable()->after('crew_accreditation_level'); + $table->boolean('timed_accreditations')->default(false)->after('public_form_accreditation_level'); + }); + } + + public function down(): void + { + Schema::table('festival_sections', function (Blueprint $table) { + $table->dropColumn([ + 'type', + 'crew_need', + 'crew_auto_accepts', + 'crew_invited_to_events', + 'added_to_timeline', + 'responder_self_checkin', + 'crew_accreditation_level', + 'public_form_accreditation_level', + 'timed_accreditations', + ]); + }); + } +}; diff --git a/api/database/migrations/2026_04_08_130000_create_shifts_table.php b/api/database/migrations/2026_04_08_130000_create_shifts_table.php new file mode 100644 index 0000000..436d4ad --- /dev/null +++ b/api/database/migrations/2026_04_08_130000_create_shifts_table.php @@ -0,0 +1,44 @@ +ulid('id')->primary(); + $table->foreignUlid('festival_section_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('time_slot_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('location_id')->nullable()->constrained()->nullOnDelete(); + $table->time('report_time')->nullable(); + $table->string('title')->nullable(); + $table->text('description')->nullable(); + $table->text('instructions')->nullable(); + $table->text('coordinator_notes')->nullable(); + $table->unsignedInteger('slots_total'); + $table->unsignedInteger('slots_open_for_claiming'); + $table->boolean('is_lead_role')->default(false); + $table->time('actual_start_time')->nullable(); + $table->time('actual_end_time')->nullable(); + $table->date('end_date')->nullable(); + $table->boolean('allow_overlap')->default(false); + $table->json('events_during_shift')->nullable(); + $table->enum('status', ['draft', 'open', 'full', 'in_progress', 'completed', 'cancelled'])->default('draft'); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['festival_section_id', 'time_slot_id']); + $table->index(['time_slot_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('shifts'); + } +}; diff --git a/api/database/migrations/2026_04_08_140000_create_shift_assignments_table.php b/api/database/migrations/2026_04_08_140000_create_shift_assignments_table.php new file mode 100644 index 0000000..cc3147a --- /dev/null +++ b/api/database/migrations/2026_04_08_140000_create_shift_assignments_table.php @@ -0,0 +1,42 @@ +ulid('id')->primary(); + $table->foreignUlid('shift_id')->constrained()->cascadeOnDelete(); + $table->char('person_id', 26); + $table->foreignUlid('time_slot_id')->constrained()->cascadeOnDelete(); + $table->enum('status', ['pending_approval', 'approved', 'rejected', 'cancelled', 'completed'])->default('pending_approval'); + $table->boolean('auto_approved')->default(false); + $table->foreignUlid('assigned_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('assigned_at')->nullable(); + $table->foreignUlid('approved_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('approved_at')->nullable(); + $table->text('rejection_reason')->nullable(); + $table->decimal('hours_expected', 4, 2)->nullable(); + $table->decimal('hours_completed', 4, 2)->nullable(); + $table->timestamp('checked_in_at')->nullable(); + $table->timestamp('checked_out_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['person_id', 'time_slot_id']); + $table->index(['shift_id', 'status']); + $table->index(['person_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('shift_assignments'); + } +}; diff --git a/api/database/migrations/2026_04_08_150000_create_shift_check_ins_table.php b/api/database/migrations/2026_04_08_150000_create_shift_check_ins_table.php new file mode 100644 index 0000000..c6f3949 --- /dev/null +++ b/api/database/migrations/2026_04_08_150000_create_shift_check_ins_table.php @@ -0,0 +1,33 @@ +ulid('id')->primary(); + $table->foreignUlid('shift_assignment_id')->constrained()->cascadeOnDelete(); + $table->char('person_id', 26); + $table->foreignUlid('shift_id')->constrained()->cascadeOnDelete(); + $table->timestamp('checked_in_at'); + $table->timestamp('checked_out_at')->nullable(); + $table->foreignUlid('checked_in_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->enum('method', ['qr', 'manual']); + + $table->index('shift_assignment_id'); + $table->index(['shift_id', 'checked_in_at']); + $table->index(['person_id', 'checked_in_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('shift_check_ins'); + } +}; diff --git a/api/database/migrations/2026_04_08_160000_create_volunteer_availabilities_table.php b/api/database/migrations/2026_04_08_160000_create_volunteer_availabilities_table.php new file mode 100644 index 0000000..f4f8ebc --- /dev/null +++ b/api/database/migrations/2026_04_08_160000_create_volunteer_availabilities_table.php @@ -0,0 +1,29 @@ +ulid('id')->primary(); + $table->char('person_id', 26); + $table->foreignUlid('time_slot_id')->constrained()->cascadeOnDelete(); + $table->tinyInteger('preference_level')->default(3); + $table->timestamp('submitted_at'); + + $table->unique(['person_id', 'time_slot_id']); + $table->index('time_slot_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('volunteer_availabilities'); + } +}; diff --git a/api/database/migrations/2026_04_08_170000_create_artists_table.php b/api/database/migrations/2026_04_08_170000_create_artists_table.php new file mode 100644 index 0000000..47268bb --- /dev/null +++ b/api/database/migrations/2026_04_08_170000_create_artists_table.php @@ -0,0 +1,43 @@ +ulid('id')->primary(); + $table->foreignUlid('event_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->enum('booking_status', ['concept', 'requested', 'option', 'confirmed', 'contracted', 'cancelled'])->default('concept'); + $table->tinyInteger('star_rating')->default(1); + $table->foreignUlid('project_leader_id')->nullable()->constrained('users')->nullOnDelete(); + $table->boolean('milestone_offer_in')->default(false); + $table->boolean('milestone_offer_agreed')->default(false); + $table->boolean('milestone_confirmed')->default(false); + $table->boolean('milestone_announced')->default(false); + $table->boolean('milestone_schedule_confirmed')->default(false); + $table->boolean('milestone_itinerary_sent')->default(false); + $table->boolean('milestone_advance_sent')->default(false); + $table->boolean('milestone_advance_received')->default(false); + $table->datetime('advance_open_from')->nullable(); + $table->datetime('advance_open_to')->nullable(); + $table->boolean('show_advance_share_page')->default(true); + $table->char('portal_token', 26)->unique(); + $table->timestamps(); + $table->softDeletes(); + + $table->index('event_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('artists'); + } +}; diff --git a/api/database/migrations/2026_04_08_180000_create_advance_sections_table.php b/api/database/migrations/2026_04_08_180000_create_advance_sections_table.php new file mode 100644 index 0000000..8e4ba04 --- /dev/null +++ b/api/database/migrations/2026_04_08_180000_create_advance_sections_table.php @@ -0,0 +1,37 @@ +ulid('id')->primary(); + $table->foreignUlid('artist_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->enum('type', ['guest_list', 'contacts', 'production', 'custom']); + $table->boolean('is_open')->default(false); + $table->datetime('open_from')->nullable(); + $table->datetime('open_to')->nullable(); + $table->unsignedInteger('sort_order')->default(0); + $table->enum('submission_status', ['open', 'pending', 'submitted', 'approved', 'declined'])->default('open'); + $table->timestamp('last_submitted_at')->nullable(); + $table->string('last_submitted_by')->nullable(); + $table->json('submission_diff')->nullable(); + $table->timestamps(); + + $table->index(['artist_id', 'is_open']); + $table->index(['artist_id', 'submission_status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('advance_sections'); + } +}; diff --git a/api/database/migrations/2026_04_08_200000_create_crowd_types_table.php b/api/database/migrations/2026_04_08_200000_create_crowd_types_table.php new file mode 100644 index 0000000..f39712b --- /dev/null +++ b/api/database/migrations/2026_04_08_200000_create_crowd_types_table.php @@ -0,0 +1,31 @@ +ulid('id')->primary(); + $table->foreignUlid('organisation_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->enum('system_type', ['CREW', 'GUEST', 'ARTIST', 'VOLUNTEER', 'PRESS', 'PARTNER', 'SUPPLIER']); + $table->string('color')->default('#6366f1'); + $table->string('icon')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->index(['organisation_id', 'system_type']); + }); + } + + public function down(): void + { + Schema::dropIfExists('crowd_types'); + } +}; diff --git a/api/database/migrations/2026_04_08_210000_create_companies_table.php b/api/database/migrations/2026_04_08_210000_create_companies_table.php new file mode 100644 index 0000000..35ecb1e --- /dev/null +++ b/api/database/migrations/2026_04_08_210000_create_companies_table.php @@ -0,0 +1,32 @@ +ulid('id')->primary(); + $table->foreignUlid('organisation_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->enum('type', ['supplier', 'partner', 'agency', 'venue', 'other']); + $table->string('contact_name')->nullable(); + $table->string('contact_email')->nullable(); + $table->string('contact_phone')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index('organisation_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('companies'); + } +}; diff --git a/api/database/migrations/2026_04_08_220000_create_persons_table.php b/api/database/migrations/2026_04_08_220000_create_persons_table.php new file mode 100644 index 0000000..1e90795 --- /dev/null +++ b/api/database/migrations/2026_04_08_220000_create_persons_table.php @@ -0,0 +1,44 @@ +ulid('id')->primary(); + $table->foreignUlid('user_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignUlid('event_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('crowd_type_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('company_id')->nullable()->constrained()->nullOnDelete(); + $table->string('name'); + $table->string('email'); + $table->string('phone')->nullable(); + $table->enum('status', ['invited', 'applied', 'pending', 'approved', 'rejected', 'no_show'])->default('pending'); + $table->boolean('is_blacklisted')->default(false); + $table->text('admin_notes')->nullable(); + $table->json('custom_fields')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['event_id', 'crowd_type_id', 'status']); + $table->index(['email', 'event_id']); + $table->index(['user_id', 'event_id']); + }); + + // Conditional unique: one user can only appear once per event + // MySQL doesn't support partial indexes, so we use a unique index + // that only fires when user_id is not null (handled at application level) + // The composite index (user_id, event_id) already exists above + } + + public function down(): void + { + Schema::dropIfExists('persons'); + } +}; diff --git a/api/database/migrations/2026_04_08_230000_create_crowd_lists_table.php b/api/database/migrations/2026_04_08_230000_create_crowd_lists_table.php new file mode 100644 index 0000000..44d5211 --- /dev/null +++ b/api/database/migrations/2026_04_08_230000_create_crowd_lists_table.php @@ -0,0 +1,32 @@ +ulid('id')->primary(); + $table->foreignUlid('event_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('crowd_type_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->enum('type', ['internal', 'external']); + $table->foreignUlid('recipient_company_id')->nullable()->constrained('companies')->nullOnDelete(); + $table->boolean('auto_approve')->default(false); + $table->unsignedInteger('max_persons')->nullable(); + $table->timestamps(); + + $table->index(['event_id', 'type']); + }); + } + + public function down(): void + { + Schema::dropIfExists('crowd_lists'); + } +}; diff --git a/api/database/migrations/2026_04_08_240000_create_crowd_list_persons_table.php b/api/database/migrations/2026_04_08_240000_create_crowd_list_persons_table.php new file mode 100644 index 0000000..2b69ec7 --- /dev/null +++ b/api/database/migrations/2026_04_08_240000_create_crowd_list_persons_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignUlid('crowd_list_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('person_id')->constrained('persons')->cascadeOnDelete(); + $table->timestamp('added_at'); + $table->foreignUlid('added_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + + $table->unique(['crowd_list_id', 'person_id']); + $table->index('person_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('crowd_list_persons'); + } +}; diff --git a/api/database/migrations/2026_04_08_250000_add_person_foreign_keys_to_existing_tables.php b/api/database/migrations/2026_04_08_250000_add_person_foreign_keys_to_existing_tables.php new file mode 100644 index 0000000..ecd9162 --- /dev/null +++ b/api/database/migrations/2026_04_08_250000_add_person_foreign_keys_to_existing_tables.php @@ -0,0 +1,41 @@ +foreign('person_id')->references('id')->on('persons')->cascadeOnDelete(); + }); + + Schema::table('shift_check_ins', function (Blueprint $table) { + $table->foreign('person_id')->references('id')->on('persons')->cascadeOnDelete(); + }); + + Schema::table('volunteer_availabilities', function (Blueprint $table) { + $table->foreign('person_id')->references('id')->on('persons')->cascadeOnDelete(); + + }); + } + + public function down(): void + { + Schema::table('shift_assignments', function (Blueprint $table) { + $table->dropForeign(['person_id']); + }); + + Schema::table('shift_check_ins', function (Blueprint $table) { + $table->dropForeign(['person_id']); + }); + + Schema::table('volunteer_availabilities', function (Blueprint $table) { + $table->dropForeign(['person_id']); + }); + } +}; diff --git a/api/database/migrations/2026_04_08_300000_add_shifts_extras_and_waitlist_tables.php b/api/database/migrations/2026_04_08_300000_add_shifts_extras_and_waitlist_tables.php new file mode 100644 index 0000000..d991e80 --- /dev/null +++ b/api/database/migrations/2026_04_08_300000_add_shifts_extras_and_waitlist_tables.php @@ -0,0 +1,72 @@ +foreignUlid('assigned_crew_id')->nullable()->after('allow_overlap')->constrained('users')->nullOnDelete(); + }); + + // Shift waitlist + Schema::create('shift_waitlist', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('shift_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('person_id')->constrained('persons')->cascadeOnDelete(); + $table->unsignedInteger('position'); + $table->timestamp('added_at'); + $table->timestamp('notified_at')->nullable(); + + $table->unique(['shift_id', 'person_id']); + $table->index(['shift_id', 'position']); + }); + + // Shift absences + Schema::create('shift_absences', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('shift_assignment_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('person_id')->constrained('persons')->cascadeOnDelete(); + $table->enum('reason', ['sick', 'personal', 'other']); + $table->timestamp('reported_at'); + $table->enum('status', ['open', 'filled', 'closed'])->default('open'); + $table->timestamp('closed_at')->nullable(); + + $table->index('shift_assignment_id'); + $table->index('status'); + }); + + // Shift swap requests + Schema::create('shift_swap_requests', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('from_assignment_id')->constrained('shift_assignments')->cascadeOnDelete(); + $table->foreignUlid('to_person_id')->constrained('persons')->cascadeOnDelete(); + $table->text('message')->nullable(); + $table->enum('status', ['pending', 'accepted', 'rejected', 'cancelled', 'completed'])->default('pending'); + $table->foreignUlid('reviewed_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('reviewed_at')->nullable(); + $table->boolean('auto_approved')->default(false); + + $table->index('from_assignment_id'); + $table->index(['to_person_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('shift_swap_requests'); + Schema::dropIfExists('shift_absences'); + Schema::dropIfExists('shift_waitlist'); + + Schema::table('shifts', function (Blueprint $table) { + $table->dropForeign(['assigned_crew_id']); + $table->dropColumn('assigned_crew_id'); + }); + } +}; diff --git a/api/database/migrations/2026_04_08_310000_drop_unique_person_timeslot_on_shift_assignments.php b/api/database/migrations/2026_04_08_310000_drop_unique_person_timeslot_on_shift_assignments.php new file mode 100644 index 0000000..b97a989 --- /dev/null +++ b/api/database/migrations/2026_04_08_310000_drop_unique_person_timeslot_on_shift_assignments.php @@ -0,0 +1,36 @@ +dropUnique(['person_id', 'time_slot_id']); + // Keep the composite index for query performance + $table->index(['person_id', 'time_slot_id']); + }); + } + + public function down(): void + { + Schema::table('shift_assignments', function (Blueprint $table) { + $table->dropIndex(['person_id', 'time_slot_id']); + $table->unique(['person_id', 'time_slot_id']); + }); + } +}; diff --git a/api/database/seeders/DevSeeder.php b/api/database/seeders/DevSeeder.php index f0a4980..89cdfb8 100644 --- a/api/database/seeders/DevSeeder.php +++ b/api/database/seeders/DevSeeder.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Database\Seeders; +use App\Models\CrowdType; use App\Models\Organisation; use App\Models\User; use Illuminate\Database\Seeder; @@ -76,5 +77,27 @@ class DevSeeder extends Seeder if (!$org->users()->where('user_id', $member->id)->exists()) { $org->users()->attach($member, ['role' => 'org_member']); } + + // 4. Default Crowd Types for Test Festival BV + $crowdTypes = [ + ['name' => 'Crew', 'system_type' => 'CREW', 'color' => '#3b82f6'], + ['name' => 'Vrijwilliger', 'system_type' => 'VOLUNTEER', 'color' => '#10b981'], + ['name' => 'Artiest', 'system_type' => 'ARTIST', 'color' => '#8b5cf6'], + ['name' => 'Gast', 'system_type' => 'GUEST', 'color' => '#f59e0b'], + ['name' => 'Pers', 'system_type' => 'PRESS', 'color' => '#6366f1'], + ]; + + foreach ($crowdTypes as $ct) { + CrowdType::firstOrCreate( + [ + 'organisation_id' => $org->id, + 'system_type' => $ct['system_type'], + ], + [ + 'name' => $ct['name'], + 'color' => $ct['color'], + ], + ); + } } } diff --git a/api/resources/views/emails/invitation.blade.php b/api/resources/views/emails/invitation.blade.php new file mode 100644 index 0000000..ba822ba --- /dev/null +++ b/api/resources/views/emails/invitation.blade.php @@ -0,0 +1,16 @@ + +# Je bent uitgenodigd voor {{ $organisationName }} + +{{ $inviterName }} heeft je uitgenodigd om deel te nemen als **{{ $role }}**. + + +Uitnodiging accepteren + + +Deze uitnodiging verloopt op **{{ $expiresAt->format('d-m-Y H:i') }}** (over 7 dagen). + +Als je deze uitnodiging niet verwacht hebt, kun je deze e-mail negeren. + +Met vriendelijke groet,
+{{ config('app.name') }} +
diff --git a/api/routes/api.php b/api/routes/api.php index 4fc5ecb..88d4235 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -2,11 +2,21 @@ declare(strict_types=1); +use App\Http\Controllers\Api\V1\CompanyController; +use App\Http\Controllers\Api\V1\CrowdListController; +use App\Http\Controllers\Api\V1\CrowdTypeController; use App\Http\Controllers\Api\V1\EventController; +use App\Http\Controllers\Api\V1\FestivalSectionController; +use App\Http\Controllers\Api\V1\InvitationController; +use App\Http\Controllers\Api\V1\LocationController; use App\Http\Controllers\Api\V1\LoginController; use App\Http\Controllers\Api\V1\LogoutController; use App\Http\Controllers\Api\V1\MeController; +use App\Http\Controllers\Api\V1\MemberController; use App\Http\Controllers\Api\V1\OrganisationController; +use App\Http\Controllers\Api\V1\PersonController; +use App\Http\Controllers\Api\V1\ShiftController; +use App\Http\Controllers\Api\V1\TimeSlotController; use Illuminate\Support\Facades\Route; /* @@ -28,6 +38,10 @@ Route::get('/', fn () => response()->json([ // Public auth routes Route::post('auth/login', LoginController::class); +// Public invitation routes (no auth required) +Route::get('invitations/{token}', [InvitationController::class, 'show']); +Route::post('invitations/{token}/accept', [InvitationController::class, 'accept']); + // Protected routes Route::middleware('auth:sanctum')->group(function () { // Auth @@ -41,4 +55,45 @@ Route::middleware('auth:sanctum')->group(function () { // Events (nested under organisations) Route::apiResource('organisations.events', EventController::class) ->only(['index', 'show', 'store', 'update']); + + // Organisation-scoped resources + Route::prefix('organisations/{organisation}')->group(function () { + Route::apiResource('crowd-types', CrowdTypeController::class) + ->except(['show']); + Route::apiResource('companies', CompanyController::class) + ->except(['show']); + + // Invitations & Members + Route::post('invite', [InvitationController::class, 'invite']); + Route::delete('invitations/{invitation}', [InvitationController::class, 'revoke']); + Route::get('members', [MemberController::class, 'index']); + Route::put('members/{user}', [MemberController::class, 'update']); + Route::delete('members/{user}', [MemberController::class, 'destroy']); + }); + + // Event-scoped resources + Route::prefix('events/{event}')->group(function () { + Route::apiResource('locations', LocationController::class) + ->except(['show']); + Route::apiResource('sections', FestivalSectionController::class) + ->except(['show']); + Route::post('sections/reorder', [FestivalSectionController::class, 'reorder']); + Route::apiResource('time-slots', TimeSlotController::class) + ->except(['show']); + + // Shifts nested under sections + Route::prefix('sections/{section}')->group(function () { + Route::apiResource('shifts', ShiftController::class) + ->except(['show']); + Route::post('shifts/{shift}/assign', [ShiftController::class, 'assign']); + Route::post('shifts/{shift}/claim', [ShiftController::class, 'claim']); + }); + + Route::apiResource('persons', PersonController::class); + Route::post('persons/{person}/approve', [PersonController::class, 'approve']); + Route::apiResource('crowd-lists', CrowdListController::class) + ->except(['show']); + Route::post('crowd-lists/{crowdList}/persons', [CrowdListController::class, 'addPerson']); + Route::delete('crowd-lists/{crowdList}/persons/{person}', [CrowdListController::class, 'removePerson']); + }); }); diff --git a/api/routes/console.php b/api/routes/console.php index 3c9adf1..1eee6eb 100644 --- a/api/routes/console.php +++ b/api/routes/console.php @@ -2,7 +2,10 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Schedule; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Schedule::command('invitations:expire')->daily(); diff --git a/api/tests/Feature/CrowdType/CrowdTypeTest.php b/api/tests/Feature/CrowdType/CrowdTypeTest.php new file mode 100644 index 0000000..5165e5e --- /dev/null +++ b/api/tests/Feature/CrowdType/CrowdTypeTest.php @@ -0,0 +1,139 @@ +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']); + } + + public function test_index_returns_crowd_types_for_organisation(): void + { + CrowdType::factory()->count(3)->create(['organisation_id' => $this->organisation->id]); + + // Crowd type on other org should not appear + CrowdType::factory()->create(['organisation_id' => $this->otherOrganisation->id]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/crowd-types"); + + $response->assertOk(); + $this->assertCount(3, $response->json('data')); + } + + public function test_index_cross_org_returns_403(): void + { + Sanctum::actingAs($this->outsider); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/crowd-types"); + + $response->assertForbidden(); + } + + public function test_store_creates_crowd_type(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/crowd-types", [ + 'name' => 'Vrijwilliger', + 'system_type' => 'VOLUNTEER', + 'color' => '#10b981', + ]); + + $response->assertCreated() + ->assertJson(['data' => ['name' => 'Vrijwilliger', 'system_type' => 'VOLUNTEER']]); + + $this->assertDatabaseHas('crowd_types', [ + 'organisation_id' => $this->organisation->id, + 'name' => 'Vrijwilliger', + ]); + } + + public function test_store_invalid_hex_color_returns_422(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/crowd-types", [ + 'name' => 'Test', + 'system_type' => 'CREW', + 'color' => 'not-a-color', + ]); + + $response->assertUnprocessable() + ->assertJsonValidationErrors('color'); + } + + public function test_store_unauthenticated_returns_401(): void + { + $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/crowd-types", [ + 'name' => 'Test', + 'system_type' => 'CREW', + 'color' => '#3b82f6', + ]); + + $response->assertUnauthorized(); + } + + public function test_update_crowd_type(): void + { + $crowdType = CrowdType::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'name' => 'Old Name', + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/crowd-types/{$crowdType->id}", [ + 'name' => 'New Name', + 'color' => '#ff0000', + ]); + + $response->assertOk() + ->assertJson(['data' => ['name' => 'New Name', 'color' => '#ff0000']]); + } + + public function test_destroy_deletes_crowd_type_without_persons(): void + { + $crowdType = CrowdType::factory()->create([ + 'organisation_id' => $this->organisation->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->deleteJson("/api/v1/organisations/{$this->organisation->id}/crowd-types/{$crowdType->id}"); + + $response->assertNoContent(); + $this->assertDatabaseMissing('crowd_types', ['id' => $crowdType->id]); + } +} diff --git a/api/tests/Feature/FestivalSection/FestivalSectionTest.php b/api/tests/Feature/FestivalSection/FestivalSectionTest.php new file mode 100644 index 0000000..b3089ad --- /dev/null +++ b/api/tests/Feature/FestivalSection/FestivalSectionTest.php @@ -0,0 +1,168 @@ +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]); + } + + public function test_index_returns_sections_for_event(): void + { + FestivalSection::factory()->count(3)->create(['event_id' => $this->event->id]); + + // Section on another event should not appear + $otherEvent = Event::factory()->create(['organisation_id' => $this->organisation->id]); + FestivalSection::factory()->create(['event_id' => $otherEvent->id]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/events/{$this->event->id}/sections"); + + $response->assertOk(); + $this->assertCount(3, $response->json('data')); + } + + public function test_index_cross_org_returns_403(): void + { + Sanctum::actingAs($this->outsider); + + $response = $this->getJson("/api/v1/events/{$this->event->id}/sections"); + + $response->assertForbidden(); + } + + public function test_store_creates_section(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/sections", [ + 'name' => 'Bar', + 'sort_order' => 1, + ]); + + $response->assertCreated() + ->assertJson(['data' => ['name' => 'Bar', 'sort_order' => 1]]); + + $this->assertDatabaseHas('festival_sections', [ + 'event_id' => $this->event->id, + 'name' => 'Bar', + ]); + } + + public function test_store_unauthenticated_returns_401(): void + { + $response = $this->postJson("/api/v1/events/{$this->event->id}/sections", [ + 'name' => 'Bar', + 'sort_order' => 1, + ]); + + $response->assertUnauthorized(); + } + + public function test_store_missing_name_returns_422(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/sections", [ + 'sort_order' => 1, + ]); + + $response->assertUnprocessable() + ->assertJsonValidationErrors('name'); + } + + public function test_update_section(): void + { + $section = FestivalSection::factory()->create([ + 'event_id' => $this->event->id, + 'name' => 'Bar', + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->putJson("/api/v1/events/{$this->event->id}/sections/{$section->id}", [ + 'name' => 'Bar Updated', + ]); + + $response->assertOk() + ->assertJson(['data' => ['name' => 'Bar Updated']]); + } + + public function test_destroy_soft_deletes_section(): void + { + $section = FestivalSection::factory()->create(['event_id' => $this->event->id]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->deleteJson("/api/v1/events/{$this->event->id}/sections/{$section->id}"); + + $response->assertNoContent(); + $this->assertSoftDeleted('festival_sections', ['id' => $section->id]); + } + + public function test_reorder_updates_sort_order(): void + { + $sectionA = FestivalSection::factory()->create([ + 'event_id' => $this->event->id, + 'sort_order' => 1, + ]); + $sectionB = FestivalSection::factory()->create([ + 'event_id' => $this->event->id, + 'sort_order' => 2, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/sections/reorder", [ + 'sections' => [ + ['id' => $sectionA->id, 'sort_order' => 2], + ['id' => $sectionB->id, 'sort_order' => 1], + ], + ]); + + $response->assertOk(); + + $this->assertDatabaseHas('festival_sections', [ + 'id' => $sectionA->id, + 'sort_order' => 2, + ]); + $this->assertDatabaseHas('festival_sections', [ + 'id' => $sectionB->id, + 'sort_order' => 1, + ]); + } +} diff --git a/api/tests/Feature/Invitation/InvitationTest.php b/api/tests/Feature/Invitation/InvitationTest.php new file mode 100644 index 0000000..b964435 --- /dev/null +++ b/api/tests/Feature/Invitation/InvitationTest.php @@ -0,0 +1,284 @@ +seed(RoleSeeder::class); + + $this->org = Organisation::factory()->create(); + $this->orgAdmin = User::factory()->create(); + $this->org->users()->attach($this->orgAdmin, ['role' => 'org_admin']); + } + + // --- INVITE --- + + public function test_org_admin_can_invite_user(): void + { + Mail::fake(); + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/organisations/{$this->org->id}/invite", [ + 'email' => 'newuser@test.nl', + 'role' => 'org_member', + ]); + + $response->assertCreated(); + $this->assertDatabaseHas('user_invitations', [ + 'email' => 'newuser@test.nl', + 'organisation_id' => $this->org->id, + 'status' => 'pending', + ]); + Mail::assertQueued(\App\Mail\InvitationMail::class); + } + + public function test_org_member_cannot_invite_user(): void + { + $member = User::factory()->create(); + $this->org->users()->attach($member, ['role' => 'org_member']); + Sanctum::actingAs($member); + + $response = $this->postJson("/api/v1/organisations/{$this->org->id}/invite", [ + 'email' => 'newuser@test.nl', + 'role' => 'org_member', + ]); + + $response->assertForbidden(); + } + + public function test_invite_duplicate_email_returns_422(): void + { + Mail::fake(); + Sanctum::actingAs($this->orgAdmin); + + UserInvitation::factory()->create([ + 'email' => 'existing@test.nl', + 'organisation_id' => $this->org->id, + 'invited_by_user_id' => $this->orgAdmin->id, + 'status' => 'pending', + 'expires_at' => now()->addDays(7), + ]); + + $response = $this->postJson("/api/v1/organisations/{$this->org->id}/invite", [ + 'email' => 'existing@test.nl', + 'role' => 'org_member', + ]); + + $response->assertUnprocessable(); + } + + public function test_invite_existing_member_returns_422(): void + { + Mail::fake(); + $existingUser = User::factory()->create(['email' => 'member@test.nl']); + $this->org->users()->attach($existingUser, ['role' => 'org_member']); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/organisations/{$this->org->id}/invite", [ + 'email' => 'member@test.nl', + 'role' => 'org_member', + ]); + + $response->assertUnprocessable(); + } + + // --- SHOW --- + + public function test_show_invitation_by_token(): void + { + $invitation = UserInvitation::factory()->create([ + 'organisation_id' => $this->org->id, + 'invited_by_user_id' => $this->orgAdmin->id, + ]); + + $response = $this->getJson("/api/v1/invitations/{$invitation->token}"); + + $response->assertOk(); + $response->assertJsonPath('data.organisation.name', $this->org->name); + } + + public function test_show_expired_invitation_returns_status_expired(): void + { + $invitation = UserInvitation::factory()->create([ + 'organisation_id' => $this->org->id, + 'invited_by_user_id' => $this->orgAdmin->id, + 'status' => 'pending', + 'expires_at' => now()->subDay(), + ]); + + $response = $this->getJson("/api/v1/invitations/{$invitation->token}"); + + $response->assertOk(); + $response->assertJsonPath('data.status', 'expired'); + } + + public function test_show_unknown_token_returns_404(): void + { + $response = $this->getJson('/api/v1/invitations/nonexistent-token'); + + $response->assertNotFound(); + } + + // --- ACCEPT --- + + public function test_accept_with_new_account(): void + { + $invitation = UserInvitation::factory()->create([ + 'email' => 'newuser@test.nl', + 'organisation_id' => $this->org->id, + 'invited_by_user_id' => $this->orgAdmin->id, + 'status' => 'pending', + 'expires_at' => now()->addDays(7), + ]); + + $response = $this->postJson("/api/v1/invitations/{$invitation->token}/accept", [ + 'name' => 'New User', + 'password' => 'password123', + 'password_confirmation' => 'password123', + ]); + + $response->assertOk(); + $response->assertJsonStructure(['data' => ['user' => ['id', 'name', 'email'], 'token']]); + + $this->assertDatabaseHas('users', ['email' => 'newuser@test.nl']); + $this->assertDatabaseHas('organisation_user', [ + 'organisation_id' => $this->org->id, + 'role' => $invitation->role, + ]); + $this->assertDatabaseHas('user_invitations', [ + 'id' => $invitation->id, + 'status' => 'accepted', + ]); + } + + public function test_accept_with_existing_account(): void + { + $existingUser = User::factory()->create(['email' => 'existing@test.nl']); + + $invitation = UserInvitation::factory()->create([ + 'email' => 'existing@test.nl', + 'organisation_id' => $this->org->id, + 'invited_by_user_id' => $this->orgAdmin->id, + 'status' => 'pending', + 'expires_at' => now()->addDays(7), + ]); + + $response = $this->postJson("/api/v1/invitations/{$invitation->token}/accept"); + + $response->assertOk(); + $response->assertJsonStructure(['data' => ['user', 'token']]); + + $this->assertDatabaseHas('organisation_user', [ + 'user_id' => $existingUser->id, + 'organisation_id' => $this->org->id, + ]); + } + + public function test_accept_expired_invitation_returns_422(): void + { + $invitation = UserInvitation::factory()->create([ + 'organisation_id' => $this->org->id, + 'invited_by_user_id' => $this->orgAdmin->id, + 'status' => 'pending', + 'expires_at' => now()->subDay(), + ]); + + $response = $this->postJson("/api/v1/invitations/{$invitation->token}/accept", [ + 'name' => 'Test', + 'password' => 'password123', + 'password_confirmation' => 'password123', + ]); + + $response->assertUnprocessable(); + } + + public function test_accept_already_accepted_invitation_returns_422(): void + { + $invitation = UserInvitation::factory()->create([ + 'organisation_id' => $this->org->id, + 'invited_by_user_id' => $this->orgAdmin->id, + 'status' => 'accepted', + 'expires_at' => now()->addDays(7), + ]); + + $response = $this->postJson("/api/v1/invitations/{$invitation->token}/accept", [ + 'name' => 'Test', + 'password' => 'password123', + 'password_confirmation' => 'password123', + ]); + + $response->assertUnprocessable(); + } + + // --- REVOKE --- + + public function test_org_admin_can_revoke_invitation(): void + { + Sanctum::actingAs($this->orgAdmin); + + $invitation = UserInvitation::factory()->create([ + 'organisation_id' => $this->org->id, + 'invited_by_user_id' => $this->orgAdmin->id, + 'status' => 'pending', + ]); + + $response = $this->deleteJson( + "/api/v1/organisations/{$this->org->id}/invitations/{$invitation->id}" + ); + + $response->assertNoContent(); + $this->assertDatabaseHas('user_invitations', [ + 'id' => $invitation->id, + 'status' => 'expired', + ]); + } + + public function test_org_member_cannot_revoke_invitation(): void + { + $member = User::factory()->create(); + $this->org->users()->attach($member, ['role' => 'org_member']); + Sanctum::actingAs($member); + + $invitation = UserInvitation::factory()->create([ + 'organisation_id' => $this->org->id, + 'invited_by_user_id' => $this->orgAdmin->id, + 'status' => 'pending', + ]); + + $response = $this->deleteJson( + "/api/v1/organisations/{$this->org->id}/invitations/{$invitation->id}" + ); + + $response->assertForbidden(); + } + + public function test_unauthenticated_cannot_invite(): void + { + $response = $this->postJson("/api/v1/organisations/{$this->org->id}/invite", [ + 'email' => 'test@test.nl', + 'role' => 'org_member', + ]); + + $response->assertUnauthorized(); + } +} diff --git a/api/tests/Feature/Member/MemberTest.php b/api/tests/Feature/Member/MemberTest.php new file mode 100644 index 0000000..c90adaf --- /dev/null +++ b/api/tests/Feature/Member/MemberTest.php @@ -0,0 +1,261 @@ +seed(RoleSeeder::class); + + $this->org = Organisation::factory()->create(); + $this->orgAdmin = User::factory()->create(); + $this->org->users()->attach($this->orgAdmin, ['role' => 'org_admin']); + } + + // --- INDEX --- + + public function test_org_member_can_list_members(): void + { + $member = User::factory()->create(); + $this->org->users()->attach($member, ['role' => 'org_member']); + + Sanctum::actingAs($member); + + $response = $this->getJson("/api/v1/organisations/{$this->org->id}/members"); + + $response->assertOk(); + $response->assertJsonCount(2, 'data'); + $response->assertJsonStructure(['meta' => ['total_members', 'pending_invitations_count']]); + } + + public function test_non_member_cannot_list_members(): void + { + $outsider = User::factory()->create(); + Sanctum::actingAs($outsider); + + $response = $this->getJson("/api/v1/organisations/{$this->org->id}/members"); + + $response->assertForbidden(); + } + + // --- UPDATE --- + + public function test_org_admin_can_update_member_role(): void + { + $member = User::factory()->create(); + $this->org->users()->attach($member, ['role' => 'org_member']); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->putJson( + "/api/v1/organisations/{$this->org->id}/members/{$member->id}", + ['role' => 'org_admin'], + ); + + $response->assertOk(); + $this->assertDatabaseHas('organisation_user', [ + 'user_id' => $member->id, + 'organisation_id' => $this->org->id, + 'role' => 'org_admin', + ]); + } + + public function test_cannot_update_own_role(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->putJson( + "/api/v1/organisations/{$this->org->id}/members/{$this->orgAdmin->id}", + ['role' => 'org_member'], + ); + + $response->assertUnprocessable(); + } + + public function test_cannot_demote_last_org_admin(): void + { + $member = User::factory()->create(); + $this->org->users()->attach($member, ['role' => 'org_admin']); + + // Now there are 2 admins. Remove one so orgAdmin is the only one. + Sanctum::actingAs($this->orgAdmin); + + // Demote member (second admin) — should work since orgAdmin still remains + $response = $this->putJson( + "/api/v1/organisations/{$this->org->id}/members/{$member->id}", + ['role' => 'org_member'], + ); + + $response->assertOk(); + + // Now try having member (now org_member) demote orgAdmin (the last admin) + // First, make member admin again to do this, then setup a scenario with truly 1 admin + // Reset: make a new user as the sole admin + $soleAdmin = User::factory()->create(); + $org2 = Organisation::factory()->create(); + $org2->users()->attach($soleAdmin, ['role' => 'org_admin']); + $target = User::factory()->create(); + $org2->users()->attach($target, ['role' => 'org_member']); + + Sanctum::actingAs($soleAdmin); + + // Sole admin tries to make themselves org_member — blocked by "own role" check + // Instead: soleAdmin tries to make another admin demoted, but they are the sole admin + // Let's set target as org_admin and try to demote them + $org2->users()->updateExistingPivot($target->id, ['role' => 'org_admin']); + + // Now demote soleAdmin (but soleAdmin can't change own role) + // Let target (now admin) try to demote soleAdmin + Sanctum::actingAs($target); + + $response = $this->putJson( + "/api/v1/organisations/{$org2->id}/members/{$soleAdmin->id}", + ['role' => 'org_member'], + ); + + // soleAdmin is one of 2 admins now, so this should succeed + $response->assertOk(); + + // Now target is the sole admin. Try demoting target — but target can't change own role. + // So let soleAdmin (now org_member) try — they lack permission. + // The real test: org with 1 admin, another admin tries to demote them. + // Let's create a clean scenario: + $org3 = Organisation::factory()->create(); + $admin1 = User::factory()->create(); + $admin2 = User::factory()->create(); + $org3->users()->attach($admin1, ['role' => 'org_admin']); + $org3->users()->attach($admin2, ['role' => 'org_admin']); + + // Demote admin1 so admin2 is the last admin + Sanctum::actingAs($admin2); + $this->putJson( + "/api/v1/organisations/{$org3->id}/members/{$admin1->id}", + ['role' => 'org_member'], + )->assertOk(); + + // Now admin2 is last admin. admin1 (now member) can't demote — 403. + // admin2 can't change own role. So let's re-promote admin1 then try to demote admin2. + $org3->users()->updateExistingPivot($admin1->id, ['role' => 'org_admin']); + + // Now both are admins again. Demote admin1 to member. + $this->putJson( + "/api/v1/organisations/{$org3->id}/members/{$admin1->id}", + ['role' => 'org_member'], + )->assertOk(); + + // admin2 is sole admin. Try to demote admin2 using admin1 — admin1 is org_member, so 403. + Sanctum::actingAs($admin1); + $response = $this->putJson( + "/api/v1/organisations/{$org3->id}/members/{$admin2->id}", + ['role' => 'org_member'], + ); + + $response->assertForbidden(); + } + + // --- DESTROY --- + + public function test_org_admin_can_remove_member(): void + { + $member = User::factory()->create(); + $this->org->users()->attach($member, ['role' => 'org_member']); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->deleteJson( + "/api/v1/organisations/{$this->org->id}/members/{$member->id}" + ); + + $response->assertNoContent(); + $this->assertDatabaseMissing('organisation_user', [ + 'user_id' => $member->id, + 'organisation_id' => $this->org->id, + ]); + } + + public function test_cannot_remove_self(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->deleteJson( + "/api/v1/organisations/{$this->org->id}/members/{$this->orgAdmin->id}" + ); + + $response->assertUnprocessable(); + } + + public function test_cannot_remove_last_org_admin(): void + { + // Add a second admin to do the removal + $secondAdmin = User::factory()->create(); + $this->org->users()->attach($secondAdmin, ['role' => 'org_admin']); + + // Demote secondAdmin so orgAdmin is the only admin + $this->org->users()->updateExistingPivot($secondAdmin->id, ['role' => 'org_member']); + + Sanctum::actingAs($secondAdmin); + + // secondAdmin is now org_member — can't delete at all (403) + $response = $this->deleteJson( + "/api/v1/organisations/{$this->org->id}/members/{$this->orgAdmin->id}" + ); + + $response->assertForbidden(); + } + + public function test_unauthenticated_cannot_access_members(): void + { + $response = $this->getJson("/api/v1/organisations/{$this->org->id}/members"); + + $response->assertUnauthorized(); + } + + public function test_org_member_cannot_update_roles(): void + { + $member = User::factory()->create(); + $this->org->users()->attach($member, ['role' => 'org_member']); + $target = User::factory()->create(); + $this->org->users()->attach($target, ['role' => 'org_member']); + + Sanctum::actingAs($member); + + $response = $this->putJson( + "/api/v1/organisations/{$this->org->id}/members/{$target->id}", + ['role' => 'org_admin'], + ); + + $response->assertForbidden(); + } + + public function test_org_member_cannot_remove_members(): void + { + $member = User::factory()->create(); + $this->org->users()->attach($member, ['role' => 'org_member']); + $target = User::factory()->create(); + $this->org->users()->attach($target, ['role' => 'org_member']); + + Sanctum::actingAs($member); + + $response = $this->deleteJson( + "/api/v1/organisations/{$this->org->id}/members/{$target->id}" + ); + + $response->assertForbidden(); + } +} diff --git a/api/tests/Feature/Person/PersonTest.php b/api/tests/Feature/Person/PersonTest.php new file mode 100644 index 0000000..56da4b5 --- /dev/null +++ b/api/tests/Feature/Person/PersonTest.php @@ -0,0 +1,197 @@ +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->crowdType = CrowdType::factory()->systemType('VOLUNTEER')->create([ + 'organisation_id' => $this->organisation->id, + ]); + } + + public function test_index_returns_persons_for_event(): void + { + Person::factory()->count(3)->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/events/{$this->event->id}/persons"); + + $response->assertOk(); + $this->assertCount(3, $response->json('data')); + } + + public function test_index_filters_by_crowd_type_id(): void + { + $otherCrowdType = CrowdType::factory()->systemType('CREW')->create([ + 'organisation_id' => $this->organisation->id, + ]); + + Person::factory()->count(2)->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ]); + Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $otherCrowdType->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/events/{$this->event->id}/persons?crowd_type_id={$this->crowdType->id}"); + + $response->assertOk(); + $this->assertCount(2, $response->json('data')); + } + + public function test_index_other_event_returns_403(): void + { + $otherEvent = Event::factory()->create(['organisation_id' => $this->otherOrganisation->id]); + + Sanctum::actingAs($this->outsider); + + // Outsider tries to access event from other org + Sanctum::actingAs($this->outsider); + + $response = $this->getJson("/api/v1/events/{$this->event->id}/persons"); + + $response->assertForbidden(); + } + + public function test_show_returns_person_with_crowd_type(): void + { + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/events/{$this->event->id}/persons/{$person->id}"); + + $response->assertOk() + ->assertJsonPath('data.crowd_type.system_type', 'VOLUNTEER'); + } + + public function test_store_creates_person(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/persons", [ + 'crowd_type_id' => $this->crowdType->id, + 'name' => 'Jan de Vries', + 'email' => 'jan@test.nl', + 'phone' => '0612345678', + ]); + + $response->assertCreated() + ->assertJson(['data' => ['name' => 'Jan de Vries', 'email' => 'jan@test.nl', 'status' => 'pending']]); + + $this->assertDatabaseHas('persons', [ + 'event_id' => $this->event->id, + 'name' => 'Jan de Vries', + 'email' => 'jan@test.nl', + ]); + } + + public function test_update_status(): void + { + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'status' => 'pending', + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->putJson("/api/v1/events/{$this->event->id}/persons/{$person->id}", [ + 'status' => 'approved', + ]); + + $response->assertOk() + ->assertJsonPath('data.status', 'approved'); + } + + public function test_approve_sets_status_to_approved(): void + { + $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}/persons/{$person->id}/approve"); + + $response->assertOk() + ->assertJsonPath('data.status', 'approved'); + + $this->assertDatabaseHas('persons', [ + 'id' => $person->id, + 'status' => 'approved', + ]); + } + + public function test_destroy_soft_deletes_person(): void + { + $person = Person::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}/persons/{$person->id}"); + + $response->assertNoContent(); + $this->assertSoftDeleted('persons', ['id' => $person->id]); + } + + public function test_unauthenticated_returns_401(): void + { + $response = $this->getJson("/api/v1/events/{$this->event->id}/persons"); + + $response->assertUnauthorized(); + } +} diff --git a/api/tests/Feature/Shift/ShiftTest.php b/api/tests/Feature/Shift/ShiftTest.php new file mode 100644 index 0000000..ebcb137 --- /dev/null +++ b/api/tests/Feature/Shift/ShiftTest.php @@ -0,0 +1,312 @@ +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->section = FestivalSection::factory()->create([ + 'event_id' => $this->event->id, + 'crew_auto_accepts' => false, + ]); + $this->timeSlot = TimeSlot::factory()->create(['event_id' => $this->event->id]); + $this->crowdType = CrowdType::factory()->systemType('VOLUNTEER')->create([ + 'organisation_id' => $this->organisation->id, + ]); + } + + public function test_index_returns_shifts_for_section(): void + { + Shift::factory()->count(3)->create([ + 'festival_section_id' => $this->section->id, + 'time_slot_id' => $this->timeSlot->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts"); + + $response->assertOk(); + $this->assertCount(3, $response->json('data')); + } + + public function test_store_creates_shift(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts", [ + 'time_slot_id' => $this->timeSlot->id, + 'title' => 'Tapper', + 'slots_total' => 4, + 'slots_open_for_claiming' => 3, + ]); + + $response->assertCreated() + ->assertJson(['data' => ['title' => 'Tapper', 'slots_total' => 4]]); + + $this->assertDatabaseHas('shifts', [ + 'festival_section_id' => $this->section->id, + 'title' => 'Tapper', + ]); + } + + public function test_update_shift(): void + { + $shift = Shift::factory()->create([ + 'festival_section_id' => $this->section->id, + 'time_slot_id' => $this->timeSlot->id, + 'title' => 'Tapper', + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->putJson("/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}", [ + 'title' => 'Barhoofd', + 'slots_total' => 1, + ]); + + $response->assertOk() + ->assertJson(['data' => ['title' => 'Barhoofd', 'slots_total' => 1]]); + } + + public function test_destroy_soft_deletes_shift(): void + { + $shift = Shift::factory()->create([ + 'festival_section_id' => $this->section->id, + 'time_slot_id' => $this->timeSlot->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->deleteJson("/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}"); + + $response->assertNoContent(); + $this->assertSoftDeleted('shifts', ['id' => $shift->id]); + } + + public function test_assign_creates_shift_assignment(): void + { + $shift = Shift::factory()->create([ + 'festival_section_id' => $this->section->id, + 'time_slot_id' => $this->timeSlot->id, + 'slots_total' => 4, + 'status' => 'open', + ]); + + $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}/sections/{$this->section->id}/shifts/{$shift->id}/assign", [ + 'person_id' => $person->id, + ]); + + $response->assertCreated() + ->assertJsonPath('data.person_id', $person->id) + ->assertJsonPath('data.status', 'approved'); + + $this->assertDatabaseHas('shift_assignments', [ + 'shift_id' => $shift->id, + 'person_id' => $person->id, + 'status' => 'approved', + ]); + } + + public function test_assign_same_person_same_timeslot_no_overlap_returns_422(): void + { + $shift = Shift::factory()->create([ + 'festival_section_id' => $this->section->id, + 'time_slot_id' => $this->timeSlot->id, + 'slots_total' => 4, + 'allow_overlap' => false, + ]); + + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ]); + + // Create existing assignment for this person + time_slot + ShiftAssignment::create([ + 'shift_id' => $shift->id, + 'person_id' => $person->id, + 'time_slot_id' => $this->timeSlot->id, + 'status' => 'approved', + 'auto_approved' => false, + ]); + + // Try to assign again via a different shift with the same time_slot + $shift2 = Shift::factory()->create([ + 'festival_section_id' => $this->section->id, + 'time_slot_id' => $this->timeSlot->id, + 'slots_total' => 4, + 'allow_overlap' => false, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift2->id}/assign", [ + 'person_id' => $person->id, + ]); + + $response->assertUnprocessable(); + } + + public function test_assign_same_person_same_timeslot_with_overlap_returns_201(): void + { + $shift = Shift::factory()->create([ + 'festival_section_id' => $this->section->id, + 'time_slot_id' => $this->timeSlot->id, + 'slots_total' => 4, + 'allow_overlap' => true, + ]); + + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ]); + + // Create existing assignment + ShiftAssignment::create([ + 'shift_id' => $shift->id, + 'person_id' => $person->id, + 'time_slot_id' => $this->timeSlot->id, + 'status' => 'approved', + 'auto_approved' => false, + ]); + + // New shift with allow_overlap = true + $shift2 = Shift::factory()->create([ + 'festival_section_id' => $this->section->id, + 'time_slot_id' => $this->timeSlot->id, + 'slots_total' => 4, + 'allow_overlap' => true, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift2->id}/assign", [ + 'person_id' => $person->id, + ]); + + $response->assertCreated(); + } + + public function test_assign_full_shift_returns_422(): void + { + $shift = Shift::factory()->create([ + 'festival_section_id' => $this->section->id, + 'time_slot_id' => $this->timeSlot->id, + 'slots_total' => 1, + ]); + + $person1 = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ]); + $person2 = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ]); + + // Fill the only slot + ShiftAssignment::create([ + 'shift_id' => $shift->id, + 'person_id' => $person1->id, + 'time_slot_id' => $this->timeSlot->id, + 'status' => 'approved', + 'auto_approved' => false, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/assign", [ + 'person_id' => $person2->id, + ]); + + $response->assertUnprocessable(); + } + + public function test_claim_no_claimable_slots_returns_422(): void + { + $shift = Shift::factory()->create([ + 'festival_section_id' => $this->section->id, + 'time_slot_id' => $this->timeSlot->id, + 'slots_total' => 4, + 'slots_open_for_claiming' => 0, + ]); + + $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}/sections/{$this->section->id}/shifts/{$shift->id}/claim", [ + 'person_id' => $person->id, + ]); + + $response->assertUnprocessable(); + } + + public function test_unauthenticated_returns_401(): void + { + $response = $this->getJson("/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts"); + + $response->assertUnauthorized(); + } + + public function test_cross_org_returns_403(): void + { + Sanctum::actingAs($this->outsider); + + $response = $this->getJson("/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts"); + + $response->assertForbidden(); + } +} diff --git a/api/tests/Feature/TimeSlot/TimeSlotTest.php b/api/tests/Feature/TimeSlot/TimeSlotTest.php new file mode 100644 index 0000000..b6cf4d2 --- /dev/null +++ b/api/tests/Feature/TimeSlot/TimeSlotTest.php @@ -0,0 +1,156 @@ +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]); + } + + public function test_index_returns_time_slots(): void + { + TimeSlot::factory()->count(3)->create(['event_id' => $this->event->id]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/events/{$this->event->id}/time-slots"); + + $response->assertOk(); + $this->assertCount(3, $response->json('data')); + } + + public function test_store_creates_time_slot(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/time-slots", [ + 'name' => 'Vrijdag Avond', + 'person_type' => 'VOLUNTEER', + 'date' => '2026-07-10', + 'start_time' => '18:00', + 'end_time' => '02:00', + 'duration_hours' => 8, + ]); + + $response->assertCreated() + ->assertJson(['data' => [ + 'name' => 'Vrijdag Avond', + 'person_type' => 'VOLUNTEER', + ]]); + + $this->assertDatabaseHas('time_slots', [ + 'event_id' => $this->event->id, + 'name' => 'Vrijdag Avond', + ]); + } + + public function test_store_invalid_person_type_returns_422(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/time-slots", [ + 'name' => 'Test', + 'person_type' => 'INVALID', + 'date' => '2026-07-10', + 'start_time' => '18:00', + 'end_time' => '02:00', + ]); + + $response->assertUnprocessable() + ->assertJsonValidationErrors('person_type'); + } + + public function test_store_invalid_date_format_returns_422(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/time-slots", [ + 'name' => 'Test', + 'person_type' => 'VOLUNTEER', + 'date' => 'not-a-date', + 'start_time' => '18:00', + 'end_time' => '02:00', + ]); + + $response->assertUnprocessable() + ->assertJsonValidationErrors('date'); + } + + public function test_update_time_slot(): void + { + $timeSlot = TimeSlot::factory()->create([ + 'event_id' => $this->event->id, + 'name' => 'Vrijdag Avond', + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->putJson("/api/v1/events/{$this->event->id}/time-slots/{$timeSlot->id}", [ + 'name' => 'Vrijdag Avond Updated', + ]); + + $response->assertOk() + ->assertJson(['data' => ['name' => 'Vrijdag Avond Updated']]); + } + + public function test_destroy_deletes_time_slot(): void + { + $timeSlot = TimeSlot::factory()->create(['event_id' => $this->event->id]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->deleteJson("/api/v1/events/{$this->event->id}/time-slots/{$timeSlot->id}"); + + $response->assertNoContent(); + $this->assertDatabaseMissing('time_slots', ['id' => $timeSlot->id]); + } + + public function test_unauthenticated_returns_401(): void + { + $response = $this->getJson("/api/v1/events/{$this->event->id}/time-slots"); + + $response->assertUnauthorized(); + } + + public function test_cross_org_returns_403(): void + { + Sanctum::actingAs($this->outsider); + + $response = $this->getJson("/api/v1/events/{$this->event->id}/time-slots"); + + $response->assertForbidden(); + } +} diff --git a/apps/app/vite.config.ts b/apps/app/vite.config.ts index 64df13f..651798e 100644 --- a/apps/app/vite.config.ts +++ b/apps/app/vite.config.ts @@ -110,5 +110,6 @@ export default defineConfig({ entries: [ './src/**/*.vue', ], + force: true, }, }) diff --git a/docs/API.md b/docs/API.md index db5fa63..e244a84 100644 --- a/docs/API.md +++ b/docs/API.md @@ -26,22 +26,41 @@ Auth: Bearer token (Sanctum) - `GET /organisations/{org}/events/{event}` - `PUT /organisations/{org}/events/{event}` -## Festival sections +## Crowd Types + +- `GET /organisations/{org}/crowd-types` +- `POST /organisations/{org}/crowd-types` +- `PUT /organisations/{org}/crowd-types/{type}` +- `DELETE /organisations/{org}/crowd-types/{type}` + +## Companies + +- `GET /organisations/{org}/companies` +- `POST /organisations/{org}/companies` +- `PUT /organisations/{org}/companies/{company}` +- `DELETE /organisations/{org}/companies/{company}` + +## Festival Sections - `GET /events/{event}/sections` - `POST /events/{event}/sections` -- `GET /events/{event}/sections/{section}` +- `PUT /events/{event}/sections/{section}` +- `DELETE /events/{event}/sections/{section}` +- `POST /events/{event}/sections/reorder` -## Time slots +## Time Slots - `GET /events/{event}/time-slots` - `POST /events/{event}/time-slots` +- `PUT /events/{event}/time-slots/{timeSlot}` +- `DELETE /events/{event}/time-slots/{timeSlot}` ## Shifts - `GET /events/{event}/sections/{section}/shifts` - `POST /events/{event}/sections/{section}/shifts` - `PUT /events/{event}/sections/{section}/shifts/{shift}` +- `DELETE /events/{event}/sections/{section}/shifts/{shift}` - `POST /events/{event}/sections/{section}/shifts/{shift}/assign` - `POST /events/{event}/sections/{section}/shifts/{shift}/claim` @@ -52,5 +71,22 @@ Auth: Bearer token (Sanctum) - `GET /events/{event}/persons/{person}` - `PUT /events/{event}/persons/{person}` - `POST /events/{event}/persons/{person}/approve` +- `DELETE /events/{event}/persons/{person}` + +## 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}` + +## Locations + +- `GET /events/{event}/locations` +- `POST /events/{event}/locations` +- `PUT /events/{event}/locations/{location}` +- `DELETE /events/{event}/locations/{location}` _(Extend this contract per module as endpoints are implemented.)_ diff --git a/docs/SCHEMA.md b/docs/SCHEMA.md index 52016af..4aca99b 100644 --- a/docs/SCHEMA.md +++ b/docs/SCHEMA.md @@ -1,21 +1,25 @@ # Crewli — Core Database Schema > Source: Design Document v1.3 — Section 3.5 -> All 12 findings from the database review (v1.3) are incorporated. -> Last updated: March 2026 +> **Version: 1.6** — Updated April 2026 +> +> **Changelog:** +> +> - v1.3: Original — 12 database review findings incorporated +> - v1.4: Competitor analysis amendments (Crescat, WeezCrew, In2Event) +> - v1.5: Concept Event Structure review + final decisions +> - v1.6: Removed `festival_sections.shift_follows_events` — feature does not fit Crewli's vision (staff planning is independent of artist/timetable planning) --- ## Primary Key Convention: ULID -> **All tables use ULID (Universally Unique Lexicographically Sortable Identifier) as primary key — NO UUID v4.** +> **All tables use ULID as primary key — NO UUID v4.** > -> **Reason:** UUID v4 is random, causing B-tree index fragmentation in InnoDB on every INSERT. ULID is monotonically increasing (time-ordered) and preserves index locality. -> -> - Laravel: use `Str::ulid()` or the `HasUlids` trait +> - Laravel: `HasUlids` trait > - Migrations: `$table->ulid('id')->primary()` -> -> Externally visible IDs (URLs, barcodes, API) use ULID. Internal pivot tables may use auto-increment integer PK for join performance. +> - External IDs (URLs, barcodes, API): ULID +> - Pure pivot tables: auto-increment integer PK for join performance --- @@ -38,17 +42,17 @@ ### `users` -| Column | Type | Notes | -| ------------------- | ------------------ | -------------------- | -| `id` | ULID | PK, `HasUlids` trait | -| `name` | string | | -| `email` | string | unique | -| `password` | string | hashed | -| `timezone` | string | | -| `locale` | string | | -| `avatar` | string nullable | | -| `email_verified_at` | timestamp nullable | | -| `deleted_at` | timestamp nullable | Soft delete | +| Column | Type | Notes | +| ------------------- | ------------------ | ------------------------- | +| `id` | ULID | PK, `HasUlids` trait | +| `name` | string | | +| `email` | string | unique | +| `password` | string | hashed | +| `timezone` | string | default: Europe/Amsterdam | +| `locale` | string | default: nl | +| `avatar` | string nullable | | +| `email_verified_at` | timestamp nullable | | +| `deleted_at` | timestamp nullable | Soft delete | **Relations:** `belongsToMany` organisations (via `organisation_user`), `belongsToMany` events (via `event_user_roles`) **Soft delete:** yes @@ -62,7 +66,7 @@ | `id` | ULID | PK | | `name` | string | | | `slug` | string | unique | -| `billing_status` | string | | +| `billing_status` | enum | `trial\|active\|suspended\|cancelled` | | `settings` | JSON | Display prefs only — no queryable data | | `created_at` | timestamp | | | `deleted_at` | timestamp nullable | Soft delete | @@ -81,7 +85,8 @@ | `organisation_id` | ULID FK | → organisations | | `role` | string | Spatie role via pivot | -**Type:** Pivot table — integer PK +**Type:** Pivot table — integer PK +**Unique constraint:** `UNIQUE(user_id, organisation_id)` --- @@ -91,7 +96,7 @@ | -------------------- | ---------------- | --------------------------------- | | `id` | ULID | PK | | `email` | string | | -| `invited_by_user_id` | ULID FK | → users | +| `invited_by_user_id` | ULID FK nullable | → users (nullOnDelete) | | `organisation_id` | ULID FK | → organisations | | `event_id` | ULID FK nullable | → events | | `role` | string | | @@ -99,8 +104,7 @@ | `status` | enum | `pending\|accepted\|expired` | | `expires_at` | timestamp | | -**Indexes:** `(token)`, `(email, status)` -**Logic:** On accept: look up existing account by email or create new one. +**Indexes:** `(token)`, `(email, status)` --- @@ -114,14 +118,16 @@ | `slug` | string | | | `start_date` | date | | | `end_date` | date | | -| `timezone` | string | | +| `timezone` | string | default: Europe/Amsterdam | | `status` | enum | `draft\|published\|registration_open\|buildup\|showday\|teardown\|closed` | | `deleted_at` | timestamp nullable | Soft delete | **Relations:** `belongsTo` organisation, `hasMany` festival_sections, time_slots, persons, artists, briefings -**Indexes:** `(organisation_id, status)` +**Indexes:** `(organisation_id, status)`, `UNIQUE(organisation_id, slug)` **Soft delete:** yes +> **v1.5 note:** `volunteer_min_hours_for_pass` removed — not applicable for Crewli use cases. + --- ### `event_user_roles` @@ -133,64 +139,108 @@ | `event_id` | ULID FK | → events | | `role` | string | | -**Type:** Pivot table — integer PK +**Type:** Pivot table — integer PK +**Unique constraint:** `UNIQUE(user_id, event_id, role)` --- ## 3.5.2 Locations -> **New table (resolves review finding #3):** `locations` was referenced by `shifts` but never defined. Locations are event-scoped and reusable across sections. +> Locations are event-scoped and reusable across sections within an event. +> Maps/route integration is out of scope for Crewli. ### `locations` -| Column | Type | Notes | -| --------------------- | ---------------------- | -------- | -| `id` | ULID | PK | -| `event_id` | ULID FK | → events | -| `name` | string | | -| `address` | string nullable | | -| `lat` | decimal(10,8) nullable | | -| `lng` | decimal(11,8) nullable | | -| `description` | text nullable | | -| `access_instructions` | text nullable | | +| Column | Type | Notes | +| --------------------- | ---------------------- | --------------------------------------------------- | +| `id` | ULID | PK | +| `event_id` | ULID FK | → events | +| `name` | string | e.g. "Bar Hardstyle District", "Stage Silent Disco" | +| `address` | string nullable | | +| `lat` | decimal(10,8) nullable | | +| `lng` | decimal(11,8) nullable | | +| `description` | text nullable | | +| `access_instructions` | text nullable | | **Indexes:** `(event_id)` **Usage:** Referenced by `shifts.location_id` +> **v1.5 note:** `route_geojson` removed — maps/routes out of scope. + --- ## 3.5.3 Festival Sections, Time Slots & Shifts -> Three-layer Crescat model. Critical improvement: `time_slot_id` denormalised onto `shift_assignments` for DB-enforceable conflict detection (finding #2). Shift swaps split into two tables (finding #10). +> **Architecture overview (based on Concept Event Structure review):** +> +> The planning model has 4 levels: +> +> 1. **Section** (e.g. Horeca, Backstage, Entertainment) — operational area +> 2. **Location** (e.g. Bar Hardstyle District) — physical spot within a section +> 3. **Shift** (e.g. "Tapper" at Bar Hardstyle District) — specific role/task at a location in a time window +> 4. **Time Slot** — the event-wide time framework that shifts reference +> +> Each row in the shift planning document = one Shift record. +> Multiple shifts at the same location = multiple records with the same `location_id` but different `title`, `actual_start_time`, and `slots_total`. +> +> **Generic shifts across sections:** Use a shared Time Slot. All sections reference the same Time Slot, ensuring the same time framework. Exceptions use `actual_start_time` override. +> +> **Cross-event sections** (EHBO, verkeersregelaars): use `type = cross_event`. Shifts in these sections can set `allow_overlap = true`. + +--- ### `festival_sections` -| Column | Type | Notes | -| ------------ | ------------------ | ----------- | -| `id` | ULID | PK | -| `event_id` | ULID FK | → events | -| `name` | string | | -| `sort_order` | int | | -| `deleted_at` | timestamp nullable | Soft delete | +> **v1.5:** Added `type`, 7 Crescat-derived section settings (excl. `shift_follows_events` — removed in v1.6), `crew_need`, and accreditation level columns. + +| Column | Type | Notes | +| --------------------------------- | ------------------ | ----------------------------------------------------------------- | +| `id` | ULID | PK | +| `event_id` | ULID FK | → events | +| `name` | string | e.g. Horeca, Backstage, Overig, Entertainment | +| `type` | enum | `standard\|cross_event` — cross_event for EHBO, verkeersregelaars | +| `sort_order` | int | default: 0 | +| `crew_need` | int nullable | **v1.5** Total crew needed for this section (Crescat: Crew need) | +| `crew_auto_accepts` | bool | **v1.5** Crew assignments auto-approved without explicit approval | +| `crew_invited_to_events` | bool | **v1.5** Crew automatically gets event invitations | +| `added_to_timeline` | bool | **v1.5** Section visible in event timeline overview | +| `responder_self_checkin` | bool | **v1.5** Volunteers can self check-in via QR in portal | +| `crew_accreditation_level` | string nullable | **v1.5** Default accreditation level for crew (e.g. AAA, AA, A) | +| `public_form_accreditation_level` | string nullable | **v1.5** Accreditation level for public form registrants | +| `timed_accreditations` | bool | **v1.5** Accreditations are time-limited for this section | +| `deleted_at` | timestamp nullable | Soft delete | **Relations:** `hasMany` shifts **Indexes:** `(event_id, sort_order)` **Soft delete:** yes +**Default values:** + +- `type`: standard +- `crew_auto_accepts`: false +- `crew_invited_to_events`: false +- `added_to_timeline`: false +- `responder_self_checkin`: true +- `timed_accreditations`: false + --- ### `time_slots` -| Column | Type | Notes | -| ---------------- | ------- | ----------------------------------------------------------------------------------- | -| `id` | ULID | PK | -| `event_id` | ULID FK | → events | -| `name` | string | | -| `person_type` | enum | `CREW\|VOLUNTEER\|PRESS\|PHOTO\|PARTNER` — controls visibility in registration form | -| `date` | date | | -| `start_time` | time | | -| `end_time` | time | | -| `duration_hours` | decimal | | +> Time Slots are defined centrally at event level. All sections reference the same Time Slots. +> This naturally handles "generic shifts" — multiple sections referencing one Time Slot share the same time framework. +> Per-shift time overrides are handled by `shifts.actual_start_time` / `actual_end_time`. + +| Column | Type | Notes | +| ---------------- | --------------------- | --------------------------------------------------------------------- | +| `id` | ULID | PK | +| `event_id` | ULID FK | → events | +| `name` | string | Descriptive, e.g. "DAY 1 - AVOND - VRIJWILLIGER", "KIDS - OCHTEND" | +| `person_type` | enum | `CREW\|VOLUNTEER\|PRESS\|PHOTO\|PARTNER` — controls portal visibility | +| `date` | date | | +| `start_time` | time | | +| `end_time` | time | | +| `duration_hours` | decimal(4,2) nullable | | **Relations:** `hasMany` shifts **Indexes:** `(event_id, person_type, date)` @@ -199,58 +249,132 @@ ### `shifts` -| Column | Type | Notes | -| ------------------------- | ------------------ | --------------------------------------------------------------------- | -| `id` | ULID | PK | -| `festival_section_id` | ULID FK | → festival_sections | -| `time_slot_id` | ULID FK | → time_slots | -| `location_id` | ULID FK nullable | → locations | -| `slots_total` | int | | -| `slots_open_for_claiming` | int | Number of slots visible & claimable in volunteer portal | -| `assigned_crew_id` | ULID FK nullable | → users | -| `events_during_shift` | JSON | Array of performance_ids — opaque reference list, no filtering needed | -| `status` | string | | -| `deleted_at` | timestamp nullable | Soft delete | +> **Architecture note:** +> One shift = one role at one location in one time window. +> Example from Concept Event Structure — Bar Hardstyle District has 5 shifts: +> +> - "Barhoofd" (1 slot, 18:30–03:00, report 18:00, is_lead_role = true) +> - "Tapper" (2 slots, 19:00–02:30, report 18:30) +> - "Frisdrank" (2 slots, 19:00–02:30, report 18:30) +> - "Tussenbuffet" (8 slots, 19:00–02:30, report 18:30) +> - "Runner" (1 slot, 20:30–02:30, report 20:00) +> +> v1.4: added title, description, instructions, coordinator_notes, actual_start_time, actual_end_time, end_date, explicit status enum +> v1.5: added report_time (aanwezig-tijd), allow_overlap (Overlap Toegestaan), is_lead_role + +| Column | Type | Notes | +| ------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | ULID | PK | +| `festival_section_id` | ULID FK | → festival_sections | +| `time_slot_id` | ULID FK | → time_slots | +| `location_id` | ULID FK nullable | → locations | +| `title` | string nullable | Role/task name, e.g. "Tapper", "Barhoofd", "Stage Manager" | +| `description` | text nullable | Brief description of the task | +| `instructions` | text nullable | Shown to volunteer after assignment: what to bring, where to report | +| `coordinator_notes` | text nullable | Internal only — never visible to volunteers | +| `slots_total` | int | | +| `slots_open_for_claiming` | int | Slots visible & claimable in volunteer portal | +| `is_lead_role` | bool | **v1.5** Marks this as the lead/head role at a location (Barhoofd, Stage Manager, etc.) | +| `report_time` | time nullable | **v1.5** "Aanwezig" time — when to arrive. Displayed in briefing and portal. | +| `actual_start_time` | time nullable | Overrides `time_slot.start_time` for this shift. NULL = use time_slot time | +| `actual_end_time` | time nullable | Overrides `time_slot.end_time` for this shift. NULL = use time_slot time | +| `end_date` | date nullable | For multi-day assignments. NULL = single day (via time_slot.date) | +| `allow_overlap` | bool | **v1.5** When true: skip UNIQUE(person_id, time_slot_id) conflict check. For Stage Managers covering multiple stages, cross-event sections. | +| `events_during_shift` | JSON | Array of performance_ids — opaque reference, no filtering needed | +| `status` | enum | `draft\|open\|full\|in_progress\|completed\|cancelled` | +| `deleted_at` | timestamp nullable | Soft delete | **Relations:** `belongsTo` festival_section, time_slot, location; `hasMany` shift_assignments **Indexes:** `(festival_section_id, time_slot_id)`, `(time_slot_id, status)` **Soft delete:** yes +**Status lifecycle:** + +- `draft` — created but not yet published for claiming +- `open` — visible and claimable in portal (respects `slots_open_for_claiming`) +- `full` — capacity reached, waitlist only +- `in_progress` — shift has started (show day) +- `completed` — shift completed +- `cancelled` — shift cancelled + +**Time resolution:** + +```php +$effectiveReportTime = $shift->report_time; // shown in briefing +$effectiveStart = $shift->actual_start_time ?? $shift->timeSlot->start_time; +$effectiveEnd = $shift->actual_end_time ?? $shift->timeSlot->end_time; +$effectiveDate = $shift->end_date ?? $shift->timeSlot->date; +``` + --- ### `shift_assignments` -| Column | Type | Notes | -| ------------------ | ------------------ | -------------------------------------------------------------------- | -| `id` | ULID | PK | -| `shift_id` | ULID FK | → shifts | -| `person_id` | ULID FK | → persons | -| `time_slot_id` | ULID FK | Denormalised from shifts — enables DB-enforceable conflict detection | -| `status` | enum | `pending_approval\|approved\|rejected\|cancelled\|completed` | -| `auto_approved` | bool | | -| `assigned_by` | ULID FK nullable | → users | -| `assigned_at` | timestamp nullable | | -| `approved_by` | ULID FK nullable | → users | -| `approved_at` | timestamp nullable | | -| `rejection_reason` | text nullable | | -| `deleted_at` | timestamp nullable | Soft delete | +> v1.4: added hours_expected, hours_completed, checked_in_at, checked_out_at +> +> Conflict detection: UNIQUE(person_id, time_slot_id) is enforced at DB level. +> Exception: when `shifts.allow_overlap = true`, the application skips this check before inserting. +> The DB constraint remains — use a conditional unique index or handle in application layer. -**Unique constraint:** `UNIQUE(person_id, time_slot_id)` — DB-enforceable conflict detection +| Column | Type | Notes | +| ------------------ | --------------------- | ------------------------------------------------------------ | +| `id` | ULID | PK | +| `shift_id` | ULID FK | → shifts | +| `person_id` | ULID FK | → persons | +| `time_slot_id` | ULID FK | Denormalised from shifts — DB-enforceable conflict detection | +| `status` | enum | `pending_approval\|approved\|rejected\|cancelled\|completed` | +| `auto_approved` | bool | | +| `assigned_by` | ULID FK nullable | → users | +| `assigned_at` | timestamp nullable | | +| `approved_by` | ULID FK nullable | → users | +| `approved_at` | timestamp nullable | | +| `rejection_reason` | text nullable | | +| `hours_expected` | decimal(4,2) nullable | Planned hours for this assignment | +| `hours_completed` | decimal(4,2) nullable | Actual hours worked — set after shift completion | +| `checked_in_at` | timestamp nullable | Shift-level check-in (when reported at section) | +| `checked_out_at` | timestamp nullable | When volunteer completed the shift | +| `deleted_at` | timestamp nullable | Soft delete | + +**Unique constraint:** `UNIQUE(person_id, time_slot_id)` — bypassed in application when `shift.allow_overlap = true` **Indexes:** `(shift_id, status)`, `(person_id, status)`, `(person_id, time_slot_id)` **Soft delete:** yes --- +### `shift_check_ins` + +> Separate from terrain `check_ins`. Records when a volunteer physically reported at their section for duty. +> Enables per-shift no-show detection independent of gate access. +> When `festival_sections.responder_self_checkin = true`, volunteers trigger this via QR in portal. + +| Column | Type | Notes | +| ----------------------- | ------------------ | ---------------------------------------------- | +| `id` | ULID | PK | +| `shift_assignment_id` | ULID FK | → shift_assignments | +| `person_id` | ULID FK | → persons (denormalised for query performance) | +| `shift_id` | ULID FK | → shifts (denormalised for query performance) | +| `checked_in_at` | timestamp | | +| `checked_out_at` | timestamp nullable | | +| `checked_in_by_user_id` | ULID FK nullable | → users — coordinator who confirmed check-in | +| `method` | enum | `qr\|manual` | + +**Note:** Immutable audit record — NO soft delete. +**Indexes:** `(shift_assignment_id)`, `(shift_id, checked_in_at)`, `(person_id, checked_in_at)` + +--- + ### `volunteer_availabilities` -| Column | Type | Notes | -| -------------- | --------- | ------------ | -| `id` | ULID | PK | -| `person_id` | ULID FK | → persons | -| `time_slot_id` | ULID FK | → time_slots | -| `submitted_at` | timestamp | | +> v1.4: added preference_level for future auto-matching algorithm. + +| Column | Type | Notes | +| ------------------ | --------- | ---------------------------------------------------------------- | +| `id` | ULID | PK | +| `person_id` | ULID FK | → persons | +| `time_slot_id` | ULID FK | → time_slots | +| `preference_level` | tinyint | 1 (low) – 5 (high). Default: 3. Used for auto-matching priority. | +| `submitted_at` | timestamp | | -**Purpose:** Volunteer selects available Time Slots — basis for shift matching **Unique constraint:** `UNIQUE(person_id, time_slot_id)` **Indexes:** `(time_slot_id)` @@ -258,8 +382,6 @@ ### `shift_absences` -> **New table (finding #10 split)** - | Column | Type | Notes | | --------------------- | ------------------ | ----------------------- | | `id` | ULID | PK | @@ -277,8 +399,6 @@ ### `shift_swap_requests` -> **New table (finding #10 split)** - | Column | Type | Notes | | -------------------- | ------------------ | --------------------------------------------------- | | `id` | ULID | PK | @@ -290,7 +410,6 @@ | `reviewed_at` | timestamp nullable | | | `auto_approved` | bool | | -**Logic:** A asks B to swap. After both agree: coordinator confirms (or auto-approve). **Indexes:** `(from_assignment_id)`, `(to_person_id, status)` --- @@ -332,7 +451,7 @@ | `reliability_score` | decimal(3,2) | 0.00–5.00, computed via scheduled job | | `is_ambassador` | bool | | -**Unique constraint:** `UNIQUE(user_id)` — platform-wide, 1:1 with users +**Unique constraint:** `UNIQUE(user_id)` --- @@ -379,8 +498,6 @@ ### `festival_retrospectives` -> **Finding #8:** All KPIs as concrete columns instead of a JSON blob. Enables trend analysis across multiple years. - | Column | Type | Notes | | -------------------------- | -------------- | ------------------------------------- | | `id` | ULID | PK | @@ -402,10 +519,6 @@ ## 3.5.5 Crowd Types, Persons & Crowd Lists -> **Finding #1 (identity fragmentation):** `persons` gets nullable `user_id` as canonical link to platform account. -> **Finding #4:** `crowd_list_persons` pivot added. -> **Finding #9:** `persons.email` as indexed deduplication key. - ### `crowd_types` | Column | Type | Notes | @@ -459,7 +572,6 @@ | `contact_phone` | string nullable | | | `deleted_at` | timestamp nullable | Soft delete | -**Note:** Shared across events within an organisation. **Indexes:** `(organisation_id)` **Soft delete:** yes @@ -485,8 +597,6 @@ ### `crowd_list_persons` -> **New pivot table (finding #4)** - | Column | Type | Notes | | ------------------ | ---------------- | --------------------------------- | | `id` | int AI | PK — integer for join performance | @@ -502,8 +612,6 @@ ## 3.5.6 Accreditation Engine -> **Finding #5:** `event_accreditation_items` activates org-level items per event. Accreditation items are now configured at org level and activated per event with event-specific limits. - ### `accreditation_categories` | Column | Type | Notes | @@ -531,14 +639,10 @@ | `cost_price` | decimal(8,2) nullable | | | `sort_order` | int | | -**Note:** Org-level items, activated per event via `event_accreditation_items`. - --- ### `event_accreditation_items` -> **New table (finding #5)** - | Column | Type | Notes | | ------------------------- | ------------- | --------------------- | | `id` | ULID | PK | @@ -556,17 +660,17 @@ ### `accreditation_assignments` -| Column | Type | Notes | -| ----------------------- | ------------------ | --------------------------------------------------------- | -| `id` | ULID | PK | -| `person_id` | ULID FK | → persons | -| `accreditation_item_id` | ULID FK | → accreditation_items | -| `event_id` | ULID FK | → events — FK to event_accreditation_items for validation | -| `date` | date nullable | For date-dependent items | -| `quantity` | int | | -| `is_handed_out` | bool | | -| `handed_out_at` | timestamp nullable | | -| `handed_out_by_user_id` | ULID FK nullable | → users | +| Column | Type | Notes | +| ----------------------- | ------------------ | ------------------------ | +| `id` | ULID | PK | +| `person_id` | ULID FK | → persons | +| `accreditation_item_id` | ULID FK | → accreditation_items | +| `event_id` | ULID FK | → events | +| `date` | date nullable | For date-dependent items | +| `quantity` | int | | +| `is_handed_out` | bool | | +| `handed_out_at` | timestamp nullable | | +| `handed_out_by_user_id` | ULID FK nullable | → users | **Indexes:** `(person_id, event_id)`, `(accreditation_item_id, is_handed_out)` @@ -582,22 +686,18 @@ | `zone_code` | varchar(20) | unique per event | | `description` | text nullable | | -**Note:** Day-coupling via `access_zone_days` pivot. **Indexes:** `(event_id)` --- ### `access_zone_days` -> **New table (finding #8: replaces JSON `days` column)** - | Column | Type | Notes | | ---------------- | ------- | --------------------------------- | | `id` | int AI | PK — integer for join performance | | `access_zone_id` | ULID FK | → access_zones | | `day_date` | date | | -**Purpose:** Queryable — which zones are active on date X? **Unique constraint:** `UNIQUE(access_zone_id, day_date)` **Indexes:** `(day_date)` @@ -619,23 +719,29 @@ ## 3.5.7 Artists & Advancing -> **Finding #8:** `stages.active_days` JSON replaced by `stage_days` pivot. `milestone_flags` JSON remains (opaque toggle-set, never filtered). - ### `artists` -| Column | Type | Notes | -| ------------------- | ------------------ | -------------------------------------------------------------- | -| `id` | ULID | PK | -| `event_id` | ULID FK | → events | -| `name` | string | | -| `booking_status` | enum | `concept\|requested\|option\|confirmed\|contracted\|cancelled` | -| `star_rating` | tinyint | 1–5 | -| `project_leader_id` | ULID FK nullable | → users | -| `milestone_flags` | JSON | Binary toggle-set — OK as JSON | -| `advance_open_from` | datetime nullable | | -| `advance_open_to` | datetime nullable | | -| `portal_token` | ULID unique | Access to artist portal without account | -| `deleted_at` | timestamp nullable | Soft delete | +| Column | Type | Notes | +| ------------------------------ | ------------------ | -------------------------------------------------------------- | +| `id` | ULID | PK | +| `event_id` | ULID FK | → events | +| `name` | string | | +| `booking_status` | enum | `concept\|requested\|option\|confirmed\|contracted\|cancelled` | +| `star_rating` | tinyint | 1–5 | +| `project_leader_id` | ULID FK nullable | → users | +| `milestone_offer_in` | bool | Default: false | +| `milestone_offer_agreed` | bool | Default: false | +| `milestone_confirmed` | bool | Default: false | +| `milestone_announced` | bool | Default: false | +| `milestone_schedule_confirmed` | bool | Default: false | +| `milestone_itinerary_sent` | bool | Default: false | +| `milestone_advance_sent` | bool | Default: false | +| `milestone_advance_received` | bool | Default: false | +| `advance_open_from` | datetime nullable | | +| `advance_open_to` | datetime nullable | | +| `show_advance_share_page` | bool | Default: true | +| `portal_token` | ULID unique | Access to artist portal without account | +| `deleted_at` | timestamp nullable | Soft delete | **Relations:** `hasMany` performances, advance_sections, artist_contacts, artist_riders **Soft delete:** yes @@ -655,7 +761,6 @@ | `booking_status` | string | | | `check_in_status` | enum | `expected\|checked_in\|no_show` | -**Note:** B2B detection via overlap query on `stage_id + date + time window`. **Indexes:** `(stage_id, date, start_time, end_time)` --- @@ -670,7 +775,6 @@ | `color` | string | hex | | `capacity` | int nullable | | -**Note:** Day-activation via `stage_days` pivot (finding #8). **Relations:** `hasMany` performances **Indexes:** `(event_id)` @@ -678,8 +782,6 @@ ### `stage_days` -> **New table (finding #8: replaces `stages.active_days` JSON)** - | Column | Type | Notes | | ---------- | ------- | --------------------------------- | | `id` | int AI | PK — integer for join performance | @@ -692,19 +794,22 @@ ### `advance_sections` -| Column | Type | Notes | -| ------------ | ----------------- | ------------------------------------------ | -| `id` | ULID | PK | -| `artist_id` | ULID FK | → artists | -| `name` | string | | -| `type` | enum | `guest_list\|contacts\|production\|custom` | -| `is_open` | bool | | -| `open_from` | datetime nullable | | -| `open_to` | datetime nullable | | -| `sort_order` | int | | +| Column | Type | Notes | +| ------------------- | ------------------ | -------------------------------------------------------------- | +| `id` | ULID | PK | +| `artist_id` | ULID FK | → artists | +| `name` | string | | +| `type` | enum | `guest_list\|contacts\|production\|custom` | +| `is_open` | bool | | +| `open_from` | datetime nullable | | +| `open_to` | datetime nullable | | +| `sort_order` | int | | +| `submission_status` | enum | `open\|pending\|submitted\|approved\|declined` | +| `last_submitted_at` | timestamp nullable | | +| `last_submitted_by` | string nullable | | +| `submission_diff` | JSON nullable | `{created, updated, untouched, deleted}` counts per submission | -**Note:** Crescat section model — each section independently submittable. -**Indexes:** `(artist_id, is_open)` +**Indexes:** `(artist_id, is_open)`, `(artist_id, submission_status)` --- @@ -738,6 +843,7 @@ | `role` | string | e.g. tour manager, agent, booker | | `receives_briefing` | bool | | | `receives_infosheet` | bool | | +| `is_travel_party` | bool | | **Indexes:** `(artist_id)` @@ -745,12 +851,12 @@ ### `artist_riders` -| Column | Type | Notes | -| ----------- | ------- | ------------------------------------ | -| `id` | ULID | PK | -| `artist_id` | ULID FK | → artists | -| `category` | enum | `technical\|hospitality` | -| `items` | JSON | Unstructured rider data — OK as JSON | +| Column | Type | Notes | +| ----------- | ------- | ------------------------ | +| `id` | ULID | PK | +| `artist_id` | ULID FK | → artists | +| `category` | enum | `technical\|hospitality` | +| `items` | JSON | Unstructured rider data | **Indexes:** `(artist_id, category)` @@ -768,15 +874,12 @@ | `to_location` | string nullable | | | `notes` | text nullable | | -**Note:** Flights/hotels: Out of Scope. **Indexes:** `(artist_id, datetime)` --- ## 3.5.8 Communication & Briefings -> **Finding #11:** `broadcast_messages` extended with polymorphic `broadcast_message_targets` for flexible audience definition. - ### `briefing_templates` | Column | Type | Notes | @@ -820,8 +923,8 @@ | `sent_at` | timestamp nullable | | | `opened_at` | timestamp nullable | | -**Note:** Track per person per briefing. No soft delete — audit record. -**Indexes:** `(status, briefing_id)` — queue processing, `(person_id)` +**Note:** No soft delete — audit record. +**Indexes:** `(status, briefing_id)`, `(person_id)` --- @@ -841,25 +944,24 @@ | `sent_count` | int | | | `failed_count` | int | | -**Note:** Bulk campaigns. SMS+WhatsApp via Zender. **Indexes:** `(event_id, type, status)` --- ### `messages` -| Column | Type | Notes | -| --------------------- | ------------------ | ---------------------------------------------------- | -| `id` | ULID | PK | -| `event_id` | ULID FK | → events | -| `sender_user_id` | ULID FK | → users | -| `recipient_person_id` | ULID FK | → persons | -| `body` | text | | -| `urgency` | enum | `normal\|urgent\|emergency` | -| `channel_used` | enum | `email\|sms\|whatsapp` — determined by ZenderService | -| `read_at` | timestamp nullable | | -| `replied_at` | timestamp nullable | | -| `created_at` | timestamp | | +| Column | Type | Notes | +| --------------------- | ------------------ | --------------------------- | +| `id` | ULID | PK | +| `event_id` | ULID FK | → events | +| `sender_user_id` | ULID FK | → users | +| `recipient_person_id` | ULID FK | → persons | +| `body` | text | | +| `urgency` | enum | `normal\|urgent\|emergency` | +| `channel_used` | enum | `email\|sms\|whatsapp` | +| `read_at` | timestamp nullable | | +| `replied_at` | timestamp nullable | | +| `created_at` | timestamp | | **Indexes:** `(event_id, recipient_person_id)`, `(recipient_person_id, read_at)` @@ -876,7 +978,7 @@ | `status_update` | enum nullable | `on_my_way\|arrived\|sick\|other` | | `created_at` | timestamp | | -**Note:** Volunteer replies via portal. No soft delete — audit record. +**Note:** No soft delete — audit record. **Indexes:** `(message_id)` --- @@ -895,23 +997,19 @@ | `recipient_count` | int | | | `read_count` | int | | -**Note:** Group message. Audience defined via `broadcast_message_targets`. **Indexes:** `(event_id, sent_at)` --- ### `broadcast_message_targets` -> **New polymorphic table (finding #11)** - | Column | Type | Notes | | ---------------------- | ------------- | ------------------------------------------------ | | `id` | int AI | PK — integer for join performance | | `broadcast_message_id` | ULID FK | → broadcast_messages | | `target_type` | enum | `event\|section\|shift\|crowd_type\|custom_list` | -| `target_id` | ULID nullable | NULL when `target_type = event` (entire event) | +| `target_id` | ULID nullable | NULL when `target_type = event` | -**Note:** Multiple targets per message possible. **Indexes:** `(broadcast_message_id)` --- @@ -938,13 +1036,13 @@ ### `form_submissions` -| Column | Type | Notes | -| ---------------- | --------- | --------------------------------- | -| `id` | ULID | PK | -| `public_form_id` | ULID FK | → public_forms | -| `person_id` | ULID FK | → persons | -| `data` | JSON | Free form results — not queryable | -| `submitted_at` | timestamp | | +| Column | Type | Notes | +| ---------------- | --------- | ----------------- | +| `id` | ULID | PK | +| `public_form_id` | ULID FK | → public_forms | +| `person_id` | ULID FK | → persons | +| `data` | JSON | Free form results | +| `submitted_at` | timestamp | | **Indexes:** `(public_form_id, submitted_at)`, `(person_id)` @@ -952,6 +1050,8 @@ ### `check_ins` +> Terrain check-in at access gates. Separate from `shift_check_ins`. + | Column | Type | Notes | | -------------------- | ---------------- | ----------- | | `id` | ULID | PK | @@ -1030,15 +1130,12 @@ | `sort_order` | int | | | `is_published` | bool | | -**Note:** Visibility per crowd_type via `event_info_block_crowd_types`. **Indexes:** `(event_id, type, is_published)` --- ### `event_info_block_crowd_types` -> **Finding #8: replaces `visible_to_crowd_types` JSON column** - | Column | Type | Notes | | --------------------- | ------- | --------------------------------- | | `id` | int AI | PK — integer for join performance | @@ -1051,8 +1148,6 @@ ### `production_requests` -> **New table (finding #3: missing table)** - | Column | Type | Notes | | -------------- | ------------------ | --------------------------------------------------------- | | `id` | ULID | PK | @@ -1097,8 +1192,8 @@ ### Rule 1 — ULID as Primary Key - Business tables: `$table->ulid('id')->primary()` + `HasUlids` trait -- Pure pivot/link tables (no own lifecycle): `$table->id()` (auto-increment integer) for join performance -- Never UUID v4 — avoids InnoDB B-tree fragmentation +- Pure pivot/link tables: `$table->id()` (auto-increment integer) +- Never UUID v4 --- @@ -1107,18 +1202,10 @@ | ✅ Use JSON for | ❌ Never JSON for | | ----------------------------------------------- | ------------------------------------- | | Opaque config (blocks, fields, settings, items) | Dates/periods | -| Toggle-sets (milestone_flags) | Status values | -| Free-text arrays (top_feedback) | Foreign keys | -| Unstructured rider data | Boolean flags | -| | Anything you filter/sort/aggregate on | - -**Replaced in v1.3:** - -- `access_zone_days` (was `days` JSON) -- `stage_days` (was `active_days` JSON) -- `broadcast_message_targets` (was `target` JSON) -- `event_info_block_crowd_types` (was `visible_to_crowd_types` JSON) -- `festival_retrospectives` columns (were in `data` JSON blob) +| Free-text arrays (top_feedback) | Status values | +| Unstructured rider data | Foreign keys | +| Submission diff snapshots | Boolean flags | +| events_during_shift (opaque reference list) | Anything you filter/sort/aggregate on | --- @@ -1126,29 +1213,53 @@ **Soft delete YES:** `organisations`, `events`, `festival_sections`, `shifts`, `shift_assignments`, `persons`, `artists`, `companies`, `production_requests` -**Soft delete NO (immutable audit records):** `check_ins`, `show_day_absence_alerts`, `briefing_sends`, `message_replies`, `audit_log`, `shift_waitlist`, `volunteer_festival_history` - -> **Rationale:** Soft deleting audit records creates a false picture of reality. +**Soft delete NO (immutable audit records):** `check_ins`, `shift_check_ins`, `show_day_absence_alerts`, `briefing_sends`, `message_replies`, `shift_waitlist`, `volunteer_festival_history` --- ### Rule 4 — Required Indexes (minimum set) -| Table | Indexes | -| ------------------- | ------------------------------------------------------------------------------- | -| `persons` | `(event_id, crowd_type_id, status)`, `(email, event_id)`, `(user_id, event_id)` | -| `shift_assignments` | `UNIQUE(person_id, time_slot_id)`, `(shift_id, status)`, `(person_id, status)` | -| `check_ins` | `(event_id, person_id, scanned_at)`, `(event_id, scanned_at)` | -| `briefing_sends` | `(status, briefing_id)` — queue processing | -| `shift_waitlist` | `(shift_id, position)` | -| `performances` | `(stage_id, date, start_time, end_time)` — B2B overlap detection | - -> Add `EXPLAIN ANALYZE` to queries taking >100ms. Target: all list queries <50ms. +| Table | Indexes | +| ------------------- | ---------------------------------------------------------------------------------- | +| `persons` | `(event_id, crowd_type_id, status)`, `(email, event_id)`, `(user_id, event_id)` | +| `shift_assignments` | `UNIQUE(person_id, time_slot_id)`, `(shift_id, status)`, `(person_id, status)` | +| `shift_check_ins` | `(shift_assignment_id)`, `(shift_id, checked_in_at)`, `(person_id, checked_in_at)` | +| `check_ins` | `(event_id, person_id, scanned_at)`, `(event_id, scanned_at)` | +| `briefing_sends` | `(status, briefing_id)` | +| `shift_waitlist` | `(shift_id, position)` | +| `performances` | `(stage_id, date, start_time, end_time)` | +| `advance_sections` | `(artist_id, is_open)`, `(artist_id, submission_status)` | --- ### Rule 5 — Multi-Tenancy Scoping -- Every query on event data **MUST** scope on `organisation_id` via Eloquent Global Scope (`OrganisationScope`) -- Use Laravel policies for authorisation: never direct id-checks in controllers +- Every query on event data **MUST** scope on `organisation_id` via `OrganisationScope` Eloquent Global Scope +- Use Laravel policies — never direct id-checks in controllers - **Audit log:** Spatie `laravel-activitylog` on: `persons`, `accreditation_assignments`, `shift_assignments`, `check_ins`, `production_requests` + +--- + +### Rule 6 — Shift Time Resolution + +```php +// Effective times — shift overrides take precedence over time slot +$reportTime = $shift->report_time; // arrival time (aanwezig) +$effectiveStart = $shift->actual_start_time ?? $shift->timeSlot->start_time; +$effectiveEnd = $shift->actual_end_time ?? $shift->timeSlot->end_time; +$effectiveDate = $shift->end_date ?? $shift->timeSlot->date; +``` + +--- + +### Rule 7 — Overlap / Conflict Detection + +Default: `UNIQUE(person_id, time_slot_id)` on `shift_assignments` prevents double-booking. + +Exception: when `shifts.allow_overlap = true`, the **application layer** skips this constraint check before inserting. Use cases: + +- `festival_sections.type = cross_event` (EHBO, verkeersregelaars) +- Stage Managers covering multiple stages simultaneously +- Any role explicitly marked as overlap-allowed in the planning document + +The DB constraint remains as a safety net for all other cases. diff --git a/docs/TEST_SCENARIO.md b/docs/TEST_SCENARIO.md index 1cea559..aafaf43 100644 --- a/docs/TEST_SCENARIO.md +++ b/docs/TEST_SCENARIO.md @@ -110,3 +110,13 @@ Voer dit uit na elke Fase 2 module om regressie te voorkomen. - [ ] Organisatie switcher werkt nog - [ ] Events lijst laadt zonder errors - [ ] php artisan test → alle tests groen + +## Openstaande FK constraints (worden toegevoegd bij persons module) + +- shift_assignments.person_id → persons +- shift_check_ins.person_id → persons +- volunteer_availabilities.person_id → persons + person_id kolommen zonder FK constraint in: +- shift_assignments +- shift_check_ins +- volunteer_availabilities diff --git a/resources/corporate-identity/Logo.ai b/resources/corporate-identity/Logo.ai index 5ee4878..736827d 100644 --- a/resources/corporate-identity/Logo.ai +++ b/resources/corporate-identity/Logo.ai @@ -19,13 +19,13 @@ xmlns:pdfx="http://ns.adobe.com/pdfx/1.3/" dc:format="application/pdf" xmp:CreatorTool="Adobe Illustrator 30.2 (Macintosh)" - xmp:CreateDate="2026-04-07T21:10:10+02:00" - xmp:ModifyDate="2026-04-07T21:10:10+02:00" - xmp:MetadataDate="2026-04-07T21:10:10+02:00" + xmp:CreateDate="2026-04-07T21:10:25+02:00" + xmp:ModifyDate="2026-04-07T21:10:25+02:00" + xmp:MetadataDate="2026-04-07T21:10:25+02:00" xmpMM:RenditionClass="proof:pdf" xmpMM:OriginalDocumentID="uuid:65E6390686CF11DBA6E2D887CEACB407" xmpMM:DocumentID="xmp.did:70988324-2c00-4d6d-aac3-a65c0f4affee" - xmpMM:InstanceID="uuid:8543e38c-d301-5444-a940-0f310b691242" + xmpMM:InstanceID="uuid:cd8f0802-2e4f-8648-98f7-759d3958d3cd" illustrator:StartupProfile="Web" illustrator:Type="Document" illustrator:CreatorSubTool="AIRobin" @@ -3721,745 +3721,726 @@ - endstream endobj 3 0 obj <> endobj 5 0 obj <>/ExtGState<>/Properties<>>>/TrimBox[0.0 0.0 1200.0 1200.0]/Type/Page/PieceInfo<>>> endobj 8 0 obj <>stream + endstream endobj 3 0 obj <> endobj 5 0 obj <>/ExtGState<>/Properties<>>>/TrimBox[0.0 0.0 1200.0 1200.0]/Type/Page/PieceInfo<>>> endobj 8 0 obj <>stream HKVˎ7+~zdFemRM$Y>X$Uw|(\s -Kt-ZSs#:ܝ8m$G2MR}'o|:&c#m}x|kF Bg1f6 /LX!* rS4YpjPBګw նF5AcAaS_$+*)w2tc$NS&= EFom9t˺>3d,&%2緫d0bs0ɚTaK}`)}"V![kXze-3ΖdLc57@r")^Wn_ 2gW:HN@ɰ$”rj]pKe)Pz'!rCHjˌ,\04(iSNE-$fu$mj@m8bW,{N-Ywv-#BZ[9SdҦ'wԓVۊ,gV4֎M]lCF(\GyJغf|4zP4˦̞#~'Cx endstream endobj 11 0 obj <> endobj 12 0 obj <> endobj 13 0 obj <>stream -%!PS-Adobe-3.0 %%Creator: Adobe Illustrator(R) 24.0 %%AI8_CreatorVersion: 30.2.1 %%Title: (~ai-b5b66246-d135-4923-8d9c-2d79ddc20b76_.tmp) %%CreationDate: 07/04/2026 21:10 %%Canvassize: 16383 %%BoundingBox: 0 -1200 1200 0 %%HiResBoundingBox: 0 -1200 1200 -0.000000000001819 %%DocumentProcessColors: Cyan Magenta %AI5_FileFormat 14.0 %AI12_BuildNumber: 1 %AI3_ColorUsage: Color %AI7_ImageSettings: 0 %%RGBProcessColor: 0 0 0 ([Registration]) %AI3_Cropmarks: 0 -1200 1200 0 %AI3_TemplateBox: 600.5 -600.5 600.5 -600.5 %AI3_TileBox: 320.5 -980 879.5 -197 %AI3_DocumentPreview: None %AI5_ArtSize: 14400 14400 %AI5_RulerUnits: 6 %AI24_LargeCanvasScale: 1 %AI9_ColorModel: 1 %AI5_ArtFlags: 0 0 0 1 0 0 1 0 0 %AI5_TargetResolution: 800 %AI5_NumLayers: 1 %AI17_Begin_Content_if_version_gt:24 4 %AI10_OpenToVie: -1333.55216514286 492.640432859973 0.295139483600398 0 8026.10309450637 8611.2035775488 1716 982 18 0 0 6 54 0 0 0 1 1 0 1 1 0 1 %AI17_Alternate_Content %AI9_OpenToView: -1333.55216514286 492.640432859973 0.295139483600398 1716 982 18 0 0 6 54 0 0 0 1 1 0 1 1 0 1 %AI17_End_Versioned_Content %AI5_OpenViewLayers: 7 %AI17_Begin_Content_if_version_gt:24 4 %AI17_Alternate_Content %AI17_End_Versioned_Content %%PageOrigin:200 -900 %AI7_GridSettings: 72 8 72 8 1 0 0.800000011920929 0.800000011920929 0.800000011920929 0.899999976158142 0.899999976158142 0.899999976158142 %AI9_Flatten: 1 %AI12_CMSettings: 00.MS %%EndComments endstream endobj 14 0 obj <>stream -%AI24_ZStandard_Data(/XܧX) ,P -m 2ǣo%VTk[sޙq2sbp  -K -98@D&A'5ҕ|63$Þi$IsXQ%Z4_93%rIԖR9ɿIMMFr7yFQ uZ1z+hH$yII٠ԍ>#,gUJ)QXNyLƍȃ*Ft:]NO’)z<=)֞Nl e:G3#AMj`IIUJ8MؕFwޅҤ#W>E3ݨ+uLÓFs= Ѥ%FHZ#r(,Qsӯ%YFD?s -K,*σR5^/[ie/=H~6]n5(?WIyCBHУXM&=6Ir4* > ը+ʥez/c'qG}$~a$b6,IQ ڸe;=>? SyhC6UߎFZahL'9daY 0f1z}8@$ -> .H*T`  -,@PA…,$4C`200#cF1CĨu3qf,OW%HKmF6<иYX 2cYN<*hC:mrw Vx;xd"*2S+Ja(΀ @ epxpb WIaTe@BqΰhT枥%La sޏp"!'ِso$%(1(| Y" oDD%0 C/`<1*H( -D$eG2"}%ٖeVXSŗ̠=:^& DXZ^*ҡdߑ .p FX["聆DBE-hY8( B@0( Qq -S:04040< D[ rE-lq ."a 6dF!ϙ3Zuz=x%m5PC*q8=o|FVʌV#frG8|0YLC5T5abH,-@ @XhX$ E.&zR4Vz㆝9876654 ,`3t\0,x - @ - !2ႄ &0,`T0(T  d 3*`@* -.\x0PT@bTp *p0LW 0Pa .A ,TPA*X -lě -4x8p1R0fT - P` *08p4(@P8pVP5<\PA`pAn`  ) (0\n -$@(AF.HehT@ ,g~%:^S֕]Q͞h<y=)/ȸ?st;:)5O+yV#3]ӋLGKBѹ'UA4mnz%U#2`ǻbR=O%I{93gF?mɵry/[]N-Yfط1lbYB2.G=hx<SDgչ{kKQ)G꒕UY8y׃RHKGzs$>R>yo%[Q&u?C4f`KVX<և~^y4 Xs{[̽":Whb~e{_UJ;R]wnݩfU۔h5=MVӌNzC2*VŬND\jogBJSY,;3bd㝏#IqQ~.nJOSdiKGn:J6Mn%R5:b]\?G?gߑ(L֯bQxojxR~ڬ^mflئV/!R&e]Vγ&`V捌,5D3(@iƣ^w)*I;u/WMG*R^L>O,|oJNGZߩ%۝߽v DW@7(pthfYNV'tõwSu\d^8ܶ|';JVszGs8d#3nn]csƒd4P .@xA4P@8@T@ 8*hUW*y~'B?+ ̹K|e,‘6!ɒNK,٢הɜ.=$>+=ǽefz6#s|-:>BE"svSܞ3yWTsդF;gsكs&xkNFӪ<׵`] z%Q uuLA[|;y7"<;L__Bd=).*X=rG0JM: ҍSK2Nf ҠD#~sG|Qа<ۺ,c3 nƜ,J^-X^\u*y28N%4NgvfsUҐ$QXIbYJӲ:9͓H#q|%ܸeGjߎ>jp=XՋc;rˆrArkfM?eUTZO?W-KVM3(si"XX'Ov8f6LlٲGt_EfQrlw9v:Qf!{Y6- Ogt5Vr͔ak=5wV* T+mP|zC;όeϤWk.qvJ5SɴJj8OY--xo)O!Q 6GM'D§ -MG-h,?U5;\듯e!xЎ^CSKK, 3ВBgX%Uud!eLnR绊0ﺥ%E2Di8GLsCx:)^_LXxm5*D+.7%v$A: -!Yc'#)MXrpf#8٫G!*%;ґf/ɲ_:;=MeYv<2:?{LJxvL4yD;)$k"ME{'dsF45yf{-WID$c݈#\ՔN7Du_,ѕi{FHlɐJ&E>uPV8ڔ]hF;ݠI?(%wXcΡ q豧+eUv~Lr}#f*v*}4 DfLʻbQDХԺ+\ؒhq.#J'V~M\5}MsQߋd9{yWB{(?~XzyDVwK6Ѭ/yVΈ(BZ:-iFOMJ6"#gi,ohP)3~=I: ^왴;x2;ΑF8ʦ5e@{N3iBٲ!m${$rRn*g^ -kGV3gt/59z~*dNՕmk*%yIr%!ͦ3s9!SN^R2d!4aF:G -]ÙTהnk]7Mr/zNc_ն+T ֐˪nW(Fgtte$YΫٽ:&G:IF,2Mzr,Idq]oh:#>Uo IYUՖ7f/OYR㓴`}+3ku-='oBd<,&&c.hUw 2lU ;D,G;it' ̛͜w@J{g:y7{2\;KUa(Xli(-!ǎ5ϓ]=UjHL5N?3hnw24+_"s\zRxZđ!=eD9V-OIC;w*sO0ģJMYh zF4Cs{]ݒNX 4BFRO7sD>eұi >S'iC3UnuTquDæwB󕦨'hW2oPoirLiT4FFSD&{L*b2UUtD9_vS񻱛]2c:OWCi79kQޔfj)֍WSs(уlY5qWWѬ~Ϙ}*]5v@k%c:S_XY^T$hpjH&E]kQf,"!YLjՉױi:u2c %MuȚ -#OUCۦdW*BtG!ReV%TJ#'Aʓ '"Mvfn99lٸ;D(58h)%5nX#ѹR,>]2ShFa03:.;4A;ݧq.wGd<)aY|ʴcLjk|N4*mHI7s4¼ٙ\fUaet>'DךVSY6H޽ efx'muB,) 6 [Ce5jn,%lݽ(36_Ȑ+m»}FܲjR7'%Vr4T',,U^3c'IK pN“t"Ar φ -^C(DT̊/}jŪfx7VI:srn1$I2!Mhx#=kWHV8e}Bچ47eb %X9RU3`HDE1Ɍ`~BH`()d@Pl?+˹{ TŀdY7Ob&tmWVl0h@ZnNJadZ -2R|) .=۩I&AҊF(&#[wibGqy?sb0 PY )3a]X0% W=/Xn|&lW5 -x%$%NI\bn,Kq'TC"9Đ7~zXbOO )|! -&x^NI֤i 3i$bEt]Kfmh-zPMqApE&}8矿'JMOXMUT Dn &>'}LYo\*׳;&lΟo?UT*Spwcz| $`݆;. .ΝrIDN߽g//u9b ,?3=2TÕmʓ@]ҧЦ[w XmO˶B Z15A8,|@KGtkn$o* ݂E)AN3tez7,6M{.TbT@VRlczۤY48e5 )ȰG_= cX9N ruIrCF)^8GOʤ MlK"@P=4T:dJH$gၪe>{J3nP\mmδrIvj?B7/8UfRQ6 ru6x bk2g'KE,ݨ4U"ţ|Wh.^+P&LkQ(lpm3ˎ?I{F>_ƄӈMRd_0g_y1^$` '':WvdE%XAu>wv B?!ȏi@- O9HAJ1CBr s_-y^B>u"]N~ƛNO ->@Ay j zE@!UL z㼻ldlV9Ǝ"!RFʍc,7I $~P=8D/JfhUe Pha "&ݕph^v:XK, w1əh,b$- !35U8MYf 'v65їAl^i_X 7/EOwz&R@81ceVA -K^/[Q@X')#*%Q(,'cNN/)յaGC vQAoF^&G'洜,3@/DyHjÜrNoҩ1ׅ$_(a,636gS}MMJgB;Ž};wI^;e -U^Q>hEְ=;KX(6Y#("%5:{ "Ey w'yn8^~'\0U.G_;ś/ݖX:>hA\`Ap^xHm]0DȎc4sofm<Ϛ\1&?hH#n;H0^/ȧ*'T+-#i=jPԞ1\Hi/I(2/fYefP&I -^Df?wl3;}Z5=NFYxB$w9NTV"ow4]!?]+Ћ (.a8L`d후T-Di*9Q\:i)Q7x4Q>ϐQ.FSlIrUTqK(1-5;}A.RP8թPwz0X A2W@S0hc'|8U>+ -NA - -x+kentؔe lxE g{Xtg!z4N%XG51$6n+EG,7X"q~;q!1-\)okȻBg d+`4)VwJ<Qĵ+ןmt(Ȧ(P TN#OEKMd[NWZ8eA\[<T{(cw }su8}{}Xrw@!\)OXNQ>2r>FIa%muDOZa)4T}ssp3M)VԙN|(evHX&[Pdd?zjS1-=dwr{^.;ah;z>b#yV1rܤǁ'&2͊KgmTљV$5"u&+Uyԛ76 nRuTp~͢F9ugt&v66i٪0`\`A9]b]>Vsׇ'MG5jdX)"-ѐQ}PTL{v" zM-R ?! ?\5t@2ϿH7tǘC81|d@W%gU}/\FGPrBK¸b B~3;HAti(aPYqrEo.B *>wCY˱I c !C&)Z緢 묈 (k>“ݜDk8l% iH?+a #f%-P10nd*ZĮNd4F@;O%rYK,w̡eeG5pr9e9wG.6XGJlurWKidĹK<hnqk ɻPY]obh+h& ntso>,Zze -D Q718WG +#Dd ư`MlSyE!NaJ|SA5 N+*;Itu@742\bģaoYHLc`̛*Mse\Ʉ~1pTKYςfx/~ t5. dujWt9do JY|ޡӛG|m9I͠ߊUJ\9'@# -Q AZ,CkÆ)$niUp pԹTjwc;s# Nу{*{{XQ6#8 Vl96.ޜTMRbgV0BlA[4M#% V͉C87ʨVY#ܮ`m16bNjJG)tFo牑y, qGJqw;e=(ܖf&\_MmTx|Ee5◃0N%rZ= ee޼DP|նCTOdzơA~\ l,auS5đ9Hp9ľch[*qb}<'ZY -əK 4XZAgʀ3yR -@OI{A=<` 򰿢[n!_ぼ؍Poar9!V#nqGva+XtNKV˳_2orV4prXCILI K+ XTM@so o%WݽxV5 *i̠Ydž! -%b!CQa -\6B'F iU&}kW(}B+h0\,¥ zvkkV{` .i#YfBhb򿩼Q/'j*U=C'O6:"zht~]/2ybi:w*/v%7ԵJ1р"ٌJ}Bbq+0bH -wٰ (I f.g`QÕ}>[F ɨ̚vkw9A#Oj,e)鼙O`.Z)"b' #<(2 cHW/O p Gۅq"{t阵`(TDtG*E{Jg71v2 &-A':b4>jZS;j"`E(J@PK1/mGäiiԏ0#ڵ{fT ̃ /4 -h]:971ߨA l4|Qި%@E7&Ʌew%''(~ydzaD+F+',#ӹ_?l{Q !oi?Z.W  oDG X0_LꡬsG)N0Nd` -WtZcYJ`Z)CMiP$] JV( "^#EdnYMU݇Q&08%ɬh3X)]dMK}%"^7r'KxQ|@M@'קIzN ŭ׫\KmY6D=bou\UAr)$5^b: a"7^:6hV\\{zBzS*`WtNgE31F{&?)Bϱ_6ؑj; $vpKnfݑ#RU}ߏBّcsGJU3n ?fh2z8Fe +n0zRca܀stc2Z/F(ȭHf.%iÛq]B弪D;e-X03}|On^t>5^9c?Q8 e%QPyݽ˒QJγQ>)pl1^(.c֤YKa7騴 {cxPn< ( HKܸJô:q3x?Z=V;Tn~8S|F*^wlSNP5 -A¶U V41ZxyA?dvKMM}`c),q1ae%ځ r=cKgNr?++ ,⺭4!02ꎉDpV0D4jc +VH*|~ ('ZXBMJ_{NvB+Ǖ xΉZekSc>/-w84f'h{|Nݮ~AeB"ְȜx 6B*D/#tW -M{c׊}!-{gFmiN'cUrή:BŢ\edΔ|Bf[$ 3@s~@T=T<|dAz(OIU\R,"anocmb{>{O`~fBTD$GouBgQAV_WQ~`ik@"S%؃aDzTp4h# IDFFE?C}{B]` e4HӢIޡZ- K-SkQJsLmc'e T^ܨ|&j^UtG!6 -^wҸ02ƍ018 zlE|[wozag=pn1v鹉k[v. A(Ǟ`TV/x0hQf(pޢű[$YQhĝ-"^2j=yO:ۯbf­)&Ɯ7<jRe' HH~B;a rMk%?k4=o]՝դ(,X8i*ӥ.YUCLB6:-C>]4UwNA8 y0dB NG{>XQNRS C^ƝAGӹIv1  ?KQ!q`Y|KҜ_Ka{V#WZ`ڪ}:"#L0S.9; >*ރ'4_D: |CN)*/lW='6RNJZ@EXsDkK}aqls7;<|>DzCr"fﻣvR%-p(A_'j|nɧ׏Ǐhb]GVkϾ1Uqk 3n?|p 68hќηd '_}K5. k{ 9COsN\guLwB5y DTNzAz]m^D(ȁ$ fF9+&TSP2'QlIP^]F=-[%dXx&`&NTyP -Kc/tH 6+a^}t({|Kٌ|>:W"= X zjMz!Ƨa) q$avf~J? -fX*aTm jR!&2S1x*WT;UqĪX% iUZ5\WU &r/ -ݲl`]# -̿HM݁Z -`v<qwGfSU xqqٮbsAULў*@LSQt(HSU։nMYpLA}BkeDZAA8L;"٤XvRͽ`'UsqrI -Qb0 ,cbP9G6 -w+5%r M]d[{K]Y^T}(a Z̈́ӗ5,!.?8!@n'6.Uġ!}%t$kei`f(vMRSf >SLBįf\޷Ki FjGp^dzzRnQ/=ؒ4>xZ雄&yAhlTB&yà:htOСlT?v FmXNs_U7$fR_/೾k;].Golp::\JcnHDwd!Z[&ϦqÏ8M!Kv[bԒn Qr*^zU q!tu.;|67;loQ\.óct1%.[vOkp |kfuwx[HE=a@mdla;zZVGRGZ|TLZo:ߢu|~ŇY;АBK:`9DXuX),3,ʯ?+w>CLSP⫫B̕8Э !Z[U._بG)Z<Re^Y|;XH{U*%; [.r?8 "RJ@jd PgP> pt*&j_]NJTV -b Ui}%WC%41E>TW3\0} [۔8WCE@ϭaP:ENj@.5+TEOB<:d[T) YiubaAgkF Nev9^Oq-TV'Il̈́PM*peHjR4pbMj+:&?p3IVq gB)4:I*! \*eƬD=M-yֽ:*<&TjO*Aqx^J3ܤhVL;_~^%7SA4ʀSGT3F?[IdMن fʛL(/2/$&`R{EyL K n -51a993H&a#|1#Q\n:h4GmđSmiW?͛V,}i2&elf @˝ai -pxfZ5/;ƿ-]CP+(w4: Av'H55B&3xyy=n4dW:VjNosfXݖ]K]d@А:*U. n9a%Mۤ>'BGKMjUiS]gU -ɥLT3U(P(m9G_-dB'l @ t?0\n2حD$P %qtxFgG$DƱM&d^IQ́ -Q~\@\ hl1~SRy Љ؃9uX)i&h"|{B2nX3z2%zf)&fDp=Gڋ~j7u#zkR>a{mmFv겤W⻠ՖsYUl,4ku~]JRC# J:eMwq4m<1!EQ i+i*G?0vUd$ݦ!#[ι1$Rm&%6$uq7fN}KγNh&N:(Ҧ`yJ;0Wʙ*=6q*W{}tS^u/Hkw)C 髜#^Ї'00yI1_nGInL/xD̓qp^Ϊz{s(#}AdG$[$Ͼ-xkT3Ur8 -1z53u:^_0H9&bGPd7pHMHZVac35?lRK:R5ù.GUVGtut~ hYBދdǯ^d)WsDN-ͻfZB:T5zS%U`;7p-0Zg0`x)mǼ"-MK˨sܷTn+MK!_1F8HAtčA]n\i"GSVH]ApH,Db`YzK.kyY*@Z"/̊fYK8,ԩMeHKZӷ\WN4Rs)Qw$Tkf͊Hhj*a URdo[M5h/v2VzNJqp(ͭNU:b:&44ie8S7*ޝU i?r:+\&5/SG(gD6S9hl%~`ubDYhML+&kGv4b5ͽp_l -bzcZz| Bi[vSR0^-"5Dj-I/ϱ&*ԋRJ -fYkI$BH))ks^us`CSGZg:Uk5+!a `Hj ym&CZx6JS~'Qw[1l/C7czࢩ P: 7mp , EB]tr2:S,eŁmi@!+e,T{N+P2&]۸$Vq Ƿ$t ˋŷ&ӈ_K`|0QۗM6$ q˥v'cMw[-ש-TZ p,~'T͵QkA'yV+qjWQK%i/xbduMJiofu֖C7uGlT3tzvCqʂd]3t$A`Y^VCQeC&ʼ -.CNc8,Pcql4alK$1 ,GZbdbG|1fEhF^PS> [)<2 :W72H_vS%bYmnlBa_L^phLe6FC@8z-+P44x~.6=pK/PyuĴlllݘ1@D) tf' έ];腦EW,(z ڶ^h9Hi2R4蝘5!]AR樨SSdpR;iOO#͗uSъuvkl,sR]nʝ:}(GWo6<[}<7{W7n'w96\H-gT&NJ_.n_o\r@P0`RY.U+@WQrY/iYѠg(c̬Y(ٙK4%S*I:"\ Khj<(g¡8Bݰ\FMԮއtA9V)㡷 7Z3C +LЬO9E,"-XT~/00#I`7lESXu잻LۋK!Jj 姚mgFIי62TN9H~6Y( :ĸ,,ޟI 6t6-FHԐ$Qі -#nu U<|95Μb;D rA -rA:]YG}%DVOsepmЂD .1RW} gڷm@; m; CKE<^(}I7NiWPrI2aQ!SWd+Ԁq@ ~V ) R|SKSH4m@S4֯/lԭBw3˔ 9ȒOdxqG7ʝ?^Ú\0QHur7EyB{5WggN`FCDf$ d,A_D)%w!) N -!v'7j\U^VvepGmۏ$+nQ̂'ZqB H:[6v~ٗi54QZwM#E51Qƻఉi$:D^jlWL"2qdg6$Ll-7)`6rX21Y l)5 Py>HDQng'*g -`[8!GYTa@hȤ /kv.6 ݭT r{X M{ xuʗ_(=\>y{G -_ˋ] RJ,Qn\ _2EҲe$~(: 1ƕ^7]Tdؼ9Œj.yZ6r>Z9JGYؿ7 {>sV -fc:eEli)[O#έToxv~`T"FBBrZ;|$}ZWm $Za4GeUZSȞhLhzBXMq/<3%ٞ3|\^4 m!+vnI]\XFJ,󝜰 #NFu&/x9#GSNYkIs@5,2-@'cBCk? 3cO&VxX ?n@fU%Ҁҿ@?hkvgjOY0! d5łI`z&]h1NAa$A|?\T~oZ'=12aL3 #ΟUtWXԪ̭s"UMPe}Tab.TU9A Ubu W ؒSYאCZ"Xz~΂^oiBxbY9;3$1rV}$y˳!U;U~jP99# T%͛fVh7ES @\Aks=`Ip'r+tm[ WDHjzX$HXJחϕΩGlM|{$?)fQeh!Xgܓ2.p#S*7"R2M7Q!I`Q(*})Aލh( |C:Sql.x0602̥ *+<BI1#:;AUGĶ@,zec?nD^ =%@|24Y*ZAª%(W;D9 l W#AZ -x!h+9\\P\=]0]:ZzddCZ%\ۏ#} 1/`WH<#N@HdX9]o /~ӣMԥS_QD-PҫSQV_y]s<;* +B}l"p@5ڛ >Z>bjNjcc b{gv;RIj}1jz] LYWE_)Rs!/QXTH/{,>7ߓOl *zEci`U4Tzξ䝫tOJj|+|oXuKC.$Ry):^"T"RŔ|/2QѨỎZȫ«+,lU0C0k(Fo/?, ##3xqSǑ,Wvp睆N|eL!BQ7"bIxLv?Fk3f[ia:p^TO*8I& dHp؄r$Ķ C kRH*/;Hl^9!tET@jzPbLp>*a|,@/DJ3OoɤٙtA''O piK6H80Š[|@g8|͍4QT.}C⮊A(v{iB)vj_oL? bؿi%q-xW,R;) wr: -/a -|ה1's&X o34-rw$>5nWhRD_eJųQ\KP4.s#¸y3Z%`PPvKPag~&ldGm;oG?15$#⅞leW=z$e̬6 /ry}H.%j"W\>)Bւt몇 M)%, gУ#"Ŗp!,Έ5¸B &'OoΔUYE{,-?Qg?2;h6yQ,xy V\Q? -h )6߆0#Gд+'hXjUԊc(8@[W*A>cBn_^ _nLR ۏg޳d Z`M -;zHR2Iߜ.wul,&bu%:Xg?CJ>]2#YdWH81tt!E!F%Sͻ5r'vDh" 7Nqo<u/nEūNxU>rD1Z R،/ob[ԃԈAAJjLAC[4Ah(V2zlbZ_-ZkB3m|j[Mfd4X:FTmmUט*i"W+7qX"g(ה]!RCĢEڲ-ۥ.wuW7@eHVU3)W0l""$DByPCRt)҅R‡721NP1!J!4B("!hG!# I!&LC Xbp]PBo!TP!4!~q% 1uB8#D*A = -"qD l"$lg -0ݴ1 tJCtek;ږ!,l| /ZJFp\bɃ(2  3aSb< #Pڲt0pQ-zFPH:g%)6E׋|Q`L> -/(Hp%|1}J kxbQ;ӕuR|f TɞzXMQX j\D)_4."S 7ZRNV i)޹|}.gka(~ a%Ua- &{0` -pۙ׫QsIU⊯yiu8'nyS"o$*%1a]y&Z|mc3fwQ1dZA<Lj u^z{>%"ڈM4:}jQMj#U%DtD$4TI<8[0" o]3Fk<ċb - Y'̂KT; 6j7j()k F"%jPtQ bqes,qu'X}[e]QV4#$6K ȩ*+Ξ(5v6V ~$ l& DEJ¨UQHPBH5WAsRvwrdZPSj ^-fopp8 -A&(ԉ0)heq#-KH_Sv5IUMncĊZP4ɚ|Bk2\d4$'fULGOO *;GD ܠPDb}D )-vF!Ƣ-}Tӏռ{c5 +JAT=8oŏ;SMX`Ů>1H@Lh)@"d6thE% Ri YͤďVk*y: QNCa:Dr~U"nxB$&C*xfz( " "".H J=Xhʡ, p:,`V4)̻̍d`(g=((P D?Ծ0z qζ!tܛT>#.߂D?>13nY gaL3KL#V`kNtRR*F-q=ijpk<.RIᷜoƮs#_شʨ)Na%l'C! Lxy{dzKF\"QVZy(!+Gq®4{B3q31Sj\5CCQ"g*T1b1'{D^Bm~?fbDs~CuMUU UU+Ή8G1}^Tmo}JQxNB(oLj2'b*kZEG"P_K}Gmxt9NfinL(Ȑw1x}a#[@$ ,׬ffsF(9z\*3,F=2aƎ0c -0B+rPG!EAAXJb{Sj;_ࣼ<k^t#If2FrAiCSO%j2Y(>6&E n.PT,2 ̯*2̜)$.s}Pp~~۷ؘjVAFV^c$4!ra WN w4GsX5%3ǬČcb&s쵰hoT?,ï6٣D/Ir9!\18$J S>U( - 1t qQHòD]qHf$ (yIpH!9Qb&/(XX$/P9 ٍD iFԉ5SEgf3\Glq8AU -2VM8U5ף0N/Vi4uի&g+SA\'ڢyт-jDbDZQ4&f(W3:l:6F]{y3 Y+9s|눈j }1j 3$mGeHfZS")mퟠt,crJMҬ|4 J8%s̝ScGz^=@gEɌ u;moyD5я -+#R5EE%~ 1EFKʮt\󒘴..UL(&(7HuL;HG' -ʈO22/"23:xx<:4[fLo$x.qchX1C\M&Q[Eo""c(ESz5!jV76BU-,ߺ=& WaڨW#ZZm´1IBԫXa<*«['JWk4ӼmjXi1432.Q )yU##HyF a-fI ~ƞDi]4+3jBiT␒XBiTiZ!> ESn1SaȴNz -, $FZR vآ5ZS5k$5i\TQn3*6x % -28ED4z@BFc*!O9UR(!92'ΐfBppV]"pt$sCe~&2I{ZL"S2K(X37Hm#&P2ItMg?l87FVi\-g'DPOFrEpD&#cD-zoZ]zբV3 ZJMCzS?C<^Raɂ%[Cn!`֭k{CTqK¦I\]ѡ(T[B՛m -uu.ՄحmTolV:e갖z2Ѫ[-jơ5s -'h43 4^4q^^+ #g*c'23 2PNv!F㘅\%Cw$&!1Dqp 4,2P 4~2&7XVœa947Ae)A/ &)6a,# O d2:`Npq.D R|cD04G'R -.QȂB!䟠k>7Pr -ˀ <>ȜsaRC*<0S6T*|LWXL5RG"{(X^у8"-EP"1QfM<8Lf3(Lx;];8m - 5-3jD\ө (D%,\p@Q9AjCBky)UfܻҖArdž(a *lG"b@V11&H˜tEh|y*ܖ(- ߭E=3~;ȦD T!!2GE),҄B T B0@@ Z^KRC`YBfL% e`]y r rDBEE" "5Ѱ9D ~C Rd S5aJ,, y5 _Wp!*ˀ" DN,i"AS 4ja2 ÓH9;bhVϢ5Rk UJRZkg%j+du5N84 445B*`@ Cbvx4: -CB1(*T 5U0FUD7 ?A{2@Ev}ӣb:0ult~O_ r,ЬQPpu uTih[I}sz.P҃t.d@шr%x -na*[ij ]%kalP9?QizߩrHj%ʓn򵏼@ P?")s $p)9 e~&_DLzT.!`9nUlE+kB$6=ޢH^h>`(}vγ־Uwb׋,J2 57 S .'9gҘm"E@\ PJ`6'{xN6͵F!5, ,;`v}`)2C~^⑭9BHŗ*& Ͻ9Q"Q-ɭj+[i􉁕S.QH2m&4xG7pW(*zb=YI4f U= \G%]:8SThܭ].)iqxniȥudY$Й:,] PS] bgǃm0&`MbIyʢD}}Qd4&xɆ9 -l\MpEqsTdfBc8G3/i?@!Ԡ[h /IՁ"5F^j(;-H@V4E]xێi " S Ƞx# :_Uu| ?Raj -!o&)fN@%B=xi4%" -;wdev0ljqCL1 ̟V\𝺂k/ -%B e!a-o98] !, fG&<]M@'6reRhݛ 4] 8~2縃P8n#ryk( -*8͞2fQHyaZc%zB24"l -mi7/t/ĦN+I{ -lsk-Q7tCa #i,9t s.F8 / ֊BdPe(:_t -Rh~s:qVQᑿL /~ZBbo{iA@&mhE雗1^T_v@{ K?c^\j'@V071 ( -t+F$g!3`+ # )ѦZl'jbD#> %0 ;, j HQ MK>V\ZɸJm $~w:ANSLWzW?m ;6A;e$hk'BX7ÌH4;lcQ;1z*DP6`*ayMFylܾ}o3"E»KU8Z껏اg b7-1}2^lM'*>6KKU.;?wꦗG}R3>'f]s& 4}#j=K8g!2^'Ĵ)ē/SMgqMWEml YIm}yIcY5ծ^~pUMg$FM1.2 Ś9$S5*[}ݵ] >y^VOxXYe -Iy^N;fnӇ3=B(~Jl@iR 1}qs=+/]@% -Yx0j#(a鄽3d'qm @ȳH2iG}HB&tσ4T=.EoTjr^){y<0"Xr׶b=_ŤJ룥 Uw6U*(!:eP{F 4*;&c 1 -rTգ?@/u#Ӎa+Iwʠ#/i`Rޡi!!lXܾ̯/ɞ12?xϿpb\SҦU]f6Yhl2up%a[b]H(p8|(RX`H: 'Б}F$ +W#4Å-ʗ!_%k+&cxzѷ4b>7!y!ʢ5k, R}cE-UW|e4 Xxp..$~MCyK* w!LW,UU"2uA'`ijBϴ:-yJtꝟ}!gSjR$lxz)rp<Ͷ8SWmjiTȋFDh ga-ގF=I 棒%\x8DYXazsiRJĒ!n~=93bJm)s@_Ivk5޵d 5WPw3/O ͗o,5^ͻ- _|K*$Ё:-Arjf܍7r_>.k"TejXիH9qx6 aġ4Z9D#4NI"`@#/JE)+':ZA6=ʭ#j3a" CL0E箓8c)K-ś<Pf^n7|_𘠣=j(+:$Ce\Ntc-CFWήWzQ\!O- FD0xeP/p H\i J cn1' 8gf ӣB*ReETfƸ 14:㄂Ivç797] iJ5FD,jUga2 +cStբNQcai53<^LLbLTSt\񖽌DoKνv -aj]cם=| @$/YŽ*FڅY U f -\%?;UjۦM'؉Q[čyj,'4!&a+> گniYz)nFx3 a$QlUĶz|$"eG>t.Az5Vkmo];~:+%2kPǀ$$9cGͬO-mqo]).֘F K.+!k['@A>MplbJ˲~Q[lkHTgmBBh"E{ՁY$|z1:Pd{%ы1EӶ]\1B>qyO$g$sòCU"DJsgBPsZ.[!tEWeyD'h 0I0kNXz/Շ68ԽY#B,= c}$ \. ]ZdV͆ u }VA("vBc۱|Mr儅fÂ80TX]^`qMP%79,^4IUfWJD4]@i6T - q啦_V>851F+X~YL?%HJ%Sqv3s~"9c+¡̥{A)1{뭠WlG2y2U}l>38{m8(yT0"2FhAP!HךX {o'O֏=ʄaqT:`Aۑ$8bLCe# M7ݲoB؛-ϳx|ST#78PC۳^3xk'Zx㒁 Pq5/(DEL-y$i@򂠵V8g{9#qI_-x:MlKׄ I }xH1/*ze -.\r -4 f !.X߲>cCՏcy!|1!'` -|` ъ*tt wxAV_R-<\r)u0ǒ2!("[(6 #;#f<ŘFGZ=B["x}LD%/ Z7)-\ԒJϗHUS !W:"Z!I1p x I -Jua'(֡;Op(#øj'- 5wowSYt_Ϊ 9@κlZT&b&GXR,DVPr75 "6q?\|ZQ )<~:o@g6Poc2dg@Cĥe%LW*,%_'. -[ȵ-ƾ0s =Y)O->ӷk| -"AUx"/ija: =q8 G峇DbFঐ֔tH2Yudm9E2yx+g+/LwK&F5Z FFgkGϧ]QS!;&Ƈr D,?FihbCzј:I81,~ߎ}~r9=x$/QUv$\CH_%_/C5%qCsĠ9a4JC\xbUՐ-[@=mp܆\nq7{up0I!*ڒTāk,0[ P= }%m&2냣DЉk!ź+i;hJv ʵ B+^-SX5hzBbUSI *!H:%5)?Ai?E);DGDtЊPijPWEP"( -$#xIx:t3NFt[8kYl8,|4bZ~Kb AfIRFU!A %YHp$]H2`dr$RЎ$Dm$c2iJt1ۄ>'DR=; ID8erwyA;?)HXeʫ 7޳WēGl>f9G< 1͹XnW3=PvP=`:\jh(ґ.%Ů8RM) ťf)F -y?@F|]#SТ1*h^* ,QA+gHy1v*b;5U=F: -ȾU0 #e`VV" ""`+ [$¢ɳ+p,1HY],TYE,QihAs.HcԢ9Z~"޻N &LIX DO[%Qqa.H;tzdJQ|eAxQ,\!׋{dEM{./(TA p0.Ԃr")yl B0ϐ7 lbC,+F 185h|\`B*qv&wDmp|;hY#wP9yRc =:̓|y|ZM$صnk~<\\}(Mpz'0,#T!y|4qL=G&=?<7mzGyQ>bwpx@Cqq86QzvL>jc`F#4ut>Xm4CYG8WG>AWHȿ#4D0Ka}| tJZI9(1H9:<6sďz#rtY;[rM,9DdBAș9?(ql `)W\:'N2QhM }ep> Uoi7\7e|EX mM47xzn/f7pG܏FbnXs77spC ``68ZmƷ0҆l\l|Sޏ޲>TI6^FG-ke5~[#>qYP5G Fχ@"?$6f7H> # !jHoH֨u ubGk 4q`Qڤ`/ ՈlՈI5b!%+DHaQ_A~4nmg!4"*I#!0AGI邈0;hAH -<BBdN_:"gPB|F Jl -3hWH:u P M2ZAXތrm F(n?tQ>J%,ŮMP&1׼d؏6ԄjPJi -U" "(|( ;K'P(EV0'2 -|"oo`Ԍ)Jyb'." -}8rR OH)o:g9Rm)DRNȔToK4M$)$ ?B@N&.^DTRC*΋ ; 昉*L )DyJ^1rSqKY/`nۻlҗK\2 -Z%Hw`n hJ[ X!J '+Dg)VhHPw+'l$qMb3!&p$q%-$A}$JFB"DC=4$xA[iH8W:Z.O`/@(#U[uEXŲll"P-!Fîk#}H7lK>֖oK `C&BշЖ=0pzx.䩇HťZpq)<ГK s!_s.K&g<ٛMu&P&Ƭ< ]vۅqw.r V0- vpruhza:Ȼp\NHCvMZt|!"t/m{F>$#9s_HiS !ʹ0U !7f`8j`z,(!zz>v^0}||0CG -'CaY8Y Sa v}y#$±0oscXŘ6i -aXe1ӆ@Sy6&`eŒ06̲P}ik4kFkM_ n1UCjjȴ!5d(; 2L2 d Vș  <4( Uag2 ?u𫞂!H Tz3C0!3b!Ll3sK×+Hlɐt :q7d֜* C`;C2 z1tn =Ù1@ CiDhh -)ѐ L,>L~94d(MD2K3+^LF'/w!z݀8P6]ة E`' ]8ww!P taN8 ^' tqH Mq<hB+!L9Tc[%8<-%}%+̡9ͥ4'0wsFNBr`!w"a9IuGB EB$yCBCBUPğC#qruz琯vgb*.@S5=&8Ã( -A?#AC0}O#:B!!,`*] tP2x:\xΨA:VgKPWRiVP2uq*Xvz Scf[c v;⎂nP;w };~C`'ӿZyDZ?Qs Ól8+Oy <ΣK y ">݃|B=ȫu9|Fңӧ^xS\aAI;ج!o}P7+3QA)8Pf8S߄H۶- 0N7~qbl+Ɛu vk;aǖ"{ {a=s`6K[3&rҘ8`봕;eb ~,ӄbƤޕ" As+C"rP49=m$rL\$ruؕD|6U9&پ8Ii6 q|2.<Nr $p5$P {y"4T>}gDeɤz(uy8(9vևjxgN b&`LZ$JDq 3}x=8mX{8@; ([/zmdpH) - -`D8DsR ྏıuK D;f}-6?fpo[DmsW .v*A 8_lA0x nj-MG7qoFTUzX,>aj lpR6hbV5@#Z?c VFQ ЩϮ -'NЧ̑{kPNEYE- @嗖#ymi08 *\|4)Ez P>(, &"$x|E3FBl8KI1a v6 Rhbfk>*- 2 -vlmAO"DgK9Jm:Pご &̢G8 _RMPGv<0xF&ڗWMA3n. -^`zmAMTR 3puW9IΦ 6_F X/6, f5]M'ƅDYߖM bb(`ӣ4鳹i^z@JR"X,B -XfRvLa1LCv;R\(OO -h& -23Ո-tځ̝S@`+)%tA|`R S¿M7RdDCB^Uѝ# 7 ߝNTY@q9j1oq}IsdU{AQXO䜲hʵꍘ'#]@pۯ~jqYAuڈכ%.Vz}x49i9kS a`44 H.D"ݧ,PprG} rM~XUDy'qx: v/E?!4|z v@Ny@K4~ExLiE&l[> -EiY_|MP;Nrr(q %~xq`uKM5?0@_jNJȪ|7g>>fj-_5`aq6R9棪ʻpdX -XGvC9w?zKuD&f`XEde@aIx~2 @6ʐ,jyv  V - N#-C=Ad_ $I.$[w!:b=} @%*JJqkVYhu!]bV'tNY >V❧h -|zwD -ߪEuy)@ f"յ9N*4XFG/NߦhpM53 O|WNWh5xm -WJFꁣ>j8O'FuU(#hUpAh)3 J[~2p95DK'@!g!҉ [L9<(6XE_I).MYR xu80lXqrEhÙ ~^;]f_N4!wrʼ#4dFK -74eIN$ Hf@0z\x-@|\9 -]3RaF_ -@8/לOۍ 5[Jė@#@D%5kHWO|,œC.6lG ,ks7_~: 7iU-l+>j eњ>"ԇqq=(4~%zίaZ)"Y4o&x_DcWp ȳy"Xp P?<*禔5k{ޫF*apAKf*vٖ"qkcϑfuL9|C',jäޓm~u5l!葧lŒP] |v/o{Y0KYD͕<78neS<-DZVr ο;+9QalqUc"6.l{J˳u9@P5; Su{fa 2~Wڊ)~oZ5q'RSL)~_ p#R'}> 7gT->!^R~ߛ#_|q7 ֽAz?8aĂ9bT7E{N?=h'}6*؋@2\\ku.J&۷mNWB'P3S}+2Z+ ƻ&^|(-g*rlWsX@w$̩o@| =f%M|jM>=.3S[iD3e"J?7AMY]\cf|f__.>Bżԉ( ·Ӂ"ş*ORw3bǿ?)PnRF?RT?Y⼄O]hm꽉7⃖R'hM6I|^S#6=cRٻ0sPN )V|{ ; ėрeaѰ? ~0%wut "#dYϝ[B9+TM~l@XM l=I h-N\^ Å z8`E_W"o{jwGMҠz_gegP>H1ݞ^#nl?HDcR=~^讲={I -=q cuIq/gy.PS9@)aۧ .&pc.b"[BZXWX( \ ܴϺT՟g58DԛE*R>i&ƗRJ: -jԦ'MB d2Z#\cϗ-% gRI];ri$0̛KLZ8h EeTu2ѧ}Px (.:(JU?gAo` -~XO@<7*9?Wr >ɀ(Qݱ )Z[;2uj$w`w8 i1߆"ma36sdSͻ lۨmਞ271q`jN=[0?Dc#dbT<3:JmeBFw_4՗+4nWeWmʛ͇xY/T *ߌʤՊ)!'-%&?mwzM'yb~ -#4r -ɴ",VmAjb#̅1N2A$Y:$ؘ3V@Zh!Bp|!TȆ7\T.{n7B>?Cd^<$ ditMTr+BTуB9W=4L<鋓R+xV2ܤx'G1uxGAo_lx[êcxXh1zҁOuc(#>F8XG|M]nC%5t cPDfD=:j6M` -_>cRyY3ɝ+6wY /ZM*q&>%-Ӽ F>߳,ߒ:*wݗecq߅M~&V9Ͷ@9rʢd⼛7a;3y*ߥ43)kMcB` JA'xE T8hj n;}+n۾( .8%o!`M#Ug2jo4j.,m9nc9I"5M;~v"Um\zE$N=ͬ ';;p͝] -Hs2 Qforpٝ^=eO!|aN>bT[r9nqiņ dztcPv6W,k;!6W˰nMRT.}4+ٯ b-G(u0] ҚpM3:#I`Fqc ]A%[byID n]qZ)ڲA:$ W1%YWDAz'P΁XwSQ'*ƿzr<(#_6G߃M<=. +m/ы]:3~^Vg=7b)~T :qB~hwAIS \:Tjb0k: 3 md^eCGG:R,+7V5~O ϒ41}/aإ`G06qUNo|42* tōgI[5K(ԁ~%C?1g-s` S00*|B8tߏVBO9_`}o%7yR5[t8xlXC'a-[epNJ;yf>פд(`qԞgpcsXDT}\?u P<\ p.9B$xZOM'G=-- -!ZQd ]|4dk_mknϝsUcJA|p ~ifޒL,v2'&:9Ac\Ġd9 |wB|1b7 --hhD8~oԈ\%{S阻RaF č r@IަNyV'~`R+6oP7n'U%79Nt(;6_C;߮EHy\SS(O"FD!Onڬl'SjSwJ7;_^[rvTc : %9ɋ(Ƒ{| /]kf"/G `琛 rseHo| -@6=Ɋ{Y}s<_{:F䎛[>W>e욅XrHh*heܸb+qN@i|'>l*{T{cjf,e|1eۊš1{l9`. M4)nx׊]еG*ȺÎ!pR8VF8z7. Rwse5Gk綄$%Xj6ho_E:;5Җ+y*|Lԁ\/ȡnhm`&2/~t s̡vcAv -G]|^6n/t.Ah[ 3Ko];T }~N]m( -4n.^]ǠW'sed_U>i6ŁwM>qٓlEXb6Ѳf>&ۺŹO>|̻Y_|θ2 - 8}͵7'qg'%hB ^-* -IxWM+`ipFLL8}/b*.0.8ðj/܊S<*8 n{kmID)8-4jT >+0͌I@f֚/pIVXx/p{_>R 2e xZ֘~8BDXTCtC^jM zoVea)$>!JP5r^ܮӜߋv ZAAo)Q-MNT"!tnHoeH82m9Iio*+5%ߒ|&%]+mQ|ڀkg;xMUB}rq8%թɇo3u޵w -Rw: ˡ"(bmF^K&z;#;\/ޖLR -PʜVCx'vtϮB^dT64tlkۯaj[o'2A-DV<;\E0x7!p+aa,V]xko QX٘=1W'p87"k"=oeT5&R!wPӟO*W!-7_2/9o`ZFS/XF.I|.xϴmm;QSbBXca/O:-nFoz.|gӚRTK7gGv?FRvS G˸`lKK.6`b- -̚rW~B&ɑ)8+!tXf1K)Qf$ȞhJ."S.Uoӧ1;،Գm`U =-ͣtS ^Ef -88Pr؉LȌ˞^IĮVyU.:ƈ#q`L4Mα>wAɴZ]~uhv8)eJڰ++fW5OܻjHYnKD ea&C%V1.媿 sAU#%~_S늭ܞ/KaOu+T/6a΀QmQ( N ͙V68Κoͩa'>朓)͡賞9LmHU[Jܙqbu"J0ʣNӉZ2m>]h6QGh.Կ0SS.0e* 2Z7HOAoeroe>*n(iWD†N((St6.-~DjۥXyvkѳ0IG/'hKI5KShn/gfpMc 1^!LſQks텼MǸLvI7c9uB?P*_1 Y.>T:n,r2i` Y>\PX,qPCdZTTB#P;]J+q " ."o١!FjU3b}N ZЀenZ>VύE1q(V:1:M)Fv6*ct ѾKSTwk2wB¥ I5z)q!L2 V+[)-=gĥ:蛗NN XLYLk:_iT+1িvp3iVN. @3Pe {Z8Kvݟe*&1n<5CMk"bWQ{X?N{*5Q]Kke2tIEa'XW~Ij[C9u8 >5hʪP}2GT=@1LU#ūʵq=J$-m@N"շ[̧JN5f$v̮@ _UG`Z HE`vg@k8uD0(*Lnͮ< r0j38Yє5[pBeOۺuNڣfW:|\v`t-DZ x@-5Y:~z -$Xܺ4#v[\Pock!ffQ{pA$z5 6dZX`(,:7 ; ȵ۹ -&0Ʉ1}?Zbǔ5.+cwuޜ boNVePdKy$8Mvbg࠾+Բ>ʂˆd;'uW7R#4E#T&څ͎WD]z6Y`?7fB ^Z!auʹ:i^.^+֢Zk'Hkwurߺ¶'&Ɔe+~WжKv.$*~pDw]<VHnb[=Fmb0ڊ%3ToڐH~ p ME2]cxfzE\T1ժ+иs3=.XhE ߞ.̝U!斘S9w3YVşA~HetMꥻTڇ|' uܕ ..2$n rnwۜgw"áxQ e.'֦ȝ:8o% IJ逸IAzW ڭ.ly^^\f#7{f*(yeTr| # ɗKp_1+e_חbְ_87T@ Z*#-ҭͿTy}`S!0ߛJΗ%'30) vSNǮ?)VEvn g.#|V־ abQg|> - b,s{> OHI`@Ntyg| ewxKv@-mk$s^E #bUv<̌h1"q+qrW'1^85tm̎īIvZ`9tRD|1<1$A'TfxPz>=q -+=qS*hc2H8fHSMISI84 IƲ%NC'cܾ,q+|Ϙ=waj#V\w$$,qw8ݠʍ%L| h4D*:gBqr1͈YJ'(F\Fj!q̺Z6s! sLM"@c -N1'z5vAg^Z!= 1YUDGo@6M+|r[AClhr^M4fl2}krJiBbQ3d{ y"aK^rɯΎ1V0h >b*V xȶs RlUCCxY;V璚.UAK~Mf-7Q)n*eShHՅS8^lpS<8))&Ms 5,pEm_S\c 8SŞlOTs Ţ&AR3<+؊%Ê v2g+#!׾6*N2Il~1gcZ@^_]>2vK_$2}^ -P=v!͓Hx\UdfX@P1uF7W!uS/H*ީ%LJ,}-<qvt9'qE2$fםVlޥuc%BE0lq lpqm@#a9$[)JHX%q7ER?hWhm2jdeoD,_sH - '$7>  YfWn:fsmWD,W[P;$ },#l%!oɍ{hl$s b gn頨 J|F@s+|E,F2iEw@bY0yZ-;WtjtUFYc bCvĢ3rEW õ&ZL/44˧hah,QDo۔9$bז}\ Ja"緉ngЪL! ͙s!Fo%'M%XU!D̝Qjس $.Õ4="VyCt+Y.TTn>Bf-%6n=/Э"P/x XmcЁnp8tg7|<!r.A %A*r:9tYke!D;-dD\nMt>r' -D4^N/MU/MZ h?e6s+Z|b}%w4ڦw=6=:Eo`qF2p-+nEḞ8ciWAw%iTBT}o*4 9M$ì[205J17'b501(}*FÙ*܅]-1}콬=82ܞձݡ= %s3F=IOrysḾ&0x m*!V>w(stGacnT{`& ݛtP3AŏbNڈo} -%=^\~CQROf -Ou܇O2}UȊBJejbZ"a -a|N FGDv^ mdI9:st6Ҟ|8;B5|9e}'6"U7[C+~ 6ѠWb+4Tg-2ߙ.B̷U'X|7F#<4;3#^qU>=S h1+B1{i_#wWA:~uPN+ -Dޘp|Ay%oɝ o|JsD*Mp=M#hT*gz, S9}&0K8)1c#N?ܣyߙҔ曟`QRF7Ff>Υ&wvS''*빘8oN-PAV^M@)*_t:&.^O_G%N阍)I ->stream -Ё+ H(yݕ?tŽOWdW-g<@AzI[0_gmW\>o:1V}>C|8h ?; ~p&t ;]JM%׾|; ]D{Y].iV# ɷ؍n,Q/1ߚ*֑ -;˯'걺W v}wǻB#'F}Z3E.38wׯ 1̀>=𒪪CҐƒbN܎*"shyӀ_O# gm'xI2+Flo0j`/1o 2dPTX,_ex]ޚ|ud<P$G5?;^Xlo4PA= (%0-Zg軳@ZpG,3xFNzC.Ue%yPo*K:AD,@5OkcF-H;ޑEH8{Wi}on} fjXGg  /5~X_Wit2NM΋bFio)%_*/Uȱd2ƧAc6c^[C_M\TS|Pk0cg5jCVak 5 J v:eTؚDTdvkpIl(||NǫoeG/H/?ur ͢M.U֯{m:P+g7gv௣/⿽ ϏOwPoF-џ;f뒋KyJc3~'ñm_S"3ES;(zlI)J}Rlz[uZ.tw> 3 '@ye~W -k-fx$`w@8?X2G(S@Zq{ @It ?mN6KL`BS @Fk HŔT4;H ? -OW l Ӳha3ӟ3 l'@ݱ)` ڗXS@wAV$ bB_- Nd~0r߁9E&:qVzg 쓿{屹=N"~2x ^2W,@O{i`>7D{ȚMbyrru9 Yo\+>NNѳCXPyBSzCZ+0kV(B3F6iDKH )xNR62% _Ϙ.Œ42`0Mr߫Ӱtf^[Ki$6h2k>&+Lykj!5]pNcAW<Ɓo !+9VRV~&k*ρ0(89"[ځ͂vNv6`.ik~E%a-( x~ 54BF(\@u'SdA -:,bK!L>J3ZjAѩFbQ~r`CkN>-3lċkoO^eJLf7AF4-(rדc6gS|$>.Ɵ IOsO?qCE.⦁:*t$^+@|,Xc#9fDhxi`vn8O._Sr00i(GskHp^ETku|3 ږ/Da1 hS 9"|J[1=>ʿ1BnKBH*tњi8QfP5;D{ka|p|` fi9ܜCҥCIJ(a -rÙV07+6;⎐A 3)H6A윑_ixSNX׍J |۟$) Wي -b(yf=N[;ЅCc -ZcҖAU Rnq_!d -ܱȯؿYl_` iaX+ְ:rOCj9EsvX0kr}5;ÏgKqc~_>WXAuL¾t\HjZ:mH'6KZvwAlSMx >@q|T" -&2`+%salܷ !KSpf, -ps@OEQ:58\'lr`cTI|a}=I$OIBZ z[(cWN&\@垀pYD 3TJrBmqwW- mEfz*u=D\:LS>@GcFkv]sqp*.vCGq4] dž1_%>.Q'"O -V[U jks@o&[rcfJL`~Q/|ݖb)u 1$DD@)@u3^ @LxTXbcpl;\虊9`F9JWJ$}tBQ0>ז|P$&>?.ZH{bJbcNR#+8seayOPM M -Y,eDHֈ >B-[d Y(Q"í e`@[ژ ]jP)Cb 7ic>L=*WRl'z3JlU(7[erc+kO9ﳕ51YSHO|?y9d5kŨ!Ũd -?&bBtIZ,Uر~K揰TUKڀmcp!$WPCDBױΨPAGtsfrzYl |RA*ѻ;Ί{jlPK°r]4[glAI,"c:#}R|=r+"Jݨ)3FMQaw}(Qge'gʇOTτv]W {XZŠhEaULlMhRb+$"M|9l3@@KVF|g@0(lJbR$/ՅO&{IR?Ӱch*.rMaî9#d vB Q(%k*\/@oVݠKj9 HYK>{$ 2uD8鱅,=`9y'O@# -%7ǿm - @q,# 3rK:'ceDU.`B0j ̍)Gxv2Wa電eiޘ˔ j\j{;BKѦ'YÉ ߄2}eHk6 ~zq}cPN܁#v`{wxԡݫi8&] +8+tCkumڳq0&c!d,bM-δ4 JKgN>D<]MWӔ p[, 2uV?㛾.dK]٘Z}n`j&++BS)!U҉JVl'xP:o\LIÁ]D; dZbPp%}`жYW=6=s!>}u-p&(Ŵ/Q/4Gh 0ϙh=*_Ljk_%S 3fVl]' )3ÍE'P@s>H:Wpu)Aq -FҨbNr|M \|vF>ҨĤK7T GT-ww#{K!C`ٷТCRR] -0xMONIħiA@IFb V#WEpԣu -dBA' q峯nZR]hD;˳۽lf%M6@BN85VƢ|:d - b~aыJJ@QJKA]"P~0OGbfY6!5dR3@"RL eͰq!Le!u&4ǜd0P?eWpEz'K86n1/2F؅Aʀn0wNĨ_'s%[RD3ԈCǠ1!u%2mLmRDtPkI!{}:3/T!WmZUʱI]*D`lWmL>Q!1#]"oGYN`]Ha(#.d Fb $⓺@6(mHXc -i)Yjj=& -4P4J"fG +3ķwp 8NJbG,&g6ȱABk\OH:)v;*"G_1PHW-Z9$Ea#Uv2 4BU^M_}OJkP3,cib=#b/ Z_NgM,`޸.UŪgy -0q+kg.T/C?!rj:b(I8aI*1fbL<Н4#MQneq|35 MB* ݭ>=? ~|p*ISr`l?FV4̌n O\Wlly={Ч^aT8L-Q{w9 -XkZ-9Ix|ypv 7mxŝՓIzmQn~G)?#bdfmcE65swtRWt1[r.J#6lAF+=av5`*k#1q-uONWA6Re -P}]ךeXŨ@qh) H-OZ~_{;㴱IM6cdNwb4KJYP 1:!tZxv#ݿ[3"ӓevo䶣eJ)e&CldnVgS< nrt\?w61JϺ} BVzS/,sEϚ; -I31>C)kz|JbfNԴf04q%Gܟɋ 0m5rVeFƒWC@`aiE?Sѡ5rYIRZJ{LiNa[Z~j/<9H^\$#/~WHğII'hNw*W齐\6 A(H?a潾=qr3Bb:% ̬eߑv)JşDH3..% E6vVkx8^ItH,ԔƇzLGaK (J4{&;5_Lft/% ä(l9H@"p{mUzxSϚݑ!EڎQ_ˑ)v!*eܔљ4Jm@R^zYs9Mpt= ,=? IH?*T=zԓf. ?JJ%44!Gɮw -ZK*$^,k6>8A_9u4̇MƙtrͯbpsSF-kfƟ*L{WRS9^vU9w8;-e]&"&j'qB_"cKY )ѷ΢yz1blz>Oު=Q˺MZƏ ^:w][g:"í7Q[8>xΔ~m?%JC#s℩IT,9 I'b: D 0%b`Ljk;9iv2ѻT L6.-0{& i Aр.!B.R_RїE `$ :ѥ@^ -䩰@_*k+ y:7Q@Q((\d_R١ (HPli&1EbZXX<0qp[CCn%wAJ.Es)9,rt\JR b!Ԇ0윇`LgsFD.% `\J-9 Z,Pjy ;`@؉,+ 8` D!X*,*))#+*VV>PaYIaQA僇-!T.H𡅥ED--+, >\p-+ Z8|h\uڇa}FF!3 CV_ˑW;pcEo@>i[öLj+_Ai7Q 0bi?I-}b#Kb~0ga'W5AN?=eHx-[g )(c!ڳͪm'J˼Lz{kZ0~"oU^qzUu4y2桘4T ܦd~'P|#P;%gfD 2`_qqcpiO]أ?e)HOG+/E/>еU=i.nr.gXo@Mjx P#Qp /=rҷH/rtpp#GVzEv 9CD'7_d(Q@(7 T}M'k<.k0-_Ӂ7d"GFSBGSz.챣5{5-ú޺k˺. epJni 3phD?o=S_ӑ`ԥ=,wXㆬcV@ -JUᦍߦS20wUMG  Hw+oY<54܃g4KZ.t..џ1;\__I۔ -KIŔ{,,qEk[)'pF&vIX A9J첐J6aJ5k}+|QR -[OLMҨ>!gqSMܺ#1= 9(i:5Nt7d?ҏ.W -B3KdliڸmƎj̩&x?A8ăZqr -رUܵ58g㧏}M1|zNö"{~^fzaePP㈨~E֗&5~ 9ăԟUGOl FUP`7 ev`h_HQpi'=*'O n\7%4Cz|u NASo*vs:p7qGTIzoHagC~v V@Ser_Z%wa#Or{]fzV]ѷE"&]~e`W583cf7=|6~zX?҂;rմjyu%`]@;mh|6 ø2wǍQ0Xa0/s /{mo>ǭ "VL%3SwXg;NBcQ@Hߎca"-A\ -9 -@{t\ nRAS@_`KㆬGDk1z .߄؛~"mԟ2eu]fzj~]&zXw;nI?k:QOa"Dr/~!+]CIpH?z'T_Z%gY\&^~OAͧ1BSBt!FX!4+/GKvjnzm7FqE l}vq޵5vfd~ޣmA>"s11&5>ŵDr7׀BOp//1rF6=ԓlݸk1WX9ZܬjqB΢;J䮬-OҾtWS`OMU3f>%g|-,]uީ$'ú~cz&3Wi;O?qvո[fU3Ce&ԽSXS༚ԛHbXit|ɍF؟wY*k$0݂z6 -aO"s4B-  OYaB| ;tߏCh -¢cae.$hom Xށ2eF_Ok=uarZГv*Y"jMGފZ}Q{I E=jݰns^M{U rYXC}x A|nJ/:;!~z*k@G&T{o~L'JɎRSWT"*y+{2/P?&JY'#!FPaioҿ~z5Q̠Ŭx\E?|%C,KÜ6ۊiH֛"!J @2z8ixFOx16GXl,+KK0j!7)cx'Lo#.&y Χ'3-N6z|/( /%dfe᝔ީ832=Vaad0-!.zƞ5nf8=QՋia喅'?:HN͘j_iRZM*玝&I˛*hїX*9:jþMxj&27JJSRu%Tt3cvf fθkq1q59InTVYUbWE;v'(ޅs2d]a[wikǼLWfUE-z[c_Dㄬ#ASȯr -;Z!ik;oYe5rMC.-Mcafh ]\$OEm|fv ',a=8PdEC,wrUrk?NOi7UT,NNt)/w ([_O6]I_PlĸB^dwȋ I-?gt&x9T(#{͇嶠h\:@97@'0X*f"vL8!b?5Br4YNTQt;e9>AMCE,HЊL$I3~ǫI¶5i1@K`wPH8Y_+zB, _PD\VS;@}k`uҗVA'\j;hҧ86>BZ_sclCxI,F$TBVQ r[gSb0,E&sBs!aD!&GVbx+1d{C# KZ ࢋጟϺT ƽAЈp@  [9VwTks!4 ic:a;D`CW*I׬xOI4WXv~ c[>kqYCrG=Cck\X $W񢚃访YU[kLc=9+_*(4h6h-9W1vħ"}PLɇb0oI;Ho{T/.=M==P\Am;OUMw~\X VW@AnX۬wΠuAkɾShW2gN]YЍO0 o.ï8@$mA򠘲HLHIŁ`Z^ H/uК5tuG<-8J>Ĕ"VG*S37f̎/A=O8SΚL8rj)ҏng>ڡ6i n/S,4dψu%Ͳkn]zQb(X1f'JsN9ƠU30, 4#6qFd"L|b*)\/v& ʹ#uʤ>Ł-^F{HX7XFA2H|f[WsAuYA11 BمfKcBE ^2ƃ_$O!L -rq9+O!}Fi-2x\XH}cp3.4H9G3ףJ==#n5}dSF9`菩~u/`]Z}h_[z+؈~!ǭvFu̞k_B7=9sj>P'X1 -& E4}s4>o3aG#=kOcMV7?RW~3 W@د:+jNъ1s zu0+~BcChP4߮vSb)Dwf1֮MgHcul<8i)]3% ڥՁ-X2[.w k߄1T.va[%[W\KjdE+HҴ(\+GTJU"E ll&"`Cc Tg$bԷC1UuKԶR[tqj0Ax7Dn t[VWY ʛ h% xZӳ@d fq,V Gm8LuSi^ek2oa^1% -xfynp1k8` y{R3zO>n*k gQRl2V/Cݚ~jg+]U(6\O5(.ek 9j͓&&ʗԌb!dt"1Py"T "j:g`ɃytPر#(͡3m4tZXuU5XYS%iUR)_QĔJsjVQ哪Ɖr!pҺ:W5haWq6DqN8eG+G@)i  -lf*[UUvW񒚁R- E|Ð31ؐrWQS.K<}88<ح^8'O]|q",MW84Z$&S.Q/-,^Tq(8gz]pu @Gl@p[A 8MH C _YFՃb& ]!_0kpcqViʐլWUɃ-J:/`H/n#>.?~Tm1_b$g0 Uyڤ~ ;pCj;Ï2n.L,/c媁ŠF|יAфnK4 OM6v P"X7[?qVC^YҠaY.I4#VI)Ǩl}}{Ttr=.dRd?E+ݔ (!A}(iG?L˯(kB% aBlWY'c>P'SX= u&eg[W5 aj}.~2@0-oG>< u%q2MNTm >с_6WU͘i $gWP'Ux o:e4cӿ(ZHENm A$A;9-d>pT/vIҸэm~.+H}Rա\WfLC(X1h -36TQl]>*]VP([A? -' 8/լ-35|Ӑp]kQgR֕xǖ5%aCxXClz7dK!<7yQV9{"Y?F|ǘľI<.7>odwK?G:1Gg5d6?𹅒UDŽt~'@ց򨞥y%Pŀqb_lEv\JjƁ,h 5;ʗ,,Wo^ĺS5B_Yʳj3(>kfJ;Bo/ځCj t=p &>%~۵98m3Xոo̘=mK^!M>w@CGn0$w]i˓)"P񒒉豛 Ab&y2ݶ#q} gqƞS4W-Akmq#B 6Zk>S"YzC<^! XUw^k/CtYCiNtgH7hMdG%~ C1^Kʳ jXO1ʠ)0~5oE)Rǚ 䞷)먏ˇj~8f2BF[L`RJ -Ș%FF{4h jF]C0}ɜAg0XD M=v=JCllc3 1! BT{:_2 -|)ݔҗTZMCg|sR⭟a6Rs -z!1bArs+} Sm3_NDqzOA#Vp[nɭr]WsS?sư]{ -8uX$ZIoGD:sK[vf#\p˭-G\Tf$MJ)kX%pSh;dھKIQ)_9 E;k;`%\4i&ݶӔ jݠӥE"Py{V2_~r\|tnD<&>2̳ӡ~6 -(]zWK ;,]R`cGineJC`~Sѫm=k{PݼJSG!t;.Ovʼ݇%`b/fJo8N9\]Sw|qlذdgL-d2[G-בfi왰+E[F̦H;(p>CvӐOjGAuXQ@v;P5FQh}0 1 &A],ngz.w:E1E0{[k۞;,: O<(04̻gPqә@QRTyx{}u$0TYNwP/BE&4͇`q]Þ2.v:\:e91fӐzs>qhՁ>>mXәWQZtsp1Y[XNY}>Iks>;ѡf݇2ovY|nW^C3 97U70AΗ,ܙ(l\ -% ugEv[g*bRh̬\WJ*(϶l"" :GgE<|O H;"b^ >UTlֿ/az>BW(~ic␫!ZZAxGG(E37G)Ʊ@YLk1qū jBfe#,aڵ ef}TB"z nJu ,ZYH -+u(Oi"Lǒy@qM"+G#QovI>[y!cP?GdO"蟟G<^8E辿Ɓ }R;5;,ݧ2DeAX\`OW$M.i"-jZo̵F1if`k5P& ~FYn]RȶlL hl  l$& $w T-L85!ÜF -)E3oըk0(}59MU=US㮤 2фR`$?z<~.dJ::QF:]T*)EFR!q~sĎq'n^ͥٵs=DꈸqQ#5Gɇ,r!"E?(Cx r"cAȱp7rw& -S´_;u,dkvMT.+'KޓE;2rLG:qvۘI}'? O -@\)qq:V}#FxO'֫=W *!>$eA8Q`#٧Q%NT)$ԣ{Jt"o!:;Z 92h z6I<ע8dܦӤr㰻E֮/rey)S6CQz|zxsQn'"&DiAJsNb~><\(dzzqDV;K .& - ʼn#U)B*L]1G"'S832pӉq%MEfسF{dh#*:P_h 5/):8(VF8+ $&4 @@x h;P| GeѢ _'>5|~wx A PjI1KS --r'gNpavLK M_IXUz:GK79$VrF&r<ge_P$kl -V2Q ȵ rHt]ôUY*^&OS'HE9?zKt[K.ϼݪRN.5U,Q\*Fu ',nQ Nln\EG`)?kiSCU)RGc%بnZv JE :;}`%=FyɶTF- _aLV/:ć;#"_93alN%,3~$F}V&D:,o*%URK龖0}-a:9m ĴPatO0Zf6rnư$X4P -^UFΆ 5h73_ N_xCDG't:@xF4L\DV6*[+ +QQZɓ͝1Sv?H5A=}L;2Bj!`WY+0UM\-"Xu&n ]LiR!L_Ѡ,7͔><&lDxx?`\~K>O  -‡\,`&+dlLsZT{J\2<{2T,AT\Ԕ:Xd,GߌpIY|5:ULS ᣠᛐxᣠ@@C -``Tp4/D `eǸ*rٺ˽B]lW)nkVe"ܻJq٬j [~Ҭ/'T핊^q/` @. 0T X"`4-?) ,\8| 0| _w.%J4]O*PY08VK*x*5:qO>&l:(4Xe`Qɉz*5 l1*$<|owD.F:#䁝azئ½S!`v)1,tH.Ŝ`MǬPl*TZiDv >yBiB.f—) · -ߥoiqP@Hj}&+qJ:m`(+\5$hunXW[* -{&6º\AWޭSN+JgJ=Cδ| /F|=| -_×!|/<|# BcDKL3Glԗ.NlX*V˥*>Y Oai/dxNN{٨9՟⑒N[G_)cIG2cۄE<o[˄ ٸw$Xv8y!k`y9Dt+: J6]w&6 -5"^iX).[v -:"ma$X8jgkQ2:e}6q2W4~%>8|a~ߠ4h@gSp >/s@,jShkt5 2gѯm@RޭɐDT+QgٳRZ}-9N ݮPl_Mk۶HVuHӁq -Za?) --~chS>-|?—# ?-?gD6}l9K=r"Y,$ -m%ZR^wV'Չ(4gur -]׮R4,w{ebZ fV+V֚gb%9-XGⓠ?O/?| _>/7.G0|¿A|6(oR;Z+zB@ lA`f!KB5쵌`SW4k 6Y|שwka++eFuӬPMQhש00-F&ZYi~*Mޡ6:V5%Sgѡ'$VcB.^<2~>'S~?'| / X\bR[&\;7"yذKKRۊWZ-TqH/x/:MnqYڃ/dFLFR J%!q4vݵ[%jf]Ռo4AC1T! uPVU,)ibX*|Q198D!(J@DP1{O8=M_gj~$!7ךNGD _lV|Bv8Wc4tc! FIV;70BzᰮJApBCM,X:Z? b8hXSU9xz/W3yg [P |oJYIR+RrQqHIV #~PFazaG 2@hx~'YX2cÀ ,)("@/}vaK_` HtANC*:z])}?AJɄɦ`phƀ9Z1O.t)'Pp[gF8mAx bʂFD&ڦKfz1 .\D&( 6}G3 HC\?BoV>AY>=UcE?h >;}Vk`1@v'hAbLJ;B*{{JL!igJp_!F% M GhNUO S~&'`<"|_*z7/3~Ӓ sD@$WKm`v~:9?5gdPp / -RKRd^XI=Px@eq4Tȩ%>REg4e:BQ( -3;3fTA\igL4_&_W[2 OCopF4;o4H3>o`=ڝy;}$g0>1:~~z'XWʌF^,~(DjHIV EL0:‚ -HT#q`C#}%S_IaE_7CƔ,;x-GET:?54=ߔ`Bڝw恴:od`[/E$w~@"R]#B: Au+GF)F?S"%HI(*XX\~ pK'KЫ|bFj"7Ä~#l輏}5_" 6'F@%هZ>$|P*J6&i#] 8$rǎFЯx j" 97# [sl+xN xALyo}7`76/ W!;AS}JTF)K;g#2Ї!?qhc !hIVf= -wj?΀oK>}AQǀg( (oR>ZQ<551׵th3%m#Um#BяT1p1p#RJ! J%hAX8؈('K8zjP3 =sNyHCU -z/z*0G2^E5cD#N8 'dO?Ra2b2)` +tWXO-S`fiР|$d ZM^WK<ļ쵒̕)lCoke}RBqV}D%V@N\AIZdtT G7u&?=vn]4> ]fu%`r펻lڄeB{422p}k@N7=/ |;tiM6?$|1y8%eW1۸P#K$D+>)$5m^iGY3$א " 6 @0EtYZhCgeA~MPςf 9-7U|IʄEo$$0tr¥fy%cO]4 #!WL?#A07/0T?FoEhI!<#ޚac™o1ć*7`,_`hH<-:l;>r >`ЏBW^{P -+"%:?!3a ޗʲz30,[RyG(B|أ-oͣHKW9,mJ蒷Ef!( -[ϰhUb`.ݹ*ЯUHX~<P."_ }JXq} xڽݺ3Q f/-`8,dM]Ǘ+"UId6O0/ ԣdfX06YP8'Y^Yʇ"zicp`ܲǚ -εy5Qh1"[W[o"gT݆UQFT`{̖1F.d\T@+TSHVo3-i:\G{w,gXضWj@!%ՋG= W[?[NI3*4Up 6ÆMhb.3'ث܊KֻN):+w˦Wz f / 8skr݀oQw,f߾LCk$ -^*_hf;Xܹ5_8BbTX;`SJoI3:o`<ϼ֊,#b2ˑ܉;ȪP{=ꀕLe:eRT1OFa=H{BlܝqRwТ!K91 }[ov-W:#\)0:жzӗ55=o*_k8{zB1{([z WH >BN!ȉy C3Xz -p95WH I~:~b8lˌ;*dpw pSkcwܩ fo"v(a:N -jPd8/d4Qa9 -QՏU#aMPQx97(Q~zczpE=}-Gl/Q >L+L+ VʼnufRh ڬo(9xuxWmA֞8z]yW'mLI3[Qj -P?P^CI[x\tWeѤ`cZ8b,6l:%e7)X,i.b!}$?A|cE ?ʆf%Ye&cj rY e;JTnQH); f0o?ULw -B7)]}؋2^3W@~O= ߱w`G 5o7Oo^+_)_pǸxV+HP% 4ha̷y8g~搎yij0`WXD.Gz &mBL[Us<(hJ <+Qhs|K tѳ{c+E{1iOeTO`f3qgdIKLMhk}I[Pv?fw jP8ak^!SR>Q -ō;{sA\%c@/K ebyn`׌8mIEy"g`)XbCb|"ѱ=?.> VWWL6^W<^+kWb6?o\?">|̡_Wh4we6xf -23pqrqP]dk,78s5G0@YM*i:"0[#g9'9I5zQg#Kk¢DCDIoEP#m!IApΞ};!e~y`k04 !<Ʈ& 72(wR:pd^%roB<g'q^?ɇPz@n},l'V/7i"x0ly"ЫLG`RKTaՁyfPݳlDK"տ , -W ǡGq.+ji3)cWG}XK= inp\1%l_{ -;p^qXsA@pKM5Wp-ŹYht4@!x&(fgEx QŅ/E35XYX!BzړITP"(;CykcCف͹CRYL&k$B  wMamsh̿\aS\Vvga/GH~?_SF@zfbsxpΎ&R0vlt7 X6'i{IqA'(6ABoӧ9im<|"/ҁ.iƸZ)!'/a0/ A^z1p7lXN.j3K(a*Y.Byna13k־ebU[DWRY> 9~Ӵud̞Zˊe -ZnQBl Z5S \?(JwV77 m%^Udf'*hVAB#a`&r3O4g2 XÚ\dcZ &qw.C7NBFЮ<Hېs;0tqqoڏibgd=GQIWkGm֢r) V"^ 6ga&s\Fq"LAQb/ ͉}sFWR Vxv&@e -ZM*i-h&NǂymBԉM`[[ 466$p]N\w1OH -ZҁoV^qb`no3ǖ?й6AuCq.4G8Q6Zצ}cn^gA(@z% ,{"=/r? -1+ߢ;yG#84ꅣ8h@mNDBěeܩF J #~4#p\źPE @R04nUDnZeAp axf&f0MTh8r ]8 -=:sN fxtdxrv›x/^cxg}ZV^\1 l옺<hyğw^hE |͒JK|]O·J Hg4,S~y^!.dže\fXP;(@n=Շ QrP`Tz1Ap QJwU{ JĵDe0sL2J7#5̵.proڻ QzQ'{;wj@ ɼ[F`7i3 jPA C+fl 00o8ֆ=vENp]Q@)5ى,5Z07X,XԫHٜtRA$c"I@3^l3hw -䗶-cABFDy.;/nW@Y_{'ʉyG -A18qC"j7_= Lˁ ME/oQg!rOgػH jDe_tiUH1m?$1nZy-k^zurMCZ ƭנï8']8_sֺ̎W~WMgh-oW7}i_ݓu\>6#h;n4yk'7 hLyEHK4T^ -(yHT+@G  :eOq!̽"˹WUS9*Q$qvwO(Ku9~m<'P ^;G9 ?TEVpCT2 c9uzșgfHF]GZ8R.Q@K 8֙nPąt̙2(Z.$r $c/%貔ŔIʹ sȗG,b}̖l~I4Zњdet_2r\ç]\[ܠ/(^ J\@6Bl7|j_OaמwwQ;)*A{5 I~Ho'_Kɍ{C:%GzQ>Gb׆mpRluNiOP3Y -~:Af*rէZؼo|FԑAX>I{>ޣ]I -c'-k\ssđO "}C;TPV53us_ŜGBrH'AyRWT#,*j s7Z"%еNL%6C aFm[G`>C >OH#BH@#>Qd# !;n6dɼ}_ MBpya`ZaʼvA,dYǰ=W=J*Pbb>gZ3g)*` KgX P^Hj=LWc((gp^Ĺ>~UGNl|,2ogKi]@Z&Xάhi ߛ}sg[ Q*4nƉ`?+Zਤ&8#$CxO=AZr̜ܲ5;*-#N%`z1zDPYG#EY -@S rFr([su?;KM&ѓ`!(#(||CXXSmJ)|]ȑkIg-j?kZp3߲Y\c5ug;pǐPj8~V3.3Wl*J3ׂ 5v[,OBԙu·iZ4%̍@6>#wƟcI&g>oAMb`;u &܇0[ð9ZC@EQ#8:^ۦ̖b 0>#n~0Z'p>gk22-7yhw>W#3>X_Swmi_{Y°8)'Td<R Tځ[1YwrTpɩ%qf#]s(Uk1vC 6QNصm5zGyq&0E\?o)`񜽵mE;Exl!ĭj?ɤʕLUWҪ0%zz1TM+ -8G@%Ghf+McFl'T~^Y=6AoT)C>V. wȝHviӵ>A7_~v 9Q 8= -$\џ8HEp^FK,,ckoW-W-yE˞r}*/mvrQg4-<ٯi: ?Y@\h ǭ̪jūSicS4 y6ţN^r=DD9dð}1e%ĝRh -0y\ٚB%Kk@f"6yNÉǫVܵ??Z^D$q^"9#1A.ZԶ8)Xfjw Eg1۱0Xf:ݩiXfb%GcADu0Ξі:{yAr>eQ$B=ZF;Vi}lc3qTKˎ\6M ۗUhO5(0FY[mA!S/ Dt0TI;(w - -}~"Wȝ|ߤX{/X`|?]SG]]I:;ŔϴyxK.n[{ j#v{\-~חc=r墙0~mh;A 8`j6[nrTAZdKֺCjڡכ&pqS') -_HAvìSS1xRd>ņRĪӔʹH&9wk7 ʁ7mJ$ɽw4; &R,AYfmO4i'%YƮ|i姘h[2KZV̀5HbN?񣛕c=Hckh6EqB]׌ieVuRSN3MCgD:&x8b\7q^k3 -p:N#Ȏȹ n4cX`٘KLQ'`}7E\(m.x plUw~X8z<g3츳$NSl*BۺET G5ΟzW誕-/l>wEiӒD;YX `}'-#}5t^hz;V?V2ԚE4ME:C۽[]ZEGr[Զ kѶa }JD!3Ⱥ -ȗW$=Q~%RS1*}gQ9zh"9;C&*3?Xz&4f `-[ewZO" E{?8\32,-lE1i5aSQ;,@8{왧kj{p௫lOl*vCnWErR2OcQή3EvCݏ#X-UyO<êRi '5-$ҩ)~i#=X?AƜ7Aqn:$CQXWAWˮπv$#tŚ/RiNII>FM~zIgh,@Mj"I_ؙ&pO0&(t[lG7z & O~y/n^'6+EqJTy -&7C:¤\;.X.ʇ&%RGw(is'UHg6whuG[ڸo[`|u 5b Ar=1Xjun9knu*&#a_TZ-Qsj3LsjsҞ(n;uIв,Q{(bG!'T.m c{[#@vM^^2" -jeJP4,RŬJ^?u<4OaF]נ@_V9:'U4ʗ"JW, ب5>1Φy+ $ $}㭂կ/WVf/\y8ߛgalN U늺q22ODIQ8PIWEMlCRGNH!2IYIJ:uڌ;l&4#(zz/Ē9 ") 'A{WYrk}m' hkGv"nѦD){.i_elJtѕ+9C=3Tb51o^Tȟr #KX;m(F]ryl#JH0G2>OUGMDï%;n;Hvv -x>ߨs6Vo:4jYm|j~ f?LgkzngwfvO)zQ1"dhp"$@F$cX\za3\ ,';7I9NYl nVjFe?uayY|XV7 eeT.쇹W{ =OC c#}n6=|ۂG=hN@ퟵl+vL7V Xy8Mfh1@|lkUhm1p$G^gWY'&1tw2!b5BDᒢ@jvԥK036{))D |r{жNp)3Dr^-ďC|9ڮM)5۟_:4 =60Hg?"UzѸEnGN[|-Gg? ,]G.m{'cgG6yqcsIZՁ̃T 8^-i+ӥ}ffеi}WEʳIi*:E&~ 6bb 488،Xzzɥ]DcCǸ8O!۝1N!)\ {~BC^{l(X!9\|wEw?[} yyj|po(WWE<()s'w(>"kaa=&NеN)S l#+ON -|#4 -[ĨՀ;p 7e$G SUނeԯv 1rk"ɺ_4Ig_._["{ jl} -/j 64p{bo'.heApL\a,T+X1Փz3[OCw]ز˛Wtck_yʺwfZnni+X*cN15 9uK9iʗoミb4N,dy.Bb-h\ Tj8v7zGh0+񃫅<(.a`,{*a?wM. )\BQ[Oxѵ-ࠫ{ -M]BO∡PCf?Į߄ʺBzǐ]q{;.g0/rC\XWqq.n.4?3Jo]=.h_BBGr.l\Fc8^!OeطC7yZ1ξ,bV &uj ~ 7u\vu|u|H:tI0v%$*ˁYe;np q/5ٵ 8ErGg 0iZ>HO;nu\ -2>#]D9gA}BR4  ?OO)AC\Ga?#<B9 ΡZoiӒ[ظHGTI@Ho :2ܱBK?*M.M2#a3Mv 4L d+ĨBr[ p[A,@#89[k̊.Bь/rkn\ <:rQ >/a0c/n[EMđIbxI8/5 mrk{ps%H}C8$t!MdvsJo^w -TUέf9g6*$G1wA8p8 Av׼Z޲x ] 7Qdo!#gOBr?l=ֆ -~lqеh##p@qF 3ry\a|l -v5Ot#r MAbиKCrK5cMJ;#}4^W@HH`_ObEQt#8>\!H9;gO-H>1)K;# D;k-z߾MZnquk :?r6aW'#P9'WV̙lj^3g} -yu8|j܇0gZ| -,`\Ï@A_ BHN1UnKP~~ DaB:(II^!׻u-ʹ5]E .+G$IWSBsFx-l[9V-k[H1c5p1X.R=.tj7H޷#/+6x0DxkF0B dk@Yg0KQdhF$1SDu1n)*5 \gWl~ y @EH >BhfF%Md$4B!9E $vCĒ>pe!>@C[,:l2bz++&UtjI--j٬A : -ч8x8 -Q[B\m29Bkq"kRgЮȚ&LxZF^ᇋ>]Unvq -0h()^j *9ޫu-)CmNZK_GII`;pP$? R:|m!6p[4vT/% hK 6awU+XEv5\G0iY4;@tjA[;Go[ccPb7(;6ރ6]k-Wځ!>?}rܵ}32CʺL?!ҮS7x%Mh_ȑCV}Cz'<^ 0^ٟ+AۚhaL\Zm -v.fWQ)h9|/`zy -8ۏ4t-z [YMzdjeA Sb.K'gbN!ȳj O`;3d,&%2緫d0bs0ɚTaK}`)}"V![kXze-3ΖdLc57@r")^Wn_ 2gW:HN@ɰ$”rj]pKe)Pz'!rCHjˌ,\04(iSNE-$fu$mj@m8bW,{N-Ywv-#BZ[9SdҦ'wԓVۊ,gV4֎M]lCF(\GyJغf|4zP4˦̞#~'Cx endstream endobj 11 0 obj <> endobj 12 0 obj <> endobj 13 0 obj <>stream +%!PS-Adobe-3.0 %%Creator: Adobe Illustrator(R) 24.0 %%AI8_CreatorVersion: 30.2.1 %%Title: (~ai-b5b66246-d135-4923-8d9c-2d79ddc20b76_.tmp) %%CreationDate: 07/04/2026 21:10 %%Canvassize: 16383 %%BoundingBox: 0 -1200 1200 0 %%HiResBoundingBox: 0 -1200 1200 -0.000000000001819 %%DocumentProcessColors: Cyan Magenta %AI5_FileFormat 14.0 %AI12_BuildNumber: 1 %AI3_ColorUsage: Color %AI7_ImageSettings: 0 %%RGBProcessColor: 0 0 0 ([Registration]) %AI3_Cropmarks: 0 -1200 1200 0 %AI3_TemplateBox: 600.5 -600.5 600.5 -600.5 %AI3_TileBox: 320.5 -980 879.5 -197 %AI3_DocumentPreview: None %AI5_ArtSize: 14400 14400 %AI5_RulerUnits: 6 %AI24_LargeCanvasScale: 1 %AI9_ColorModel: 1 %AI5_ArtFlags: 0 0 0 1 0 0 1 0 0 %AI5_TargetResolution: 800 %AI5_NumLayers: 1 %AI17_Begin_Content_if_version_gt:24 4 %AI10_OpenToVie: -9072.26598322492 3633.52822154739 0.073784870900099 0 4143.19327159829 9819.10702594909 1716 982 18 0 0 6 54 0 0 0 1 1 0 1 1 0 1 %AI17_Alternate_Content %AI9_OpenToView: -9072.26598322492 3633.52822154739 0.073784870900099 1716 982 18 0 0 6 54 0 0 0 1 1 0 1 1 0 1 %AI17_End_Versioned_Content %AI5_OpenViewLayers: 7 %AI17_Begin_Content_if_version_gt:24 4 %AI17_Alternate_Content %AI17_End_Versioned_Content %%PageOrigin:200 -900 %AI7_GridSettings: 72 8 72 8 1 0 0.800000011920929 0.800000011920929 0.800000011920929 0.899999976158142 0.899999976158142 0.899999976158142 %AI9_Flatten: 1 %AI12_CMSettings: 00.MS %%EndComments endstream endobj 14 0 obj <>stream +%AI24_ZStandard_Data(/X̧W' ,P +m 2ǣo%VTk[sޙq2sbp  +J +w+% H,"HzlXVѷ$ӣzqw{|~rt$mИ23Ous0-Mò@`bb"p. ua!lJy`AHQ+t l]42( ө'/4V)FNIL,}Ov”Ad`Ѡh`hHd6 łaH,^q"*ِX$.ai`@^!n`@<F'p p&"hh0~EaQ+T8P&O0$ "(;' kQ D L8՞ 3/c6ԡ&-}'`nJ&";% V ` P'.Q '&ph@\9 W( + AeYZ0nq("~ 9_O‰J\21GPِ%O,I4LT.q 0toh )~"`HJԘ1ARvϨ-#gZm^f5˳4Ba%35_| ӭeB0YJJ奢!Jΰ8Ȑhu!n +Ka,hZԂAѠX( +AP0)JA +CCÑ@",Z@ࢡ**Ak,Li91U׃ZzV e0TygdmHa;jf.x3MM'1 44XC5PCaX( \aaEPBhp` o8H'Em7n#A7*3|mLʌ,5Jg2㷚C_ױow45 "B(bPDMe|Ahp2::%XP59TB + ++땨r(\ffvBa k0s/PFBCcP 耓M3HTdC:伧;|MPD& a1 b\|X,mk704,@w`pX N(+EÂpH 4jNN6(PG44$DvqciG)H!qhX0 ?sxԀ ؐ#C;++#9C Q14$"; 8ޭH"̼,JgpqLLr8ǐ` S%ddn(JTт*fTD! CB`H 'xVpMLG4,8C>?{!!HLd"311--+)Ngs@LаXt[û`iifPb(D!Ë(6fL03cB  &7:YSi˺+wuGRXV*UDHl?Qݵr'g +N'vf ˷"S]g$xX96ߩ."?wSн» 펃#CGTJ*_Hu\1Kb2MΤ՞SWN:x8v/I*wtoʲ;ut{gR^[M:dQew7q*y-=<+ܔg2+tsvjfFvPgtdj}ZboR_q"^M]UcdIFO"vB14ie4%b`oHm}d5RY aq=KKcXD8#sݜY}|GiN^Z4 ZSGVVΜەBD+͠&}.9n +em4f?lR)E<i*]kDُ-{O=*ԑ73zjjG& ﯗl$_*N{;c9X[7hTnt'uX3}M?NuKX)J.fOI4^<ؔzfnd\sʟ9SCP}' iGyцͲ+;7Oks$!4\qmNv>.q&Gf@#h <\h +&X*+#mn=C%'XzE)9]$zI|VD%z{l&G0@1آ*#T$":kh7U +Z ;wE57_Mjs6wX9̞=p_]HѸ`簄^9|&\mƱ2ϼn[ԚoI󭯱Cٓ }RU:ͥՌNW&8Z^T1ZgIS)IuVD2.QzUYVSV/e٤UZԣ6#{s=mP +aGNJh͒Z[zw0KyRf.Jk.YVz& ߇B^E$Oӓ7sT̡gLod;^X93:jp=XՋc;rˆrArkfM?eUTZO?W-KVM3(si"XX'Ov8f6LlٲGt_EfQrlw9v:Qf!{Y6- Ogteg%LScxgŪ9N6תX?3XLzŻ2gXc˓z|[x_xz }e&p +_gj(NG؛:ÒԚ:tؙ٘>zvnYjеefȎCW ݋kIcΏ(Gv3 +R8ISfѶT1N.b8wXpjulĨNigԲzÚ\Yt^X DfY3b2Phh@!<@b,BEA ]:RT9{D$ɸ aŃv j~)fak%řehIL3:RG̐IY2rY&7]HwӒxD"L4Q#si~!M[exr,RIy6BVɚK" }t Ҭʑ˔F,9p8NeգOdX’Hwy3x×dY/rYL&Ʋ,;si~۟f&C%<;&p{T;R>ʄw"P&W(CvuRj畉SSIlI4md8lvR?CK(ED2+g=yy?׎o,{S+":+e4Gᛖsnj:ҨʈKv/Uv#4h.t2ȧѝԓw40o6s>ީr +qݨKs,Ty̢Tb͎3JΗcSh;׼>Ov$V.¢!V\38]h)~ݵ^дtS|̭OsJjwG>BS<[DS<% YSߩc=D*5eߧ>zN ~uuK"+;c% +I>Y\YJǦ1$LhHx2WIJQ1+UND7 Wd9_˼C1̅'j37Jɑ*$l)0,5ޤTR՜죤_ߛrNsYahl9"Y~y +Fdfp8&wRC.ӝ=9C[G)7;Dfu9Eʩ"˩*ǜ*z"($Q92l7R~k/U mc KI3cdWiT4FFSD&{L*b2UUtD9_vS񻱛]2c:OWCi79kQޔfj)֍WSs(уlY5qWWѬ~Ϙ}*]5v@k%c:S_XY^T$hpjH&E]kQf,"!YLjՉױi:u2c %MuȚ +#OUCۦdW*BtG!ReV%TJ#'Aʓ '"Mvfn99lٸ;D(58h)%5nX#ѹR,>]2ShFa03:.;4A;ݧq.XLfd9fSd,͑-q=R.SI);Y8KF[k?f$XYDG[2YgfuD$3/XsYՍ Όc(snri퍮Srh\˅c)v۵hr7JڜfU^M]!2t&9"UQuʊⶈn&tdS*y`]QبJut%ZUw˩獯>UՉGcsWD#7L>6ʔ6&/˷DKކ[t3G#̛eV^vZF{as~M̃dt:J12zim"ʘD:#YlFOWa|xtEMjYIڤЇML/~.֟IG2I! i|LqL/9ǧi. e蚻77eBhs WrO'UN^~TdIk)1^r Bīī:Wxg>Ce/*kY^sG2gGZ%GɕTV̐\ GMcjYw}l.XwK29W_k!Y!ݫ5}Sg3;{ɟ6 kIsU*>*g\%my՛Z~~)֙ g,OcZPU`s{i1:iT)ݛ?)P[iw[w/Ē`Ӱ5TVckRK2ce Ҧ)g-&{sm\b%Z/ZISOEq +RuE?;3v)t +gT.v^PXYP(p"Hlق@ɩB?r{M4o:-SoڒggIٺzy<N#l&F ۮ^/-97[(VuSImK qΛzoHUoAC}u,"u/ewu:7WDA`9b:<(eY͇n $ΣG7֙:͡fWC. +&õ2}FaB~ +BV1jcuՎ{N[`%VL:,3d9`%f"~b"+hOqӮrAY0\<-"--yGqLbx +\xVG%X9SdqH}ўJ3~*/Bfo8qWR3ngɣf,u{B>Œ_t9JT<)ؙö/c5}lA{)z#ڗmESuadq;D)bqṙg֥:6Q ̤4I~3'pAD=M#xNt^X@[VH7dByL^MQ}X "wv wȮ6s 1@8} L.K;Q24fMY6zFs֯n +WXð@fC s '72vT`E'^Z(oDPHE@(*J{l#)pb-1$ +)bϙs)F/ܨa(I6t}+EY޵ϱ@Q=FZWS6mәg366 +;^{9NQ9$JH7q[СVM/&@8/(6I3#Xfr?(艳I(Ù_QBSZ/kqUָ5-Ya¹@Gql!L YYptx4pƓdS4CH% *">Ɵ*#aHa>:p盳,h45ݗw{YFOAHi!7 +*^ӛ:8&kmA=oe3+yq&ѕ,>It7NkrWONk/q̓ШF8اtD1>*ݞq1 9N+O(I Dh)D!fg#]fK V:̉+f?swy*h6:5=[67؍hCiՅqFV4Jy +i:]C+R̿,# @dy )k$7>Rw`ӀŌAQ|4ɽOGQS۵fJwTQrB/bBqX}.)+ؠ);Z}쟖 )^kPJ}8>U +RiO^Mhxޣ,94gwl2kI +hf%:DW(U9:fXse:6C>Q uO[;sAM͍@%fgp/]Bd IA:Ze[|IY/Ltv5Δh4FYLTEA8}Gwјsb*91d*ΧlDQW83^.WXDEQsg_[=LEF]b9bVE?Bw;p!5YY[* Ʀ&T_f D6,EP5\lx` +,Uc:£GuWغɝaf\ tw0kA6J6iūN=l̷fX;*6*-smbK!8[,Pr^K见du, 6[DEPHi*J(4OByw0?0%)՞aVW̆hikaq\e}iMfyp퐕1bsO:jw -1ʈ1ŅUDVE֦^yӼR ߁]5N5\tܼ߀I,et1h߼=}@XE|d 5Bg/!cFG*3VrL*yP?i?n x1IA\tPjXpgrKd5/ i^˱I dE$!&VRpPF" 먈 +(^,8\֡9*l8,|ea18PڰH3dya8Mʡ3J)L3J2r䥏8,wİQb?,@7pטy9RG]99z,7[; s%4R你ЬJYHF֜E:R][qK2^B$rY7Ĵa9]|BH;"ʘ1eucIC-B1ȯ8YSy(h-Bx*cZA<{_%ĭD +Y=JD0@!%[x£ڟQScnE81)3/.`5TbFoUw/^q8E+ބ_,i R@]M;2D%fowE@#r?/y3؉,|BrNί"& *wq"(H+|x0C/,1*5of|"glu!plN];ehIͅڥzWWˆp{>4 R.p?cvCCȀxB,3a GGo1+ؘ-YTmPsM}ӭ䡉εuzspM]OB4( V>=( >^)>bm8c; g2#6 (LD5›ۚt TY o]0Q%kHKWNc@_L4S{0p +o+ܸ=̽מP.O(x2_h-X\mW =ͻd\]-6*Yg t'4)vQ%FKM%~pF +W_#kḂ'̝aNܚ,YtdOZk z-(BlH,$5T=zo%UY?MmXQԴwhc&hQNm]QHtu.Zavi%uCT#ܫQ+$O!kp<avkh'dk[KxI\p;T'; hVIJ_iwmmʅzZ܆P 9? {a8`[ciZVN ma`S<ݿfLy 3@x })p*}6ZĊ w4ɮ[{7%7+S08C`ӥLA!ru5,z,B riOn-:rg}8QۛEXQ!dgFa(3IԉȜ#$ֻ(O.%"QVeu*rDYtN)z"tz_gWTQ" 6탰kIoRRZ* CT kX=)a>XӆO<Y/Av4blNM!EoAӨvSPʐa̖'F *GKx ,G<1p7u YL=cƹ̙t!КT7 POp-wa8CY[pЈ eq߀: V7kIX@21̫ ѳ}TH +%x"P,t!A]\'Xp@)S؃qI!A +9fme$q:4hƫbd ; b0(0 %j/DV!1M6"Ev{/"{-xii@"U2]lP !_miy!8') +)+X@mTt!q.M$I\G:jg0Kg +!ˁ +B(_Vw|lrpFO>$>P<V5~> B0ղk﯅$I%κ<$#,b""h5p=  ~wtD%C ~SեMi@q<Ѣ˒iR"\+LXI[ w.-]N3vղ/.»*=}9E'frws@9eR"4v6Zxl["Z8#%q&C3,g7 _MhVhQ9d5s~: jfzB;io㶊's"P{skI{E ڭr,40&FIO#CM&zzQdϜͱgk%Y#rxøGNHCMKP,zh 6'aG{ P@sNBցYDB`hh8tǻYJ=3bTwͲ[)WvbvVPzrиsHAσwq)ap;jG␗f`W\V_-2|ks"x" S^x3Q<{Yt=7.wnȅ!u3۔|OBB"up1QM@rZ#ĉqblu-k.*Onl~%"^$>e疹ƔaY "piK 'HD½)կ[Mh671'^%t '-zU+r-̹X5n샎WD,C&%`_f҂WVN.DYAivaQB|Ÿ7V;.aiH}WmgDsR (< 'L,CEhHW?+2LR 6/}O.k~MD5,S2\J!ߏ` ^11EUШ'O:U\*El*zZ>3*:fkMe2}TxL1R!yq8mH1/ge+ P^q"5[EU@ce|spL>eH@Q^O"kxU,.^iX'B|T Cpثa+5qJd]qΠ'd< #\3qBm!tBmD‘VI`N4 ei-Rzœ?Pg,FXH_;5K$偎8@5=JLhHpa6IY=\5R^u$ hJ;C-IfrZBD"{U`1"BUH? KXݬ%dK NB/m4W5?L[ӯiJd2Gd쩙\Wgu$h?%9!Jҳt~!6jZ7}S^4ޛw9buz;\4/OU2zek6Q4@A3xj3 {(L%Q$平{#Aݐ-2<Ҋp 0&&*S |FMp* aѫdju=ՉNN|0d'@Oav +A3| + +0*-@H5R"@" 礯p ݥj!BJt<&)&L=BWBԣces^1}UxL$;ڔ`-nF~QOJ0| aZCdpWF z `7\5,^q\-? `t H'_g=:u +L +{h)ZtnAtjT1'92u 5JVI27rkpQ=dM&6]'X.ktv@j{JE ^~FP]bSR+,cᤠ9|ָFZ弄B.{?|َD/AЕ&.tKimvp0(&sDL,POVۣn ry'&DD2HB8jJMc^dᯃ@GeN3^XuZo,HEj RMa_D -ybœ: uSBt}L +Zau:I-r2qv^v.v_ɸڪ]dџ<ہrZ9 t"HsZ?Q@1=@bA +j+$S)}%vg;W@ӏ~Q{j =AYǫ}O<F\+c%i`ꥯƚ/^4S!|p,RbPn) 9;AaQsMc8];]ޘ&ʚmWn|.jױpgkb\Ķi5(p6+ 5E(c69x^Iō?,i-R^WRCv-M(|/cEmqs@u'ڎ?t$qeau/*Y;D-2*Cjnnkd 55p)[;D_kj=K-;c9iq5-^)>:YWppiC)=^%k˃~!0 na Cam\FEe_ƀh1oZBUWl>+R +bSh[/ 핗C:C{ kGTKagzz"U<0le KQ>8G&4A0jMT-٫ueFPI*HB U2Tq|XO\i`n: +p)9]1n][NJp!""KCz]kǻ$ܖS?^,H=fTO;}3dKQAI ju_-5~~&qETz!Tz%6窘@r=Ium)T]kR{ESqI5K7`{c|„jdYIɂZDARi5 +eWjsH?]\(7$P500P.yReZ=xC6~"2 AT7jL :VdO+gS?W>4E71jiJ5$/d6SR09J1%BbWϜFզV|\V%Q a蘀tƂc6/&ۙ'6L?m L2,1*DCb0uʒݺ% RumfYʿ8R4e փca%lED4?z"hs)nW?} -'Qn Bd1(X-'9&j{KCzu1mL:L +'0ݯZ5 +)Fatjv =HnKs)IB#mff?ޘR Vg՟x̧9ȅ7{_Efgr#Rm)W/ehJI 3Zwu!@,r> +m@:F_MZg`WFP"U^4B;gM-Kk2f&Z#It5iu}XcG.B >{_IǏ<ΌE)9Np:9Z3ZTT1ɧJZ`;'pZ02ZUg0Qҭ\;RǼx"3M~Mޟ޷D=+cxKA>18@ ^ibGSKHApH,D|baYJ'xQrFU؋s$SW9ddGYidf!# p]"4X\SRbG݋)H{WSD\O4 C9owJ"HLİ4ujN*eC-'?5;[>3vMIxݝWXdVtqsfj+K (zGiY,)otW뗒"7Fh +jƔ0}~lè]k G8pּż3YӤƶ$Z0GZyq=3 +չiJB`ӇLR*=xS;u$q+DI=z)tIߔcMQʠ¨իD_CI ׶-&k^rv`>-\ɖ1E~y8H !$m. KyiɅ'Õh}BNR;kﶇ"i_~sZ!d )V}ܵ!8hC^'o9VY +A thddV-ԏYdT4U<ڴҀV2enY$,糬纵AP2&jےdq7$7ο;+Ƿӄ^K`l0lۗyMZܶ$宗F'ӻM1w[sW5ٖk* WD> <7[*v*VT@5uTkZ<x85i0x2VJFN7i3u:kKcF{u#5hwm +Sr Zzv`t(B's,]3`$s*`]^zgL˔QerC_ +e^cɞS0!S 1gex1'6R֩% 1-iA12#Vw|Pf4#^u+lTb)AVWa[y.t\/ qH)]; l)9$GǪW|#bvM+'Ѩlj_g!hҿFsD66]YLgoiL` 2rak zPZ\M$Fg.CK5¼c[M.v7@ 7 #(:q2vW'=mOȺ8 {rch @pyR<)PiR$@5@sv9OtOxoERv5v Lb"!v8ԭCoyqA+q{8~.2ErN]%,7>qS g(0)/,g\J*+T4RzYE)v7vEJ~3$y6d5b!ԩ +Rݡ>H9WDyx&e c;Zʻ㙻+7PnV,,htS>D Yc@<dlq1 !sTWO' >(ȇ,]2S O3˶H .`0#u*c +3< ,H2׍[Kg;.JRZJTᬯ@8ɣWʲ<ӢNji3=I_3 ]AΑ%2Q9f"_N]i >L! m٬4bOb%V% ePʇ;t[[#2ҿLad@nqA.z_"跺-^*ֺqcALD`5D ϔpW龖3-SĖ,≃\G C*9giE"/Y7.W%I50QS]32(ׂ#@V B*۩i" X )_aBѯxy7V!;QvZem`psFs9{v2x<׸u?/u1;i1Vpjb<=+߳ [c'~|0#[͡]b6"^4}22@őMǘ}(h0/dYlJeo jXfdU,jdB~RPL1]+x[u^Iy1][/? [+D d\&+8H aXnWsw[|xL䕖fԇIQY-Hw#&-Je}cYnR.zЛ/&R8=ӹfA<#(3OW]E +@ZC` !5]7 * e0 U~e1xmNޥf綻IlAh+5i//6S aG |iukb2AZ%M0kKFT[,FUbK;$'PP H)n_bapQ~@WX'; Q(5>k"!=RLꍰk0ќeGK-g ebj/1Lr3zr˹BB:RzE:"N@=oP"eY.XzLL{'S9E <.4se= ~u%dWO5^)BIVs0'g^[#CF,)j}cxzs۞ w!f*Mx9aNv]̄mĒ@?6_ aNc3L|7,&!.1;8ҍYd4۱1)(7@ܐ#zZC2Pb>3sšx D!I1(*JĨA4ljm):$i9ir"VҳNMʯ6f=ʥ}ށW&_ 7CRIsxuYW&˕]rCjbk!!Ƈr CB7}Nuf?v5֙>UwRs8THOn3-vx3D۳5fLZ0r.=3;gN40wW:6qp*򍭹Vh/zh'-ޔ\ԕa iSO3cCVD| DcGZN}&K(#a'*_!Z^Մp &7TdyF&,O-/χ'\0teuv́KCt8ڹ=Og `-2g vK׻O~>3cFYK9)I+1 .R( ,N'},]BBU/EԺcONn 4Gj=%jV՘ݙQz\>!+lI]<89Ϸ &Nu&92GNC3IsB52b'cBO+ 3cK&VxXb ?nFf%W҄";hcur{@!IE`!Igz&h1'Oa$anF|cUwC#S'~"rmKu刿Z;ف15 ߪ2=F?2,۳Sz W'I(,7>SIRNۂyqZ +R,Aj=p{#!/y" +ٛQG14{fNcޙ! {W3V\#&X{5?m[n)کo0RT[%Z/'_S!|K`^"09C 7 mRԗ(پj7OCTI8W,sRi:F}ybxh;33^{: +a$0Jub,O`hP\ ʆ2H ;{!l-Rje?-zqt^;nˡ_)cl'J?ٸ{h{{=i6H9]&JG~RPpH#?o(ffjLu$Ҝj} Etf*"3WAvA3jDUQhYV) -OFR_~}$?)VgQκfh!no2.p#S?Rן5.>U5"n0I0(TT*aw)Aꍼ( |_:BةYz/Sq0900uåS*KI˝G :4,r(c褄 ތK $-,^lj= ԛ%(Wp"Rc +!/".*/x=+W߰\Vֹi?<\0:|9oAV%Ρ%}4aZ`WY$=#*LuC*H˚"**X9l@SҧWe;_D-PһSYV)_Q].tT&#pP[:IHAŶ\5pjache}@{gv TBcaNF¿%%WrV]hT$؆gcխKtc/Ogz ~[fee:BaK`m%v0IsjBQ}}0׺gWe>}>2[W! 0B!^]2i9*IzU@ةhg!6VH!PͲ7;wYz3n>yg[@*+'c?qB, D̓H5Nx妋PM43KMS FqQy=R&q/ЮyXA +ػAߺ{ F+ΐ"M+Gv4Fm^gwzMSszL;Mh`Qj')s/0τ:mNj'EJ)XmA6kxQ`+MG +`r'!Cۂ#";D lBy&"HIFq<8#1EddT,RӃ`ү33(U凘gGj=w:T#dK@*MN'q¢4g +{Dʷ3Dʑ?#,Һ1&o=#Gn IH1}e$UШEQ_&Tnn GPkc- _M}7:ɢ +ߐ&?'Q՝rhh_1p~m|M-ClJOي-fYˬ=@+5M^ \nVA'*kڱ.Z'v%ސY93-}iJ@se+w#WqEB]j#)eQD|#<8i"-;**T>SV3aӧ:j۱GBS}SIL@"fV6GPj.9KǎDŽo9yO>"İCP)z]\!L +m})P’p=i\a,. >?^X +`edQ5uYT)_܇pu'/3Sw!3+Dr.ykhަe? >bsm%"|+99A+ڎ|f9E cZ.ٽFp v$= d:![`M +ipzx=~L)IoPEx&bu%:Xg?CJ>]2#YdWH81tt!E!F%Sͻ5r'vDh" 7Nqo<u/nEūNxU>rD1Z R،/ob[ԃԈAAJjLAC[4Ah(V2zlbZ_-ZkB3m|j[Mfd4X:FTmmUט*i"W+7qX"g(ה]!RCĢEڲ-ۥ.wuW7@eHVU3)W0l""$DByPCRt)҅R‡721NP1!J!4B("!h$Bx1Af !SPB9V\W+['B!*T@a6 1t_"&DIBDp]jcJ-BABH\(E(BBD8H B&L7mLt:R]=e`n>f4-K-[5~#rK#o+\X`:JEL8Ą'9Լl.5L%\@m.RYAJ pxQ"_EeFj+K05D 1(hRI׍F(CTđ\k) Jj#$2HA /;4t8/zEVbZc/R1AJ45ѤT?J!rъVaV*m4lQ*X,D0F #,NNrDj` &Zx @(E}$aBKB*h 0aXLx/e0 #R %ڇH$ÆͺRA*!롙 +sMyэxf&mʱXJeTQv\ؘs3tJQCSEd0h2TBx3sNΦ̱QCy%޻Ӫocc3[)IļYLJ{)C2L"hjJgY_L,/kaި~0Yn_7mG?^ʏ#rB>rb 59qHt|'q *ׅQg*cZgC(eyCڻ(^̒IQᐨCrL_PH^ʡr,ҌL 9kLgLPLq4dpjGua>^2TEHi$8 WMbF) +VmMEkOEU t9K5ZԈtň6hM.;HͤQfZuBAلuFum2_fWs5bgHڒTER8Z?A"OY4䬕ƛ3Yәi,qJ$I;S[{5.@gEɌ u;moyD5я ++.jͥj:Yq->J 4S[EFKʮt\󒘴..UL(&(7HuL;HG' +ʈO22/"23:xx<:4[fLo$x.qchAJNB/E +%CdN"+J*B$lXaCr?bVdmJi(̈́2T8SJCʤό ^*)sI N,^$ј +d1sNeH̉3Ya29-?;Uuר+a,!T9e*xNUbi-:BvU>O[F4hѺ*5jP[4\,qћBq} +r CM\.1"2pӗ >P;n]#:c$vFX6ME*?J#ocU+ w&tn -Um;u'z> e{ҡ(TW,ՓVjq5U3 &иS(M= D aЏ*ZfUp8D +!]󱼁2 SXHHAdF0~#*U1 jRe*B2peܨ: +C,n .‡qr6l⁞ df03y7Eayǡ6Oxd(oKP4hHiᝑP#NMXA!*a"tXφJ + RK-L*ZkO2ޭ "Co5<6D ePas@(\~? ~9(S ˀ&m0 HBj #yP5'Ð5a2hy-J &IRσId R04(Lepwȁ +E-F{E,(DÊO?:5H%LՄ) 2$|U^3wī…,@29ͳ!Mu&h:L\a҇4 O" ^a,>ny-^2o/rx|e6?=^1(u FhW<bO*C,b' ЗH%t4ӄZ#;@.'$ҔTk +H9pk~YNmgɔ"#; h'FȟڊB%C{pCO ULk*`X (n&U8^8}8cR=2 +P9HʜפP!\0R 2Vd*Tj.j>jhrC "TŊPGө)- +EC5K(K! dzg1!F(bh< T-w9gO\ͦ*K*x}'4 i}he` hEM3h:òLCLL<1CTyUA8 @ x" `6E|/\6#z\:+XE3hz&EU!Q|2GQsT$F"s;/51$jiIv3H*2(3T$X ;`y1haeł)h0I6Q3 9kF%Sqz UG)iTjYFSt-JIYk6D-5ܸzŔܑNݟ AiC$45B+`@ Cbtx4: +CB1(*T &6D6$6Ѧ 1{ "xQcz%F:x닆笯ŗaLVO\  2@ir:h+QR^FdqChN! +Mk7fD/x +}$t؝KFܮ$ltOv6_$4hv5>2MV\70BpYhcQ(n1ڔ_=gm߉[C1:OUn4'w7Mj˚I0 ,}= k$HmX`!ލD)jw|v5C.;$"Qqj̢ooIc9 & +Ww9KpTv (l)x7ѦI<<>i-*vg<E&_xaz*=yIPgh;xgK5.6Bฉ5{/]QhCG-,|rClJRl-s!}B:89J(?z]sd/&9=~E+?-8W +>vr{TKWJތ[/L%U3S?9s: +pk&uq+!CVcKaX y CIITYF+Eb⋤L/2'fkO袉ny^7])Se4sprcm d:@  4T唡9k# ˎD3۠/Y +_քxϪȂʶQzN}E. c[Yop@XlG@oߑȜ&BPkІ|@6 yKp}lpb=p_K;g}R0xKm`Q"Na4?ԣlkC^Rrƌm. !N3QUJdgM i0Nlf$]]2y7䃗NS/)R@n=sx&,`#VPar9:ʒ@ߦ/p(H;]:\+r YʻrjVàͅ)7`L( Q.qx +DwbHhnk\JkP%}tt<<*=6@¸0}-/sE%/H5R'5I#Q6e XhHQ|1{K'6uHޱ-N +Fvn[ni !Wcݍ'ء|lx X+=#*CS&B ͓ 'QYRyKG% Ns4yS R7@3f^zϙU^sw\S? *@[^u1UݒV{ il^9(U蔫KYrhHAYlPJŎԚc9bk? ?)_q^YU^`2mz +^v&Rhc13OM8rdg0FWgA45!w[)9fz%¶LX\R׃9w2mZh&痴L/U,vgg( R3G0 rAՓjvb^x v_؍)r+E3E[eWJ 0[#/T~&%5Y[V)t3:7"aӽ rGHoe|Ex|2GUukm3r~+A}쀍)!'mA07Y’ĭc3EeSu=Bp~{zDM +?',U3h>qZ:ϳ5Bm +i{::"s,Rf\sP7^ I*h:ϭfMۛpF /Iu:cBE pNNNډiՀ$?_g6Ϥq nab;ʇ0 z۰?DzkWMcF&#Fn6 2`#.s>S:[w5m v-]vOj\Co +:H4nh^5-3rL\v=BЪ`6i@h}ʣcz[b-1/MYx0j>)*fv^3טq1mL@ȾH2GaXB&t`0Tm +N]Hodr R:m{IG UlT&"(z@~6hTHwYtj%#/5(IE;^Wt)ɓB3F:^uq]abCB!e}__=3d~qT9ƤU%9*gm rQze4"J]U!/= zP.-+pQRK91pᑑatl HY.WFd %L[oo<<,*D6_0X+͸41 `(LC*$ї/ÁBcY6, roϠF&c]PoYsB] ޲){1_kw&UVaq@)_Sv. O_4Z0`j7h= vjq<<ۏӭLRλXڥm=;=];Gx?2IGGuua,.S-0Ra})2&R0 -?&Aiꪩ=']&/3N*Yz7~ :&M廱C%7q+=xtٷt"Π!egX%;=cEi&6 $t IݖMN-F\+7 s2UX5m)'6aAXnL +:3Fp^Cwl^XAJq{^h!:qKTQh 8#|ЮFDC6 DHXT*u(pS·7ʀIgzc32U$jk9,z7[,_HÐD*E+0vsT߲0]ZBfaxF="a] i_!Z=zg*CRwX`"P-Y"hFF&i.fcJjQMv`0Sp0fًn-Fo5Ls*1awx4lDnVb$9E 5gTaD3MJAj^AoPBS}7(hJ9$;JT'Xa"B\ީryZ&Nt8ST0lB)GWSsÂVR+8 EfN@Diy@_r5h3?BIF1!<ܱ`h6#,OkاZէZ-/uemE^dA; x$[\ä\ !.lW0"5R@Km!뀾L6k<5n5_WٻooTk,4ѻmI IFK$:-|Aҙhf7r_>.|"]Tåi5͂*Рoyx3A9Eԡ,Z,@g4oiF@L%HT^nW"^_o[3r8 zc`eQ;L^z&p RH>!&}'ԲR m<&hτ%Nn!1W=qXJHGZp}ؾUC\%^Ft\ 0Wq?sҿԹ.I3Tiy8/uht5\_5XjH6̄rCn}Z^P4hnP.cP)y!^PRq0 ɢZds=LL崧CjZ9T- +\7,{ 54FG5(8)X]`i@Ԁ*J+޲axN܋lŽ\!F§~KpsL*+0=ߍ".jxSs/]e tr៹c(].F.;H7y‡0 C0z%膁;J}ky<aP]MJl@B,"\HCWcfP/A^T#"J^( + ?eE'SWBXfTHG=ba1?9MI;WU۔LյJc( +( ͑mܯC Vcq8`kv#FY~Dqq>%L Oq*}iCrgv~?,b[/L_"C ~ D"uAN23\eιZ>'d9 PX2 +wQ +^e⫍¤BēU>P!7,`5N כֿ=QsN>hJۑIi$~6!߸V ĵٷX]QgID̪¨`x@Ta+ o@g)7屒@C\ +P|i"|Jł ¸سEI#PV48|74jpZk jgJyA:K _HB?NŸp, .14䬵(ojꐼa<ٚMFP"O1Y s@ݒI|7Y|hW;%?|ƕx@81M:_ظ aSx50{BDS S !:% )?F)?D^sGD슒PD[(WE`hȋJ#|IxD:3N`#{85 \:@h ~D$^pΒX$CB %|$ H $BIS CHd$*ahۄ>Dw IEe47 ARV>(HZk*7ݳV0l}$r›x^'##5:# 3=b0{ũ<2zB +w(#Daxґ^E9J +XqĞR8/K\x y)t)FXݧj(Tؼ4KY<֜Ra2bܩ rjh{t^^}p<9XF +(6JͭEZ.Wʋ4+PExqFlHœgWL,"Xg~E+b`Y4wafb)HG +S!EQ {(Ik@u"b P[8JB[0>%BoƏ + c!HP ED&LDJgJHJ!]( "@@x^XC yK_X**wBuH +C s\-ȿ +nȓ°cC0l!u q12&Z1/Ņ8-c_!60B(6Rs'# 2@*K |&$e!^mp f3~:Ci3:H> AE`A:+cG3SNy-0a&: uLa /5HJ"nYc5n6A0l( W +I j6ri㼵60o v!`K@ȱVn8KL7< f 63F{C}qo { 80F8 1Ꮥ8\~Tfqd?l1.S?*ȡU~P@Qa9n{G%t8>lfu>N3YЇapbGKGޭ㣀;t&i "-p&w C>txX^{`x|g/#( +z0di=pTD]ԃXZCa|@}hǙ2Qk ytO;#% k??>6Av"k"RH5<"GgcGƻah?x$W<-Wqy w@>zGX1>bwl|XCu˗;>1fvD>ic-t"GF,ut}mnkbY$W4~cWX81NQE&}~ r +Xi981H9:>6s;?#H6Y6{uM-9@9!B-ǡ;?,ql`)88:'N$5qh(aK }p>pA[i7\\?7n K|P0|M0o|n]f70! #_FOanT ċ`ZH2 ֏$0 $. jKrtIcfMҗ_ $'4}Q{(1^H+|a)1s/*E%_ .J{s+3i /佋0rc jYoնK~]p"m,Ob%f z-\ %EK1]Rf\8`.ttE-nkH9Ky-Hfh ks(.YZKZY>yQ t-״HxI ;-? [Y8}l8,Z%5[ȸpX.\t%Xq|8a*XܲX\3K'~K{GyQY]e}]axvKD +[QI-ii+ՊHrV@KZf֒mӒ"V_EHخ"`*%Y:^ުK\Xr`l&%HS1qhQ&Ζ +9f"Txxՙԉrhi$TUS0vD)ļpl + Gs]Hty,9 y:yB)L؉M$Ťx7*O}'zEGj|bnOLdΟ(]vOE%"b&(UkyXi"jBAP_(& 4AQJÃMK'PPS  S>Qp 7Q j)<&H9 +>IlsR'g:Q'8i}ɶqbK'BdUo5M$)f ?MXNVi"Spr *@S"\P& )c<_1S9%/`vܻPK\3ĖeZ(Kw` Pʮ[ EbJ&+Qgţ)XH @ġDwaOx$ʁMbAL|$%HH>FW#QE"芍Gu\q5$v[qH81xZg-M/@(i#Ir2_[E`A0,%Cb @q?CCX^>d, Xܡ.4LLj,¹b?Re 6/˯M +F=e9}@ r%C6ee,_lr}Y0Dp:oRC r`Y+`EZRSCeG@p,J,qDe 6fPACpepv5 9KA|b)l〉ևj.Hu пXC44ւh}u-~(Ö\-6ɇm1Lݢ{ز.\.< +<.zrAc5t5cʯŪ65ܴD 9 2և RW`d,C2/Jd@$,Lzu. .ʠ3HMyޟ:cEO$feoLș̜6ƌːve Ƈ!Jh`4 44 +*!h L,>Ll9 e(MDP(^Lۚf'/Ӹwԧŀ86] yԸELM~oa՘rT5zVc- ZpLL-l5OE]pJ˂_c@Sc!;QX8 L)|X ]w66nMBڸ +F VF-Кf7︍T6y(0TEƶrs*ՙ̈#=7z_n2v#r)S +ws7*.o`?F( +PW(XAy +WXȬъwp*چvAǵ{i|쁷zPa4yj>.cUz> h}c}`{T;J6>Fg>I,ĕx&*h74~O& 74Bvm[P;F Ys :~u`9n]F1Q s<U`M?9>}iwizU̦Efj5&Clr0L!%rpPMߘ›R !h9HzND~<#"5Y=EAҒn0E"'vM 9,ms؆m$4̋::B= ́C +qIEk o` l8M>S7'`],,!R}+dw~EJ]`#̕A$}8Zb0jX_V^l- F2$"%"68TB e_G]78ٞ@;?s8Hђu\5@};vcM X/r|p*yt%K?s=nl/fnH7֩W; X5mqjR<'B4p39T5c 90$k(-GXar0;@T*U i06rt'q4ejH- 'E"MPc2A!DˋAX :_ HbQDu goh-x?8~F`I3W›qiGe+ ^ƭq2PFi&@.Yd2Kq@( xVԮU)^`HٜbS9*ha'Ʃ@ ^WXh%_zl[йAK`| a~[И%/G'E8'#F x"M[9> "h@ܲ7,Ĥ!QeO1OX E,h M87Wor +WP?@YA}=]RFT$ SG15¼{:}@̷@5yPv8q +0s'8i#mJ ݻM +/,)QPXO#d䠉(X)nw+ cޕBP)'>YJ,_ xfkg";ЖY1xjK6DQm!wka +NYc}ҙ,sZS.bkI ldzKJ^s%4m+0 !Apz4/THUr{=NrӋb!404g\n+4m.Kz:T6.zfnMQ@:b?h? 1ȜM}19ǚ;%4J1TQzhu^X;G_+HF+}l޶4Cp`[P^G`e>=P']FrWxfHM9Tc۹"p EBQZͲuQ\N8(]}Vh(lZA9Pĸᒟ48T%K/pz oo8'%dQLb7#O c@^()R jQ>rh0 :|de:T3"Fd2 Ⱔ 4yfSc`XJb`yPgÀe"O!@/ c2\ EkStm`y!#ԋ{Y@7.P@ +70H+'MbPО +x]yj +ͅR`NfkW]]Ui(0\O_|7)fȕ$Cu |1[9 4O(k5{.0KF;u` G ?' &Br+u&)>3_^q(o7fxlw)_"il(&0]]ɁNLsa|{*-۠'&<ILE6 B$읤Y`WմͲ j;[V@)ÿGKop~]*5 iR0gdC~Bܧ3hf&)`pb{FP\TC]>w +z*FAS̲_փ"df. kk\J/FS%( +P\^ 'h50-7 ȇ( >]o΀E=CSڜ/I>cuxPɧѸK{8韺#[4Vsa6*‘4):Dv@NGMu8oS;HVbRbWRPg@l~i _y`50zfKm*1o:,u"ECFє}a[ ' Ft/ ax￾PұV.#̹}^}r#X@LJ%]؞N؟$v1qɠM/YKؗE?!|6mXZMav }w֔?ti KO-^j\y_]mԫ'1m;cJ!dzKfҤ5g#]֮y_~O-U bD%b68^ڑ~:g;h3W-Gy۟P~6wWOe?ʗ|@[R?oӆrbOp~ײ&yIBPK%p gBuQS"hۥ=_ +~'KyA!,ʿT-?/dy~^8iO4$ZO;߬Ԅ'nfЪUWqů@]Ғ[01o_RUkT{k,i< +ğJi1T 3@?(,@g4UR _Y1c2}cV. }]﫤 !çj,Y}Hwiwq>V܏yZ[>wU[c|v +_>}6Շ*h//bχw0+k7+b/qj !1+|X'ۥ&$  + +#ԟn:ʜ.86 3Ve<ޠĘ*gsO(*J?_DC6X=[D@?@ +s6[vaBjL&|Fuv~c ;83qjKOX|Lh~2' +dO`~I#%} -gkj`mlo0NKϤ<Sc7eU%qF`:^% kL )g7k*Z1hPyk LVĸӈl yvs@|=>pd@IYῃ`B̈́,CyCV-N!NJ(4Fb +H[ʶUju'ixA,+qq|#%n|Oޖ ~k0K<`4Yc VKb/CqRITﳨ<00GQvً;bAF{#h_ N*ڣ1 ݵ(AlՃoMhY_`;i3a>":C$gJLI9q{VjOZpE/&`( KjKIJMˊcmM?\_=!+gUynљaIPܟs?lϳ^YX^0 wb"QLJ}|PX L?Cey !Lc>pzܳՆMƬn:^a}o.c~ASp׫owo~i߹hYuVJe>P4X&@R Un~ gA_`^bᾀx )?O) +:|a$SJRx)[l ;U84vDFjd9PG4;%"߽=TVhAj2K|>i3ΡDBSFj$32*<~Mh'5xPP.x=ٞ\vςo H$EȡzҲIHP.&+JU;W泣7w\?8iҘxS'WrC݌X0K]O0Yc6f/ҏ߾T U)N g$ +X(#PFTAqu9}"$1f݆Sjh# !,?GͶ9tԍnC/+DaE7qg:WlxO/QwR%2BoO!dLLy[F{޾gC~=V %>Tlc9! |zBjZ zgg9[+Q(+ܰݶ|̼P5q +I7%` 3HNEc*/D=ݗE*OA*7 KBwp=(8tn=C꼕 Ҥ{@,{vW܇a'6Rw+"Q*ϸŴ݉%ƨIg b];4x3pox/XE {Ux?HN#T3&sY)f*uiLھDa%>5M}iuGlɩt|DCH77, HҮY +YaEJrg> wo;">i2 +eC}'JPEWR#;_G2TŎI9\ ?%]I@̓}Svbj`X{>SznoC#ICO_GJbEWo׉&ARu`r& q18)&>@ܭKy/?Ek5㹑@o9ל!Y7I%˳"֥j.dbUWs2Dp,w]]Q%Kl̨Gla%++@rqޱi4U'x( kyG +;9R<5%3Nwᕟ.KӤ(2f.uf;fSYP @subrЧ#x YRyoz>͚TrOg:aIq^Z`G6A'ov4|+@Oz aIpZb4 +nԼK%COgtB+h`)de_|Bt8GsBzb}%xr5/-6)xۍAG'[>) }apHW'&sK>I7SJ屩=wv`ډʺ8r׀jY7oulUQPADkr}܃-ŕxS}X +/eg\Znmsd웍ls+llҽ1?kTLl"j͕#|vs\;^*msi0ܶ6M¶y͈-6_HTܿf`A66iꮚ6|mثiUca\97ǡ$g]Xx|B JH͗669|<.2߉)f>wFV +|Oq{nMi9SJ\}ܘf +g2ǃrA7&t^bW -su(![И)C`yNC 0˷먦򜌜2E4kn##R zu>lMSVYμ廬$=BUحu**'8OXr,(%T,SٻQdEʑ<)jDbZ rѳOI;r/*PT؆OS0>ޒ9E< XԲhM(ɍ:HnE$ó.hx)E.&ues}"#1 Ԅ|ĝ3\ t`M%O^Κ9?'Cww<Ɏs:N:s3U(Džt.s3?QtmZƍ(֚OkѯDysͦGjg;fhO1IxȃQcS)31YN}1,RZ {hdtT,9[<"ZfH,!`ݵx ?vMm(m XuU)LY|N +ˬ,JI_?nYאJa)5ܯROKv5폸ŹݝuqŹ3L!cVk7'$RЖefoT`6ewY{~-< gwT6Vl;=Nı9 ;b['1kSl7u@.޿Z.~\01DY +3Rfw$,TvqG.^Y e,-ek .~ qU!%[v/H%^Jr݅qޜYɃgx ꪗ8pz#BԨޚ!m=5g&{@7zZ 8,y'M )W z[‰n\& ۊ-#;rKٝl7 X@Lw_&oIx t E +IF +ya$T.)X˄ݵ ׽E~7!Z#?tK]ӅȺ7VmZA,1A uݶcόV,ny +4JsStˈI֔NяxJUFTy8ЧjO*wwqlҠ%W3'R;PB~?1bϵw kI8~Z3v 5qO>ZAUVӈ{U-,p6|1:<\V^4Zz~^Zj֡u'/0!`ʺuC\Ed-^-g ]m wV>z> ֱxlE%B_XFW+&L%)VG33NnEtƀ$-Yf?Rꚹ]!p-W0m+:-n;]}P,y/]MgKZr  /ٺ`BĜ6ȁnN)%Ӄ ֜zvR~hd=>zԖsCĝKWqRp([.R#4 |rfZn G$U@lōkD,K3詮G9rt񋃣*R@ lܶ:S\:#x1Z㞪F /[ׯOf7MÚO3ZO;%%p DC5s~c4?{d]X@>PpqȁaNK[guAhhAjW5bA ~zBpT)ƅ &xx72P?ԏ|/'J9)FƍZQ7G' ѐf4:٨5 6r.MQQ IWPY-Z$߱n +3 +$ $,H +&{PI܍LjDž0NJzNĮ XYena9o,.O^zQ8;,gb*&l2%d U| =M X`\%`N#87glUNMM[]5v:_giuJk<K řOKT[7P'QA׈5*2_Cu3NEBb9Qidw#dJN!ZX0"=ZIOVZaa>P:g,PB3Tv8zS'Z~zsR8+p _\7:^-s: ]L"@'b_׭pJ4NjT^wl5ȬW׎`Bm֥1p|6W43`߃& z[+^pAmA6A}^ؑ$J.\_\0$()6\Ypo:e,s獳2v_\IB5=X"[7Dh3xYYƠ],,Tsۺ٢(Rc@/,f=JгYyAk+lv , sSH+UZ[O9'L !OrsQ[0:h\5XfqZZ}L}}>V`F.ۋA[?_*9T$ٶ#v [mVvLLºC!6+>TaE~<EC2QƄka w᛭qߊ6[qƝ q2`#vA,7K5`4Ęȹ{d9z]q 5kR/ݢnOwk{hotHX%B䪅]@S)iȥDQ^ņ4{^LO"n%+\Q )./ȕ -2q* I*JOSQzCb6[oj^f0G;{¨M]T0M:a!&  ~bv2e_WjV_{W#*@+g|#. ҭ uaz> %˒ |( uEB'ǦA,6)CK~B n OjJ;>XkOBxbgK ƩPk@7=]d׽ #ax`Qf i~{4w=\@-ks^"bHpp**2Ob wYO^HX xRiM``4j䖳1쉓*|,Y,1D7s=UqZdP};R8VmPBf%ra$ɾ) RH@|eXfęiyFq/] 1\.Ң:Y ڇg*@mp ~SW29*dbaRNdbtlr +H"eB;%21VwYB&Leqt&N&J3hxgn*4vNmşvt+67'L]f 2K쉷a<1\w?IOc#S)6DX52$+^bZ1jxؿ0ѹXBOTWWpVܢb6th 1K`jS 𵏗6hbK`b=8ӛD)pM1\SiVS5ŕB02 ?L_V \3X0V^qѐ\`$ԭb*FsI +N&t7(b`z.bj-fa^Z_PΦV8Wl٠RX/L׿GeϣK$-*2 Zl;nrh *t3Xe-bz%aljhq+XEƷLoQcIhg(w9z`Ĩ: &68!&FKֱxH.9ͭ8.&I}m,y)q<X9dž6RwK@LDN㯻98' 9~D9 JDP!OwmfݨcNDI3 N̙c`Di/s6!8;HAyZCYt6v0,J,Dn\t`3kp#+1!Gv\&$'T`}D{-CJȭ.=h=gB]pl!D9'DjE$rTzTCQp9NR{0ξ ] +$Vl NS,+ӱenlp9b v^;VE8tz06DŜ{#H)?fe*NLs9 Qk}, Sg+n.%V"#S5qn3'*29G@pCOyIMY +lScMo|6$+|hDWnB=5o *2w-[z+'-̃Jo懎$Htc飯AI0-Kb!UDLޔr1K=n*e(azpL@5wc N]`PBK8UmeE8Mi:y+'"7 XCVy(Ӄf +یuIDFv6f'ο.*ᨕTn6XI۵KA^{}Fr-jE@F4K6clfp9v\X [qU{JaۼFv'۶e1v!>S4Y&\uCҦIu6eP_*O zj%vo^;{TlP7m3@gXݶjjyū[a?{PEmy"M mCWs?NTZ S?sݕf}܏<`OXA ߋ۽1 OUs5,7 wrf=6Uǎ2&3Eքp'm}jB ''˅e=71obznh*]э!Bl7SaBB5q@M /91;a; Mb6=)\}t-"=ݐ%`ӝEV*ũI S?jpTG8)9%a7)jh[q=]{5GtW, =?a9ݭ#_Uwp:P~ziqѽ)7HQ$(W.89~nݨɯa}y ܛ M#u=99 ɣpмks/:i0=3u'id _^йc8ε=wj-4V=5)e0ܣ|*@-/ң>ܧŽk嶊B 5W*9xU9`?c2+7gJ'g]klW\gS2I-5#$B ȸ?P(*>K8ݱY7=0n]!2w ĸ6&DIXd[?is"{=?KmO=]n]g`Lqs=fZr*V>R*6V:Ѣ,ӦyÔ v;gGp;8q.(qF* G,1JSm⠊.s>VRy~nsbHwbQwK$ /nP-(鍡iw,.w/5 b゙ oԕfwym0込a܉bf&A.!{&NS-u X8xZwES p W6-HP opS;<"t}v4SǙvw|,k1rw÷E-pޣ_0+("Vwm1W8^< +oi̽|hЯb3;32QMyh'TQBh15u] 9_teo +Ka+ HwsRw ~@u[%j-1|^/:Wf0L%/;WynAFU|CeU[ _rI?7ˁor EDPv)F{mW/Tkŝ(^v6iU{Y7^*>^uV;e?6 C }' +k{<2gˆ}Ofw9~=4FԚ 8*Y[NGy7͊wZ_BlYQpJBs t;' 7.pѽwzۅ(F8g~0 ;{M&W~•k1;gBj2EF9/UH{q35t tN[+ +Dހ`|O{%ג;:&+T&_{LП9rGNљ֓U+JY.=r!kS0Ic>Č|z̯μgL4u +a\ +fq~@眝ODs9j8*I\|5=%B y5S(z!Y>u/t'KnLNz+8>T]@ endstream endobj 15 0 obj <>stream +ALG`:S4=25}цHwal.*Siҽm>f]}j58X]7wBA$U׽U_^`٫?XCʾ^&38}v;M{H;lZ?v/C,$>pW'[weFѽk]XG+0jwt';sul:5D 8&]%Af.kg.>xYo; >}zL `$"? +?cHëmU|݃pF2<{BMCYCd~OoY{g5p@ߩѱ|YKα|d}I8^.kB ~xsY&<99:9x7X#BKd(gZqݣ_f8)lWrBzK{t +Vo[{Wo3vt0 _}Ծq߉|~[ϼ4X}_߃]p_Og1߯t +_Gf _2)K~P* ^j۱$' f茆 Ϙl3y$QQũ{?~ck5Y17w. L߈Bږ4ڶ2+-JqS Tf*^ec:i&pӪQ3Ӓ?UG^MFi纝}{W?~7}zt$"!f\o7QR|dw~IcL[W!YMr_ OWlhl+k1%;J/CT +OI"Vo:oGgI"1)ɪ_j;oGZFŀ +,N- +@W|'ݖmR" ek3 @ +ӯ#(@U8GlS1MGz>nN. F5DT UKے,Tr^g'f3YE {sl$͐WE,)="ت'Y "M|EEՉ_x1 $RpـCUɨxوϿA'Ŧ̽c7#$UJN}0e5C$Ѽ{ dc)oy\6C8w[ׅɧUry`6 ]-R1V+[|FKԦaSg: EZi]LoN[hD3ek>Sv{!H!f*Ev$JsA_;2Q9D*g*U^&7I͆Yb_, +‑SdþJAla9ڋłz e $R漥L=TMơM#)7}V 7b6*ݠXGX@, +ae'FJbԠwv&s1} 5\?GQVot…L3Bq1F>)ŧgbgЗjTkk+FbQVON弓Fk|ZlL. ۞ȩe>La,d-Br׃C1gv |t/6}ޮ]ڟIybeF/g|M/tnNEx|U7TIAj=9Ղ*yCKY]i` `7@._S40%70cGs~h^ 0D+,V|W N -~HcAhS "|b:7bo 5A!*P#Ś.nUkf=քk%ͤC" څ$i{tqKy$ʕb^…rΒPa#4+^!щB* Zv\ )z}0:OM`;h8PI{{vhrbyG)p3ɬ=k3V9Y Rs7` A7Ag6`ir~> +n3VQ1`[frF=9@ĽXȰV6kB~¥!Isl 3`X&[6d=IAPyud֮}?yK wu6FB=圮 4%"ǯsk{z#alCG>+/E`S MT:ZLW hu&zA !k|BS}%?;2gW$DV9 +4S p5rs'HOAQZ=~]kزbpcd J Raa=):qPJB[(cTN\pYjD3TVrzekw_*-r] b *j=YD\shc?]~ +O2=贽EtawKɫlƌ%C(7)cJ^uyi9<|C"dDv:=::L50~:1%ž/F 9*nEfBW1;#9D6+H߱!_qٶzԉ +AqEU#-`MeSc5qM@&p@ST +N^wI"tvFy^x%y2-pD]8S:u \0EmtsG.Wʉ$ tGcP Ez+>Wc+`/T$o&cZH+ Ǽå^GK*Dq gSifN형PYHհhGSb]Vl͓y(cj.~IC<[D >-10.܌kI9UBО2h{~fc!zϐQpRf (8S1K M)(j&Ӂ`JV7fD'dDeMKY;a##uf6Bo)\Ul8mJ2#1'|pl4Rp3a䝃?"{ro]s>gRxo'<$ L}ZԜv4j`RSFC)$?-P(Pt>5hjE)6=.d9շm\Gc^Gߙ9p u ~_).$ K2<ȩ}I"9k,UsP!3 zS Jy!]kC #}"(~ФPv1iSOQ#Xʟ1~)BD}&- K4*)Qbl*fMs6 +>*F?H27rV-Vps&}>id1uHKP +GF'ec0Wn]BfAcH b;yg/v*t1V=&$R[ۅZq *R5 MwEoIU&*rlש"L~B-+1 +n.dKF\ƛcwQ{X)L?9%.s 5o?H`HsqH֌>4&m9 4$㛕H4 XZN;_45H0"9V^&^9tc7㋡>a2C5d=A-PMNf&f#d*Ap e=pᢊT`g6HAB5mF.~ +K +m" ɯjՠ- lj2܉r@͎2 H !IJ_>?%5`ATٙY41C/L-Ƥ_ΖƞMm0h\3Wefyl+kgW7*!$%j;byhI(a]*1f\<-4#EMѤneqV +MJ|lݺ[uthVe]&mKi#+3m;QZflioL {qK lLG_#'%=1;y{ߥ@_IBrـ ar`de<Ȟ + + +@(J40斉Gڥ(!:#͸<uZ%Z{% : RSI3No /O(^d#>1Qѽ0.d] ",}bEVeO=kvGsi;;G}-GC=UrSrFgB0+mpIy g= ?#,7$0l3&"k,VPiQO40+)q0=ߚ& +Z)h.軒 o'cxEjPNb}q3&7gz$5^iUaO-UO*2~>^ƷKMmXx]W܁촜utS Y-d.ҳDߺ;jǸ˟SP ?ٿ +{j F.6i?.KZxBtZli"4+nƮ*: DYnHn;SE+ 9̉&SY0$$耴1DÔ1qet* DBRႂ3ڸD^{왔30N/$B"b FHK!/)JE_ +A4"\4@^Fy+}ͮ$HD nbD 肢D4sUtrp~Kf4 ABAĈ kJ  <hiQiaiBD,T.eeC +K ,XѥQ@ 9n>Ȑr>UgXQrC-"GWP#G)5=vMC5p9+8Aظ$FObK ,_3p"FXoUMjOOb"FY/R5oa3> +Jdm,+qnۉ2/ӯޚ, ",[UmS? {s9E C⹘0exDᥞ5{uvU'$,i y(&/U)y $2qNə;Q" :ؗC\\;*hGS{OFmJ|-Q*K,j˸OtAUObGZd-qVz^C/{bKi+-*ҋ]?ȑq%4Cǐs0p ~48Mo´![t#-ui V!X!PgsicԴ wU="/8~[V#OMk|( n<5͒z4&}Kd,Τ<חW6%?RR{1% Kܭlѯ} !]gb;7kBPκ,仒M)gRMZ +gSO;u_Dۥ6>M4Ã!m)H= +u'wrxrLʮ;A u$ ٦G."QF?eWHr(wƖ}%=;Nb/{ 2~쫫pB]δ a# 0Т_0"ؗ_^c7 +Aܸ`hqkrA9@˜ҳ0}4jiOȠ;n}E%TS8n㩿YZ$4WyMi:Q ]{NFFɁ+@A%24m\6[WcG5mTm< A{P-GI_9v*k{LǾJ&}ǘo> Y= 'a[wi=Xxm?Q/3k0~qDT?"K`li^ᄜ~ApϪ#'6#l*(2;U~ 0ă/g8N_y4vieSƧ7ԛ!=C'x7;ӹx|@\8T#*$G7Y(bЀF3tДm\fV]أށ<qU{kzlô,Ix_fzUl"LŘMz꧍d֏F5m|f^]=X׹g7NG=mc`&{v0þqD~c!?VX9ܬ,q^yOq+>vx VNrnƽƷXXHDZr#+׀Bp1@¸zk~h3 <Ҹ!Q=nCނ 7&5_Hm;D g_ds1DF]f!~"!Nr 6DJ}5 UVY"S{)}k-{ Eԇ]{MNJ#ҶwBq_Qj|#+z)93lhj)]\wm];u(zz[P\mI qq+4M(5KllKQM魨C>G7nAVrǪ7Z*Ϊ+*hqߓo%SS䌙O_ K+pff]w*pI zǘI{'LUrpe55YU .ǐ4nrk=u<VT48!4&4?V0A~r_'9dEֿJ.7 n Bؓ bKグ>GSVX1`Nㆬ3hXXe_g}Q8.Y`ߟ$0S&=l +})Үt᭨EPԳFi= 6tW >)0a7>G^@ cR'9pHYoBLL ~z+(%pN9|E%r-B \ ntNUI|?"o(K e?H& w] P̚U$W"04r9̉iûaI!J r 4 V`3Ja^zM +J ++됻 #r2wrfx0rbBz_NOˋW|z=drRaO6nnVOI 읊3i)ceFvYۂj+Ho@aYXF9iӃUH]nY{2JAԌQOfz.%L){< 䮭riɭ})Mu-ίCΪ9ܴQ{m/czq9%XWNE>1fwj``Y';Y3316m@uiU%vU4SO~m{x|@ ^]nM='Mf{~U~i;Ўv@{|5-nV[ԢgI5=UM8;N=Ĩo>k*EXSYV#4;O" 8Vna߀ET7i܌oBz[_~|xCɁÚ5@A^l="}iK,_\%wm4}[EnyBO +{0r׀%d#ߕIH/Evnt߂qFm@2|h;Qn vs4n:p1T@hoH&&NzS6!cx&癐N>ru2J"0l?m2 Y HQQ ϔٱٵ5rW_\$<Տvj✊X1|J[Pup<&fYVa_a nԁ^]m.&v=鏺t.)_?I2,7 p)_Z躨R*2':6'{HT" ֦ܙ0@L$ @g@ oBw5Ifh%H@LT(%UW]E:޻/@F8DF?SKdFCf,Y {[i %G %wk>5&[pl + <WPi6p tA@ +`"U1`D; ^Ud:QG! 56r A#2"'I +zV `ԀOZ:IXE!(B6&g |8Eb$GZGO}lqu/|&Nm{q_w rOUq|m"h[+ 6k=wN1F؆Z]XnE"VQ r[gSb0,E&sBs!aD!&GVx+cMGRZ ࣋ጟT ƽAЈpu@ nFaj\# Z, .SD!ʮW8]AB\>aFR{;np#8&˯ȰSOI'" ̍s=1 U$8q6.)fP Ippwng?cSsnmR)Po9[HVR3M54EQ Ă*;9@`hS:BZm}]٨v!.*_1,řp;pP.n`m {Ѣ+_U]y}j|Řk$L@Bem3m3.4K7Ɛ7-|!iŖ=i~-*n'#As1Tur+^SU ժ[=HT9 5{ΚIc=mOl;kS-LK=2)ߤ)7ET8LT|mOO#YUt * w̠8fMt˧dS^/,g|Ac0, <#:!Q#6 & +C ˖gڙt{n1;eT/+@C9I 4=4qC(! :-&i*+PݖoЀL0 h B3& PKhl|`8Ub_Wg M?x /+?qVC5~woM.,{1GY^ģak,"?m=n? +ǜ^g^}G)'kq\z U/ͭ΂0O._EhH]WJ{ޚHN;L;fO6`$DsΠJ5`("(3 GTD4(޲=Fi<>k_UvV; _(O@ZZKjVz1txQ)[uTMثÄamEd bu˿Hh׮w$ϡ;4 Y2hE ŹՁW.x3.K3k㌛2.31T.#.K,5Ufvq^픮OSD# EW h&!bC[`TT%b]CIܺ&pnj{M{-L0 8; a¼ ^1E1$QsUy5ZeOz%_VN|˲jla~+MX~ Ug@*6?PjM렸uOզ+`SsQ* x WʻVt19rQr@Ah Ch~Ȯ럈"h.|?oֺ檿Hzp e*G-ʫR"x(8敯9ES|Ūvr[ aY3/T=nVdQ,~"]VttĢsԚ. b)V"rvqjGD; +eZQ;1嫎ZiJQx&pʚ?q?,Sq+;"7ϔcэڇɆmk8+׌:gMVᒚUvg]5X*%TJU۬izZ C= +i$J"@,tؠ &rg,1/Oc]E9UA0OPG6>81.!"$oX8zL_fr%b.J-2/Gvz T~$=oT]H1..CEz_WEie]a培u%M}ů^9 7lr%HQ>'V`3QBŏEл:xBաr1v87w ?pa Y2hLvO@aFY$e(Wƨ.̲Ax.ZWkFM+DwNړߌWyԠ5V~' v&BPG3pW0az`~Ǹim ̿3e\v3gL؊K? I?--0 qF/= Xyvr8vRu ZXfUQ3*ҿ0< j@6ӭgWHL1탶-sˊX5]+_S3eag}Cv[CzT^3RbKcIGT';d>' i 1^ķö\7ae\EaZaYިnq! d/C}֮8a1ɆXXPaSl|Č㈝c^=(A߈?MbC{KsO9'"]X4 Ӫxnr!~ F*FX1g#y9ҍoGu@&d@A\ApZ_kܮ=mT! 搁ھУ댝K3`꠷0_& 7$i#@ =0y+zy 0 4)w8%=˒j0e0]t"MHZ(clԌs?tN>d> uʠ7eZ*^0_q +b*Q2(lVs.L,2XV~Y-ϲ1 ͘2q3's\$o3=$_{{بFExv*F[1Cva1.ejtծ_OWBt٘j/c[~{5^dvy| _ق)VOsJ/$+)"0{ŏn2x#H.>,>$u#n~DwܞTCHߥyUEv/3KX3ٮ?\:닞h]&k 5˻4_Z BwvL;|e`[АcotoȖ]-ʙ|w h +"h4QmHߣvDu[wApXǸ3BDYpÆ㰺 y-߃2Q.*4-SYՋb + +O(7ܜa.Axٽ|q Eh%b_n~0.F# mJ⺋$z*[0lQ<+D+H@ƇvҨřYU;HjpY@dsEv:ilUb_]tbe,0@k&g]^bFQ(ThE &eߧ,]XuKWՇin-OG.<u&q :-Y0)[]m $蛱7 ?_H[xUߟP<'=t6 v,ע_ !~+H@.W]SfK*IhН]y4c¤.*>\ >[ĢW.sК&U;F7i' +xV 6Rx&$>1 Cx Ew[/,[`9JfIRudզvi(Hh!4 #/5;PZR.>;zw?*%tHsM# 5p'r|q;/2vLa5qz!ʏCA1h)G[q1ۄC7NGh>pVoA%!KэnyQn#Fn!ڒWA;l6/$atAHYO ,"ޡb$prA(y7XFe;+q +<4c BƓx[ɂa\Cqq/I +JCM8 (q^N;(V#\ ]Mf rᎁw +2Q~lvE ?ӆ=lSOK:hȎCބ,nu36Dx~ħzy >iOE}Ы,]vBv#KkUv\)ZLqH'H}b)d=([XE.P|.)2\/,r^jyZø SZjM3.FL<At=oL E'#hÑ4'"hbuQ_w5Q.}7cL_ZȋJ::ҍoO +Յ}߉€6B%gN膗怤\l ]i΅ +>vfH6~V;iQ +;})OGAv^+ ju+Vr}8;' >Μ(K{b'Zㄙu!KyԠ>$O}L 1'v%_5hPmYkoyReh0Va[^&+g ;gnC۟6`Af ւ-*%V1l*GKԊ`:JU/JAl1d(qҘ=eKCIgFW8gYH9]_vߨionC -}I{a(!SiҎ Či#vp#A$i~%->-7iT@≍bb#gtQPGXX?m}ޮHO['!ktN0oQֲ ˗r9nCv ja[sPk瀄L:B-wSj'q4 $1q5߄|Bi`!&A&a:ZC5zI& kUhOD5U$`vb`DjOEvݞ|#<~Nsk^mC, ͸6Zm>$[=S0 &Ιz A|^%ԪH: 爙F"W!'-J4$I{r4'L><pG፱r ɚ}xʼn[Yyi]Fabxbĉߊ'cN/s4nEjM ˇkmn"BFS@L` SL +#>G{\K ƁÑ1]ɜ7e=Krjh*Pfx"m' ֊>v= ݡ yl>0 |Fx"G$CQ{1! BT{:0'ؑ8T^ײlJצ(=O\gg;7FC{[u®-s,L;i9\Δ;㽁j^бAh _6@Q(}Ҕj&DV]#G.*Np&R +VXDΈU{jsk { +cz/`g{*MPkmMm)7$>Aw9>zKv/D8(uT ,{N~f84h94C9Rt+ @,AFyX6e8Cւ[c4xUbxF2[E*>Ѕ-`۴n/O No>#\,Oǥ/@W/@uiki U:aQtDY) Cm#)@# TBg4nuN;̻8mi@ '<7rN'u(cbgB=lVbxZ<ēi +-<)[kakZ/N!J.!ˈm[*kBHfېhwPȖI_8 ;,>"jgH7(llS10Ezl +P"ڬ1;G$qFC!+7iOnjwa.ae 5 0-SOH!&wsQNUpݭ\1f3`#1^{@GUExp`0N-[Wa[{C1 Ga׵sz&A-P aQE{HoPӛWQjtup2Y{X^i}>Ik>?ѡ(e#j]܄+]sqظsUJ:He׊ԹV/+P]YypԘUX+,JqL;mFEwz74.rB-gE +t;V2(wX~"q!gCN=s,#>^\z5LXARSYcICa1x Ήî:m5t^ώ+lzR8J-o>oGISc(ʙ|b&T; W)ՄfGبC M$,db E ܔ^Bشl"vpLҾE +%s#t7C搝FX=GR1ޮ32qoBJɤʠDa@@x<rM}i%A;"xlw7>Z%dF= 򡅲>VH Bɞ\\,DS.5ly&McoOm6H%Ľ5طsٸ6H$RXZhl.2H2Zyj,D·y&TfQsa +Q sr6",v+}ѧF}IQ:9e)RWIRفx !}|utR/#>uR ƻT\Rċ%EC ރӉ-xQz) +j'x4wTȝVJz%ԗ8'o WC[ y|ȳd|kӼUSW^Q(dNٵu<yQ#5Oɉ,ry"P#E?hCx"cAȱp?r"&=LP.ٻ{Ƈ<_ۛw7%^dmJ54ZܞBf ~Ty#A;YdIْ{wD]dLM;m¦XE]_M^ +SĒc;-9dovNV.o,:gK޳E8y/<2̴;GZqvߘtɷ}'C + W +D@uR.aΧ6Eyl/\j6(x5j߳G(-s< LiŠH !Bh<GNvh'FC- )\w!DOu茳.q%hH=ԇwKɃfJȔ:"z9ĜR?'eGO<׬bA/L*C*q#(m a~]9ijȹ܆"gD>;mNnJ(7nӃ$ExzDqn:t[(9%7: +G9:jf5ͷiYwLKա$ =L}:~)h YX)ď.q"iD(ċ"s >=ǜrG/JAH$ܻW>,{E~{1:kxy-LilwQN*!7f7&͖.v}9Ey(/dϙ+nu1 +O;'I(1ϧ'L"⿨H/_%>'6 +@0 p=T _7 e(>~7|{C`=p`&茳_%IQt_Ic>IQKS$1N'8PmKU6vA"m")VJ,/xv $5HJqӰW[,*(S~}$4#M d[iT82)~  R|2.d|G=9^~F9m ƶQbuh P0jf>"rnȰd%Xt$ +^U=esg!gfc阬z8g H2 +A΂{A6:e)jM9@W &1!T(r +$80joO"½{=Eʄ&fܺxŒO-5o*:fB&)s*3EA;Kg9pzʾ_Jy<wz="(pDxᓀAd`O~/L"F +^.['Y;d|T{Z\Jd2D2\,EUܱT;he<GrI[6<WPUHSTOLQ o/?;ˇyy`=o kb:=Tr)NU0ӵ +fNqϮOܼ-=&A n~)?(ث*eUsbފ}dF\_|ˏPÇP7{`cX(֩p/TeJ 9ͦ䱧.dS1= +h!V֬bjPR~:@)z`?CCO>_OKp_|gamɨI_Ia/%aܖ +E^JYWW6h#9ȵ,Bu㶦}(4=$K!@? ï7 ‡a^˃;p/11?§/|2'P( +ՌLHcJ}˪N_9+؆se]Z" +%fNwaN]=TP4 +bu!"-Iw3f$4'1.P RGHhCM2IDg Sթ{guaRl*ueZcvN3%TGy@/7U a+Ç%|.A +_ÿ9wo! ߇bsT`]Ha]&qy~u rnNu&)VB(a]ST=K-Sujv*ls^߶NAʘPoHzT9~ ~_[pq-?C!|w/;S1w/)| _'/ʸ+wdY* 5 ͚@un Wh,֢ҷԽ>yOuU(@*է<`T*5vakj0k^V-?kTS7pɲaD| +‡1|'| +?‡1||a~!| bċ 3ڢj | ;5xּ԰G+q5! zxkT ӎ[v)XԘڅ 镄~.1$[еu'[umbfvPPTec^S2Ū-7˪L{_K`LB0BVj9R6OgWA^@ +QD0H$$"b CCDT"RFAQ*4ÌԿfA̰'\ +⮻=a(!AkdNW _t ;A;4߿Gߦѡ ,,:20;='fȊԷ~ br')CQ*`s\yREZͨ=!Ý֜nQKXQ[0՛}?Z ϻ%8W.G0L-۶o'bŦd$ +bzI +l>X\a7eKٛ?<_blإ%[J)Evm+__*UŸs +2_LF!8A \Lǡ-wnݗuU39 bP؂A@:X!(嫢YRXEİJ**|Q198D!28Bŀ=}p7}ԫ988Bt`9'FG^k;E_%~{EZ?.požž&%YLܰ4 ú*S 1B Y4Y8't`~Xq|E aM]WQⱂ+Fn^jvLoer~B1G5o }4FA!1&RTBaˊUX=%S&4y#-!H*d`૞~tMLNTxEaUn_f0%AI@cC(hu~s~k>h;6P^4Tu^XI=Px@eq4T%>REg4e:BQ( +3;3fTA\igL4_&_W[2 OCopF4;o4H3>o`=ڝy;}$g0>1:~~z'XWʌF^,~@]TH$BTADaAcu$*FБD +"􍾒)دSf0Kۢ /^KyܡUcJX~n + z*~oJ}!W;_@Z72-̎l!E$w~@"R]#B:9uñFQ$~EKPTлEzoχ OG #yhkHE0;?AZmŃRWWK,<""tȊK\+^BS+wڸwot $`$>|L!2)cI:&KXknk֣pq8 [QЗO:z XLRz/&?y1^AZSHZsYp]KGchߨj#_"~A:\>B8HI?!8G>6ylDUbC8zjP3 =sNyHCU +z/z*0G2^E5cE#L8x+ WJ#ot4OHidIQ#BP#F="Kp B4hP>m^ve Hqk ث%Gb^ZIJYAnA6!O7׵icxwKyRBqV}D%V@N\AIZdtT G7u&?=vn]4> ]fu%`r펻lڄeB{422p}k@N7=/ |;tiM6?$|1y0eW1۸N#K$D+>)$5m^iGY3$א * TR܃s:,-W S̳Ͳc&gAVU3Yܜ[!7U|IʄEo$$0tr¥fy%cO]4 #!WL?#A07/0T?FoEhI!<#ޚac™o1ć*7`,_`hH<-:l;>r >`ЏBW^{P ++"%:Rl]KeYfڸ; +jd:,[m=kױ5~̆_ qQO^LF3uQ5,@n(9+.1)6W g%;-bЄtc|Շ<kU1уk3h\8m81_y9a:i Ա[C5ao ﶑ۅx`Ԃ l0i˺_W关(YWqi[dpq#{XzF_F59?`l>[Ry?@r>Q%TŜlr6%0Ȓgs)h9nC$–2dz73,wꤘg!تfw;+_+Ri**!A0+#zkO u:/"]zڭ;5 o~BBu|"ri)ۨ\M6;o42I @ j[L=H nn%)#q1Z蕥|(`6VH-K{0\X%1.u]oYG=6̮z2r7c105ruqQSNu0*f[$t.]@XΰmԪBKDž!zj鯶D52ොfTh(*l %f)\fN.5WE'wRHu/V +6hN?0VM\GӺ f / )q__ sߢ&X̾}K]o.bI0 aTvs)kGpJbTX;`SJoI3:o`<ϼ֊,#bT'>2ˑ܋;ȪP{=ꀕLe:eRT1M~Fa=H{BlܝqRwТQʄ-YicI.eqrh[=˚75===-+ws`bD!<̡,I|81WH I~:~b8lˌ;*dpw pSkcwܩ fo"v(a:N +jNd8/d4Qa9 +QՏU#aMPQx97(Q~zczpE=}-Gl/ńG1|Vgž񋭡=8ޥY Prjۂ=YqrNOژR!&g*bv ]- bˢI]ǴpXltJFnRcbMCGUy=$p(6LfAƛxɪ5e%^7(QEiF!Y\3]0FW3}+]ߤtYV`/`x5d^<$2|ǂYZZu?.[ + ?Yhzd[|! Z#yB7d}c=0Nᰞ] g..jC:Zϖ"X]aQ{1,9 +2#o]W񐣠Y(1DIs-2ЭF &~/ŸkV#?YS==ٟ~jܛ bgf &-16g%io]CȚEHvpCnㄭz).K`N|b7erb_V?gVr:_ͱ,1qw+qTA&牜`ً6ц"X^G[l4X_] 3${]9z^z0q2~E\kdޕ|)ɱWC>t.ܴQԀg5N\P[#g9'9I5zQgܐ2rsUU\5 gcFU Bj ;)aF8?~ށWyF[7B fz3X 8#x0fDl,~b$@~DLL=m ILVZI Cq]kc/ ț*$!H$-%Jkj`qki>C  =|0ցpXN<i*_*oҌEaHDWA㝙 *ܩ^̫1?̠gVD8 ;/@S-=Y/ ^˯C1"\V[Vg$5SƮB-!6WȻz.*c +J@پ v^T"N;->j:fјؓ˛ܛD=N +0bea + 5>kO&&SaB`,e@N@ e6d2)pdRa@V鈯Z&ק|76v8 ʥ6Emh7}A+r 55nt`[k,^0g +nb- c&^PHw#aeiax*tҌh T h;}J. >qHCF.ߦ÷)r((+l{R!opJfR #M1?~VeF?Tf/TrY)幉S9άcV[ًUmQ_Ie D;EN֑2{r" h-++XKiG Y2h9PrvNU:I#0P: $)nnL,KͽiN>Ur +܅d 7F,L4Tgo+gie$5Z-V?lMlk\.nr]y.!a(vaN;o޴~- E{22a룒h!0M,'ER V"^ 6ga&s\ D88Ő_*}w7ݍூ023M hT+[“ 8 jOOdUQ xS'"6>moR[{۸ې +pw9qAU:hX^}5X,g+ % &IK iwnD yxWǚ`a.TI\Hd^ [=dP*s}c H>2X\kI[+X@zuyw{ ̻e4H V{sV<@0lɶs scmcW4M e DYs pqx\ARDm3Xk>Axb0=z6Ns\*蹛sL$ h٫#ڝm5 0!P6e,H(߈30sgŭj(\ZcO5D9Q~ɳĽF6zbId]N#ʋr "p/X$5`K[bbĸRpi}p!1* >K$O@4n`kr-?xĆ>Aևii1j8j9%H@fz8pȩLE"2LRv[P޿4Ӊ9r'~(.ՑNh^jv}{ѶAb"oH}7H>]9i 4Nm^ ߏ͓W_)ww,is] ='FKzP^%s%nql҉qp&Zʠ3 |jtb|z;>p $H<|{PC;߹PH>f ݺD:fL Po9ݽʽ<ǙF..T sICA{K)SJIJ +u0ͫրC[- JlPUʖ]ӇǍb|yPɈ!I/`@pkXO +RKz -h`tNZ8{h?ĀTB-!ŸE-YURpzOpQ22 0:l}AXS%-āE'(IP((kp P#PCqWF<ɚ,p10)7YAlG2r8gȱ矚<"3zjFּv- 1,~ vcȆ^1+fW4 Oqׁ^嫯=+#-j^ fGcc<(g׃5ÿ* +v*gQIMAOL Y.0 +&?VG;*66$4$S;rU.گy33 rDq:zR}=uQcXOn?[?$7)zŹntO]Q {䅿#aNn(n|q5Y:rΞZaH!^"gOLy.x-9~j9f*[zɊ3qe w3>4`A:-xޚ l Z64>[N3W_b\0 iӐN@2Va l#]X71R%(G/Ke@6ooq*ي}x:SSJ(TndL;q;w28?M0ٕ&`2xT6L;5TPxAQdQ\E7>4i&6f ftKxDHJ4T:0P$ G/U("$ (r,#82,[z圽GP2u#s$t?leEWM,c9i^GG1ccL\%%1~ +6| XIeW#4ނ[ںf|hF8bU[f.K x/;?,#`>6S&&P)wHc3?KmOˆ$I~$]rq5:ܒ*W> +qrţ]%G@wJJ!L#Ԙywr!-h<%.ho3kas0]O+w}hhBO{=Q9 Rs rJ +xM2>:ɉ#n뙢@rQ(MF^Ҏv@FC 8#=#Evjeke]^y ATW-g4b2pdj%;c)8#]CPZQq0F9)tjS\5뒉0(c%LJT'0&ę.mb3Jؕ\(aa\kL0&DH$\)5yK61tcre`8KpAef OTz &;&t؍Qȴ"7 KFƾ!ӯAZDJ&yRx$P ҢWqe׃(Bȕ6>ƮSu#< vODAaD ȝ$JQ;%]gQ㚝;F&< 0CZQl 0r_ȅ\ғG +* +&%ʽ:܍sVa4s,tT Đ0#_wW@cϏ(HB-MQHB_ =11Wxr'\,؇Z42/V[μX1lUO/켒+իѻȕ}x*E +XvrzɎ<>[U+ +Iv4xd|ś1p_UУ&5~e7l%MFϴ.` )} u+fV4\KMAɴfyg;έZ(K3CD/ߕ~]Q`SpTR #$CxɃO=AZr̢5;ꪗ-N%`z)zPIG#AQ@Q(rFj(Wsu7q2YOI'=A0@EI5G4=aG`GdڔQ qlvƮ s>_cmK^O9zjvv1,B\)~Ii+ꫮU*`K=$chV^a 1 qB%Kr~:X3?ڎwO9yk^WWXPS?[ wz"٥H^LxزQKtas3G44'0K {ӓFoH#MiymկTdg[]A`d-{Zؾ6*䪣0i4fKy_Еuh *)jr@\'UWj)USw|WWQWrR멜kQ/k/f5?d83: ?E& #3ܲ"!/|s̡;nvȤMFdOob8vl{# A*Eu,i[,Xv;kۿ.pKv,ѳ9i|żb%a Q]z!_b?p̖ : ("YbH2[jra֨3pUKˎ\6MڗUhO1qO8Y9h5X ٺBfލ@ۉ0(`)&v YYP2o<|݄D;IqON ba`'5EvWENmv$lR?Ӧ!r{ml)l0rݺ Cq __vR{E3AYLPowAp6Zdj=6;nrTA[Kֺ¶zґכ$hqS*' +_8AzBCS1xVd.AĪkHʹHҮ&rsnU/ڔH{-i2K# Pĝ;nhNf;fP<Ai姘g[ +ZV aQV!dIPI$9/53"˿}qk!D[V4\2 +Q) Vtc8vBgķ:n|8"\7a >g1裗p2F#ˈDșŒn$_X@ՐIHI%`}EDZ'm*vtlhUw~V8z<W1谳 NQl&BܚEXG4_zU*-/pvDiD;Xd瘏a`}0+#}s\lkzKU+z͊etь:.B31Đ&r,֮%Ӣ!8vYҶ kٲQAH m 4!Ⱥˆ#%Hz!BZ)TuF9yg"J9C++?YZ&@n-&KeUvZ/| ŷ{g;0T3^2J6݂墁ᴘܰLX r;8Sh2&6;Oa[msGV38iۇvaq(gWЕ˺K z+׬`[zRbUZl3;m| {HtJ_ȇ*zaK!KLiɩ$P<h;l1G3!!ݔDJ&TvF!~I^";c@藠4v& Sw (|\Zu NbɣlEC]ܼKlK9JPҔFbItĐ0kxKI"ɢGy!ʮGI4v|YwzQ6.ǑCr =yP Br1juklu%#@aWTZ-Isi3LsjsҞ~$l!F;tmAP,`q{ RE%Tnm0Yj'0lgU R1 ~ ATHQ+i6qy׏_`OA3(mVΣNz9f%SBzjMz Į:ig {= +ɣ]"gE||B^JݢR'4';}n}gK`3tG49]_34+El<?$rv2@%]/=JP"gM +eu/is/԰K8aEI78wj_ha_ %3~%$&$ޡG_Md٭@6  !p{6%bN/kA+)EGUfBbM_|yQP"yB <A }#td{(l?7U*X~ePOz1|+ #%{>TGq,@Å +՟wuY F }.@9*<:5ύP8]"#l;/D.ϪhW^@˾DygUثBqKo|I,EM8ɝmI6g-o'N{%s.ezp7ZYqb^Ccْ$ V:}mc8sWq-[唔ҟmjSf~B#q 2ܞbp],?ُHM/nq˯%YkOkݹ"/y:U٩Ծdl}v$k}1n =I]y[Kjʸ,Q@iv}w}^ _Y#gCz}M/z(^ (BʔRvKIiF_yTIGru]#YI ^̃yNPB9ҔЦ$7!>=$QfפI5oWCb52NJ>֖t|9!{ղo`9g`֦Y5kZik" 52qg_JJGRPGf~GnҬMk׺-ZKIu9la̋2XK`[ŝ .mYQV{??Bq Z+O4yb (kw-" +lC |SaNpXKXxbHy1+X_R\5kw!s@m}N}]ww@{ao>}%Gyy$ai)?\nK5`_ ?NC8Ƚ&uU9tX#wƭjːĺ|^8m\sF6lOtEN^a} P'_SR6l?8m +ʣH|_ at#m3jR9hL9zgBOaz r'BxDHw_.d]lV: to Ӻ38#WV +*?{zhY t-Rt%^LU`APE1q;p }dQa;=yM[զA_/q6XI''N5rYBf*e3iS- %99o 5lV :KV"Q%@H?l#KbװYy/+ FP[+(CSY]pBɏ;Fx[06mW;<.d]~ۅwґ zzϋi oK@!IT,qO=z_l \/g~a@D;'CCU z~ǡHs*S>\0R)0~ +J?J;"`Hp Ä +dBK#q$ 9M?F!"sTl !Bd^1Ch1P0D@">@ahZj61 1H!""QHD1D!#4ĴS"EӕI +x +I +`p% 3Ӗh5-KP:ub%u))H@a$T(|c2ix cx  }yzbw..;3W U:UoZЅ u YZF-aϿ%df8qݪ?YK8gԤ[?usX'yZyIAb qdC\yFUD6ph c=8pǐE,.JRŘ9s˻:ma>[[v0! L<0mu!oeǘMq}==y P)?ljӖ`5q.Bjn:]yZCH'BF j1f/{h}A*9K= [ճx֊pQ{uH>KW?iViP|h +o#G58LPvcd5N؆19K+# ! #PQ! !1T@CDIQ9e9!-V:焤e~rsjVٜd9ѕq'`9YZPnf~9 W(}sS +BhSTIZ~ա]numP>eA Sb.K'gbN!ȳj O`;Kg0!i@;75\9(Й׸h75@x` o~$bDod̻pd82787d56-ff10-4654-a757-addad3bed434a4306506-6cdc-4da7-8c1b-643650ebd677551.ml10SVGFilter / : /XMLNode : (fxmlnode-nodenamvalu1typ/ArrayeTurbulenc;children/result(turb2attribute; ,stitchTiles(noStbaseFrequency0.0numOctav,feCompositininSourceGraphicoperatoh100%yoid)AI__1idxww; {a&$Inq8HtIH" )bI -8$$DH$ ЈDu up>y+-0Niw^qmC%om qrqA2ɢ̥<K:5"!\&#0ylZ Xp9c9bۡ=@ ިC`}5WC҃ruޓ^ȁIVU$lV\ZlH_´G) d,2mIiVPpr!qDcTv" #9Z+Eft[pE(G1G(Pp3D.z7ktno@Wy[Eʭ( qHd\Nrm$ߑU&1BN HaqD>Xc a@9ì#MsdEf~HQ"YKTbN"M׬x7עxO>N: -y+P*>|'" ㄉI­(y좬%UN[agFyX@#eHfQ:wF*^js | eJYxTivˬؤ19TUTYCDeG|*bf8SZ5:T !`NmUNGq"հmTtp⟿~g^o#kD'jŔEmѠ$l6#ډI q]OJds*toKxyqԀ.5@CĪJ T0Z6G(*l}oɚ@2kH.YQ/!9?-du{Y!Q\L@7z n6 EF厗F xfdZMЄ450g$ޡB߮)3EOILApۏ9R] -άOL2!8>\FP!~a[b<q rFM?Q,=OMGڌo֪EK*X}@YVvȖhaf||.;|0Ԏ4!0h -YlcAKdq,M\Sj5ee!52F˦>*lw.-)s r.>ri>kswܙ0,D8dZA=bWCl?+u\Bvƀj羍Pp @*xe|<4Iު#2$Ȩm# $X-2J[$h.%A m2&iDn 7a@nF4FKWFZm2^3wK+u@C t63s Z, -cL/ Ĝt_yԭLqEԇѝ[C T/zncp yG@4`GD  -f^TDxp̖v<eB 7'Hf21~ʂtbNc&d,,ʢ#FR(Ԗ=qxdm_ {0!X ABGBa$.F -xu -R*Dq!v3RT&?ay]1hkr 68MP0Q@ƏqVe#$`,^ZЌ׾˔SƉG(7GCM$5@5z -w"n|T1UFcoOEW#W7~(0Eͭw+'q׺,rE[m7hdYXVY ^Q㦍?vn}A( ,tTYQ!S185O,,6CRlIujqMI]gabNECsHG$^hݓ ŧJZ@;&$dM,!5=I&wdcu5RvWkUtҥs9Hշ"*|1ad8(.KC5[Y&`,N]%hZ -UZ0J"Tl}O^!'ҡbCjpB v#C֘wyQ9S挪Ɗ8ciw iF$/Bec\-`Yyz*8@1 -g4-ϨqD+ea-h=0,e``Rc,I3QZO OJںrt]mJ *b Ȁ;DPҬ\xJL#٪梀2a~ 0(耕rSRRWg@B6ȶ".@$U;.U iP#}"YMYʤ΢LJU5M*J3!,@yAUkP!RvX(44sn`4I΢lP EExwB!ayh(ۼpM=?0|?HO€ r -Rr -r$4}cOAT9DD/ nj̘4w/ xmtrΖ_]sNr0y - -jgx\9l\1׶j[Ǿ۽ -bL\$hXg_Le<{쨻51$D0@Dl` @r44LHHH݇H - eXX'% 20*$ D$qSt?jx@aUotUoosi0m˩/M"Mphxp 1a`cnC^uލ֡MPcǐto4֛ C;۳Z4ijI}֟ZU!=aT3IIɆ͸9ՐN iK,@N5}5ğX ؘfм Ȏ.-B-"aw%DTomϔk{M8KC' YCfhu-47ԯvmKxj̹F}3y֥xβLBS-ѱ"92Pvyi>5[ -4׾YRằ! J@\I@QYnZJrIf:q*~YզQ¿?KJV&{XǸ,_,OYCF'RHmLsNIHz-_N1Df\Nw(*/S 4 ܉7͇Pb6T*r(, +ա=mmo:ڮ0k(n@Om5YaYeOé]UUcU=_ Pb@%4~f=",7eKȜS"+E۳BU9d`?vHڌ?%*J5<2e}fW&a?[G%lصT'+bxHikvd{AJ'Ky$v:ujIgh%9䃖 #2_V+}Q4ɰe],߮2 NpL2$vᤘRigu]{+~_̘-rg]7ˤ9U} sq"4E#'tQ!29z$B%:ktd} ~cIl+]!&u|5x3ρjSW\FYaffi(( JF9PaPN%ПP^lB:IVF1fcGjJp0P;$(`DP:+pHԥ0,\+L/h:=l ֡^ zƦOV$KG͆*6cV%)SyK)~5naΖUQng掦D d/")CI\@e.{,@\$yGxu#o ǵD /MKDgJ IE92dV2VB;0ʉjZ7v_BA(qaBۖ D0`C؜=hGDTWu6)K"Pw](Bd^4-}`Z'Hf# jƯIk%Iݻi/8;+߿Gc:EQJ;tgpp!Bں4@66NA  "P ^fqG~u]FP6kcx|;` -j .GE$j5@&%'QXȰicqnn3+\AR/3D9')*hˠ;qXCs}H},Gn0y<ɋ(;%S/-hٞ!5p Xn٤~Σa&E&w! dl -qvn]:P%mc>e ] -8xg#l>D@ؽR݀"GݨsAb jkByJoFQ,71 -Jjathj_pڣ^+5b֥T`~}F䬜o a7i@`ԀX/lx[*Ի< !g",2/Ƕ;-2Kofv7|d9DBv\ r {#D}_ jwbPzoDP OԬgmiדfWf0*Se;LhC؏[7K8̨WhIR{ )lO |ʙ6-Lj >@\64$ފ2rMލ\xL< /;B1ѭGml~( 3'pq tNϳ<0@>< GV_TO%xOi^o;X!# NwGǡ2ŠRGuj Yl$%:+:ɼ.i }(X~V>lJim]]앚}c?I?Oo>iuE!Q3* -aTr4tAp6߀T' -*Dtw]U6Nbb|t؜^8J[DHJH|P@X7vB ad= Q Wfh~&A8AE<+X: - zzFiA$b\tT,zp*A//x"mC -ô eT -^OIcUtU ~7UFw#JU| -0bFGQR6r=`SUP/Nˠr3PFү,h5O:(7b_NHqF75J#Ŕi7Y*%uɄ-u,=[_U) 3S@]7i\6SEܨK= K3L9J0sz U+s:T&GV r5fg qԒj;qNoH @W9.SṐˀ=,5G{ 55fn~p",x,g[Xv98j'm1b؈% B6G,~Y5~Q=\) ŮLlkr o}IIWb8%/igq֣s{9(Z!E HS9cq)!).)[:k -ddO1^XIkSF\;%֝C)f$$oS7W!ʒH_[)#K5"l5˔g_ R# #-@"d}IΎPg'rV=I<ףL|:ꩵPrN$dO'B$qks@i0% ,r|Ȩe'zZQ'>Ҽ/'h]8I1JUwxVx4hzϖˁ%NObЀ'E} VŻ /tyZpjGWU7\>] { ~D,.y-!4A4 -< K(#;ƼDO_u W=!E$:U|I$O`fW<}ՅQqˌX7³V%ۡuۖG`O'['T./#[) Q?46*'_߁D@a]dGA]'N#٧+KeWEKX;x>}C(mhQ5[.ص٧li7 fج&*-h..J*]'~,)W4n(n5X<ŭ? ͉H,%-h! #!5p F޲_Δ_`pɰ/,]`PZFGif69L:Cu hi5⚮NT-KP.Qs+(TӞP].5ӄ*tz`oRc MYdSCaCe8S\%0 JA7:n n.0TG"k.b`i`qhiK0q2 -wGLuyWƒ87~ND0BG7mźN j?!\Ace{@9Հ>6E ת8aĘP!Tk)*M# -d -f7ʃE/g-4h =<Wt/.PbJnј4= -h"}7 dYZ(lX_^ML -4czRw@wݧ6lUqT>W3Z9*wy?=a3]K2En?0ABٟmm dhyz%YݢlBӨUa9׹~ҖD(a89LDp؃6 -h4do/'ҝH TQ2j^r`E]#J,$TURp;8 -9p= -"JnN#VY.8ȱA=Ha0 3H cEn3N#$$/ p7m|fv0F܈^7!Wkb>Hk(yܠ hae !P6-ωD8~ыy0G|º4r73޸n!&6!dMc{{"v -4"ul4Z0A9X6~=#>}{+EVѲ,L7=pUhB՚{rӛuDҞ=wh%M̉ 5'^6S748݅"kդg S7%vy$C!o9a\LO!zȮ◯th{|+a/qiTN% 7LؽL{_#0ujt!V[F< .Uu TwSqi%1}8v5GnHtAYÉLiJ.:cC֟=) x8i_0T*LKaX^aU\JS-E`p7\,O~yoFݪ;#$`r4(keTnѢՇsĎ$pgwKLeathU@W8U^5#iYԧq5|g[˃Ed&uK2>ަom*{==Qg4i)D<-Otywq -Ly%o5adFx x W<7;OM /,b}UA 3Mxf3lh&D7.T~79%|u>(L ݗQul/6X5[n?o icҒ0! z&K׀uYI̖-[$STF>{fș!38u!ɽXdӍBzݓ-:($HAr=OH@"_3vKӮ+6LFVVPu_7$,mT'u+,OFrB('8sbmzU;v<7랃PUb1UW ՀWZn;+* -2~%`q{d$̙/̙//%AqB.y8.L15v#=9:K9həqS c[H W,:+G\lq-r?Sn ua˦ж<8bQ Sږ?_"%_C/]r[5D.d$#E( t Gh(&Ic mB_S6s3 oj3ΚfZҼ]}b]1^[ribdwU3uitQ -_Ǹ/.i)И gh&\CQH($Np&J-m/-΀ߗ)l,rqB({I$BPs+8 .\A2y}YJSb7L\s0X8?k4\sdUSm`3x vttь"d6#IJEl^Q$8_X x) -`$ - {)Qs%Q}Lhh*ǔ{QtNYNZs$d2>6%&_d"IQEɞ57Ҍ2HFE]$8c-(FAI(g^9!3 [a&$a|֘I1–'zi.u<⁳8슅-ܺz2Gd{eK0xٙQYS'}#Xb/)bU;aO3 A -ۋrA,$(O(b6MHqA\2afLu/ܠm *f%Smo}QAg@c@_i>Z xWbhߑ. 11'ݵJlN:ɔ=(G~7$ aMjQvAv]ZR*Xfjj$ -@2`zso}=!V -b@pf+>JNPݳsJ0QVsY+?  Z@V'D-7Fd WgPOJ̓$?e4ۋ@h>^uY%=4 $ /suv{l$K2BZw5g.XEv,v9RcN>^X&\#oT9ŒyG] - -b DvKL`HLKh?Y$S$?jܗUzF4 P# ->1.,>sJ+Ays]q -.ƺ)sUӀ*Zq -(y$c4)3bHJJ~v 30r35a&6tèadlw\|.6n7ȹFq]-rba7[UEC^ ߀+)  -$ &vD -嚸f4YΜ2!>45n$X-Dґi^LL#hr -N, -OLHagPcO7`;`| If$ޟs:i$G-/@I3& FGX_hrYbVuhhΝ~X+7UKYͪ7c?!N҇@a"LXB!0Afq~ 1zrOfv,#+H ∴2X^Eyd񙣻 4S&@>#(zm0t2@!䈴>m- =D ;&ZՀ_ 2$aXy0{zg8 El%JhD<|x ӋW˙ۻZ-2X4nKZX}qƏQbJcЊųƏð  IP`ko^c-h}d7Y̗f$)9 s -fNvL|h*ju0/gvA$13.^z6Bv˼̌Łw,F=g^,h -6 0q -&,7% -b?`I#=mUu@b-NU9`E0YvyHSDGġ_cpnvTf7'pdGv;3∫ۃYcFBwJÃ:;(YgZ_[5M4er35u%eQexG/]]˝;(LiފY" -Պ1·Y5^I$VN}vY-|o5p1$B"X*VLo/FVBYh+_=אrchS#w-!g ÉP4Z:A# @O@0E#1/av(NUlc7p2fl2>uYi "N%U9D-ANCcJI0:>6}zQxūRmI^t&vt4HDcn瘠u}V(6tqL=;x0GtQvrnB2U.A"2rS -%ۼp٩"D I! -޹\T&H" -D}tuG`WC XIbaeHΥ yh m)DEi'+DM9(jW`"VEI&6%B2)jJX 6 L4_vCtuGUEnD;UP?glQ.[D:_.fR -ݶaN͏J/}efA2K Ԥ(< ӚC#t`(kgLQnhwvpGZ! -(>Ԇ!=k4ĸ=N&jjg^̓6ZBĬy>M>zd][,?QEA֧^-|>tm]J+$"@W%RCC6RDP宀RR5t2}ftVQ nO4埀vZ| fL$Se / <)*[e4qt:24(סd5k/>-*ty:D̈́byO[>|[g0 Z_=4t4DMԬ:Ʊ3+ҁ_-j@pC/G94l 2&rm==N&[yk7+I2>뉪 -x>pjYtqC]]*Tr Sk,-!/S%eA$Qb[/m@3H=/fj/N%?~7@hspF= -C0,۾gCs׾//_y7VQ}e s7"iA%2!/3Eڇ7[Up?%0b[G6/滂Qw#įЍD+ʙ3ػL~<#/_MUDF&)\Ӊ]w%%h5*y -(;2x (ZOݿ!B}Cmw.PH2kTk? SD'9?AZ_xl^6fW!kJgΔIiulDTLs{q2(gDM.Ǯz"@i#; ?j5&(MNEC O824Jظ@ΘfV뎥3wVi&) 2SP= -j]VKpEO9TaEQw4c. -ݸ2`YjZ endstream endobj 16 0 obj <>stream - h6S'2]54%ja"(ؔ슠K.PJZf.߼ R*> ѠSldиy2k<fH`/Pw3EE\T(u3 S,qtYg ǢTnQj$QӞEmLC' k-EXt 3>kxfjԦR3vIې|. IRqn3TH޹EMR1] @oX^ RZ5↔jeJ1Έ'Sq!ډ",$D(e .p[+:GVc -]l{8.677KRhN85|#d Dr`b;ak|y`K0[U&Cy#!Ag`IL acיUD5nuj48&n*5@]͠P4L9(1qt,VB9AM"hŠwFRUW2't#"IC)dI{-)x!O+mȴx]`u4ehEuT6c1S8(rhrޡ6ZL݌n^!HT 1勁ftʟhudtT;Ƞƨr2 h &0^F, XM]P -]zd%Ùew*K9 kY-D6ޢ[dNAE*Q@BԨg6-|Mij\cZI,<;hj$pIW9RsϨ1`O$)ubJZ2VX,n|J axY 7mDsl"q  e zWg,T`pHF0bA`@B&iL- ӝGH -n@>H;> )vhTkȡX& mg.BBueS0*Tgr)>TF'##I1^L8"N2 ry,HF\o;OrvJ 2N3"13,'d jż 3I(K`k)ĢNC`BzB. [h$IݐdK UJ UQN)j)&e L if$H(!LIaSn~"gbԹ-:lH= r0á hlw(=/IZ]#*"$# wՙ`60auM -.^G4:-@e.룎d~zdxe[X㣽8nz p*Qvzq@.rWbYVM99{^Wso\1er߈=qcîvڸ 1}s`];8er=/YV۳9LֲqXw\x\ˣpAXāM.;PV>R\7%hk1oup%}T^0>B`YQdc0*=$14Vbfv;um. S wW4Yj<L%<(fa.j]l(]dʹ٥?-'HGV F8'aOre37: v|\PGrrI#IMwԪ;&-[!]Ya("z5]qXvVi@mp.WJnzwV2")(N 8͵2"j:o`cb(蜓nq"fWJ -^omPz3:@VE $jdP;P;htBHh -Sۇp]ޏERFDDD$mqD@ c排@IfI2D4̈I0\ -s= -[9%:p$-4rD0: P~Vqy)\_E8_8*^xP[2G<9 Z6w#Ṣǟ \JΥl;c̙K3"s_!IlԘ 7w1R؅\*GFnŠډ9k6hVyFIu/u@a*2! J:jqpFĈՄ8"z[Dr!!U5Ow9A\JרًmANDR9Gݝ\\ι#٠`mVCnڂ~+ƢJLE_ff9NM.#tw$p4k|taUڼ]L.߈\ h2s 㼔΅}H_GD 0ܜ c-PLymC}h2']0)PkNH]Bj3H3#OhO?u4)Mq\ils`h>b$ABq7J(S$ޱ -R5&B\-P5Ma4HsSMYQK"W%֊jےa*Q)¾Q -3SV]@ UTfRugB8[. -nP^NW/8CEzK*!&3M?ZXG6Ӹe"zG5UN$hW |x(V8NEYҀ7-Wg<- LQ㯬3҆m - T!d(`Rw m@3 8}莺Dó,${%T4)I4y-RoխmwHd xSz. @B;*dRS--j׸:Z\+EY6C9iO~.|DWʕz -U[4'ϴm֨d -xlTݞ^%THA<UGg0ct,0.³-l \KG! rbu3p5| J9ݤԌm3;P&;M_ -tt#Rm pEJɠUЏFGlNuc l ρ.ҜͳJm {=W@ !09l W ˲9p<5CCn0#63OC`x>G3I;+ëp΅$ON0Vh$5$ݐ D & u≾ld/~(E.,A2eͦs|qm:F -^ e㍄3Ռ{5aj$H`qX\ @0QsOUmP6O`TlGg"s*NNې@urLsd=D4δ|fڊjie?5UP¨\8s7T3Ƿ("NPgOš\o_Z[q\SC*Gd"< OPa0-Qoa%D"K9`DC T$2]9j V6FmF f^kR w3o 7OQ$󬻐%Ŏ]9/At[t+amJ&B "MJ-W,WV4Q;mPI"q~GXLBBa/we~=< -&g=([pBհM %Ff5 -f5Ae)\!z`T T'#,{9!!a@]^ANMw!Q\ GҢ(PK%P+;Gܵ8nFZ'Pj0I\vs`iVpxƇmD ъ邅_q(VwQ+@ ::HNs\BObiGllzĽC1,!k'MUAI&@Mi<Ǣ?ao 0.&qXz 夎Q:|, QBc8UK ǀ7o`0_20uuXu2LE@$@" -[Vs_X}̅X|=nsslghs/>c*9PҜ%Tඁe 3`M'6xmP̞4-leZxZ ʔ1"%>*m,WkB8꣼O6aƈb( dSj::jJ%dڠ9dlPRSό_{Iv.K3 (:L?LC 7u.y/0u_;X|a/W ;j꾊*%3Ji!8IrGUVszb -L_|0 -4Tzd? RKIt.^,H6eNNϴQ܉[\ E#|[įݲv ԝ'!Jγ5)?awaWҢR#T9 DY[@կy{4hVZ{o>MՙWg_4z|.KӺ%c*B ܲ -3S+Óp16Irj"xe<[: -L_+. -E2LW( v{)}eUؿUn}c [.;5}Ra6EYCJ]05[͜K,f*N5!E`i \:a%\I׌ق2(A 9Jt.LF}p|\ )YPcЖ-?~Q4՝ƞA@ҜKGsn?pN@Fs@ Й}g>ꀐ!ObND4N`QSj(QRC-6,Fc#Hmgd㵁t}YA9MHCyd؀cayFEMƿO &2[oPht "d2TJ%m0c2٠Wyu^6 -Dozu^YgxwtbvL̗L\yZ6]S]"v9JHl_|(-@SA<2kץjhM%$AU -$iHX*mjI* -KU{Vwdb>:vM-J QOU&Xl D6+SGx|%QT[v\ q /`VkHxvo NhR&Ҩ<냒a%Kg[ -+C+ĆgIBU*>퓑Mv_mx>QpG3KC/Q'ɳ ETϳ03uuTIg]Rw!MDxQ/js((Q#IR5\/QClH)PgtEE_TTkOGɯb!#e=³@DTϥ Y$xu5e RHlf8,YVX k&$ Bu,"OyP$):֨ &G8DSj5r(qgԾ ~+QsIR6OL~GY }I 뮂& `!0:ZgkgZ\,*ͳi%HRx՝gY =֌mklž2݅993R<%IWUadw>ov^q - -Ctbo8 (,{e]( ># -yઘX J% 4\a!!lwCbIQU`-dS<3S9ܔW.GYo_?7 NW-}g̡sȳ#zR  JHyE/FiyppIwaCŦ(fa[ jX`au'0LɬUígY,*77*zġ .:'{Ɂ!pұN)ejyjٸE+ym'{WhAȓTUM.*4)>H~t{~DN6"eHY"ڂ>|#e,sG~b`-B6Rݿtv]lY#^=9E8!V=bDwhFkvZvXj1 SiT ǐF0|uѨuս$a+$.]TƁ$ݗ5 ?Wxno/P3Q+X=HR=P[t娱'jehpJ KW@|8؅vx3ߟ)I5xx(L[jA畊j,GL MՒVRm9՝PO`L&0m@/fL^G -(gCfDm`[P~kwIHP(g((e+3@@9VSQl9JŽ~\1P|_lD~ 7[|4g5"j ,j<¨5F.v Aމ.}+ 9QZEQNAł䲻7 4{/5O:"[щh|#/Dw =Bt_QWymR|܅ Oϼ.ǟ15qalq5 wx MՈ{ iyœ :M"E炤⼹sbL܇sTy7- 67dq46TZrw`H܊˒h?Y]CTFm2N&2+\+LYYSp%HBsxL(hx~-G?ҏ%ִT =7gCx]BRS-7U6JYo {չk㇧!l -m!*Jg6>lFd演m(2|l yP=yF ;=j&Tιgo.8cgCI;Uoym`CAmchW -S"-m!D($  ) B -B[~>gkYtt8#x I N9IC6= ˈyuf}hޥ34F5,ܠvKWpyFsP壮 37(c҈(J?9ts2Ι`:(a"IƦcLSnQAgsN$HGQV~IR{^)sб. El'6J_ -9C;[h.9pݳ \ CoB J ƀ`hd] G4mpUs@:X><|m 蜤4z,4&_Arp.S}/%e[73)%?Ex*Q,{Ss< <%rG,%bct"4티rIg;g]@Z3^[}/>1bmT+.^?ǣ5cL+ QyV1@a{njJ D~&Kr+m%’,j;VX`R$pHuG Ve9dbo0Ԟg%+y|gXuKZ$}UT%O]2ʘ d6䋖}ZgS0K1<%+yFُiY@ce$>6D:!Fȁ{˴96p9p{`.c>O 2%O̧qSb߶a80rJװeyHpG{\rJN jiA3M╿ư(q@Bgk5NJ`U`;#@}Jהc˝əQ#~pffc</}LrA[%Ƴ h-9"U,cM=p9MJɡM*VQ- },9a!g<(."&!- Dvę 2`  ʅuz=, >>>00&.(qP(q Qr$ HXQ/\2Ee,d{o>z4>7|֦7zTc7S ټTYί`{?Z_相 7gΧR ]LFbP&9Q-$-Sszp}^Ӡ-Ҽ<,ύj?#fL0$QWOڗ:k(YT!Tח^bp$oc1,4ɉ¶˅XkG;#`SÚê08g-Gr!jIՠ3f{`'Q"h.=JjGȽ21ͭnw35karDЄPA>S9Y_1hT%;exa[߁yg6{ۣ0ャ3/ǵo2FdQcWFqAlrEc8~figgלѠBΛ!pOOYW1fr⨕ :gҨDY]ZxK" դc\3- m\QqޜG}m n{QW?)u( -Jr`MTQt@ЊP7c.it8#[bt.6}=ŷLUeadۙ+;ΰ*GIL-TKy~rT#EdN6?T.#q_̲L,]\ezSrƅluUƯB-EAq )[fq%Xs x{5RBܷhܔ`Y1V986J}S7F6c2NyKwJops\Ѡ -ڟ 4skqFur)]/ţvQ./8DAcy0nb#2x!n\ʭ;vMyNU/vUÒHa7 T.q 22?|&ofp.nKxǗr:jo,Ygve;>1rIo?Ž¢Czau ;傼;oFMOL)ImN~qP_5<( >SJd_%gR;=+ WrϴtŵL["t-'?xP(w:I_dsMH(t_20z_H$w .ys:fCwq4nH Na ïSkϺ4҃֊k3jq Δkfmʊ{W.L9-8GA'@D99Gً#pnրacOMU 9 -e,m nhP8iƊ?[}WmѮ.0*(ȜO tin8s)q1$V߃]`u>>0#aX?z Q:pqFsZ7R@ꅲFz n %=6TRӚ8$w@Lp]uOpb% _j{EpSpnBC$ޚg]?jFAGy8]6dύ." iiL\e>! -&7.:A `vFfhFj-S,Lml~,Glj20Owss/_Οy;%ЙX{.(v`i|;Q?dV+AeyUmjjJHNw~fuܟC~^nOo嗅)S|o2k(F}`]ʰJ5yڪ:VxUE&hUrir7儩˝}_0R缄 -@ځH Tu/r!()'.J5*'6ev]h|jʻ!l}""$BЖۓAtjp*!yYIY2_D5GBԹ% 9fz]mM Q~}Fj!V$%.7 e{mGj+R/F?D-, ;{~ZQz[.'ZD>*Z.tyAG>0#|F孈Z,H7 mrCP9p"wD&OseZ46Pˇ>AjBחj !pCVFT -;lȍoq1 mZ]P U] I{J /os$AwOt Q&$ .dƬRSp2~:SO?,ga_&;њ?ꭰ/hBt@P"Qe :XC3Z6 -4+v; - jבt }-U)EUv22~il'n?wnU9`.rp -cke'TF*Bb4>2,~* F$99e{VOX{5+%$'Ɗ3ď-<7Pc_l -I^c)L&\~QIᅊ{]pc4k奁SCtYƒeBu$g`00u{CNQ=$WfK cNkDf=W6nҹ$aƒiHbo*_>)j0r+xV'&lFrG; C=d?xT;x\˛DC~ld!-Le;D/ss_i^s9mA R8(79+O;pD*$}}5Vy N9)oUj+4̕V̠ -ބ77_[qMFl"\jqkW%l#xV]]-䧻Jf^FP#">ŬP!nk{)ij3˒l˹<*($C$ZD*+O -V/k-o /u.(KW=9Q :2AXFUG>$XZ1bjMl2{~_f}g]X%^\z3AEbMţ _aA (У[^ߪt`m(VtP`cIord}ͮ̒<@Kܛv 09+,nqZȵw#\ˌAjB[i$ӹ(VHP1L#]XǕy[-6;q{A $):+Iqs71?AV;7q6K2o: rUCE#A7,r@CX^UZ (`\+=AaIR(uMfSF+?`8]Ky\IsWRAEu)xDvc3MIODzĞw>jM0[(LftOtN| -_-;vV9]6)@.o5$i*dϫt#to.hI4˖3 -G/>r}E>\'L'4Fsv)Xo6e~XuRT s^@ g3}@ֈa=+ VQp .K߫gbnUňk0F"⦚F b$?ܝt4]2b)Im^"BG Q0aeI=py6tk 4%>^&=6y3vI^ȹ=&V6 .fbS) NC Ka#Xaew(W'(df@gTjOXP:JQhHu6 ,-V_ޅCЖ AȲrX '4KIhYGqAu>^A*uVe ٠AƱ e6]vT ;ל;j*PTQ6fk&\лA׿/wO/mAfn9?zCHS[˪˛G96,s]W9ł YtƎ3skZn=,r;~N~(U19Ӡ]sX11.lJ/8ry 3G4QgPXGB=&{舑v~ޜm3]G1=*NXϙ' 5Hؘ%=MށDz&i346YL` -/bӽ7:' ǙZA]M2©04P,MOGK~4Ζ!Ӛub\)PKԥ@Gc .h%Plul@l&Bn|$\*,3iN4!b?dIKQA:}nE+>A 1"ŃB>c*6ߥԜe"/YDm0 J|).qs{F6F/\[DOwZ-Mz)Ε$M/~: +GtZQPn),w>-‚r0X>S3iBmj5'ms_W%~q- `8n &c Z0V*i7xѳ7*Ay^ݱ(?@Cՠ`8^ݍ - o%oC%@w}2)EpR^QWc͖,C-Pg\|'O]H<Х.Nvi+&vzݣS׵!r+Lʒ>'dJ]R٥.Ig"I]DYaz! NI.Wk;9JRO~JEG*E=G>S ~"hcB#h{F=0utz>]qԎ -;κ:WzʺE{e,*뎝h{{_SsVprY>9QLv>vMi٬!F7ϻ}P6͢1\ޓZe*+Ǩ>9p?\NZzXZUpȡQ'X? 2*})@~4W`,-`anY}EiӇ3CYm Vʛ|pqzC9EJ]tp jI'VЬ4j.pc B.<O4a“ V(Rn1n -ȭ+v.5TZYlWLFv2AkU$fz1A$n*XAd8 wNFQZJU+/u)ylOX0 _3JRz(l\F9te8b}KѨЃf kfZno$WUGZ"BrRRϣ5@_*DI:;~-CPՋ.ҿƃb߯{8% 5 3)dwUpsU+BT0O.ZUnj.[|qLi"57 (̠>NlWC=eIE tvRQHt2|^O[ v> S,JΦץ7Cm.k0/ہ0ŌP [c?a^E4 +&6jrkH5ރd7rV] r+nS?i_rkG"GJrKdS -[q7%0r'NN)hb֒Ub|!fe'̎^`niǮs#Rne0I.}-/a3Y0{a(2ɾԂos>v#7n/N/z! `{F1W]FC\/12#m1 +7T|@^=rxiG1N`PphSY$УNh;oa[ @׻.+@ff8_]{e&| p]F.OLh-"XFeF)Cmҁ1 n[,`g~:ͻ:ͭ0~ڀmP2?,/ۅǖ'QԐV$LL̘SVuNJAyjL4fk}K#1ՊP-@@{GTb2qbs;`bDZKX^thOMtPjPh+8rpG1Kh#e!(^H!L 0L+&+[ȋfbkPYX{H$޼7@2@>i¾&(iX -R -zޏNX!jMo|E[A ,in `8LAw)J}j:@2|6L)$J, -gM z -=w%!6"̾g@IY Pd[vo:h'` AslzXI:szX*X\ϖL,#4 -9-]lJMy!$>?r “7YIg/ݪxiq֏+,$2vAtdLs~#2Ȑs!/UQV zg`I-UnBLbB|8^Ag$' Y.r*֝Ex-].,t^Ѿx‡-G;G߃@A+ NlEenYH`aeVdAf1c\ݐp@m<)N+Pi6a'=js%ẅ́f[?RBCgifϼ{ZeB3%ULh.gvP7?s[d -6 4T* YWg^nhF\}fnUe5jRg79\/<ϼK3ԀܦjkbϬ6154m?s͇fFG^@GL%4u>7S 945-3cڬ^ĜRcFg?kutH7@*xo*SɈs31TqXF-)@C+ )&h1Hµu{u3UH3iD &&&b3i_'K=\O`t'%uYv83k\5]SM Yn&$x7_Y!u@A'Š.GJF?nz|&5mrSgsT x"F,4Gq_2HɎk Iu31yNj#k}-e*|L3]4+TiÊr9T,"0 ,ȟKHEC?g`ڹ)pw8yNŒR{{ K{K5ẻ:;4\a@2AbȦ;gi.YvuXʇHhL)"1?k\C -:rl. -anNJZ6yMwmRNr=:L|v)XT7~̀)@m<σsPn[ma2wU;)נj0fA$ҀcB"vv]Ε 98PVܾ^^ -#rgL4ƝtFY$X̢e9?PLПOX90TʇkLMX$a"vf, -Zy[>.(=mlyNa%_xre!%&v)@1 His`M4GopWA6xȱFB[RdZ4"X=PObfW]leaA[.q4LqGGVr*E<ԖjESEB Ү1[TN8TsN ׇ-jR,f>f8 Sϡ#&)L[WQ0RZF3ֻ,(f -X#8 -_6Q/7)JB(wY?߷Ft~ɀ厸@:᳴ -=AjPO҇%wclA@a_GG /+d!!r9>LJ }B;G,@3~+-^K6ё'Pc]M}qf:D5d|5/P>9Yu2+i1Z+Z᝙{*QC.,̳ױ{$y+|Fqd)y -H[ A"Lu/) RgH4 0&7mq%όfq&H;g߅fP4(_G;?U>:C+/f?\} +`113iìxHmY#rp?P[,bCW'(yXMD0M*\hjgQCRH,e^Qэ7|.!3%0Il*B,OFGBrcYp0Ug/a*u2)sͱNaNDl$hԔ\ ^l)q|/`4Չ:R\ 4gԺ!@\X~U WEZg @j/U-ZgY cqXf$:X+e1ԁA)~!|[֨F"4n?4aEmbVۀ@4=P,x$>}}l5XYc^7Q1 r9aϠ©{ChUXN3kA8L+.$ׁGZ5*T%Y3#'|VfMƲ${!MT,T[#Ju-yZ挕h/"VB%55@9H -6.CE>zz LcKUTfVb4.(<3yY9`mӤ3 Cȑ*=tZ凔 >Zm<*@4ʷH &fuD&(j5]P41lUɖLLR$~נdyEL!;;fSS27ZGSR2V *~[㩡"m˘xF$y%y=UK;S\_G;P< 榆X Z&جs(&.+cN9w;{ջ51tPq`kW䥵3ta<~8|'D/Gh>) Ȯ֢P%L}rV?0ä3mZaǟYnL6%ZwM8nts9E6q̚Ee[ xmq2Z:#;\Y oߕ@ ST Oit/-G/*lAiWD3Wf3-S 6IZLهRjGƎ]v8eMz~bYK(ԉ -V??#k@6t`W|5R>N>;Frx\'c8)-F'R|˸[VwS rS|vE*Q#: Y8[+Oa7/}v CWt;'xÄt%@gRЫbt='gew*'.%m^@H8u=G#JZXg_~%&|6!?zNP`PԷg%rNz)i,)paZkìwq0,$\mM3f솒@ZHUxRJ" !%)}SW(rP&! AΨuT,b灒rH(t|Od.jR+i+ސiju qt8 -F!0౐BU7%ِkfF@h"(2<2\(f{>jB愙<&̌03ײ.t> AԊtq qBQh*h2%r~, i( 9XsBZsxe@̼]BAM=߄]R48 PERk @` 1-D qҀBϪÉg&<>څrad܋4Dڅb1pE 0bTP؄T( L tk8XJL0ǡY}>U ^L.\-, П25DXԉE"'<<$w:y( @(Yv1 34NsL1058I3c//Dڈ69 W+ -%h.\x p)@Ԇ.Q0)ePNNL D`*|e+ lРh^"$ll8a"8(}/#2icp܁D!$D -EF8,Aka ?a'⃕N" 9rY8 mR1bI] ->|)df3˂K$]"38$ }g8}lH8hP>B @Sk2I<- S(88AP&~Pbٰ Ȩ$*IPm+* -p0au? D4,M^!4 q1@h NyY( Msdjf8}<'{,Ber -(G1I(qayt|TwCMڠF9qaT 䤱@E( 6raλ9lJ4 ppP,xWkbA у@h^s0@ -{l*I#$>e:3IdP )x]2 8i(Bw.kt)!Wx8Iu<T`-ޯؔ(Mlll^L&5 @5_Up `)Q+@C84FkœgSb2F8| 9VK, -by 2ݣ&&g>_0qHbyR{X -LajZ:M Я*8KaϥSGS mLVDr☚i61.h 2Blyˀ`54R?(A`ahDÔ)}*bx{PPS8z]he==v|&"1wvDkuHa!hൡh*p 䠪Iu$ebSMeBi@.iH_AGT3KK/^ON;v[=笰ջt֏:l]+7f'}ْ7_oi{J7ZY(&_rr3Ҷ^Wn뒥Uin??طaiWw>r-'[i{%)msX5,J׶ڿRRYm*k -Rc_j_N~Kݣ}jܷڿ–=#_ٯ|e[czmYh쾖>v֮.#ۤm/8X$c -}Rlx\J]X^Ţs}a뻩ug~۱ﵵ][RFIFF{ih+7f VNKƺXp5`n:^bg(X Wqam1&ΗK bu2,6XKry&6akV ommo6c:,6Mwd1{PccXg1m&pbn[ÙKņ ņgf ݈uP@m0! v' Lu# t]Z(3}Ks"KCԙKKQS&0Lɥ  U83*JQMd⋑S +0k,9#q VDh:&⠭' fŶAkO32}F~V b4dfO7 cpAKmsb5kf,X2WY+Ś1xhVuKo,ۼJIGy6vRJjV/KH]i9ij}Fvҟqԟ>ScVh.Rlkwҽڦ_irf[٧۷2v:n96k&o[귾J{Jmgln_ٓ/]zّ)ߣFTNJ.Z6Kic[+iSپ]~tz?ַoRz,),g[)?R~f)ѥoL))gWLcr[zYQJ9-_n:yJ1%Kӥ6lNK2KNӓ_oMNZ/KemJ/*Wo{Qs6{^osn+󻄜z_+/Wig|yM>Om-ەo;_Y_9#[;6Zx6{_\ײ焭VeTVmu{ZZo۲Y[kf)Fu%luSD%Ѐs( 2@=(8@>*( ($Ba8 `XhXm~\*|G/WXVm"ـj2S8degwjGmXG?iS@Ʉa -uŇ_r8-Յ1Ο|0"r׍敌4rd/]@Nc៶b?Lƒ haotf}4?_B>ٙr@st~{~Sja`F+Nw9HҮtsm ]oK#֗4=5+Öbj~;d 29e%;0nTca:QShA_k{6;tgQhrNZp)0~s:a4ՂLH5/GavUBZN&=S'piQK!>۬ [CW-fĕV G>&XI/cݚc'PC؋z0֡FhK"+T۲MXc[v"CQI J=*y# N8=*;Ps=2pQF~z*]`0FatrĴ}sp]FXUT#h!D $S@ќԳӊGOmQ:qQ;j6h, .fdF"lMM}-fG[`';!Z$(Y&M7AnI0>cRzUP ^A;QP5#-=Ei1fIY2d۔ .vD7v@'¨zD;VAR >tk GSS#^Z!SAϊ{#K3F]1dw#pe6Ғ2-&pif=!¾J=A,̋Eu  -WXS -h]3~=Sz\9C0 -[0Sd͂L=,@u*'J C4N*<7PkZjl0ё)܁* - f,UE ;@!<3m#tO̥іv#,*JM),7 - zNЙAoC .bIya 7\$['X)U?͔H~t޹ XiBZI<1G]~zb)s B sߋk\ji{?5Q=ezPwA'P׉J.tfsy Tf36հ-&2ʓ#("jW:"d , 9R#aR+bF"F/D$T}r% 6z /'jOqc@pvaZYL0==3 ={7颦V㘪 *-ka>';*8c)uR:3V)VWq3i5 Lк`*^Go 1)FUDܪ:u m,|@wv9. -fV[:͔"˰ipԄnArA#9k&8j`B8RUVdRਦ"NQfb@r0PAr -*LCp`mcYؐA^qaˠUB@&FQ˚X@M߁qw=RɉE#Em/1`TNh& * \4UukjǨRx5$4/XsW|tc>)*P -O"=7?ˈd}*gP2-rŐ^^E{0\|X$(ΙjD'{ȁL  s\(F(Fݑ F`Pb -D`EO;(J8 #g ^KvLh>_ϣ %QAMQM @~h)+wǤ]`P, uoC9%bdmR8h$IiE& XQ(V,ơU)d$GnʾX$$n,2[T M/':R'\-zGrR,_. ,C>_6[K=TGŋ D2ù%ŧ?w GB) ;أ.O٠ϦGy-u$n( uEZʽat<('Y`I3ъ:xiWfQ)2h9zNlyΦ>B#:"T^JgK)˪fA\919[9K}HjKHKbh5m"LϟHUSG`7 -DUܽMd܀ZjRُaEt5jp~^3L-Nd_ؗ}l.*F#3ivPRG3y}l4BftH:ΗU.DӦ55ڀXj -9K5^!3%uKkQ^dzڏ!\R5&-$}nͲuk=D o%ڠ~TGS\=:Zee)iS|G#?Z!XnF,[JZ))1+Q{O6zflomds\g$O8(?#=S-:JӨȖ=u0BGP)tU`?F%Ž Y®ђ?EZvwp͔>}h۴mG-=w tڭ~>JRvF;=8`*dkѬ3ˢᐤ>H:V6@눴X#V`Q -ٚ7yxtp&ly#d u"8v)xo;wVb=aa$q%NK4WǃB;"4kS=Ia(KV.:I f--hqwЧ* - CuIqm-jf5=f)MQzcf`Ȭ׷Hvǁ)a{I!B^jn)D'諄ZPm:< -fR1%oXJo,`9t tB{_,:4|-jT fog 7wJ8kâ/h$~d-jY`T+C6Рjqu#.L崎D,qB+{DscZj]} d2hZwD n^J26JwM9vMq+}4/FC_tWR)wGe~CJ`g`N\`.zfHeL{r=E&i(quv?KBtr0CTo)n44bDT_CMTԆYGg,]gSf8B;s\ -;T 6V%*W52tzr*MHj)_[~ կ?{=@H//>nQn&SOKM}ȏ3j` @B)L=9*1vf#f\M'W'zV5w*w# Ƒ~Ri)e߱+#=ieg9+=w,@s5EPuj?%Mi)if/+~3ͪ -NdHJ$}`y>r-5a0yjPDLu܃T&BmJ|'9V璠[}a5!'zW%TS7x ;̆k#&CUh+hDϮ^> eҞŀ~iN=z֊P1ό{(AH'#B-\9VJ8z+9zP[zpDn R -ǝy|atƢ'Px'L9%`i/!BMcx5;`a݅_44\S0>Vpo>\ʦǟ5Px ZfL1{rV A%oyi,4f[zQx8`G Rg}ζ0Q[,kcK | -ov]tAY{kiz&h"'Sr{:z^!Ů) ey~(Fz.@!zMўlLg`ڞ<~ u̘^'ew(i72eCй/cʁ`O}Kγ#T&(B^9zZ+ *F+2`6c =y//2."zZĶypW`^SKW٣j'LOs&OY_ tv45>DG*uKE(DL -;"̙!ēNե- (.Z!0)uy)TůdD(%;v7u#Z&ĹH̴n!і| m2Y`gEpԾZ`=ǥUfhX-{N,Z5m]ot#^|o Qׯ zj(UĹ, I*0s-Ƚq͒$ JyQtC!Hr_mB|DNoƶաHQȸRW|dRhM:I3St%A=h ʑ?b B&T_B8Δ6QB9 -FE92tc0 vvjh+UD<EQp+ҶFi.aoEm.Da <(\.ytx-2,bujs1FM&-W@4W:ĕlNvFu79c.%s_NC1[łNOD\ED`>l$Vq))LPۑo.|הJ[kY$ az pXM(&da8f -axo\Twxgy*b?Em3ݽ(jIY*|P[/qVnIV&Cs.ѽW)~rR1sKӡA)z'!5hf3n/Rb-f2KY*,tki @ -@OMm\pX7v_JCE)-i,%D腎9!a L͖1xƴgE];ӎ-c1+u+ -y@BTw%#2+Ryb#>l=41<#}"7HlۘeKbߍy POzE&s)2+@y'[S3I-/%^;E<8 uTGVQ -/͡ w5F,bc=ބv_so^H]|YЁ^qMI$7L#,̋h(s3@ttu)bA3c;d\'vOpj+H"A+P̃ W~HS79Ъ&p&SȞ -h&xhhu čEh7wtF XUkeQ ;\ItTSIR=؝%URDcd:7DN x(DJ#)!S> 1RڅO_^ <T0%H9ވ2^7!"si47ebYk͜ɱkH] &DDBDѮ$[W#)  -!ɜd̐e]#)N/gIw҅3g ΄ց5:"@"S`s/ѱY1lЧIBsYuዾH)lʪVA+-b ڙ'Oan0?èKlq@5[YY}%_t|GǼ@p"<wgL+EI! 3DN" BT̨Vς*ذ.L 5inh;$iy_?!`%k8Ω|D#O+,㳉4 -_4Ee%tB3|hjotiˆFeawʁ3sǘdJEC_viNn ~f -7ل|mFNW + -X~zSn14*>_hAyͨ 4ql>u%>ۡ< ??3~~ 944׉|s8x?4;wL -^6#NofhGmƴ8p18 G#JC IКE͢cyF+WE9 |blDtQ$1kpnPQr/7>RB\-2vIglH7>ꐇYGSt&tOXKV-/LěTO.A "ױJ+<43vȹ??{^Ոi9V#̡ EdUhE2 eL#XFIGTyֵ]k -*49+4ߏrI;8<#b&j*,+6c%wjV.7o~c d|A.cpe8pE{i·_/8߱wg-~[ѳ.qPdՃ?i4,zL!p~QzvgAOl<| endstream endobj 6 0 obj <> endobj 17 0 obj [/View/Design] endobj 18 0 obj <>>> endobj 10 0 obj <> endobj 9 0 obj [/ICCBased 19 0 R] endobj 19 0 obj <>stream +:%#Os's5{ `QD]!DZ!Dl!DW!D\!SC)R)u +PBCB!S<?E'%&O(AKFPH42BK@Ɵ&S2ixtBG#q D4KGfSq=@pr"Se~sdS@$@Ț8jix9}7ǰ59Z f|~pD!D1! +(A#PQCtDȣб Td:9Eft$@ Pq(4:ĠcHd6 KȠ #PrHCD\jb5 -9-1AL $(lKCKȢ' ل46~ND#ӸD46D'bqY42f/ A!"<|ty.A"DЉO5@ Dj;ajTBC >Kg0!i@;75\9(Й׸h75@x` o~$bDodͻp482787d56-ff10-4654-a757-addad3bed434a4306506-6cdc-4da7-8c1b-643650ebd677551.ml10SVGFilter / : /XMLNode : (fxmlnode-nodenamvalu1typ/ArrayeTurbulenc;children/result(turb2attribute; ,stitchTiles(noStbaseFrequency0.0numOctav,feCompositininSourceGraphicoperatoh100%yoid)AI__1idxwza&$Inq8tHH"0)bI +8$$DH$ ЈD M>UEpQ 9~5Z ʼtOXTa=kTP?,/ hX76X Y9Ryࣟ Φ<6PJo9vڭNLC|29 +iV~ʀL AާNeWSH*(+S*!6;~'Ce:f~)$Qm 2 +6<"=&Dξu">kZ,Ȣ&:OoFDj#%xߌ2m3k7cϚ.lHVܪ +*x)tw/Def ;fractalNois44GaussianBlu1bstdDevifeOffseddodSpecularLightfePointLz(-2000z-1yx5xsExponent(1Constatyll-color:whspecOusurfacekkkk1litPaiarithmetk44k3k3MergNod42BevelShadowx4Morphologyradiu1.ddilabnb-bnbn20DisplacementMapxChannelSelecRy(Ayb3s3atri14m34animd(5taralwayfrombeg0sNdditivrefillfreezaccumunoncalcM(lineatoto5n518ccccccccc81ccccccnb(-CoolB(-5x1D_663erErod6(711 1;20 15;200 200; 15 20;1 1 repeatD(indefinRremovsplidcPixelPlay50 5;20 20;Diffuse5yellow;green;blue;indigo;violet;red;oranDiazimu8elev6dn1l5re010102red68813152xx40.0.cc5nta8xx00.5doFloodfloodblack; opacity:ndsCdd35nn00(10Gray-OxCompBlurT(1nentTransfFunctableV2FuncG.7 0B1XferFireA15-filterUn|lIR(H 0 JOG0{:@AAaH pA@@ +`0( FCe'$wx *| cȜ>,GSq#q$ &u'l^ķڢķߩϺ'ljb1A l?8JAN_>Xp<9b˩=@6u`}(WCK.!*ruQ_2Au)3PYv밦V$_TFSAYْAaX1(䂅' T)UDR=d_ErџhElC׉%2]` ʡZ QBCoR_2 άS]\kNb0*RKFAHrFZhЏCȺ/Êo"<~svJGhSOz"znض GÑ fLmJb*Z>C5p%@ֿ{4r=}js"D>?\"PXͮ1LJ>.ڡr_ފAgJc\}.Je2G>4j"&%|pSed M2V6C1dzOduBɡ4^?<5>wHTp,cIqX FȅnFǙ24oʫ_AmIV?DDkR ;DG.Lh+[x(6bGԔqrTLjQ%Q Q3ОZ +8POim"R k7;j\9*F<` \qZ͔']j+.&3"f+l ל;7텝 cHl;Ɋ-n TV1%YSr$K9O%{?ll /kh4J*5 h"Us0*";(ixQ9Aw0k0yĶRy5LQS ۔xȚ9LNtºLHbQ3T8]X ϫdpxYxEtG!e6󛅝cE)#uUJ#&2+&*ve3ƀpP(Zd2/e|qr5⎩I^,&.jJ 3vYYZH+뺏j a:}хz«2=>$2GށIssFc3T+,ٮX:2{8Z0.4/Ƈ!`O7fJp$WSk@ FqboFQwG ++VvV\C Hc)̕Uژ`; +(1~Gx2֑3NZRjzOD.a] Uf!K'7SbVd}6!(# `XrwiM1D *× i +o9He´EeP̷4 +=/$h:=*̟UD ZoMd*yJLj5A7ANV~i<:5qU "AafO!l1ɲJ xFA@R+ȗgQP4$x6LcЂc^>T"!pzc)TD.BM !j>0H)Ga( 7%DLh~.CNj'8spY)~8ׅ1=R  Kװ5v Zj߱o`.SyAM Ŵd}9 l<@>H1P8ЙZb?7'w*NPtu0"nE&ps47 $7`KAAA1/$8+(2 ʣ&:`ܔTF(p?C6l A1A!DR%_jSr_UR5'BՔJL,ʤWUDOr4:  Dɑi7ѻ.tX)"nGeJC]8F,F2]YwW,4r갸͋P SnO' C3)JJ%&DK;=!D,K{J2~YUgiby %T$*..24@LX `E%7;3!&2 ApC + :8|]9nm5S0U*:.T\ \HHHHHHHHHHH(H.E jwodžSP&1/5-uK˂,i* Z{XLVAMLanca}ijCEF g9jYE͚ +lƴ+cbapᲚPkCr̲* egMff,3;LĶ/5N̵+r-ZdCZ ^~|=F?wcyOD\LXTl@ &H( p@\8,,@TTP#  |=~xЈp<(~ȇVf,{`_n1j챍7gn76y뜎hdjgQX +(PhiVs%9fu#symƨ'Z_yfDwn`4=l3QcS6χU 塡@T k, I\XLH^  + P`rx.* +RΟ0Vsf mx׺N 1TXĦ mHM"5+ С6š="aUeCʥq{y~[d2KTTLѩ"j*s +ˆI]p}]Eh4wz̳ud,"n.6dM9Eeܮu,U~o2All7|L=m K[FUAjmc4KܴM<OA](A؍篁Ur/͗(Uҍӡ6^X+B,/ dCwzVf9 ` *`\>+r>wrٱ^f +u !wP?uA닾|*Ҡ/v CPD\px[<^ږz9E/7 4?1W|Y,RUҠG5.RFE~ Q9ɑ$Is !WrJR%!bf/ee!B5}Jtu  9I4h1퍟s_NAxz1i^NA +ۦ-s殝ba6fdrٸ +bmնa[Ǿ۽ +bL\$hXg_Le<{쨻51$D,@Dl` 0 !9($Gr$Gr$CCr$ 2 .(@a€ 20*$ D$qSt?jxPaUotUoosi0m˩/M"Mphxp 1a`cnC^uލ֡MPcǐto4֛ C;۳Z4ijI}֟ZU!=aT3IIɆ͸9ՐN iK, N5}5ğXlL3h^LdǂdK0 IKjIHW{ۿgʵݽEե +yiKƬ!l3WNSGZ܂n[AR(.`,8deEu%w9=Mʋ.rːӪhxg|aX]zR6azRz2`}5 +l7쭦}&$GN`bA eJ 0W?$ +jwu3R%9a$Cx3Ng8}h},)zi߁P]_%S%pGoý]p|"s\If yZӧ,))PE8 ~wA:uUzQ46/9 STe?B Y QvZ*sp/W hZ 'YDCBi@fFU"@ۉ@Gj:)ʀű.w|]G |@*>Zr'j'<ɴ @R75l$I)"0hqODVr{MA{V69FT-CͽQI±D|P*,$`zιBTR{j\~zM%7c`Õ+װR)7A*g Kq%m$g,=4JШ."*KTw lU KQa FǪMtqgIFqؓܳ (H,^xgB!Nы4}jO-ϟcGsF?2fzB/ +4xt:-KQpPg-P'>{pwlTgYs"V24CѨUQRR/"`Ǎ%$@Hg( +W0;nPا$F{b + zac2f\Nw(*/S 4 p܉7͇Pb6T*r(, +կMmmo:ڮ0k(nO-;YaYeOé]UUcUgH=_ P" '5~f=",!eKȜS"+E۳BU9d`?vHڌATK5.>Y*euL(a?[G#!l؝ + '[bx ׼vdwAI'Wy &v0q΀Huh!k䃖 #2_V+}Q4ɰe,߮2NUpL2$vᤘRi穛u]{+~_̘-rgU7ˤ9U} s^p"iK"'4qf%?C729Hf%:t@^m:'cVt^g &)5u VWbF&7ÒfaRܨ( S'r;PPJʡ_aQB0WF!,;fiGjJp0P;(~Dб:+rlK+ ,[{Lh9=]E^;ƦO,IJG6cD%)SyK)~5naŽUOPlgMh/"(d{ZO\:$\ \8yGu#/@\͹kq^5l I]sdH12VB;0ʋhZ7v_Z$ȉ`ΐl95G*_&w_ٷ%X?v@ziͥfl: u,2np[RJmaB/cI'EvȐ|8p]?oIC$8Covaj Z):&5&%!#_ȭi3O=F"}n+?D;R.3 L93O/xˠ;sCs}pd)ߠJUhb +6¨e ] +٫8#xg#l>C@ؽR؀"GݨsAb jiByJnFQ,w1Ha~ut^j_s 棱V9ZbR*`\a?JFQsVoXX 1j4X jA~,/lxD-@D ]KLVݟ/Íd"3>.F+d"X8,fV*+w'4cLjcUcJ:=B)װ_ ,Poj*cG sM*Z5ߦUl'»G0H5_rnsM18H4" !A\w#^i OˎPLtKhQL@J2?D`G̉A\<>;>]#g-LP-(T2'dWC$1)>48}sMK htGu{*S *EZTڊFYCl?aBdw;ie垭}і5cTp4f#\V4޼r_=uJAyCgHʹ82_KwQoo+/G?8혫S/bmap<%HMdxQ̜_~ݯ+DtivAӓ IKldGp /bm:|F2x2 fDiA +4&mQ <葕Ӣ<drԐLZ`#L^慌f1"}u#X*)ů(tNI[g"'u*؞էK uRP@ldAH cj䅠=fc.3ݵ/ c1p`(<*rdd3*i"sƎ>SfEr`v/.-5\H[=q# O:>_p + z{ p1X:rp%{<'|Ð!Lg@i8Z'ZeP?GpP 0Nb;ݯ2>PQ:EhԯhA>-Sޚ[:3%]:c^%'f pZHA}Pь +X IT7עpci<ԏMUNaRmޟE; ń@&#{ܾLlD2 P+W6P橤Gdz{yܤMH %a}CD +P.}]aK;"KK+#*}ʁ(tCW.CQ5QGhimR$-պ"7;zWUA&A#W`g\`ClI`ZcnK~Rc'UK+ME~<컩ssȉ0nR!\𙩲IqwI%p@MUJ JLfًtYt/&@w8[ chLd;Seh:gR{i|@żAh)7eAb{RgM!l)>J+wYHv;c)ok'ٺ(Ds3u £&&-:DCYHMYDdfbg*Ǩr`u*>ՒoP, ƘD}&+e_Df"u$ /Yyūܠ)qdR`DWR.U~BL*jK#J2lX/.}@|E1+>E؇L=[47e0jY& -X Zkk{tCMyzɕ1JGjգglԭWW]hn .DaI?N%h5psTi1\qhuz2p\n p $G!TT0#<2"Yn=5 s]qKD ,3b((-1\<1%URLth^)V)YQPuslP#שz3;!a|ai\?7MUtT"zᘗ>!اgA,4w6#2I(:t\Ӹb&6'#~RL`mK.|BXr)e%|9lQ$þvexr Qp+Y+ړFim0O AP~ͅ;Q#Ң &BID HϳPQ{BuLХ뙮A +| @?K!7Tdg}#M} ]L NqPg.( x_q'707CM`WXP9/% *p1E]aK +hܔa~:)޴:eR>Fַ0s +uW%\6NgP\~Ȳ4V2)`( +nx1v +\]vZ4\}ٌ;{~-:IPajTĖ"˖f,ǕVZQ+~1|Bըnd)ζM +r d{ۡ KlpsW{yw.4:@[nw -9 +^`?L"d~y^xcُЕ +STV'>6k5N q-XΨ ޺h5N).rP A!Y}uF0{ˏu)`Ԙѽ._ב^9EAQuWT0gIWǽ& IE=ndS/ FR(X!RdK;%#T3U hmeǹ<7Hӊii)g&B;.%|˺;:Nol|sH<0wK hwxe0ѓ'$D,͐2W-[: vPcRC(K ꄺV1wH$2G@ \ !b,n/pϫ{b*)#DB{oVɲ⥉1a}T5Q [ ޙ3vޫbxR\l˙ 6bϴ?,Hވi , +}$6Ji\kDgAv q +`Nq 9$!E sFU`ž!U-k+tnI-{9DN]n> ҼtK5%&QQyBB>z[k oa$u42A $d #,4 +ap< C]Hl7^piwvhĺ#%l|YG5Xls#h ؃"\QABG)DP` MA (KI yiy3> 8#hs&ː3z }ئ@4-GiJb|g'S8A*jFD܃j^Zd3ٷg4Qt-+`|AѣUpF,p 0Z8E7YI$iosF*]$Μ@\se3P?}C]XX1YVMS2uSʼ`)O"2Jf\ZI~A:Xyy*@jl96]CFᴿ"˴g_5&sN N0nzhႱ.w7?u*9 &Q/GPnޠ )QñY#(k|8Q#)MB|\gl324!1KbJEՀC| 6, K5LKob99_  qɇɯ=(|[u9{gdZUΟryLʝ4Zpvԑh5pU@rC5pLnOr*1' +`i2=@}3 <4޷o  yȁ],6"Nu O˴VMӴ`Pt/z'*f?-Tu.8Ni4oٹ9LA_fG)!}oоT*<(p#a /{/2MĞtD*o֧ټq7}2.Ŧ7*V 9w-;2{%y,CZʠtR)5[t t<z^@zYbPfKӖ-) }=3̐vxx2FgV]ݠ:P`2F.Ճ뉥(86(,ɉ$I_JJ5b1y2NɜdrڂX=:Z۳`km=|"AP " +A&mB\б7ˈ]QSW[LfS'nk ҇+!uL~g_Pn *Q>Er5bmZM?v' ^);b ~knNs/" :6~!;#E?Q$̗/g0/gHgxS$ⲩ\r=.b5;.;rKX(k!_Mm.wk1gBx|Џ6ʎV~R,ey8c7R66p1EBVϦ9Lziotܪle:qgp,=QrB Y 1(#{n&',3_8{u(ATI$"8T~ﷴ2݅ $i$PmE3y6!`wEqMFٚ "5-=8!(/<_@gq>]JA,xd 򺲒F7NYKR*'AJC q +glp=[EG{ZN/`٠{ffӊO9{\q=&'O.Ѓg)܀{ϼ 𝺩} \b9B(zO=d_I ; +o4?CBй |r5]{?c^+8[ZM᳞ 5kr9h&a|qFY06rA7N%aq rm(_k=唰A؄N4]s[Y(c2Fr BrƦcz[9&d02OA9ug@/Q*z' vJRצckR9b5$lS_F\skF.nmT8k$ +?A$cHXnhn1}qVLݔGRۚ2=3HyGVIYcO&4ad?\5c풐 I'(226"'|X b9$D#;Wco4V_\$0ཱc*g"[WCX*ቄ!"Tti~'h42I]aoj3fGH3y;$iL-Su8sKB՞CF>e1;S$5+ 09+Ku(H8*_8"ixc* 3W |^C)K.>h:糵Xz0C`* 7/ט*bK׺C[\#X+aRN@,xG!K\5-SJp_C+ͥ*v֍י3?I+CHo5AqA(`+-beӿuOcj4:f$)*DgS)̗=rIhPXV5X}HtAᗄKDK dt"iEPޔ$j[_cPPٯtS7! ""Vk5c[aT-ѬbGv$l9xJjt(:)-š@GqI0pI`Z30Ds6K1]dti7;3RD. X”~*5n&ߩ̰Smqo#gA6Hz3-:H4@ H h5Uұe@ @ 1C! * 45~lCWFLc{)~<%' K*bJ T| +ћ@˚),B,4av2Lt0 $}߾*VBUQ{ѩs'#1D,Zou>{cJki2|U4hP*(UQ" ܾP)tG1t=%DP ː&VwҸU ̳TBX{qxaMkA%lզ@bhچhZCA}EBx j 2V.4benjaeue" -1U8u肽"Kj4 +Yf.ȥIZ4"A] ˝"ֶt,Btw->WZ Vڷ_1ɳMRf([1x>nEK Lf}箈ڣc`-1嫧txOUv\Ao Ƥg +M'/{ h};LHCdqԜiK@KsavLΔ^zfi/;%S5H_gFOV匉~q`,Wln]Xs˼f%(zJj "X#gM8znPnFTH.˯/];83D !D`{ƒM wFٴIrpKr=&Q AK[!&FhJyI,W\v$K*`ۗ8elZq +ߢV+#ū$&=bl$zJekp=A XڣȞ: zF2@^׀L,v6Z[gg |H BOe4ȭ .lgtq (譑ޞvm(7MpZKL׽Pfb*1ug7A▰rsQކVq8SR띳rNیU2?K0U[g|9IGFCG*1yZ)˨/_G )3 `0v-`*ZΜ:<3nf͚@ &\}^0~- uI[h ;taZ Zj[qR~))L.ONl)Q~YNz9L +;>l\ E` ^ZO"dya#mp-O{u䡶C#V}eF+}q> +oB[łe"Vb;X暘C#%z0AOpU\Gu tMBGLc~6 Gqt0/, +W$xz '"ɰZVJ[D+(!*Tp +.o +?Թ0'$|NaC#tjW.H +g8Keě{QDӠ|z%5cS9O:"$GLk43 hF=ؤ57#ag//0*DAN=@:"&I Q沠Ϛ 8*q}I/FΑɘے>Ԣe0p%Q܍nC)[*O2_xA)bQ\&Sx0%E|p~FEvx'/y!)&<)2d,y-s̞]6]oтMf@m +ͺyw~:?ތ)CLHNWBCiy-⇆HI$NW,tgȘOIlH. + (BW~/-< }6v#}5uS)*X$ҦID!/[8'E:M[N>MC4q~gAL`s!,hz ,3X[yUf&^ktOr5Ot$.JSF5[::`k$>hDMĊg:\;X zid r6nPn3V֤kTb9^6MJ4˲>>!^g/2Mv9QprL Y-}wPӕkɡLDg2V&yY%xZ}F=p `A`{DJq+BƄ@ #@W,IGAKQ;LQDKHx\G~( b$ K 1 9DžցABhaJu0DaqgrgU-w[uCΥ?̎hL|֑P9ېHãx\#N1څ&&\*WL\ΐhC} Ś`mlOwz r᪜](Tl AeD7&ٯ$ѹa^.u-MuԶZ@Cr g6ɚ'O4h*5^G5;61R  Eg-n4U[&e tBK1eQ]㰒mԪ_tE6 qP'e]^" (PnJ9Ɯ` D&.7ؠn?l"AS^-YIL=fo:S$ K8 2! Ib4K>[Uc jIC?˷}L2!Q&Di>G,@4 + g W Co4+$Hf HUӨp~ɔLXXP>qZ *0vrdB%$ZWzHK g]P7-@Z>T9@@#ÀԊ uJ)Hbi8OpXUټɠRkK nXpDTUDs.DM+3 6iLa0A(Fmsѥd1K͋##yYAc)RSDTk;L**3TWd"و21V&lL/z'8RN^%pX*U%=Ѳ"*Jh FX +- AdqkC#,g=ZrTCj!Agkd,d36R53p;ßqP^%`Cȁ]eRiՒ2qxq`20Zxv&|UmBoYNo.ӌx5r :dBPMCIzM4wɭh_ʡe^=ڤ6lsՇNVHٻ:K js=7xǘfP %*$WB3RCJBǽx4IEK)TnA.HTL aRQ+*OcsJ@B/&{eW]@kBܪѷtV!bTC(P +\9ܚ=P5z@ w`AP=A> <+ I4S^mThyx(u1s endstream endobj 16 0 obj <>stream +CmLIhÐPHw]y6,l<<Щ@lb! +@tekC(uJ! ܔ +)AbhRP 9hG>Pl-P]<̰v҉tTikEF`VJ4?2WK"Jx)rdX~OjF,el@$2 4r=ML’,(ImUzpJ2)%4WqTF-7F,L, qNd#Q">KۜGC)?#[~BV]PT۝JX9l fF{Cte(^ 5F]nVI7&e.b*R)k>o]v> }W|D 2k!9ʯ/TcmɼQET3wPAt4P"1rG/bL@rNZ`XŽ.i +m!y  * +hm \/37qH^ò*ARp92pb 8C'=p!Mir*eOya[^[Ymh#fUF26 l,*.&&dA6W#Q],*wuuSО`y@^3to$gᨾn7Fftr9{xw.mU'TܜSX4>0rUD်TãktAq+T BGȐ!ݓF~k}}'l'UM^L.D񡧑Y"n$ttBfK{]^~_RfRY0fV{xݾ`_?^L]廒% t;r޻'{R`.rC/z="1[({98ܻ`ΚO[L'P#MA$JCK<&MQY(զzkr [ð(`g/9o$"*Dl$QTAdʅE8`\h `R$bĈ'$yr50iĊx_l՝6`U''D%@r^WRp&vx:|Pj`\*mzMz ĶjEw}dzO2` 0xMT_*%;z:$Z9XN #>Uuz?@ }Ċ-'{ {(*b`MN C&  ; iAォ:^[ٰ+J +,@lT&0d`z߼>U0(uX-.I. q0O7UNa޽6'TC^X3`՚)8ӏ*v/ HhX5SajװZ>[8kh\Ɯ߆hJ +S]h\]+ nh\^=`JFlĞ- g_6L+MA` tu. l @Ҕ, +7[`ZY@Bu> p0Xj/*,٫lЮz`Nu q[ѩl%[]w_k +yV 8*Y@u^0 +r(XNєƼV8&ot$U[N"8,7cN0Zv/=,Ng].j׽fFY`4Ղ`p {brzbV2K_l"(A+ Gt-_Ya`<0q#2ԫ ta6gx@*:bt8^:W8k1\U`4d5X`, ,Pb^Uqe=qZtdZ!SAݔ!;qZGvPNN)z~4CvhbE>Y&V/<쵙h2C+f)hL4Yoʜ%ŢюEь,vTED}XdykY[ˡ߁HNhg!}cdl8Tv% SdkeBCiwV^PS~^YɦX^ۼaHҗC?T33O.ȨVUkV j%ZeV-V6<[ 9}[ }wp6Hdzon OXI.G>^DYƻSOW^38^' ΆQ"f\A &-N4H"S>Ed(O B0$Be'8y T6< .ڱ'&X:Qq!CI!ˣj-s@-u݉y uVR,vE(A]d""0?I̟ZHCx>i D \bÓQ=0,cFSt"A_,@GXm!; DB"x xC @'g{ +/?V$ *E @TVEB8tJ ِe$GsAeU iW,;ePid!ww1z@8&OxT, 򍧉ij2+.aU=E']vNX-e䂏E\ +i^sUA(HZ=/ydo.{X˖%"IY{McGd⮏u!4$FL9VKD#a9φ׻4#MY_iɎaIٽf:Z]*\xr*`iB'6)Gղ: VʚО͡&ZLԢ@!J(4)51!fcǒ;_<)ϸ[)3^x&#v6F^pdeu9xvZ +r"HĔU)QA:xwVTbpV`!~l3z2s,&Ӊ=a 0yЖP~sp-Mb}7&1Y@v B򟾮d;>"3@r@N ֗zQWǨo1BbAkFMkKo< $"³_.$4PE42C\K\>&L)ol>;$TѪo-٦2? F9~FT`2MU^&Nհ2[Z[/~/JVmOy .ծUɎv}ZiR p'u%]]pN5l}۪SYY%~V,٬׷\f*qWdzۅW.TyM<nEnɔ'$+OI]x <&XBVͪa+>p_)K.^P]Jݧ֌Z/} h$F^Sp@y0 ׄ˴*󚣋M](77PK*{[VF{M tz0;|X_ꋳsq*E߬U`_ME#u^"j(< 2U V8r4/J*y+5.T#ETZg3Abs$\Wzzh^'*'Vj$6}bF&1,/Q4'MB$JJcF_*'AscbzQB-hx2+cbݾمi+a;Olm$^eFWL*8U8UK^Cca[ Y~Ao8`TծjZ 5p{&ۭYm*nHZJreWo{8_/k\Qfr$3$EYAkxgn]  N`6݄:=u w|\z'7K k5|8 l4>(O4)w("KlaW,ƥ' In=wFOB58>d0B`6 *%#f#g;)ѧ%x3cR aP={ZQQ=JI' .6qX@x4诀6Aة{ME,Pb7:; !6=RHA>5 |S-ct/qeFL[fP(+|zKq"\ 1rAr_'Q[tn=vϺN625X?Gŵzo/{јkG +ČDŵz"yM ",]NfcSh4>dCή?f*3񘝼T__tuJp8 >YzwswdU ǘ +c +[ypI7mjdMFl5,;}۝;}/Gt#/^8^p.$͟U!2 #ub@x9>GW"d8r)tN$JBOJZ]iQLv8-*--OrYx(nh1%58 |A ,j-ţvGQЈhfvg:9S$z89*X&9h-]O%YMKYxF`;Q?N?c qy1ݑU p;wֈ&d\E| iaF굴WWA0*0Rp;R9^މxu":!rݘ^ A G> \ gO(z +! #}ܽ`!p3p}'7Ǵ|PO9&L: ?竣_jY+ݦ@`n:"Đj nDSDPe:eR҄DD'dJѼ!;u8'Ce"B"1]*|eoh&HAIcg@jkOCy#k8"Hሕ5d8† †뒭\5FVEEVÚUU׬5 ";0;*n p579$\|]{ sN:O006QE-D @6T@?ʞSNqvT/CbA0zD`><=0@sDmqAydFq!,m }W`k3GJG= )q/"]P&CY,{4q 6"3LF B7{Ɇ pN8dԐA%fb"8=X<ЖM8/XԵ'WуGX(<UὉC"KDk PȈ\JGBSg *f#P| + G4& +P!UCg:DD,zF6TƬd_SȐl 5x >_d#xB^p,CRe|G`i&.HzAF6N/`B<Vazh@triFCE攥slo[[PC+ZgBL;RV#f*%M$Lj&l(JnD¦naC}aUho&U U"y)Q :a4i[\Y7tX(YYO£>2 m *RIl*Hf Tsٔ ykV8ތGi͆jeߪ~ʙt'q10sob9or码*~Y˱;Fr ׼5ymvǎ_8e?(Ӏ(!(p[?d? +ǟᎅHI#b"\!w(}3#BdzrF":i90+rh@QHDbe(Dޏf*s2 %N-HYλt^1U;|cr6q')Hc)A\ƂhExJ?Y񖃱V',o{< Jl0&<хND'>Cq\7҇!RWt -UD="PpYl tZ6#VOs&Ԧ T:ܬ W;X^ (qPR6Yxk1W&QW&,Ҟ2'>Ͷ3y >B*pܭP@du2@R?F*:-SmJh"n T,E92riĶH) V)nlP jCa8HEĸ2;gez(51U\wm8-+û8 p;dJ]`눴'!"\xv^N{ޙ j{Qq|$ a1-ڥZeDAdi EJRsrY198ȾxԤ I^c?,e@Nd~"Mcc:c/84SK=L1^Z< ńhkvqF2ʖҵ>!F >@));ՙku+S8J׼6dk^u(d?\N쑅R?{d6̺{(TEW׎܋ӘhPk^Z Լ&[E{Zs_a*VDEKokIY*.12@19^#(g`D Ĝ s LneYE a> 7X*\YkӢ톰?}~ +)KGh%]byǎo&q1T,:J1PPB6bbb-Ukz⴨n"Ek^˹fZgawT})2shb23CfK/l 3|tfb +Bqt%tU![>z͕[?Q+~6ex(Pk"mdk^^ +-,UiqG,/1:/Ҍ!Qjei;䎍]yp^8Kq|IUEO/Y"aATkPvKV- hNkDa^7Epwz+u k%$KTL(؀5RKAU +"@ '2vlbx&/0h}ѱI gFZ0SkE:%LSp5zmLo)8.^CQX ߀&^cePʋ^|A0պ5^Ȫoٔ`x54++k`` $d(HbP F@PCh(yI 8R&- psP\I)ÉZ7*z=xcIĮYI/[w6e'tNe uGL @YUtG jķ>_,(=L?$QtԩXTJfFD@F$3 >A2"@@B0.$,&G8(aGbd9w.5:bZЄf0|WJg&97(7bu{3~M ,Mϼ x^w|%0DRzC)GdltEee! +]i +qHER'#>qZ!IɁȱ=^'&=?ٴ8CS@$Mu!umו#qPtA#n$+C%Alaۥҥrߠ9]r7kNSqA0@A`.o+E] C8C[9kKRg ggJ +O=8/-CJd@"? dU^W'&D~1%c#:^6c<;-R}iZYx DÍ~Q%LN-(m#{1FR96,Ap܄e>L)*yj,~-/UrOW"$r ȦwˈPpAde6 tf:~%\gGDOіxxd^銜3!|4OTi߸:\S;"&=jb`k`ۊWF$~hd#fR RVgã艃%YZi|[TǪV:Dc }QP@`T5p0*3 GOdszs % *I] aduŋr +ԨvS}_թS{FxS&̧ +o2,RMZUjz&m4)F.EC℩t͝x ~Uaxd#M咩"%*%UPHtDH?-a`qǩ*0"*jΊNQy'@CtIڗ-R$KѲoIoRف~^\bnhq_&S5(;iZf0%}rVRꯕ֜V ke-s9N+EVP=%]ZלVZRO+ Z*JO+'ZnBBN:CoV`ki "J@`0+R(zK5g/YF+u.VΑXb2]b>T0 T[G퓰yX]J`\L96ga!ĥ`Dr1u+rdCgՁ)U"ZRRɿ`-R${=pjd" 0(;5]=0\;1#lje-ضK 43:Ok٭YIK;L$7j Li,1冨!ÆJ|LLl1]WHUFzc[ڌNxtc `s#z,Hu +<,U%=UXl;໪.]0qMϱ/@#{jVEK5ESwuhzN:_ @]"f$J=KUDT<՜ Z|$+4DH*Ghk$‡:# Sq@JԦG*|pΟ(Px!3.;B(*薏B(+Q^OVW 0/yE:[k\tAVD-EU,6 +Pr4gygp+#G&OSKĸZQѶPKr$oNiїk ,ys_NTUװ4hRIƭd`1͌orrڗȎU&o,^[:sP΋M,d89[=-~Ck`%AWxJiY΍L#53; >nY;]@ MaA?~JMLw}Y/cnb4Y#EC,K~I|.5f#V̲C `sh6y{*BЦF/8mq%r+w(mF!f9嶓z+EK}M^i{fyBKHifH}h)#(u=_] +/ʢ4onWetXmw;qzWD{PlȗR~,*ݴ˖b&Mc! =@mYLiӾda?fP`N3h]eCzh3ni55;GibRbxM!nPT-vufhf[*1V$cGEz?D3G,b<)ylZ"?5HY=la?dZ"* qːGoQp.Iv =B[d0r#ؽc qDd2U@pa%FmYkɹ5;ssf- {]{*;6CrMާˌ/b~عOD< 35UgiZIqΜYac7A5mm3!pM\UF/if=9?ܗr>r& ).Ee{+`^vm8i7 ^8=uH@ [wSOjuMMqێ#!qĬJ:pK\œZ[lu{-W㭮 we n,KD;@E3hx~HC,UBOC$bͫ7h,½ܝ*Nz$$6*WziCԬE˫V72YG9Pnתۂ~Y|Z%; =!C /  Fxo1?Jya:4Lfr1m5UWx$m}Sdll=iWE@?bbҺ(-xdVA4\wa 7!T!DapOfpC8eʳ_b]@\X0q5)".*uJh= CbjD^ڢ8Uy3[p.(4+03MkI錴Z6+~l8b"HC<z錧+ ȵCQa8[\'쯢;r.<51[>>iOg,|9 n(Ni@+A;0r_xhW]3rX9dY &Og/ڮfЭ8lTA!_N`|t9g)חv]˂~.9R"4Ҽd(šԑۻb06Ɲ2f-H6vp7H&I2,17 s Ui?lVUjcvd0ͩIE=ׇJAWn9.2qcH74nrn+ۊ69EAlR=@ +%fbW0_zJZI\,t/ժfs}b 0GשE]y*enX'έ\Rv:y + n Kqo^bP)x&DmlDI WUy+\x(pC/uiHS_)7J&eL+Hɡ\آ?COX >*\sQ@¬6nczW :-eŊ +aoH~yLLTw1KSmllMr*4HؙϣSHny jskZn$-ոOz@5 cFh[1hE{\{'%(%LEUeb݌:ȕa9RHP` F(qhB[,AdG'!Qkm(&G5ۜWt[k:$ \%][c0 \0BSVhPXm9L0E$2(@GkoƤҘ 斛;DinBjw&Dwk~RNӘT>ZuB=WfU9mg#sz@ &h! fdsSN'g9X<%Ѕy Akx6/ny+QTI]e{q){D +X7~ $C/2'`LѻG>cF߉މZ/;z jS +':{z)Zf72{A`#Q P[AR#C)4L=,A1ő4,4'j-f4Y0-n?Yzm%'=/1?O`(yΏ[4FOfk{F!^IS8~?Qq>6zz ;gFTH8ٖ3&Y&Hr C1`[L_Acvn/ Jİ#,됢-av8p0&ܾvw-ش +8N*~V5>1N(jԢ+Z #Ѓ+˽Lȉ'k^Fҗ-rwP{|%kAs̕,[ X+gKL^M$c;24!>Ϯ(u(|5S[ק#L/S%w2& +}g Hb>V!$1h̅ze;PǏ*(]"=NFg tsMB;7`uׇzf000*>ّ֓smcsb"=b;"wO.‘ +! +EWkm)GNf~n&6Pw)l^u?F $c9$ddSt4+^5č3)v;ngֻuY7ʙ?h)E\llZނ,.J\uy,BĴAHł0]Vuꎴ&xCIwAo3IГiGd@n5dhxd%"F8&`NjAĘIo1(>{'2xpcvӵf :,qps|&}V- "_2$XIJ:æSeaFgbuRyW!|7Ǯu5ݖmzwÌ5nűwopؚr,?:KMuTSa8A7AR+ZVYuò)[LWl8_d0YR#@hBY5$R` 8oK[w?"z@a !{I>LOx/uIֽOƚ3YWU\·^9N=u@>ڥ%7u89ʆDYX} h9Iԕ~) +J+eo]0O";UKF"䤜8qŽ\eaшxBA$,.wTz`vpP|NO@';iAZ>L-H9y@A6o8V{\8͏i{@55(``5&֜U':~8q'h=]fMnv|AXqp]Z ;STnĨ?(zFUz+U227~6hҘRpM :`'#2$V5q +J>g;Dz%ɻYZ>5~̴#p}H2rHSz;.d.d=0#w7hjo-oB l +RxSItIT#]1fzz&`=îXى%]F̘bWc(R D + ~;s|SEZJ!J'4OzBYT\@ˆ'Px2DyuQ_ w|'Xu}Ff(Xkͣ W7h_Gq]AYzVJΕvJܛ0K!y|;8WuD>OM"cqX~ةHsuNhk:6"^Ygag v<R]iHҪdq1nRRSD!K-STYC'jh,ENC؊T*7 <)lXYƎ]jtҨ3 C:M\ro:0bK௰>[LzA5߇0Nb3Swqv?12O)5h3Mc KSax*e=8@rg>_MiꉰCc.ɂM=׍G6"F-[ 4ҽ 4mfRgҵz~XE47.HW@e6 e`-\0.m]lѯNUF7G\؆"/!C&<rvX$Gz~ѮVjXD`+\.vhn%&t}@{!%"-ƝlS6_zH`dͳwW|Z# Q LcFCŊG"!bUH"ŠsbE/_(N&*G՜؋Ry,B t2_UsXԳI o)J>aTm7hr$KZ_`+MB@s W$:?jئ4g8.$IBBZ p"8Zbʁ$h"l}& +Q7[m0": ޵_#;deDfu@4hP7ܷMW+h=q#Q@v̪H*}(vp an!<|bEx݊-Z +ne$#COCA۷\0M$*']hk7+ڊɷ~8t@ dk|T/:] c9/ms 5*hʘ1S5lAnȌ-\Lay:jC4 }DzTOዔx„6 +Þ8V(tW]Z Au}ܭJ"GZL`UAeA8i ;Do0^03ynVCƔ'/J)d҅łx?/ H<ð#a :֮7''"35<}Ō(_/n& "Isp# TgUl7Ueϧ1 0aPSy.|BCpm:P(~:T! +(Y+8fS|u:o%UR-z ਲ਼U1Z I2˖i4IW\0-g* +"za|[ nR<bjBJ8,aQM yY;+*RH(Yz]M<'#3Yne1i݄0-Hcv)J'%Jâ +lhۀec֧\Yir"T>32(shf u|Bz -"ܛԣ :4-6pW T%{>E&x|*{P5踣IxwHdh rBVqllW/-\Ħ)ቍ~(DT/d4k$[Z TL#II' Uj\'pX[[t$#u`;Ni^< M+ ]MinܭP@Ӟx>-hɻG:K|`;3؂ӗ+"cxt 4V~$71HVbH0jٙ-=t@=]$bj\د-dS,SLs.FN]󫝔G&Kœma&sǠ_0ͯ׋ͫz6i0ߍ򈮐4~mC!6ͱB7Igm~*e9Ɇʶuc4pbl otgT-Ӄ@t!b#Rv. +f)`UL퍾Z8qss"Q@ +-M9ˆDVM%-P~NALgLaQ+2t ܏i|l%C5*)eow 9Ɯ3+%máGV&OϷATV}N-wSNʍ%lgl]UH +A]>wOci cmL}B?Sx]SLo\tFЖx})YFHM MQa|By>D< P7HD걘gv9cmfڗWw.0bZB#^eB +PZ?Hq`}y;8 F;%k2qb346hEVG\Pcxog{yxpֈ?JO88mtS<6b ?[*L8MB4z*ZΌ\9Q;XU ?s7sxQ`fA?%K%Ր!| uˢ l?20? 1}1yZ@iMn$, 6V45ȿ| ?ֆ` H».Ǣxezr:@C^ѿɦt3K&s9.N;[LjaiR" PU5ܸ>x|$Q]5g$?Sr=`r~RQEiwƲuJF0󪁁}?yv&%3}:Ӱ P>èqZȑDCFl!Ǿ BԬόA;˃L[m"Fz}P:U=ˇM<3;t@{{b"g⻟O<@=+ +0< i-_t2$`=St`8]≣ZqĢZt2HTWl"?^Wߕǹ7_*Oq> ~7m^hxa}2TOF5_[b=5^ B)r+MvN'sv_N}W3s̀2.C`sZ07mCv+0Rj2H`B n8rHc3w>y(P:õbg +8qRLVP()ykrye($!|GDhVAJ]h3gȺh]1@ +\$t"WE[@ "/E.,YGѹQZEñ\i􉎺"z 5R >7ˏ14]pkZ0t41l&*L1|z(Gx\#mLט+ԙ究5d^hd{a*o=GLjfyz~ᐎ1J7stA"7eG]NðJV`dӳR0 <˔9` KCnC$/'STШDu%1; IBBx 33&.AKvAzj0(Vnޢ)b-vCu,c08T.H0F5AAC7v*4OD7Ds}G3&烷pM &RZ~"r $nN{az>(ɋI͸$K^4iR `HT_Jp"Y?nP*5!\qT[VӠgNnFHjDޢt@5ꐄ40 &oQ&hӫ$N"PW/>ގ+:"2rIrr,iQI?ZV+!d/o 0^Kn +׵paqWjN+pݔ5ӏ0|m=H8Ϣ`=Q%/BZ 7Ĭ0yl =jH"Ix`tM;/*`O,*Gf +]Ufw|v-7<7Zpv2 xny +XfH@ke0 =o#ȕm7{@]"Ӿs& = cz^[@18WQԀs#[`d +l(pfƩ32 (HN|I=:Hԓvr{K8 _A"wF Ԗ&9 E+ VuШρDI?ِFAbdj?Y0#6^w,;+282]i0 ~Uc})sIq }.Y,}7;uOO=*ׇzbGf _ބ, Ck(Az+<0֋:1 ({xOoBn:oQuQ_)J2>ё@j׉ \lffujmUYQ~aOxv!Y9rRSm/Hl+A~0(5| /Ta2Q-T;cۭL׀Ղ}·8" ?3"gAdKCb.XYOkYm% t0y>9NMz uG2ȵqR"$!by YFM?9v\xr*GSW*]pw~I&0'< 7c@w#_l4C 2-$m`|cZK\,RFdw2I(549x 0 Y5FP,'8 +D,hHjKe # :/hBRb3 +4SJbP Х:P@'.^<͋1'OӴpC3dl|V78m4/^|6nrQt<NO("jf$.46 ÅyhDw?qB;_] +iE0/ ¼x`&<NbGy~G+0Ux*Hӣ +, +4 K̔TDΐ% !pUȜQ|PB`&&υ27Z`p㜂pB^oCG+RԙaCH +e*0,xH0O,TQ03R2?B0X, {3U{t9=h::%! +/)V$d>k&5HtQ(pa"q`P5,CSI8e&96H䛇t7FP/pE`.꜁2cqJ78D0 B èMh4ByL& %cK*8Bp !T(Y 0w]χJJC`(y@Q*s3xiuS` < `#i$p^R(2 AAS4!vLvqŬ+f]\ps$BP&RցH  =L i=p@y@ >BU aB)50h,硐HJ$B&tgJ5 44<=TGD3. .]_ץ>LR֨XQh< m$ 0< M8|îj<Y_$dyN.i!@%(NncN#=t@(6߀K$B % ,6U@N^ Ϥɔ>3sD!@ !xA2kF0i$ NŋYYb1ȡ)P&wJ4&q=::hq?-\!JyU#)a,dA>L、Qƒ(uW (Ui g,t( ڰ0  =xiNbb Xy=~#@%1`;|,qcf`.'QFƊ BN V1 A&Xf^geKj/~)yZZhe_|ɵyHۂ]KjJ+uvTƾnLFy[z糍-|d+|t?-N+4^_VVWJ*m[e^/ؗnڗRh_[uJ[iW[o=#_ٯ|e[czmYh쾖>v֮.#ܤm/8T3V$_ }RlpXJ]XZJfj֝mvY翶-/oN2Jj56z7ߵKF[š b GJ + P``n:|Zp^W(T֬a!ƚm]{&ΕJ‚ `bU2,6T+Ri&2K-WmۛhN-K;r-Yؘ=zuMccXm'ں&xӷb,B(68etDH +;w0/腾yy ךFPjx'n +AT*XjI!,JOzi8"b%D %b"/a +>HS\2~9"q UHh4[K m{4.;i* :p:$2C. 3)J8 +BZ XL,3'G?%iDySOi`}}Z8X*X^{dƲPX3JfX-UlC[|J7FH_eyTJJ?ky嬶RR۷x,]rGN˱NMW3RەSަ: S-GuNwmfg^cn6}J笚f}ʾ}+SqZoǮN-^mLVdmKYio}S)W+{չRp9;[>}={ȖI)%_F{)]>_[z}k%m*۷Nj[y9&'KJ?:VJ_lҿݔNҿ_Qo+[ޱjr[zYQJ9-_n:yJMcJlgGw}-[Y+4O(/m:->8/m:ijlltzox^*^pWzi7VY{Ӧo{z[w[ߥٮrvƗSFr]k~*K5Ne_=si#ޏZpW^y-ۮ}Nmշܪ#nvo^^ n~[6˾|kܬ;e?ξVcwsΪz׶RVJOy4%Ѐ( H"<=&8@<,*"($Ba@  `XpPf9FHj~ +z +Y8@joqip+y(?)ԯy6PׇYjlV~5z~Dh +7WV2rKA)#}yOQC:O 4׮%D6)B,3㉚Qh2H$cW*Xa^)P"#͈xOÖFe]P긺vxG5 +*JĻY=$!~3m_cHF{kSf_ȵRn+C蔻XkrKM=iOu\y'3ߞN]rW! y ɑhWሸq*г +Bn03BV.xWnbV>4TVDsCT1(C_YH$l{%OAnX9{¹9lrWAbHZ'~yk2F f`H޷+F-}_1Bz:[~}r F7g1]񔄘vcYWX/U_geA̛!l g?nNͮny?"c8H@ +)P }0)TyEc& :F kNE~eq(U{|l7#nQ]j9yN"p8v߷SEω`lIΘyJ\\V$(ֳ;i>Kg!fNcU=TR.#Q(dY3uv&RlͪW 9DQ9C;0ڔFDNBEJg ZUDj:^*ȁuY0*p?t^2nIY7"qIJb7JEw~\@ +(ϥR0GQc}d +Bk,+I._bnl]rFn;pHC8S=NY DYv!I.T +jhV څ,M  5ClүMi0ȯK* +,Cszɺ)(h7 3U?}%e[CA#hF)8Sڅ&oRC!O=7{g;śH2 +x` {[ 4m,$c%ȔD1(۴]\kJ&SoN-5Q!Z m?6XE7Nu_?;ba4 RIl%@܈^ҐSz>'8zvj26:uQH9!K^trCmQZkK#( Ox.hX03ai3xT+^ \)ԘVj`UдLkq-hqY+3gj#Euttu>|ThL_$"t|Ml*p{-Ql nNQS[Jw#g7bղ-ij+NcNfJp? 㾸6.͈sAA3!otQˡ<:x&;*v)V}X5TW5(4oBJPţ|~Sy2s+Mic1(|qa>WYij|ELσRP*^4F9WZ_ LQ`$mf:HB@ FR8jL*>xpT sXstAl 9d3tGSPppꨆwIP$\YUa}=`͋!(gZdBFznEO `zG> A6ŖrI5I 6^[|~lSU4}?e%Cz nb[txmeҤqLe:?Ԏ1%x-.m=A@Hyv6u +!Z*OF-%fV='U2΃IIRkeK]ITir_}2F-DSHs*x(ǭH +ɥUB^Al0K_rُ9aKK)#N:lEatYUyN`;b= ;eip%1Wrkoak OѸNҋJ<:Uܰ݁ݶqV|7\`ytŋUI7V3JOx~XF㯦fD4,G5lpvd  "4}<uiX"_7ꦒkyܩ*n`gyFsp!rퟢǶ%Iwx c6hjfp>x0PhߴsqR5~egKKMlTGQHk W-lR"bJ/+_ꀴ.(JVq=Ԓ̢tYiw']+`Iܹ9dZ +ٍ&e TZwT׳F?5# NIܬc8 ԰`7Q:3{fK*r:mg?jݝ\or 蠶%n-J)9-wD& , +"YYvq#<@e)g$l w0H8jM+|%ʧTNxUk? +Urkhy}I6pk 񇣽։Я /8Pq|S+wAu)aƞU-%lXУptOG֧m6,\+ ޟRһգǸrP{˥F +54z.i5md]214 L]y uFsڤ L-%>Q:ŢNlϫ{NFH|̵kpXx\^nֶ4jR.iU<W}J)vй1VQ[Kf a-IN;)plȆ!D-βr2&ypQ^0CW MiS;T2N+ kzv H_RǷBoayB4kt#ia+\ ֘x;afKvg/2 vSnpU2>{,UWVhBDfEtE[UX4n +WݙTL>3qpKL`[:2a/ҙ:¹,dT͑Öal ̫Ǣ8cdmj}=W!w|,o4 Ct_Ԁfg:։JdȒg)GLnzĘVCKu>+y +Tya,{3/%~ִd6L9$/XMqq+ngЬ(b Ey`<"_B[.@gvHӑ-g[E<-R=gH3x\G%qP1kHe[_*Nd[C寨AM*[u8h^1ےMlCq3Ztw?7C VUj3 o(Fž\dy@*[0Z3=HKlkuC7t's W) MIͽHMx"t(B(l]8n5&捙Qn^/U8=+;gk'F 6ѹu׬Èﻷu!QMg9̹/v.YV] jP%OGkK;ҮԑjTR+8%GR##?jmBO39RSm{,A5!MIE|VsDihIPaWm'z~ҏƨojb ;T}|Wt'shAW@u/]}Q'4\<4gkx/*g +-щX)|23>n]DAiwݛ^1ȤM<{1&Ƽbu-%e(R&o Zй?ٹ%pƆj2!*2N& /z|$F&cOq =;[h+;_9@=%'SK3W_h}6$:Ӝ.f:]whNQю1an*uA#S,z{/B,)Q^!EI/GW{&h$hq  K0q0W`5ś^i. 6|pOr1O}4z-m]$[p-eߕ%qW"8QL5O: +AGDИ.yB^r|+]bWR4@YН.08es9ުI\ٴ/ >@m$m/X? r>(aĮ~CӋf-tl EG𥕴-QM=&S. +լ˞V]p&-[T4nZM { jEH92ٮoTDa6 1BSEpK@%^AmyXXD-Ckؠ|St'N0uk;gI Pp_i}PG`,,LXٵN_/}*y;AM~ETs7: *{Vi`f'i.Ч|cp'seW.Gϫb}etþ)`sclPzWVܵۮ-Iz=puؕjQ#ObSPx-~D})pp1(tVL >h-}|_^eJjTW0oO:-n>|FuQaN 6/[Օ3w$c8n"t.ś-Aؒ}.S4= ̣OTlk~owϢ5`+@On.nmnk y`1ʇxB}1>Ul3 ) ٴcM%&d}mUj^8Zd +1k,b̦I k8 2TAB:ݖQ +K|s(>j jQvPtaĦ%I7"| +22B,bfQE!TmTG&mQfBX_V4-W_Ġ.xR^;(ĥ@]+`e212)J.,T_jOx 4- +A +x(& +}bDy{-~BjZ 0%Pl#%k(D(L(dP- +!+&Ú + +#MQV 9pGކt]acCR"Q(Dcr/o(c0i"7:c]ޡjڷC   +% +1I"0 +E!42<r$ +a1%N~K'\.iDa$:4 +8* +AL5xZw +! _r@/0CC!aPBHJ]>#J]ȣ[t'CLҕFb_d(Ċv+H2 4¢Ѥ* +t)uD:Bt^s3ctm+ +#;RĩkC5t(gmv4K+s9X$ASݨe( d ՇBx5x^hlVBG8L $*OIPzsyC!ZM L!V'ۊ%y$h_ +^v.+qyAkw.ju͸\Ѥo'x%.(,"~?//sHӬZ 5A_ZD2&> d*I4 +!ZfN}z=0z^R~aLnklXi:TۡmK}LsG]F=zn=IЗ?1 t@ǫMx|Ԗ&naN* aa rkKls+;dYM6GφrKz[<@րHf[G_OjZeN[TuGה[t8Ḙ`J|*-T[U[-~iܴ \~mmsAǘR8 +Kh3 +g! |̶4BF"¶v2!5T!i~U$+iz\-EsӜǬU:"<%XV—.tYd>ڕ:*K(Ƒٲ +\fv CdFM%A\?.y 'jc^ t(~dc Xbtn9Y=17Ve@r2vg=Okxw $+>|1r(S@KpTV% +\܉p?61zez6\Gpz2c ྮh$xQH2om&R8лB/F2@kHt.Ym;8Лhc& %bU<m<=%KZ$/ + hq„D>Z +m3ض};YWсBtAd; m~$m%9?|cM=3.DCNH<ޝ4dQ3'r_z3BhVlBv31ޡ ͎JGHkhhz٠ہ0S=ٜwh&o\z(4̨d'4 3<45W/yS8=w%B3^k>s}w;]LmXd=#" +>} ݦAw"8|Vh1ttSW]!?{d3n0JoJRH728hMQ?z4+^Td{B4 "BKA˛yIO{l9ˀ Da|(F1I} Zi(8 f vUGdi{SFt5;2ƏtOS*a+--U:[tLx^lȝD A+zVANnԄA|R0ANST\}ڀ4[4+jGanٳ<|tڤG&>n2ji3|丂8`yвCCyVyQ/Cb`" EHLd7[&sov;hp+i=:KÈ[tsULX9N(` +kGyk`b""amO]j;,lmkSX#kHaxjvY5RВ̛xczjۮXXܼ-97JvD鮷.Ye -ҙ:l%Qost.z׃ʼn}r%Gt[()&hCgÂ8_\|C qfg/h'BcS_v|3AL~98mڞ@jFgSӈ"hg(h $@=VRx8SK!<Cxq֧[&C:Yn mziK졤c5<]/ݱI~&gG#+rIZjFzhEn],bdI5VBxpޔs[F4Ꮤ7ɛx*. +RŽˈW8wrS^7+zv869`l ƀGKN`xZ8BсƂgA,k,n/|o(z{o*O-矼N7_oo>(oԪ|o||S6p>5N[o @py\{I擏fY29{_{O 7J$8?<|滓3W#2qbB=<6Hp|DX=ȵ.bs] KZU?}`i(}g~3b,^\I܃! ':l2j?#MSq$){݊S$[p}4$K?raxaftKT 2NNҬ"Xs✟yWQj}TՔ8)l,e}\LJcGZV*xU7} ˭l@LČ5&Uyra1v5{"_Ǐ ^iXC>c؛0p<<8ȭ}p~9yp%f8R,VѪz 5>8@ƷG;A08=Nv~~,ToJvgjiUNl]XYjk[ 9ju:<+GUY /Iڨ>=ƙ< +x ߷vs]i .*k2 ŏF (RVL?c_9gq쀽&`wt4\e5o,.4+Jv2se3:"(K["ڽP9_zPP{TEh%Xo&tˊ*jI!f"qŅc+P{_uי#SMJYKl[bz8hXlJХ!DΝ;p`[l ,@Γq"/Z:|?* 78qΑ ѠsqʁS.ҕxԝ +Bϵgom,lb{8x/W/3]`z d '!w]2Y꿃D \1hY? %n> endobj 17 0 obj [/View/Design] endobj 18 0 obj <>>> endobj 10 0 obj <> endobj 9 0 obj [/ICCBased 19 0 R] endobj 19 0 obj <>stream HKyTSwoɞc [5laQIBHADED2mtFOE.c}08׎8GNg9w߽'0 ֠Jb  2y.-;!KZ ^i"L0- @8(r;q7Ly&Qq4j|9 V)gB0iW8#8wթ8_٥ʨQQj@&A)/g>'Kt;\ ӥ$պFZUn(4T%)뫔0C&Zi8bxEB;Pӓ̹A om?W= @@ -4468,7 +4449,7 @@ HK N')].uJr  wG xR^[oƜchg`>b$*~ :Eb~,m,-ݖ,Y¬*6X[ݱF=3뭷Y~dó ti zf6~`{v.Ng#{}}jc1X6fm;'_9 r:8q:˜O:ϸ8uJqnv=MmR 4 n3ܣkGݯz=[==<=GTB(/S,]6*-W:#7*e^YDY}UjAyT`#D="b{ų+ʯ:!kJ4Gmt}uC%K7YVfFY .=b?SƕƩȺy چ k5%4m7lqlioZlG+Zz͹mzy]?uuw|"űNwW&e֥ﺱ*|j5kyݭǯg^ykEklD_p߶7Dmo꿻1ml{Mś nLl<9O[$h՛BdҞ@iءG&vVǥ8nRĩ7u\ЭD-u`ֲK³8%yhYѹJº;.! -zpg_XQKFAǿ=ȼ:ɹ8ʷ6˶5̵5͵6ζ7ϸ9к<Ѿ?DINU\dlvۀ܊ݖޢ)߯6DScs 2F[p(@Xr4Pm8Ww)Km endstream endobj 7 0 obj [6 0 R] endobj 20 0 obj <> endobj xref +zpg_XQKFAǿ=ȼ:ɹ8ʷ6˶5̵5͵6ζ7ϸ9к<Ѿ?DINU\dlvۀ܊ݖޢ)߯6DScs 2F[p(@Xr4Pm8Ww)Km endstream endobj 7 0 obj [6 0 R] endobj 20 0 obj <> endobj xref 0 21 0000000000 65535 f 0000000016 00000 n @@ -4476,19 +4457,19 @@ n 0000381121 00000 n 0000000000 00000 f 0000381172 00000 n -0000556098 00000 n -0000559088 00000 n +0000556915 00000 n +0000559905 00000 n 0000381546 00000 n -0000556397 00000 n -0000556284 00000 n +0000557214 00000 n +0000557101 00000 n 0000382380 00000 n 0000382454 00000 n 0000382650 00000 n -0000384127 00000 n -0000449715 00000 n -0000515303 00000 n -0000556168 00000 n -0000556199 00000 n -0000556431 00000 n -0000559111 00000 n -trailer <<0C254DB274964809ADFBC1051E87D8CF>]>> startxref 559318 %%EOF \ No newline at end of file +0000384128 00000 n +0000449716 00000 n +0000515304 00000 n +0000556985 00000 n +0000557016 00000 n +0000557248 00000 n +0000559928 00000 n +trailer <<48A7638FE18A4613B2C3926CAF2D5D12>]>> startxref 560135 %%EOF \ No newline at end of file