From 20647d56e0434ac79ed142cbc4ac47f81f00d437 Mon Sep 17 00:00:00 2001 From: Dave Earley Date: Mon, 25 May 2026 22:58:26 +0100 Subject: [PATCH] Feature: support occurrence locations --- .gitignore | 2 + CLAUDE.md | 1 + .../app/DomainObjects/Enums/LocationType.php | 11 + .../app/DomainObjects/EventDomainObject.php | 39 +- .../EventLocationDomainObject.php | 24 + .../EventOccurrenceDomainObject.php | 23 +- .../EventSettingDomainObject.php | 22 - .../Generated/EventDomainObjectAbstract.php | 28 +- .../EventLocationDomainObjectAbstract.php | 146 + .../EventOccurrenceDomainObjectAbstract.php | 14 + .../EventSettingDomainObjectAbstract.php | 42 - .../LocationDomainObjectAbstract.php | 216 ++ .../OrganizerDomainObjectAbstract.php | 14 + .../OrganizerSettingDomainObjectAbstract.php | 14 - .../DomainObjects/LocationDomainObject.php | 50 + .../DomainObjects/OrganizerDomainObject.php | 19 + backend/app/Helper/IdHelper.php | 2 + .../BulkUpdateOccurrencesAction.php | 5 + .../CreateEventOccurrenceAction.php | 3 + .../DeleteEventOccurrenceAction.php | 4 +- .../DeletePriceOverrideAction.php | 4 +- .../GetEventOccurrenceAction.php | 4 +- .../GetPriceOverridesAction.php | 4 +- .../GetProductVisibilityAction.php | 4 +- .../UpdateEventOccurrenceAction.php | 4 + .../UpdateProductVisibilityAction.php | 4 +- .../UpsertPriceOverrideAction.php | 4 +- .../Http/Actions/Events/CreateEventAction.php | 14 +- .../Http/Actions/Events/GetEventAction.php | 11 +- .../Http/Actions/Events/UpdateEventAction.php | 4 +- .../Events/UpdateEventLocationAction.php | 43 + .../Locations/CreateLocationAction.php | 44 + .../Locations/DeleteLocationAction.php | 35 + .../Locations/GeoAutocompleteAction.php | 54 + .../Locations/GeoPlaceDetailsAction.php | 56 + .../Actions/Locations/GetGeoStatusAction.php | 23 + .../Actions/Locations/GetLocationsAction.php | 37 + .../Locations/UpdateLocationAction.php | 42 + .../Orders/ResendOrderConfirmationAction.php | 23 +- .../Actions/Organizers/GetOrganizerAction.php | 2 + .../UpdateOrganizerLocationAction.php | 33 + .../Event/UpdateEventLocationRequest.php | 36 + .../Http/Request/Event/UpdateEventRequest.php | 4 +- .../BulkUpdateOccurrencesRequest.php | 25 +- .../GenerateOccurrencesRequest.php | 5 +- .../UpsertEventOccurrenceRequest.php | 29 +- .../UpdateEventSettingsRequest.php | 27 +- .../Location/UpsertLocationRequest.php | 61 + .../PartialUpdateOrganizerSettingsRequest.php | 10 - .../UpdateOrganizerLocationRequest.php | 17 + .../SendOccurrenceCancellationEmailJob.php | 11 +- .../app/Mail/Attendee/AttendeeTicketMail.php | 93 +- backend/app/Models/Event.php | 8 +- backend/app/Models/EventLocation.php | 30 + backend/app/Models/EventOccurrence.php | 5 + backend/app/Models/EventSetting.php | 1 - backend/app/Models/Location.php | 36 + backend/app/Models/Organizer.php | 5 + backend/app/Models/OrganizerSetting.php | 1 - backend/app/Providers/AppServiceProvider.php | 29 + .../Providers/RepositoryServiceProvider.php | 6 + .../Eloquent/EventLocationRepository.php | 45 + .../Eloquent/LocationRepository.php | 81 + .../EventLocationRepositoryInterface.php | 15 + .../LocationRepositoryInterface.php | 19 + backend/app/Resources/Event/EventResource.php | 35 +- .../Resources/Event/EventResourcePublic.php | 19 +- .../Resources/Event/EventSettingsResource.php | 4 - .../Event/EventSettingsResourcePublic.php | 9 +- .../EventLocation/EventLocationResource.php | 46 + .../EventLocationResourcePublic.php | 48 + .../EventOccurrenceResource.php | 7 +- .../EventOccurrenceResourcePublic.php | 5 + .../Location/LocationPublicResource.php | 29 + .../Resources/Location/LocationResource.php | 31 + .../Resources/Organizer/OrganizerResource.php | 6 + .../Organizer/OrganizerResourcePublic.php | 17 +- .../Organizer/OrganizerSettingsResource.php | 1 - .../Handlers/Event/CreateEventHandler.php | 46 +- .../Event/CreateEventImageHandler.php | 4 +- .../Handlers/Event/DTO/CreateEventDTO.php | 34 +- .../Event/DTO/CreateEventImageDTO.php | 10 +- .../Handlers/Event/DTO/DeleteEventDTO.php | 4 +- .../Event/DTO/DeleteEventImageDTO.php | 4 +- .../Event/DTO/EventStatsRequestDTO.php | 10 +- .../Event/DTO/EventStatsResponseDTO.php | 27 +- .../Handlers/Event/DTO/GetEventsDTO.php | 4 +- .../Handlers/Event/DTO/GetPublicEventDTO.php | 10 +- .../Event/DTO/GetPublicOrganizerEventsDTO.php | 8 +- .../Handlers/Event/DTO/UpdateEventDTO.php | 27 +- .../Event/DTO/UpdateEventLocationDTO.php | 18 + .../Event/DTO/UpdateEventStatusDTO.php | 4 +- .../Handlers/Event/DeleteEventHandler.php | 4 +- .../Event/DeleteEventImageHandler.php | 8 +- .../Handlers/Event/DuplicateEventHandler.php | 4 +- .../Handlers/Event/GetEventStatsHandler.php | 4 +- .../Handlers/Event/GetEventsHandler.php | 15 +- .../Handlers/Event/GetPublicEventHandler.php | 38 +- .../Handlers/Event/GetPublicEventsHandler.php | 6 +- .../Handlers/Event/UpdateEventHandler.php | 60 +- .../Event/UpdateEventLocationHandler.php | 76 + .../Event/UpdateEventStatusHandler.php | 16 +- .../BulkUpdateOccurrencesHandler.php | 87 +- .../CreateEventOccurrenceHandler.php | 22 + .../DTO/BulkUpdateOccurrencesDTO.php | 3 + .../DTO/GenerateOccurrencesDTO.php | 6 +- .../DTO/UpdateProductVisibilityDTO.php | 8 +- .../DTO/UpsertEventOccurrenceDTO.php | 3 + .../GenerateOccurrencesFromRuleHandler.php | 10 +- .../GetEventOccurrenceHandler.php | 20 +- .../GetEventOccurrencesHandler.php | 8 +- .../GetProductVisibilityHandler.php | 8 +- .../DTO/UpsertPriceOverrideDTO.php | 10 +- .../DeletePriceOverrideHandler.php | 12 +- .../GetPriceOverridesHandler.php | 8 +- .../UpsertPriceOverrideHandler.php | 18 +- .../UpdateEventOccurrenceHandler.php | 75 +- .../UpdateProductVisibilityHandler.php | 14 +- .../DTO/GetPlatformFeePreviewDTO.php | 5 +- .../DTO/PartialUpdateEventSettingsDTO.php | 8 +- .../DTO/PlatformFeePreviewResponseDTO.php | 17 +- .../DTO/UpdateEventSettingsDTO.php | 110 +- .../GetPlatformFeePreviewHandler.php | 6 +- .../PartialUpdateEventSettingsHandler.php | 21 +- .../UpdateEventSettingsHandler.php | 13 +- .../Location/CreateLocationHandler.php | 61 + .../Location/DTO/UpsertLocationDTO.php | 22 + .../Location/DeleteLocationHandler.php | 48 + .../Location/GeoAutocompleteHandler.php | 23 + .../Location/GeoPlaceDetailsHandler.php | 20 + .../Handlers/Location/GetLocationsHandler.php | 21 + .../Location/UpdateLocationHandler.php | 51 + .../DTO/PartialUpdateOrganizerSettingsDTO.php | 4 - .../DTO/UpdateOrganizerLocationDTO.php | 16 + .../Organizer/GetOrganizerEventsHandler.php | 11 +- .../Organizer/GetPublicOrganizerHandler.php | 3 + .../PartialUpdateOrganizerSettingsHandler.php | 13 - .../UpdateOrganizerLocationHandler.php | 52 + .../Domain/Email/EmailTokenContextBuilder.php | 111 +- .../Domain/Event/CreateEventService.php | 1 - .../EventLocation/EventLocationCleaner.php | 27 + .../EventLocation/EventLocationData.php | 26 + .../EventLocation/EventLocationUpserter.php | 96 + .../Domain/Location/LocationDataSanitizer.php | 69 + .../Location/LocationOwnershipValidator.php | 37 + .../Domain/Mail/SendOrderDetailsService.php | 21 +- .../Domain/Order/MarkOrderAsPaidService.php | 16 +- .../SelfServiceEditOrderService.php | 31 +- .../SelfServiceResendEmailService.php | 39 +- .../Infrastructure/Geo/DTO/GeoPlaceDTO.php | 21 + .../Geo/DTO/GeoSuggestionDTO.php | 16 + .../Geo/Exception/GeoProviderException.php | 11 + .../GeoProviderQuotaExceededException.php | 9 + .../Geo/GeoProviderInterface.php | 18 + .../Geo/GooglePlacesGeoProvider.php | 259 ++ .../Infrastructure/Geo/NoOpGeoProvider.php | 27 + backend/app/Validators/EventRules.php | 29 +- backend/config/services.php | 6 + ...26_05_18_000001_create_locations_table.php | 40 + ...location_id_to_organizers_and_backfill.php | 89 + ...22_000001_create_event_locations_table.php | 28 + ...nts_and_occurrences_to_event_locations.php | 243 ++ ...add_raw_provider_response_to_locations.php | 22 + .../emails/orders/attendee-ticket.blade.php | 4 +- backend/routes/api.php | 40 +- ...LinkEventsToEventLocationsBackfillTest.php | 223 ++ .../EventLocation/EventLocationCanaryTest.php | 366 +++ .../Event/UpdateEventLocationRequestTest.php | 86 + .../BulkUpdateOccurrencesRequestTest.php | 48 + .../UpsertEventOccurrenceRequestTest.php | 45 + .../Location/UpsertLocationRequestTest.php | 79 + ...SendOccurrenceCancellationEmailJobTest.php | 9 +- .../Eloquent/LocationRepositoryTest.php | 132 + .../Event/GetPublicEventHandlerTest.php | 9 +- .../Handlers/Event/UpdateEventHandlerTest.php | 175 ++ .../Event/UpdateEventLocationHandlerTest.php | 171 + .../BulkUpdateOccurrencesHandlerTest.php | 215 +- .../CreateEventOccurrenceHandlerTest.php | 156 +- ...GenerateOccurrencesFromRuleHandlerTest.php | 12 +- .../GetEventOccurrenceHandlerTest.php | 11 +- .../GetEventOccurrencesHandlerTest.php | 6 +- .../GetProductVisibilityHandlerTest.php | 6 +- .../DeletePriceOverrideHandlerTest.php | 15 +- .../GetPriceOverridesHandlerTest.php | 10 +- .../UpsertPriceOverrideHandlerTest.php | 17 +- .../UpdateEventOccurrenceHandlerTest.php | 240 +- .../UpdateProductVisibilityHandlerTest.php | 30 +- .../UpdateOrganizerLocationHandlerTest.php | 144 + .../Email/EmailTokenContextBuilderTest.php | 258 +- .../EventLocationUpserterTest.php | 275 ++ .../SelfServiceResendEmailServiceTest.php | 32 - .../Geo/GooglePlacesGeoProviderTest.php | 194 ++ frontend/src/api/event.client.ts | 9 + frontend/src/api/location.client.ts | 73 + frontend/src/api/organizer.client.ts | 7 + .../common/AddEventToCalendarButton/index.tsx | 7 +- .../common/AddressAutocomplete/index.tsx | 113 + .../common/AttendeeTicket/index.tsx | 27 +- .../Controls/InsertLiquidVariableControl.tsx | 15 + .../src/components/common/EventCard/index.tsx | 33 +- .../common/EventDocumentHead/index.tsx | 71 +- .../common/InlineOrderSummary/index.tsx | 15 +- .../common/OnlineEventDetails/index.tsx | 37 +- .../common/OrganizerDocumentHead/index.tsx | 15 +- .../src/components/layouts/Event/index.tsx | 2 + .../layouts/EventHomepage/index.tsx | 113 +- .../OrganizerHomepage/EventCard/index.tsx | 33 +- .../layouts/OrganizerHomepage/index.tsx | 6 +- .../layouts/OrganizerLayout/index.tsx | 6 + .../modals/LocationEditModal/index.tsx | 138 + .../OccurrenceBulkEditModal/index.tsx | 237 +- .../OccurrenceEditModal/index.tsx | 290 +- .../Sections/LocationSettings/index.tsx | 256 +- .../TicketDesigner/TicketDesignerPrint.tsx | 20 +- .../event/TicketDesigner/TicketPreview.tsx | 23 +- .../components/routes/my-tickets/index.tsx | 25 +- .../routes/organizer/Locations/index.tsx | 152 + .../Sections/AddressSettings/index.tsx | 168 +- .../AttendeeProductAndInformation/index.tsx | 2 +- .../OrderSummaryAndProducts/index.tsx | 40 +- .../product-widget/PrintProduct/index.tsx | 8 +- frontend/src/locales/de.js | 2 +- frontend/src/locales/de.po | 2646 +++++++++------- frontend/src/locales/en.js | 2 +- frontend/src/locales/en.po | 1746 ++++++---- frontend/src/locales/es.js | 2 +- frontend/src/locales/es.po | 2644 +++++++++------- frontend/src/locales/fr.js | 2 +- frontend/src/locales/fr.po | 2648 +++++++++------- frontend/src/locales/hu.js | 2 +- frontend/src/locales/hu.po | 2648 +++++++++------- frontend/src/locales/it.js | 2 +- frontend/src/locales/it.po | 2610 ++++++++------- frontend/src/locales/nl.js | 2 +- frontend/src/locales/nl.po | 2648 +++++++++------- frontend/src/locales/pl.js | 2 +- frontend/src/locales/pl.po | 2796 ++++++++++------- frontend/src/locales/pt-br.js | 2 +- frontend/src/locales/pt-br.po | 2648 +++++++++------- frontend/src/locales/pt.js | 2 +- frontend/src/locales/pt.po | 2648 +++++++++------- frontend/src/locales/ru.js | 2 +- frontend/src/locales/ru.po | 1732 ++++++---- frontend/src/locales/se.js | 2 +- frontend/src/locales/se.po | 2644 +++++++++------- frontend/src/locales/tr.js | 2 +- frontend/src/locales/tr.po | 2646 +++++++++------- frontend/src/locales/vi.js | 2 +- frontend/src/locales/vi.po | 2646 +++++++++------- frontend/src/locales/zh-cn.js | 2 +- frontend/src/locales/zh-cn.po | 2644 +++++++++------- frontend/src/locales/zh-hk.js | 2 +- frontend/src/locales/zh-hk.po | 2646 +++++++++------- frontend/src/mutations/useCreateLocation.ts | 16 + frontend/src/mutations/useDeleteLocation.ts | 14 + frontend/src/mutations/useResolveGeoPlace.ts | 10 + .../src/mutations/useUpdateEventLocation.ts | 28 + frontend/src/mutations/useUpdateLocation.ts | 17 + .../mutations/useUpdateOrganizerLocation.ts | 14 + frontend/src/queries/useGeoAutocomplete.ts | 19 + frontend/src/queries/useGeoStatus.ts | 17 + .../src/queries/useGetOrganizerLocations.ts | 13 + frontend/src/router.tsx | 7 + frontend/src/types.ts | 62 +- frontend/src/utilites/addressUtilities.ts | 9 +- frontend/src/utilites/calendar.ts | 80 +- frontend/src/utilites/effectiveLocation.ts | 5 + 267 files changed, 32435 insertions(+), 18051 deletions(-) create mode 100644 backend/app/DomainObjects/Enums/LocationType.php create mode 100644 backend/app/DomainObjects/EventLocationDomainObject.php create mode 100644 backend/app/DomainObjects/Generated/EventLocationDomainObjectAbstract.php create mode 100644 backend/app/DomainObjects/Generated/LocationDomainObjectAbstract.php create mode 100644 backend/app/DomainObjects/LocationDomainObject.php create mode 100644 backend/app/Http/Actions/Events/UpdateEventLocationAction.php create mode 100644 backend/app/Http/Actions/Locations/CreateLocationAction.php create mode 100644 backend/app/Http/Actions/Locations/DeleteLocationAction.php create mode 100644 backend/app/Http/Actions/Locations/GeoAutocompleteAction.php create mode 100644 backend/app/Http/Actions/Locations/GeoPlaceDetailsAction.php create mode 100644 backend/app/Http/Actions/Locations/GetGeoStatusAction.php create mode 100644 backend/app/Http/Actions/Locations/GetLocationsAction.php create mode 100644 backend/app/Http/Actions/Locations/UpdateLocationAction.php create mode 100644 backend/app/Http/Actions/Organizers/UpdateOrganizerLocationAction.php create mode 100644 backend/app/Http/Request/Event/UpdateEventLocationRequest.php create mode 100644 backend/app/Http/Request/Location/UpsertLocationRequest.php create mode 100644 backend/app/Http/Request/Organizer/UpdateOrganizerLocationRequest.php create mode 100644 backend/app/Models/EventLocation.php create mode 100644 backend/app/Models/Location.php create mode 100644 backend/app/Repository/Eloquent/EventLocationRepository.php create mode 100644 backend/app/Repository/Eloquent/LocationRepository.php create mode 100644 backend/app/Repository/Interfaces/EventLocationRepositoryInterface.php create mode 100644 backend/app/Repository/Interfaces/LocationRepositoryInterface.php create mode 100644 backend/app/Resources/EventLocation/EventLocationResource.php create mode 100644 backend/app/Resources/EventLocation/EventLocationResourcePublic.php create mode 100644 backend/app/Resources/Location/LocationPublicResource.php create mode 100644 backend/app/Resources/Location/LocationResource.php create mode 100644 backend/app/Services/Application/Handlers/Event/DTO/UpdateEventLocationDTO.php create mode 100644 backend/app/Services/Application/Handlers/Event/UpdateEventLocationHandler.php create mode 100644 backend/app/Services/Application/Handlers/Location/CreateLocationHandler.php create mode 100644 backend/app/Services/Application/Handlers/Location/DTO/UpsertLocationDTO.php create mode 100644 backend/app/Services/Application/Handlers/Location/DeleteLocationHandler.php create mode 100644 backend/app/Services/Application/Handlers/Location/GeoAutocompleteHandler.php create mode 100644 backend/app/Services/Application/Handlers/Location/GeoPlaceDetailsHandler.php create mode 100644 backend/app/Services/Application/Handlers/Location/GetLocationsHandler.php create mode 100644 backend/app/Services/Application/Handlers/Location/UpdateLocationHandler.php create mode 100644 backend/app/Services/Application/Handlers/Organizer/DTO/UpdateOrganizerLocationDTO.php create mode 100644 backend/app/Services/Application/Handlers/Organizer/UpdateOrganizerLocationHandler.php create mode 100644 backend/app/Services/Domain/EventLocation/EventLocationCleaner.php create mode 100644 backend/app/Services/Domain/EventLocation/EventLocationData.php create mode 100644 backend/app/Services/Domain/EventLocation/EventLocationUpserter.php create mode 100644 backend/app/Services/Domain/Location/LocationDataSanitizer.php create mode 100644 backend/app/Services/Domain/Location/LocationOwnershipValidator.php create mode 100644 backend/app/Services/Infrastructure/Geo/DTO/GeoPlaceDTO.php create mode 100644 backend/app/Services/Infrastructure/Geo/DTO/GeoSuggestionDTO.php create mode 100644 backend/app/Services/Infrastructure/Geo/Exception/GeoProviderException.php create mode 100644 backend/app/Services/Infrastructure/Geo/Exception/GeoProviderQuotaExceededException.php create mode 100644 backend/app/Services/Infrastructure/Geo/GeoProviderInterface.php create mode 100644 backend/app/Services/Infrastructure/Geo/GooglePlacesGeoProvider.php create mode 100644 backend/app/Services/Infrastructure/Geo/NoOpGeoProvider.php create mode 100644 backend/database/migrations/2026_05_18_000001_create_locations_table.php create mode 100644 backend/database/migrations/2026_05_19_000001_add_location_id_to_organizers_and_backfill.php create mode 100644 backend/database/migrations/2026_05_22_000001_create_event_locations_table.php create mode 100644 backend/database/migrations/2026_05_22_000002_link_events_and_occurrences_to_event_locations.php create mode 100644 backend/database/migrations/2026_05_22_000003_add_raw_provider_response_to_locations.php create mode 100644 backend/tests/Feature/Database/Migrations/LinkEventsToEventLocationsBackfillTest.php create mode 100644 backend/tests/Feature/Services/Domain/EventLocation/EventLocationCanaryTest.php create mode 100644 backend/tests/Unit/Http/Request/Event/UpdateEventLocationRequestTest.php create mode 100644 backend/tests/Unit/Http/Request/EventOccurrence/BulkUpdateOccurrencesRequestTest.php create mode 100644 backend/tests/Unit/Http/Request/EventOccurrence/UpsertEventOccurrenceRequestTest.php create mode 100644 backend/tests/Unit/Http/Request/Location/UpsertLocationRequestTest.php create mode 100644 backend/tests/Unit/Repository/Eloquent/LocationRepositoryTest.php create mode 100644 backend/tests/Unit/Services/Application/Handlers/Event/UpdateEventHandlerTest.php create mode 100644 backend/tests/Unit/Services/Application/Handlers/Event/UpdateEventLocationHandlerTest.php create mode 100644 backend/tests/Unit/Services/Application/Handlers/Organizer/UpdateOrganizerLocationHandlerTest.php create mode 100644 backend/tests/Unit/Services/Domain/EventLocation/EventLocationUpserterTest.php create mode 100644 backend/tests/Unit/Services/Infrastructure/Geo/GooglePlacesGeoProviderTest.php create mode 100644 frontend/src/api/location.client.ts create mode 100644 frontend/src/components/common/AddressAutocomplete/index.tsx create mode 100644 frontend/src/components/modals/LocationEditModal/index.tsx create mode 100644 frontend/src/components/routes/organizer/Locations/index.tsx create mode 100644 frontend/src/mutations/useCreateLocation.ts create mode 100644 frontend/src/mutations/useDeleteLocation.ts create mode 100644 frontend/src/mutations/useResolveGeoPlace.ts create mode 100644 frontend/src/mutations/useUpdateEventLocation.ts create mode 100644 frontend/src/mutations/useUpdateLocation.ts create mode 100644 frontend/src/mutations/useUpdateOrganizerLocation.ts create mode 100644 frontend/src/queries/useGeoAutocomplete.ts create mode 100644 frontend/src/queries/useGeoStatus.ts create mode 100644 frontend/src/queries/useGetOrganizerLocations.ts create mode 100644 frontend/src/utilites/effectiveLocation.ts diff --git a/.gitignore b/.gitignore index 111504f948..0ed72ecbb1 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ prompts/ /plans .claude/worktrees/ +.claude/scheduled_tasks.lock +tmp_translate/ diff --git a/CLAUDE.md b/CLAUDE.md index 0b0c572958..ef7c84edb8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,6 +58,7 @@ cd docker/development - **ALWAYS** wrap all translatable strings in `__()` helper - Domain Objects are auto-generated via `php artisan generate-domain-objects` - never edit manually - **Always** create unit tests for new features in `backend/tests/Unit/` +- **NEVER leave dead code.** Code that has no production callers — unused methods, unused DTO fields, unused constants, columns that are written but never read, classes only called from tests — must be deleted, not left "for future use". This applies to both backend and frontend. If you add a method speculatively, wire it to a real caller in the same change or remove it. The same rule applies after refactors: if something becomes unreferenced, it goes. Confirm with grep before claiming a method or class is reachable. - **DON'T** add comments unless absolutely necessary - **ALWAYS** sanitize user-provided content with `HtmlPurifierService` before storing, especially content rendered as HTML diff --git a/backend/app/DomainObjects/Enums/LocationType.php b/backend/app/DomainObjects/Enums/LocationType.php new file mode 100644 index 0000000000..8001c30d17 --- /dev/null +++ b/backend/app/DomainObjects/Enums/LocationType.php @@ -0,0 +1,11 @@ +questions = $questions; + return $this; } @@ -112,6 +115,7 @@ public function getSlug(): string public function setImages(?Collection $images): EventDomainObject { $this->images = $images; + return $this; } @@ -128,6 +132,7 @@ public function getEventSettings(): ?EventSettingDomainObject public function setEventSettings(?EventSettingDomainObject $settings): EventDomainObject { $this->settings = $settings; + return $this; } @@ -151,6 +156,7 @@ public function getAccount(): ?AccountDomainObject public function setAccount(?AccountDomainObject $account): self { $this->account = $account; + return $this; } @@ -175,6 +181,7 @@ public function getDescriptionPreview(): string public function setEventOccurrences(?Collection $eventOccurrences): self { $this->eventOccurrences = $eventOccurrences; + return $this; } @@ -190,7 +197,7 @@ public function getStartDate(): ?string } return $this->eventOccurrences->min( - fn(EventOccurrenceDomainObject $o) => $o->getStartDate() + fn (EventOccurrenceDomainObject $o) => $o->getStartDate() ); } @@ -201,17 +208,17 @@ public function getEndDate(): ?string } $withEndDates = $this->eventOccurrences->filter( - fn(EventOccurrenceDomainObject $o) => $o->getEndDate() !== null + fn (EventOccurrenceDomainObject $o) => $o->getEndDate() !== null ); if ($withEndDates->isEmpty()) { return $this->eventOccurrences->max( - fn(EventOccurrenceDomainObject $o) => $o->getStartDate() + fn (EventOccurrenceDomainObject $o) => $o->getStartDate() ); } return $withEndDates->max( - fn(EventOccurrenceDomainObject $o) => $o->getEndDate() + fn (EventOccurrenceDomainObject $o) => $o->getEndDate() ); } @@ -224,9 +231,9 @@ public function getNextOccurrenceStartDate(): ?string $now = Carbon::now(); $nextOccurrence = $this->eventOccurrences - ->filter(fn(EventOccurrenceDomainObject $o) => $o->getStatus() === EventOccurrenceStatus::ACTIVE->name) - ->filter(fn(EventOccurrenceDomainObject $o) => Carbon::parse($o->getStartDate(), 'UTC')->isFuture()) - ->sortBy(fn(EventOccurrenceDomainObject $o) => $o->getStartDate()) + ->filter(fn (EventOccurrenceDomainObject $o) => $o->getStatus() === EventOccurrenceStatus::ACTIVE->name) + ->filter(fn (EventOccurrenceDomainObject $o) => Carbon::parse($o->getStartDate(), 'UTC')->isFuture()) + ->sortBy(fn (EventOccurrenceDomainObject $o) => $o->getStartDate()) ->first(); return $nextOccurrence?->getStartDate(); @@ -347,12 +354,14 @@ public function getEventStatistics(): ?EventStatisticDomainObject public function setEventStatistics(?EventStatisticDomainObject $eventStatistics): self { $this->eventStatistics = $eventStatistics; + return $this; } public function setProductCategories(?Collection $productCategories): EventDomainObject { $this->productCategories = $productCategories; + return $this; } @@ -369,6 +378,7 @@ public function getWebhooks(): ?Collection public function setWebhooks(?Collection $webhooks): EventDomainObject { $this->webhooks = $webhooks; + return $this; } @@ -380,6 +390,19 @@ public function getAffiliates(): ?Collection public function setAffiliates(?Collection $affiliates): EventDomainObject { $this->affiliates = $affiliates; + + return $this; + } + + public function getEventLocation(): ?EventLocationDomainObject + { + return $this->eventLocation; + } + + public function setEventLocation(?EventLocationDomainObject $eventLocation): self + { + $this->eventLocation = $eventLocation; + return $this; } } diff --git a/backend/app/DomainObjects/EventLocationDomainObject.php b/backend/app/DomainObjects/EventLocationDomainObject.php new file mode 100644 index 0000000000..0d1d2dbf51 --- /dev/null +++ b/backend/app/DomainObjects/EventLocationDomainObject.php @@ -0,0 +1,24 @@ +location; + } + + public function setLocation(?LocationDomainObject $location): self + { + $this->location = $location; + + return $this; + } +} diff --git a/backend/app/DomainObjects/EventOccurrenceDomainObject.php b/backend/app/DomainObjects/EventOccurrenceDomainObject.php index dd8f9b9d6d..9bb2979da7 100644 --- a/backend/app/DomainObjects/EventOccurrenceDomainObject.php +++ b/backend/app/DomainObjects/EventOccurrenceDomainObject.php @@ -10,7 +10,7 @@ use HiEvents\DomainObjects\Status\EventOccurrenceStatus; use Illuminate\Support\Collection; -class EventOccurrenceDomainObject extends EventOccurrenceDomainObjectAbstract implements IsSortable, IsFilterable +class EventOccurrenceDomainObject extends EventOccurrenceDomainObjectAbstract implements IsFilterable, IsSortable { private ?EventDomainObject $event = null; @@ -24,6 +24,8 @@ class EventOccurrenceDomainObject extends EventOccurrenceDomainObjectAbstract im private ?EventOccurrenceStatisticDomainObject $eventOccurrenceStatistics = null; + private ?EventLocationDomainObject $eventLocation = null; + public static function getAllowedFilterFields(): array { return [ @@ -57,6 +59,7 @@ public static function getDefaultSortDirection(): string public function setEvent(?EventDomainObject $event): self { $this->event = $event; + return $this; } @@ -68,6 +71,7 @@ public function getEvent(): ?EventDomainObject public function setOrderItems(?Collection $orderItems): self { $this->orderItems = $orderItems; + return $this; } @@ -79,6 +83,7 @@ public function getOrderItems(): ?Collection public function setAttendees(?Collection $attendees): self { $this->attendees = $attendees; + return $this; } @@ -90,6 +95,7 @@ public function getAttendees(): ?Collection public function setCheckInLists(?Collection $checkInLists): self { $this->checkInLists = $checkInLists; + return $this; } @@ -101,6 +107,7 @@ public function getCheckInLists(): ?Collection public function setPriceOverrides(?Collection $priceOverrides): self { $this->priceOverrides = $priceOverrides; + return $this; } @@ -112,6 +119,7 @@ public function getPriceOverrides(): ?Collection public function setEventOccurrenceStatistics(?EventOccurrenceStatisticDomainObject $statistics): self { $this->eventOccurrenceStatistics = $statistics; + return $this; } @@ -138,6 +146,7 @@ public function isSoldOut(): bool public function isPast(): bool { $endDate = $this->getEndDate() ?? $this->getStartDate(); + return Carbon::parse($endDate, 'UTC')->isPast(); } @@ -154,4 +163,16 @@ public function getAvailableCapacity(): ?int return max(0, $this->getCapacity() - $this->getUsedCapacity()); } + + public function setEventLocation(?EventLocationDomainObject $eventLocation): self + { + $this->eventLocation = $eventLocation; + + return $this; + } + + public function getEventLocation(): ?EventLocationDomainObject + { + return $this->eventLocation; + } } diff --git a/backend/app/DomainObjects/EventSettingDomainObject.php b/backend/app/DomainObjects/EventSettingDomainObject.php index baae62174d..c48207c9d0 100644 --- a/backend/app/DomainObjects/EventSettingDomainObject.php +++ b/backend/app/DomainObjects/EventSettingDomainObject.php @@ -2,13 +2,9 @@ namespace HiEvents\DomainObjects; -use HiEvents\DataTransferObjects\AddressDTO; -use HiEvents\Helper\AddressHelper; - class EventSettingDomainObject extends Generated\EventSettingDomainObjectAbstract { /** - * @return string * @todo This should not be here. */ public function getGetEmailFooterHtml(): string @@ -23,22 +19,4 @@ public function getGetEmailFooterHtml(): string HTML; } - - public function getAddressString(): string - { - return AddressHelper::formatAddress($this->getLocationDetails()); - } - - public function getAddress(): AddressDTO - { - return new AddressDTO( - venue_name: $this->getLocationDetails()['venue_name'] ?? null, - address_line_1: $this->getLocationDetails()['address_line_1'] ?? null, - address_line_2: $this->getLocationDetails()['address_line_2'] ?? null, - city: $this->getLocationDetails()['city'] ?? null, - state_or_region: $this->getLocationDetails()['state_or_region'] ?? null, - zip_or_postal_code: $this->getLocationDetails()['zip_or_postal_code'] ?? null, - country: $this->getLocationDetails()['country'] ?? null, - ); - } } diff --git a/backend/app/DomainObjects/Generated/EventDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EventDomainObjectAbstract.php index 8badec5cd6..19d2bf039b 100644 --- a/backend/app/DomainObjects/Generated/EventDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/EventDomainObjectAbstract.php @@ -14,10 +14,10 @@ abstract class EventDomainObjectAbstract extends \HiEvents\DomainObjects\Abstrac final public const ACCOUNT_ID = 'account_id'; final public const USER_ID = 'user_id'; final public const ORGANIZER_ID = 'organizer_id'; + final public const EVENT_LOCATION_ID = 'event_location_id'; final public const TITLE = 'title'; final public const DESCRIPTION = 'description'; final public const STATUS = 'status'; - final public const LOCATION_DETAILS = 'location_details'; final public const CURRENCY = 'currency'; final public const TIMEZONE = 'timezone'; final public const ATTRIBUTES = 'attributes'; @@ -35,10 +35,10 @@ abstract class EventDomainObjectAbstract extends \HiEvents\DomainObjects\Abstrac protected int $account_id; protected int $user_id; protected ?int $organizer_id = null; + protected ?int $event_location_id = null; protected string $title; protected ?string $description = null; protected ?string $status = null; - protected array|string|null $location_details = null; protected string $currency = 'USD'; protected ?string $timezone = null; protected array|string|null $attributes = null; @@ -59,10 +59,10 @@ public function toArray(): array 'account_id' => $this->account_id ?? null, 'user_id' => $this->user_id ?? null, 'organizer_id' => $this->organizer_id ?? null, + 'event_location_id' => $this->event_location_id ?? null, 'title' => $this->title ?? null, 'description' => $this->description ?? null, 'status' => $this->status ?? null, - 'location_details' => $this->location_details ?? null, 'currency' => $this->currency ?? null, 'timezone' => $this->timezone ?? null, 'attributes' => $this->attributes ?? null, @@ -122,6 +122,17 @@ public function getOrganizerId(): ?int return $this->organizer_id; } + public function setEventLocationId(?int $event_location_id): self + { + $this->event_location_id = $event_location_id; + return $this; + } + + public function getEventLocationId(): ?int + { + return $this->event_location_id; + } + public function setTitle(string $title): self { $this->title = $title; @@ -155,17 +166,6 @@ public function getStatus(): ?string return $this->status; } - public function setLocationDetails(array|string|null $location_details): self - { - $this->location_details = $location_details; - return $this; - } - - public function getLocationDetails(): array|string|null - { - return $this->location_details; - } - public function setCurrency(string $currency): self { $this->currency = $currency; diff --git a/backend/app/DomainObjects/Generated/EventLocationDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EventLocationDomainObjectAbstract.php new file mode 100644 index 0000000000..c550b8bce9 --- /dev/null +++ b/backend/app/DomainObjects/Generated/EventLocationDomainObjectAbstract.php @@ -0,0 +1,146 @@ + $this->id ?? null, + 'event_id' => $this->event_id ?? null, + 'location_id' => $this->location_id ?? null, + 'short_id' => $this->short_id ?? null, + 'type' => $this->type ?? null, + 'online_event_connection_details' => $this->online_event_connection_details ?? null, + 'created_at' => $this->created_at ?? null, + 'updated_at' => $this->updated_at ?? null, + 'deleted_at' => $this->deleted_at ?? null, + ]; + } + + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setEventId(int $event_id): self + { + $this->event_id = $event_id; + return $this; + } + + public function getEventId(): int + { + return $this->event_id; + } + + public function setLocationId(?int $location_id): self + { + $this->location_id = $location_id; + return $this; + } + + public function getLocationId(): ?int + { + return $this->location_id; + } + + public function setShortId(string $short_id): self + { + $this->short_id = $short_id; + return $this; + } + + public function getShortId(): string + { + return $this->short_id; + } + + public function setType(string $type): self + { + $this->type = $type; + return $this; + } + + public function getType(): string + { + return $this->type; + } + + public function setOnlineEventConnectionDetails(?string $online_event_connection_details): self + { + $this->online_event_connection_details = $online_event_connection_details; + return $this; + } + + public function getOnlineEventConnectionDetails(): ?string + { + return $this->online_event_connection_details; + } + + public function setCreatedAt(?string $created_at): self + { + $this->created_at = $created_at; + return $this; + } + + public function getCreatedAt(): ?string + { + return $this->created_at; + } + + public function setUpdatedAt(?string $updated_at): self + { + $this->updated_at = $updated_at; + return $this; + } + + public function getUpdatedAt(): ?string + { + return $this->updated_at; + } + + public function setDeletedAt(?string $deleted_at): self + { + $this->deleted_at = $deleted_at; + return $this; + } + + public function getDeletedAt(): ?string + { + return $this->deleted_at; + } +} diff --git a/backend/app/DomainObjects/Generated/EventOccurrenceDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EventOccurrenceDomainObjectAbstract.php index 4c6f465bf5..2c4cdcb196 100644 --- a/backend/app/DomainObjects/Generated/EventOccurrenceDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/EventOccurrenceDomainObjectAbstract.php @@ -12,6 +12,7 @@ abstract class EventOccurrenceDomainObjectAbstract extends \HiEvents\DomainObjec final public const PLURAL_NAME = 'event_occurrences'; final public const ID = 'id'; final public const EVENT_ID = 'event_id'; + final public const EVENT_LOCATION_ID = 'event_location_id'; final public const SHORT_ID = 'short_id'; final public const START_DATE = 'start_date'; final public const END_DATE = 'end_date'; @@ -26,6 +27,7 @@ abstract class EventOccurrenceDomainObjectAbstract extends \HiEvents\DomainObjec protected int $id; protected int $event_id; + protected ?int $event_location_id = null; protected string $short_id; protected string $start_date; protected ?string $end_date = null; @@ -43,6 +45,7 @@ public function toArray(): array return [ 'id' => $this->id ?? null, 'event_id' => $this->event_id ?? null, + 'event_location_id' => $this->event_location_id ?? null, 'short_id' => $this->short_id ?? null, 'start_date' => $this->start_date ?? null, 'end_date' => $this->end_date ?? null, @@ -79,6 +82,17 @@ public function getEventId(): int return $this->event_id; } + public function setEventLocationId(?int $event_location_id): self + { + $this->event_location_id = $event_location_id; + return $this; + } + + public function getEventLocationId(): ?int + { + return $this->event_location_id; + } + public function setShortId(string $short_id): self { $this->short_id = $short_id; diff --git a/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php index e9b7b492e5..654948ff84 100644 --- a/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php @@ -30,9 +30,6 @@ abstract class EventSettingDomainObjectAbstract extends \HiEvents\DomainObjects\ final public const HOMEPAGE_PRIMARY_COLOR = 'homepage_primary_color'; final public const HOMEPAGE_SECONDARY_TEXT_COLOR = 'homepage_secondary_text_color'; final public const HOMEPAGE_SECONDARY_COLOR = 'homepage_secondary_color'; - final public const LOCATION_DETAILS = 'location_details'; - final public const ONLINE_EVENT_CONNECTION_DETAILS = 'online_event_connection_details'; - final public const IS_ONLINE_EVENT = 'is_online_event'; final public const ALLOW_SEARCH_ENGINE_INDEXING = 'allow_search_engine_indexing'; final public const SEO_TITLE = 'seo_title'; final public const SEO_DESCRIPTION = 'seo_description'; @@ -88,9 +85,6 @@ abstract class EventSettingDomainObjectAbstract extends \HiEvents\DomainObjects\ protected ?string $homepage_primary_color = null; protected ?string $homepage_secondary_text_color = null; protected ?string $homepage_secondary_color = null; - protected array|string|null $location_details = null; - protected ?string $online_event_connection_details = null; - protected bool $is_online_event = false; protected bool $allow_search_engine_indexing = true; protected ?string $seo_title = null; protected ?string $seo_description = null; @@ -149,9 +143,6 @@ public function toArray(): array 'homepage_primary_color' => $this->homepage_primary_color ?? null, 'homepage_secondary_text_color' => $this->homepage_secondary_text_color ?? null, 'homepage_secondary_color' => $this->homepage_secondary_color ?? null, - 'location_details' => $this->location_details ?? null, - 'online_event_connection_details' => $this->online_event_connection_details ?? null, - 'is_online_event' => $this->is_online_event ?? null, 'allow_search_engine_indexing' => $this->allow_search_engine_indexing ?? null, 'seo_title' => $this->seo_title ?? null, 'seo_description' => $this->seo_description ?? null, @@ -409,39 +400,6 @@ public function getHomepageSecondaryColor(): ?string return $this->homepage_secondary_color; } - public function setLocationDetails(array|string|null $location_details): self - { - $this->location_details = $location_details; - return $this; - } - - public function getLocationDetails(): array|string|null - { - return $this->location_details; - } - - public function setOnlineEventConnectionDetails(?string $online_event_connection_details): self - { - $this->online_event_connection_details = $online_event_connection_details; - return $this; - } - - public function getOnlineEventConnectionDetails(): ?string - { - return $this->online_event_connection_details; - } - - public function setIsOnlineEvent(bool $is_online_event): self - { - $this->is_online_event = $is_online_event; - return $this; - } - - public function getIsOnlineEvent(): bool - { - return $this->is_online_event; - } - public function setAllowSearchEngineIndexing(bool $allow_search_engine_indexing): self { $this->allow_search_engine_indexing = $allow_search_engine_indexing; diff --git a/backend/app/DomainObjects/Generated/LocationDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/LocationDomainObjectAbstract.php new file mode 100644 index 0000000000..082a9ac2cd --- /dev/null +++ b/backend/app/DomainObjects/Generated/LocationDomainObjectAbstract.php @@ -0,0 +1,216 @@ + $this->id ?? null, + 'account_id' => $this->account_id ?? null, + 'organizer_id' => $this->organizer_id ?? null, + 'short_id' => $this->short_id ?? null, + 'name' => $this->name ?? null, + 'structured_address' => $this->structured_address ?? null, + 'latitude' => $this->latitude ?? null, + 'longitude' => $this->longitude ?? null, + 'provider' => $this->provider ?? null, + 'provider_place_id' => $this->provider_place_id ?? null, + 'created_at' => $this->created_at ?? null, + 'updated_at' => $this->updated_at ?? null, + 'deleted_at' => $this->deleted_at ?? null, + 'raw_provider_response' => $this->raw_provider_response ?? null, + ]; + } + + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setAccountId(int $account_id): self + { + $this->account_id = $account_id; + return $this; + } + + public function getAccountId(): int + { + return $this->account_id; + } + + public function setOrganizerId(int $organizer_id): self + { + $this->organizer_id = $organizer_id; + return $this; + } + + public function getOrganizerId(): int + { + return $this->organizer_id; + } + + public function setShortId(string $short_id): self + { + $this->short_id = $short_id; + return $this; + } + + public function getShortId(): string + { + return $this->short_id; + } + + public function setName(?string $name): self + { + $this->name = $name; + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setStructuredAddress(array|string|null $structured_address): self + { + $this->structured_address = $structured_address; + return $this; + } + + public function getStructuredAddress(): array|string|null + { + return $this->structured_address; + } + + public function setLatitude(?float $latitude): self + { + $this->latitude = $latitude; + return $this; + } + + public function getLatitude(): ?float + { + return $this->latitude; + } + + public function setLongitude(?float $longitude): self + { + $this->longitude = $longitude; + return $this; + } + + public function getLongitude(): ?float + { + return $this->longitude; + } + + public function setProvider(?string $provider): self + { + $this->provider = $provider; + return $this; + } + + public function getProvider(): ?string + { + return $this->provider; + } + + public function setProviderPlaceId(?string $provider_place_id): self + { + $this->provider_place_id = $provider_place_id; + return $this; + } + + public function getProviderPlaceId(): ?string + { + return $this->provider_place_id; + } + + public function setCreatedAt(?string $created_at): self + { + $this->created_at = $created_at; + return $this; + } + + public function getCreatedAt(): ?string + { + return $this->created_at; + } + + public function setUpdatedAt(?string $updated_at): self + { + $this->updated_at = $updated_at; + return $this; + } + + public function getUpdatedAt(): ?string + { + return $this->updated_at; + } + + public function setDeletedAt(?string $deleted_at): self + { + $this->deleted_at = $deleted_at; + return $this; + } + + public function getDeletedAt(): ?string + { + return $this->deleted_at; + } + + public function setRawProviderResponse(array|string|null $raw_provider_response): self + { + $this->raw_provider_response = $raw_provider_response; + return $this; + } + + public function getRawProviderResponse(): array|string|null + { + return $this->raw_provider_response; + } +} diff --git a/backend/app/DomainObjects/Generated/OrganizerDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/OrganizerDomainObjectAbstract.php index 6738021dfd..eeec006f3c 100644 --- a/backend/app/DomainObjects/Generated/OrganizerDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/OrganizerDomainObjectAbstract.php @@ -13,6 +13,7 @@ abstract class OrganizerDomainObjectAbstract extends \HiEvents\DomainObjects\Abs final public const ID = 'id'; final public const ACCOUNT_ID = 'account_id'; final public const ORGANIZER_CONFIGURATION_ID = 'organizer_configuration_id'; + final public const LOCATION_ID = 'location_id'; final public const NAME = 'name'; final public const EMAIL = 'email'; final public const PHONE = 'phone'; @@ -28,6 +29,7 @@ abstract class OrganizerDomainObjectAbstract extends \HiEvents\DomainObjects\Abs protected int $id; protected int $account_id; protected ?int $organizer_configuration_id = null; + protected ?int $location_id = null; protected string $name; protected string $email; protected ?string $phone = null; @@ -46,6 +48,7 @@ public function toArray(): array 'id' => $this->id ?? null, 'account_id' => $this->account_id ?? null, 'organizer_configuration_id' => $this->organizer_configuration_id ?? null, + 'location_id' => $this->location_id ?? null, 'name' => $this->name ?? null, 'email' => $this->email ?? null, 'phone' => $this->phone ?? null, @@ -93,6 +96,17 @@ public function getOrganizerConfigurationId(): ?int return $this->organizer_configuration_id; } + public function setLocationId(?int $location_id): self + { + $this->location_id = $location_id; + return $this; + } + + public function getLocationId(): ?int + { + return $this->location_id; + } + public function setName(string $name): self { $this->name = $name; diff --git a/backend/app/DomainObjects/Generated/OrganizerSettingDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/OrganizerSettingDomainObjectAbstract.php index b09f7c756a..a0d4fd53a4 100644 --- a/backend/app/DomainObjects/Generated/OrganizerSettingDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/OrganizerSettingDomainObjectAbstract.php @@ -24,7 +24,6 @@ abstract class OrganizerSettingDomainObjectAbstract extends \HiEvents\DomainObje final public const CREATED_AT = 'created_at'; final public const UPDATED_AT = 'updated_at'; final public const DELETED_AT = 'deleted_at'; - final public const LOCATION_DETAILS = 'location_details'; final public const DEFAULT_ATTENDEE_DETAILS_COLLECTION_METHOD = 'default_attendee_details_collection_method'; final public const DEFAULT_SHOW_MARKETING_OPT_IN = 'default_show_marketing_opt_in'; final public const DEFAULT_PASS_PLATFORM_FEE_TO_BUYER = 'default_pass_platform_fee_to_buyer'; @@ -46,7 +45,6 @@ abstract class OrganizerSettingDomainObjectAbstract extends \HiEvents\DomainObje protected ?string $created_at = null; protected ?string $updated_at = null; protected ?string $deleted_at = null; - protected array|string|null $location_details = null; protected string $default_attendee_details_collection_method = 'PER_TICKET'; protected bool $default_show_marketing_opt_in = true; protected bool $default_pass_platform_fee_to_buyer = false; @@ -71,7 +69,6 @@ public function toArray(): array 'created_at' => $this->created_at ?? null, 'updated_at' => $this->updated_at ?? null, 'deleted_at' => $this->deleted_at ?? null, - 'location_details' => $this->location_details ?? null, 'default_attendee_details_collection_method' => $this->default_attendee_details_collection_method ?? null, 'default_show_marketing_opt_in' => $this->default_show_marketing_opt_in ?? null, 'default_pass_platform_fee_to_buyer' => $this->default_pass_platform_fee_to_buyer ?? null, @@ -235,17 +232,6 @@ public function getDeletedAt(): ?string return $this->deleted_at; } - public function setLocationDetails(array|string|null $location_details): self - { - $this->location_details = $location_details; - return $this; - } - - public function getLocationDetails(): array|string|null - { - return $this->location_details; - } - public function setDefaultAttendeeDetailsCollectionMethod( string $default_attendee_details_collection_method, ): self { diff --git a/backend/app/DomainObjects/LocationDomainObject.php b/backend/app/DomainObjects/LocationDomainObject.php new file mode 100644 index 0000000000..053f362454 --- /dev/null +++ b/backend/app/DomainObjects/LocationDomainObject.php @@ -0,0 +1,50 @@ + [ + 'desc' => __('Newest first'), + 'asc' => __('Oldest first'), + ], + self::UPDATED_AT => [ + 'desc' => __('Recently Updated'), + 'asc' => __('Least Recently Updated'), + ], + self::NAME => [ + 'asc' => __('Name A-Z'), + 'desc' => __('Name Z-A'), + ], + ] + ); + } + + public static function getDefaultSort(): string + { + return self::CREATED_AT; + } + + public static function getDefaultSortDirection(): string + { + return 'desc'; + } +} diff --git a/backend/app/DomainObjects/OrganizerDomainObject.php b/backend/app/DomainObjects/OrganizerDomainObject.php index 3f432f5f83..650ee1948a 100644 --- a/backend/app/DomainObjects/OrganizerDomainObject.php +++ b/backend/app/DomainObjects/OrganizerDomainObject.php @@ -24,6 +24,8 @@ class OrganizerDomainObject extends Generated\OrganizerDomainObjectAbstract private ?OrganizerConfigurationDomainObject $configuration = null; + private ?LocationDomainObject $locationRecord = null; + public function getImages(): ?Collection { return $this->images; @@ -143,4 +145,21 @@ public function setOrganizerConfiguration(?OrganizerConfigurationDomainObject $c return $this; } + + public function getLocationRecord(): ?LocationDomainObject + { + return $this->locationRecord; + } + + public function setLocationRecord(?LocationDomainObject $locationRecord): self + { + $this->locationRecord = $locationRecord; + + return $this; + } + + public function getLocation(): ?LocationDomainObject + { + return $this->locationRecord; + } } diff --git a/backend/app/Helper/IdHelper.php b/backend/app/Helper/IdHelper.php index 107002df21..653fdfed75 100644 --- a/backend/app/Helper/IdHelper.php +++ b/backend/app/Helper/IdHelper.php @@ -14,6 +14,8 @@ class IdHelper public const CHECK_IN_LIST_PREFIX = 'cil'; public const CHECK_IN_PREFIX = 'ci'; public const OCCURRENCE_PREFIX = 'oc'; + public const LOCATION_PREFIX = 'loc'; + public const EVENT_LOCATION_PREFIX = 'el'; public static function shortId(string $prefix, int $length = 13): string { diff --git a/backend/app/Http/Actions/EventOccurrences/BulkUpdateOccurrencesAction.php b/backend/app/Http/Actions/EventOccurrences/BulkUpdateOccurrencesAction.php index ac08870f81..9fc5297890 100644 --- a/backend/app/Http/Actions/EventOccurrences/BulkUpdateOccurrencesAction.php +++ b/backend/app/Http/Actions/EventOccurrences/BulkUpdateOccurrencesAction.php @@ -9,6 +9,7 @@ use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Services\Application\Handlers\EventOccurrence\BulkUpdateOccurrencesHandler; use HiEvents\Services\Application\Handlers\EventOccurrence\DTO\BulkUpdateOccurrencesDTO; +use HiEvents\Services\Domain\EventLocation\EventLocationData; use Illuminate\Http\JsonResponse; class BulkUpdateOccurrencesAction extends BaseAction @@ -24,6 +25,8 @@ public function __invoke(int $eventId, BulkUpdateOccurrencesRequest $request): J $event = $this->eventRepository->findById($eventId); + $eventLocationPayload = $request->validated('event_location'); + $result = $this->handler->handle( new BulkUpdateOccurrencesDTO( event_id: $eventId, @@ -47,6 +50,8 @@ public function __invoke(int $eventId, BulkUpdateOccurrencesRequest $request): J duration_minutes: $request->validated('duration_minutes') !== null ? (int) $request->validated('duration_minutes') : null, + event_location: $eventLocationPayload !== null ? EventLocationData::fromArray($eventLocationPayload) : null, + clear_event_location: (bool) $request->validated('clear_event_location', false), ) ); diff --git a/backend/app/Http/Actions/EventOccurrences/CreateEventOccurrenceAction.php b/backend/app/Http/Actions/EventOccurrences/CreateEventOccurrenceAction.php index 9aec2e065b..63d4951dd6 100644 --- a/backend/app/Http/Actions/EventOccurrences/CreateEventOccurrenceAction.php +++ b/backend/app/Http/Actions/EventOccurrences/CreateEventOccurrenceAction.php @@ -10,6 +10,7 @@ use HiEvents\Resources\EventOccurrence\EventOccurrenceResource; use HiEvents\Services\Application\Handlers\EventOccurrence\CreateEventOccurrenceHandler; use HiEvents\Services\Application\Handlers\EventOccurrence\DTO\UpsertEventOccurrenceDTO; +use HiEvents\Services\Domain\EventLocation\EventLocationData; use Illuminate\Http\JsonResponse; class CreateEventOccurrenceAction extends BaseAction @@ -28,6 +29,7 @@ public function __invoke(int $eventId, UpsertEventOccurrenceRequest $request): J $startDate = $request->validated('start_date'); $endDate = $request->validated('end_date'); + $eventLocationPayload = $request->validated('event_location'); $occurrence = $this->handler->handle( new UpsertEventOccurrenceDTO( @@ -37,6 +39,7 @@ public function __invoke(int $eventId, UpsertEventOccurrenceRequest $request): J capacity: $request->validated('capacity'), label: $request->validated('label'), is_overridden: true, + event_location: $eventLocationPayload !== null ? EventLocationData::fromArray($eventLocationPayload) : null, ) ); diff --git a/backend/app/Http/Actions/EventOccurrences/DeleteEventOccurrenceAction.php b/backend/app/Http/Actions/EventOccurrences/DeleteEventOccurrenceAction.php index b80cd03db2..3c8fabda09 100644 --- a/backend/app/Http/Actions/EventOccurrences/DeleteEventOccurrenceAction.php +++ b/backend/app/Http/Actions/EventOccurrences/DeleteEventOccurrenceAction.php @@ -11,9 +11,7 @@ class DeleteEventOccurrenceAction extends BaseAction { public function __construct( private readonly DeleteEventOccurrenceHandler $handler, - ) - { - } + ) {} public function __invoke(int $eventId, int $occurrenceId): Response { diff --git a/backend/app/Http/Actions/EventOccurrences/DeletePriceOverrideAction.php b/backend/app/Http/Actions/EventOccurrences/DeletePriceOverrideAction.php index f371e5eb4f..66da13e8d8 100644 --- a/backend/app/Http/Actions/EventOccurrences/DeletePriceOverrideAction.php +++ b/backend/app/Http/Actions/EventOccurrences/DeletePriceOverrideAction.php @@ -11,9 +11,7 @@ class DeletePriceOverrideAction extends BaseAction { public function __construct( private readonly DeletePriceOverrideHandler $handler, - ) - { - } + ) {} public function __invoke(int $eventId, int $occurrenceId, int $overrideId): Response { diff --git a/backend/app/Http/Actions/EventOccurrences/GetEventOccurrenceAction.php b/backend/app/Http/Actions/EventOccurrences/GetEventOccurrenceAction.php index 9d280e31c1..b3f80ed88c 100644 --- a/backend/app/Http/Actions/EventOccurrences/GetEventOccurrenceAction.php +++ b/backend/app/Http/Actions/EventOccurrences/GetEventOccurrenceAction.php @@ -12,9 +12,7 @@ class GetEventOccurrenceAction extends BaseAction { public function __construct( private readonly GetEventOccurrenceHandler $handler, - ) - { - } + ) {} public function __invoke(int $eventId, int $occurrenceId): JsonResponse { diff --git a/backend/app/Http/Actions/EventOccurrences/GetPriceOverridesAction.php b/backend/app/Http/Actions/EventOccurrences/GetPriceOverridesAction.php index c5c47cf323..950ee0ad10 100644 --- a/backend/app/Http/Actions/EventOccurrences/GetPriceOverridesAction.php +++ b/backend/app/Http/Actions/EventOccurrences/GetPriceOverridesAction.php @@ -12,9 +12,7 @@ class GetPriceOverridesAction extends BaseAction { public function __construct( private readonly GetPriceOverridesHandler $handler, - ) - { - } + ) {} public function __invoke(int $eventId, int $occurrenceId): JsonResponse { diff --git a/backend/app/Http/Actions/EventOccurrences/GetProductVisibilityAction.php b/backend/app/Http/Actions/EventOccurrences/GetProductVisibilityAction.php index 9d9c2762f4..1bb6b6adbd 100644 --- a/backend/app/Http/Actions/EventOccurrences/GetProductVisibilityAction.php +++ b/backend/app/Http/Actions/EventOccurrences/GetProductVisibilityAction.php @@ -12,9 +12,7 @@ class GetProductVisibilityAction extends BaseAction { public function __construct( private readonly GetProductVisibilityHandler $handler, - ) - { - } + ) {} public function __invoke(int $eventId, int $occurrenceId): JsonResponse { diff --git a/backend/app/Http/Actions/EventOccurrences/UpdateEventOccurrenceAction.php b/backend/app/Http/Actions/EventOccurrences/UpdateEventOccurrenceAction.php index 4cef0195b5..553b318f65 100644 --- a/backend/app/Http/Actions/EventOccurrences/UpdateEventOccurrenceAction.php +++ b/backend/app/Http/Actions/EventOccurrences/UpdateEventOccurrenceAction.php @@ -10,6 +10,7 @@ use HiEvents\Resources\EventOccurrence\EventOccurrenceResource; use HiEvents\Services\Application\Handlers\EventOccurrence\DTO\UpsertEventOccurrenceDTO; use HiEvents\Services\Application\Handlers\EventOccurrence\UpdateEventOccurrenceHandler; +use HiEvents\Services\Domain\EventLocation\EventLocationData; use Illuminate\Http\JsonResponse; class UpdateEventOccurrenceAction extends BaseAction @@ -28,6 +29,7 @@ public function __invoke(int $eventId, int $occurrenceId, UpsertEventOccurrenceR $startDate = $request->validated('start_date'); $endDate = $request->validated('end_date'); + $eventLocationPayload = $request->validated('event_location'); $occurrence = $this->handler->handle( $occurrenceId, @@ -37,6 +39,8 @@ public function __invoke(int $eventId, int $occurrenceId, UpsertEventOccurrenceR end_date: $endDate ? DateHelper::convertToUTC($endDate, $timezone) : null, capacity: $request->validated('capacity'), label: $request->validated('label'), + event_location: $eventLocationPayload !== null ? EventLocationData::fromArray($eventLocationPayload) : null, + clear_event_location: (bool) $request->validated('clear_event_location', false), ) ); diff --git a/backend/app/Http/Actions/EventOccurrences/UpdateProductVisibilityAction.php b/backend/app/Http/Actions/EventOccurrences/UpdateProductVisibilityAction.php index eeffd8722f..2a5910689d 100644 --- a/backend/app/Http/Actions/EventOccurrences/UpdateProductVisibilityAction.php +++ b/backend/app/Http/Actions/EventOccurrences/UpdateProductVisibilityAction.php @@ -14,9 +14,7 @@ class UpdateProductVisibilityAction extends BaseAction { public function __construct( private readonly UpdateProductVisibilityHandler $handler, - ) - { - } + ) {} public function __invoke(int $eventId, int $occurrenceId, UpdateProductVisibilityRequest $request): JsonResponse { diff --git a/backend/app/Http/Actions/EventOccurrences/UpsertPriceOverrideAction.php b/backend/app/Http/Actions/EventOccurrences/UpsertPriceOverrideAction.php index 057b50d5bb..a50a49a8b5 100644 --- a/backend/app/Http/Actions/EventOccurrences/UpsertPriceOverrideAction.php +++ b/backend/app/Http/Actions/EventOccurrences/UpsertPriceOverrideAction.php @@ -14,9 +14,7 @@ class UpsertPriceOverrideAction extends BaseAction { public function __construct( private readonly UpsertPriceOverrideHandler $handler, - ) - { - } + ) {} public function __invoke(int $eventId, int $occurrenceId, UpsertPriceOverrideRequest $request): JsonResponse { diff --git a/backend/app/Http/Actions/Events/CreateEventAction.php b/backend/app/Http/Actions/Events/CreateEventAction.php index 088a90d09f..46942bf1ea 100644 --- a/backend/app/Http/Actions/Events/CreateEventAction.php +++ b/backend/app/Http/Actions/Events/CreateEventAction.php @@ -8,6 +8,7 @@ use HiEvents\Resources\Event\EventResource; use HiEvents\Services\Application\Handlers\Event\CreateEventHandler; use HiEvents\Services\Application\Handlers\Event\DTO\CreateEventDTO; +use HiEvents\Services\Domain\EventLocation\EventLocationData; use Illuminate\Http\JsonResponse; use Illuminate\Validation\ValidationException; use Throwable; @@ -16,9 +17,7 @@ class CreateEventAction extends BaseAction { public function __construct( private readonly CreateEventHandler $createEventHandler - ) - { - } + ) {} /** * @throws ValidationException|Throwable @@ -27,11 +26,18 @@ public function __invoke(CreateEventRequest $request): JsonResponse { $authorisedUser = $this->getAuthenticatedUser(); + $validated = $request->validated(); + $eventLocationPayload = $validated['event_location'] ?? null; + unset($validated['event_location']); + $eventData = array_merge( - $request->validated(), + $validated, [ 'account_id' => $this->getAuthenticatedAccountId(), 'user_id' => $authorisedUser->getId(), + 'event_location' => $eventLocationPayload !== null + ? EventLocationData::fromArray($eventLocationPayload) + : null, ] ); diff --git a/backend/app/Http/Actions/Events/GetEventAction.php b/backend/app/Http/Actions/Events/GetEventAction.php index afbfcb5655..0662641308 100644 --- a/backend/app/Http/Actions/Events/GetEventAction.php +++ b/backend/app/Http/Actions/Events/GetEventAction.php @@ -5,8 +5,10 @@ namespace HiEvents\Http\Actions\Events; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventLocationDomainObject; use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\ImageDomainObject; +use HiEvents\DomainObjects\LocationDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\DomainObjects\ProductCategoryDomainObject; use HiEvents\DomainObjects\TaxAndFeesDomainObject; @@ -34,7 +36,14 @@ public function __invoke(int $eventId): JsonResponse $event = $this->eventRepository ->loadRelation(new Relationship(domainObject: OrganizerDomainObject::class, name: 'organizer')) ->loadRelation(new Relationship(ImageDomainObject::class)) - ->loadRelation(new Relationship(EventOccurrenceDomainObject::class)) + ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ])) + ->loadRelation(new Relationship(domainObject: EventOccurrenceDomainObject::class, nested: [ + new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ]), + ])) ->loadRelation( new Relationship(ProductCategoryDomainObject::class, [ new Relationship(ProductDomainObject::class, [ diff --git a/backend/app/Http/Actions/Events/UpdateEventAction.php b/backend/app/Http/Actions/Events/UpdateEventAction.php index 87b2c788cc..d50771851a 100644 --- a/backend/app/Http/Actions/Events/UpdateEventAction.php +++ b/backend/app/Http/Actions/Events/UpdateEventAction.php @@ -17,9 +17,7 @@ class UpdateEventAction extends BaseAction { public function __construct( private readonly UpdateEventHandler $updateEventHandler - ) - { - } + ) {} /** * @throws Throwable|ValidationException diff --git a/backend/app/Http/Actions/Events/UpdateEventLocationAction.php b/backend/app/Http/Actions/Events/UpdateEventLocationAction.php new file mode 100644 index 0000000000..1f49b8fa1e --- /dev/null +++ b/backend/app/Http/Actions/Events/UpdateEventLocationAction.php @@ -0,0 +1,43 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $eventLocationPayload = $request->validated('event_location'); + + $event = $this->handler->handle(new UpdateEventLocationDTO( + event_id: $eventId, + account_id: $this->getAuthenticatedAccountId(), + event_location: $eventLocationPayload !== null + ? EventLocationData::fromArray($eventLocationPayload) + : null, + clear_event_location: (bool) $request->validated('clear_event_location', false), + )); + + return $this->resourceResponse(EventResource::class, $event); + } +} diff --git a/backend/app/Http/Actions/Locations/CreateLocationAction.php b/backend/app/Http/Actions/Locations/CreateLocationAction.php new file mode 100644 index 0000000000..55dad4af76 --- /dev/null +++ b/backend/app/Http/Actions/Locations/CreateLocationAction.php @@ -0,0 +1,44 @@ +isActionAuthorized($organizerId, OrganizerDomainObject::class); + + $location = $this->handler->handle(new UpsertLocationDTO( + organizer_id: $organizerId, + account_id: $this->getAuthenticatedAccountId(), + name: $request->validated('name'), + structured_address: AddressDTO::from($request->validated('structured_address')), + latitude: $request->validated('latitude'), + longitude: $request->validated('longitude'), + provider: $request->validated('provider'), + provider_place_id: $request->validated('provider_place_id'), + )); + + return $this->resourceResponse( + resource: LocationResource::class, + data: $location, + statusCode: ResponseCodes::HTTP_CREATED, + ); + } +} diff --git a/backend/app/Http/Actions/Locations/DeleteLocationAction.php b/backend/app/Http/Actions/Locations/DeleteLocationAction.php new file mode 100644 index 0000000000..3a5d7c4152 --- /dev/null +++ b/backend/app/Http/Actions/Locations/DeleteLocationAction.php @@ -0,0 +1,35 @@ +isActionAuthorized($organizerId, OrganizerDomainObject::class); + + try { + $this->handler->handle($organizerId, $this->getAuthenticatedAccountId(), $locationId); + } catch (ResourceConflictException $e) { + return $this->errorResponse( + message: $e->getMessage(), + statusCode: ResponseCodes::HTTP_CONFLICT, + ); + } + + return $this->deletedResponse(); + } +} diff --git a/backend/app/Http/Actions/Locations/GeoAutocompleteAction.php b/backend/app/Http/Actions/Locations/GeoAutocompleteAction.php new file mode 100644 index 0000000000..de02371034 --- /dev/null +++ b/backend/app/Http/Actions/Locations/GeoAutocompleteAction.php @@ -0,0 +1,54 @@ +isActionAuthorized($organizerId, OrganizerDomainObject::class); + + $query = mb_substr((string) $request->query('query', ''), 0, self::MAX_QUERY_LENGTH); + $locale = $request->query('locale'); + $country = $request->query('country'); + + try { + $suggestions = $this->handler->handle( + query: $query, + locale: is_string($locale) ? $locale : null, + country: is_string($country) ? $country : null, + ); + } catch (GeoProviderQuotaExceededException) { + return $this->errorResponse( + message: __('Address suggestions are rate limited. Try again shortly or enter the address manually.'), + statusCode: ResponseCodes::HTTP_TOO_MANY_REQUESTS, + ); + } catch (GeoProviderException) { + return $this->errorResponse( + message: __('Address suggestions are temporarily unavailable. Try again or enter the address manually.'), + statusCode: ResponseCodes::HTTP_BAD_GATEWAY, + ); + } + + return $this->jsonResponse([ + 'data' => array_map(fn ($s) => $s->toArray(), $suggestions), + ]); + } +} diff --git a/backend/app/Http/Actions/Locations/GeoPlaceDetailsAction.php b/backend/app/Http/Actions/Locations/GeoPlaceDetailsAction.php new file mode 100644 index 0000000000..04cc26bd2f --- /dev/null +++ b/backend/app/Http/Actions/Locations/GeoPlaceDetailsAction.php @@ -0,0 +1,56 @@ +isActionAuthorized($organizerId, OrganizerDomainObject::class); + + $locale = $request->query('locale'); + try { + $place = $this->handler->handle( + providerPlaceId: $placeId, + locale: is_string($locale) ? $locale : null, + ); + } catch (GeoProviderQuotaExceededException) { + return $this->errorResponse( + message: __('Place lookups are rate limited. Try again shortly or enter the address manually.'), + statusCode: ResponseCodes::HTTP_TOO_MANY_REQUESTS, + ); + } catch (GeoProviderException) { + return $this->errorResponse( + message: __('Place details are temporarily unavailable. Try again or enter the address manually.'), + statusCode: ResponseCodes::HTTP_BAD_GATEWAY, + ); + } + + if ($place === null) { + return $this->errorResponse( + message: __('Place not found or geo provider unavailable'), + statusCode: ResponseCodes::HTTP_NOT_FOUND, + ); + } + + $payload = $place->toArray(); + unset($payload['raw_response']); + + return $this->jsonResponse(['data' => $payload]); + } +} diff --git a/backend/app/Http/Actions/Locations/GetGeoStatusAction.php b/backend/app/Http/Actions/Locations/GetGeoStatusAction.php new file mode 100644 index 0000000000..5fadde6675 --- /dev/null +++ b/backend/app/Http/Actions/Locations/GetGeoStatusAction.php @@ -0,0 +1,23 @@ +jsonResponse([ + 'data' => [ + 'available' => $provider === 'google' && ! empty($googleKey), + ], + ]); + } +} diff --git a/backend/app/Http/Actions/Locations/GetLocationsAction.php b/backend/app/Http/Actions/Locations/GetLocationsAction.php new file mode 100644 index 0000000000..e3b6f0b31b --- /dev/null +++ b/backend/app/Http/Actions/Locations/GetLocationsAction.php @@ -0,0 +1,37 @@ +isActionAuthorized($organizerId, OrganizerDomainObject::class); + + $locations = $this->handler->handle( + organizerId: $organizerId, + accountId: $this->getAuthenticatedAccountId(), + params: QueryParamsDTO::fromArray($request->query()), + ); + + return $this->filterableResourceResponse( + resource: LocationResource::class, + data: $locations, + domainObject: \HiEvents\DomainObjects\LocationDomainObject::class, + ); + } +} diff --git a/backend/app/Http/Actions/Locations/UpdateLocationAction.php b/backend/app/Http/Actions/Locations/UpdateLocationAction.php new file mode 100644 index 0000000000..d788337727 --- /dev/null +++ b/backend/app/Http/Actions/Locations/UpdateLocationAction.php @@ -0,0 +1,42 @@ +isActionAuthorized($organizerId, OrganizerDomainObject::class); + + $location = $this->handler->handle($locationId, new UpsertLocationDTO( + organizer_id: $organizerId, + account_id: $this->getAuthenticatedAccountId(), + name: $request->validated('name'), + structured_address: AddressDTO::from($request->validated('structured_address')), + latitude: $request->validated('latitude'), + longitude: $request->validated('longitude'), + provider: $request->validated('provider'), + provider_place_id: $request->validated('provider_place_id'), + )); + + return $this->resourceResponse( + resource: LocationResource::class, + data: $location, + ); + } +} diff --git a/backend/app/Http/Actions/Orders/ResendOrderConfirmationAction.php b/backend/app/Http/Actions/Orders/ResendOrderConfirmationAction.php index c38c6cbac3..01d8d81219 100644 --- a/backend/app/Http/Actions/Orders/ResendOrderConfirmationAction.php +++ b/backend/app/Http/Actions/Orders/ResendOrderConfirmationAction.php @@ -3,9 +3,12 @@ namespace HiEvents\Http\Actions\Orders; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventLocationDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\Generated\OrderDomainObjectAbstract; use HiEvents\DomainObjects\InvoiceDomainObject; +use HiEvents\DomainObjects\LocationDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\Http\Actions\BaseAction; @@ -36,7 +39,17 @@ public function __invoke(int $eventId, int $orderId): Response $this->isActionAuthorized($eventId, EventDomainObject::class); $order = $this->orderRepository - ->loadRelation(OrderItemDomainObject::class) + ->loadRelation(new Relationship(domainObject: OrderItemDomainObject::class, nested: [ + new Relationship( + domainObject: EventOccurrenceDomainObject::class, + nested: [ + new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ]), + ], + name: 'event_occurrence', + ), + ])) ->loadRelation(InvoiceDomainObject::class) ->findFirstWhere([ OrderDomainObjectAbstract::EVENT_ID => $eventId, @@ -51,6 +64,14 @@ public function __invoke(int $eventId, int $orderId): Response $event = $this->eventRepository ->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer')) ->loadRelation(new Relationship(EventSettingDomainObject::class)) + ->loadRelation(new Relationship(domainObject: EventOccurrenceDomainObject::class, nested: [ + new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ]), + ])) + ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ])) ->findById($order->getEventId()); $mail = $this->mailBuilderService->buildOrderSummaryMail( diff --git a/backend/app/Http/Actions/Organizers/GetOrganizerAction.php b/backend/app/Http/Actions/Organizers/GetOrganizerAction.php index e38dda8639..74692bee31 100644 --- a/backend/app/Http/Actions/Organizers/GetOrganizerAction.php +++ b/backend/app/Http/Actions/Organizers/GetOrganizerAction.php @@ -3,6 +3,7 @@ namespace HiEvents\Http\Actions\Organizers; use HiEvents\DomainObjects\ImageDomainObject; +use HiEvents\DomainObjects\LocationDomainObject; use HiEvents\DomainObjects\OrganizerConfigurationDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\DomainObjects\OrganizerStripePlatformDomainObject; @@ -32,6 +33,7 @@ public function __invoke(int $organizerId): Response domainObject: OrganizerConfigurationDomainObject::class, name: 'organizer_configuration', )) + ->loadRelation(new Relationship(LocationDomainObject::class, name: 'location_record')) ->findFirstWhere([ 'id' => $organizerId, 'account_id' => $this->getAuthenticatedAccountId(), diff --git a/backend/app/Http/Actions/Organizers/UpdateOrganizerLocationAction.php b/backend/app/Http/Actions/Organizers/UpdateOrganizerLocationAction.php new file mode 100644 index 0000000000..a96e66b8b2 --- /dev/null +++ b/backend/app/Http/Actions/Organizers/UpdateOrganizerLocationAction.php @@ -0,0 +1,33 @@ +isActionAuthorized($organizerId, OrganizerDomainObject::class); + + $organizer = $this->handler->handle(new UpdateOrganizerLocationDTO( + organizer_id: $organizerId, + account_id: $this->getAuthenticatedAccountId(), + location_id: $request->validated('location_id'), + )); + + return $this->resourceResponse(OrganizerResource::class, $organizer); + } +} diff --git a/backend/app/Http/Request/Event/UpdateEventLocationRequest.php b/backend/app/Http/Request/Event/UpdateEventLocationRequest.php new file mode 100644 index 0000000000..0cee9ffa92 --- /dev/null +++ b/backend/app/Http/Request/Event/UpdateEventLocationRequest.php @@ -0,0 +1,36 @@ + ['nullable', 'array'], + 'event_location.type' => ['required_with:event_location', Rule::in(LocationType::valuesArray())], + 'event_location.location_id' => [ + 'nullable', 'integer', + 'required_if:event_location.type,'.LocationType::IN_PERSON->name, + ], + 'event_location.online_event_connection_details' => [ + 'nullable', 'string', 'max:10000', + 'required_if:event_location.type,'.LocationType::ONLINE->name, + ], + 'clear_event_location' => ['sometimes', 'boolean'], + ]; + } + + public function messages(): array + { + return [ + 'event_location.location_id.required_if' => __('A saved location must be selected for in-person events'), + ]; + } +} diff --git a/backend/app/Http/Request/Event/UpdateEventRequest.php b/backend/app/Http/Request/Event/UpdateEventRequest.php index 64861ea23b..1b94095b89 100644 --- a/backend/app/Http/Request/Event/UpdateEventRequest.php +++ b/backend/app/Http/Request/Event/UpdateEventRequest.php @@ -13,8 +13,8 @@ class UpdateEventRequest extends BaseRequest public function rules(): array { - $rules = $this->eventRules(); - unset($rules['organizer_id']); + $rules = $this->eventRules(); + unset($rules['organizer_id'], $rules['event_location'], $rules['event_location.type'], $rules['event_location.location_id'], $rules['event_location.online_event_connection_details']); return $rules; } diff --git a/backend/app/Http/Request/EventOccurrence/BulkUpdateOccurrencesRequest.php b/backend/app/Http/Request/EventOccurrence/BulkUpdateOccurrencesRequest.php index b0ff0d328e..13fc9e2fda 100644 --- a/backend/app/Http/Request/EventOccurrence/BulkUpdateOccurrencesRequest.php +++ b/backend/app/Http/Request/EventOccurrence/BulkUpdateOccurrencesRequest.php @@ -3,6 +3,7 @@ namespace HiEvents\Http\Request\EventOccurrence; use HiEvents\DomainObjects\Enums\BulkOccurrenceAction; +use HiEvents\DomainObjects\Enums\LocationType; use HiEvents\Http\Request\BaseRequest; use Illuminate\Contracts\Validation\Validator; use Illuminate\Validation\Rule; @@ -20,16 +21,32 @@ public function rules(): array 'future_only' => ['nullable', 'boolean'], 'skip_overridden' => ['nullable', 'boolean'], 'refund_orders' => ['nullable', 'boolean'], - // Caller MUST either name the occurrences explicitly (occurrence_ids - // non-empty) or opt in to event-wide application via apply_to_all=true. - // Previously an absent/empty occurrence_ids silently meant "every - // matching occurrence" — a footgun the bulk-edit modal hit by accident. + // Caller must either name occurrence_ids explicitly or set + // apply_to_all=true. An absent set is rejected by withValidator(). 'apply_to_all' => ['nullable', 'boolean'], 'occurrence_ids' => ['array'], 'occurrence_ids.*' => ['integer'], 'label' => ['nullable', 'string', 'max:255'], 'clear_label' => ['nullable', 'boolean'], 'duration_minutes' => ['nullable', 'integer', 'min:1'], + 'event_location' => ['nullable', 'array'], + 'event_location.type' => ['required_with:event_location', Rule::in(LocationType::valuesArray())], + 'event_location.location_id' => [ + 'nullable', 'integer', + 'required_if:event_location.type,'.LocationType::IN_PERSON->name, + ], + 'event_location.online_event_connection_details' => [ + 'nullable', 'string', 'max:10000', + 'required_if:event_location.type,'.LocationType::ONLINE->name, + ], + 'clear_event_location' => ['sometimes', 'boolean'], + ]; + } + + public function messages(): array + { + return [ + 'event_location.location_id.required_if' => __('A saved location must be selected for in-person occurrences'), ]; } diff --git a/backend/app/Http/Request/EventOccurrence/GenerateOccurrencesRequest.php b/backend/app/Http/Request/EventOccurrence/GenerateOccurrencesRequest.php index 20915d1180..dd5fb6068f 100644 --- a/backend/app/Http/Request/EventOccurrence/GenerateOccurrencesRequest.php +++ b/backend/app/Http/Request/EventOccurrence/GenerateOccurrencesRequest.php @@ -22,9 +22,10 @@ public function rules(): array 'recurrence_rule.times_of_day' => ['nullable', 'array', 'max:24'], 'recurrence_rule.times_of_day.*' => [function ($attribute, $value, $fail) { if (is_string($value)) { - if (!preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $value)) { + if (! preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $value)) { $fail(__('Each time of day must be in HH:MM 24-hour format.')); } + return; } @@ -36,11 +37,13 @@ public function rules(): array if (is_array($value)) { if (! isset($value['time']) || ! is_string($value['time'])) { $fail(__('Each time of day object must include a time field.')); + return; } if (! preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $value['time'])) { $fail(__('Each time of day must be in HH:MM 24-hour format.')); } + return; } diff --git a/backend/app/Http/Request/EventOccurrence/UpsertEventOccurrenceRequest.php b/backend/app/Http/Request/EventOccurrence/UpsertEventOccurrenceRequest.php index ea6005c304..627046bac2 100644 --- a/backend/app/Http/Request/EventOccurrence/UpsertEventOccurrenceRequest.php +++ b/backend/app/Http/Request/EventOccurrence/UpsertEventOccurrenceRequest.php @@ -2,17 +2,16 @@ namespace HiEvents\Http\Request\EventOccurrence; +use HiEvents\DomainObjects\Enums\LocationType; use HiEvents\Http\Request\BaseRequest; +use Illuminate\Validation\Rule; class UpsertEventOccurrenceRequest extends BaseRequest { /** - * Status is intentionally absent from this request. Lifecycle transitions - * (cancel / reactivate) live in their own actions because they fan out - * into attendee cancellation, refund handling, recurrence exclusions and - * notification dispatch — none of which the generic upsert handler does. - * Allowing `status` here would let an API caller flip an occurrence to - * CANCELLED via update without firing any of those side effects. + * Status is intentionally absent — lifecycle transitions live in their + * own actions so the cancel/reactivate side effects (refund handling, + * recurrence exclusions, notifications) always fire. */ public function rules(): array { @@ -21,6 +20,24 @@ public function rules(): array 'end_date' => ['nullable', 'date', 'after:start_date'], 'capacity' => ['nullable', 'integer', 'min:0'], 'label' => ['nullable', 'string', 'max:255'], + 'event_location' => ['nullable', 'array'], + 'event_location.type' => ['required_with:event_location', Rule::in(LocationType::valuesArray())], + 'event_location.location_id' => [ + 'nullable', 'integer', + 'required_if:event_location.type,'.LocationType::IN_PERSON->name, + ], + 'event_location.online_event_connection_details' => [ + 'nullable', 'string', 'max:10000', + 'required_if:event_location.type,'.LocationType::ONLINE->name, + ], + 'clear_event_location' => ['sometimes', 'boolean'], + ]; + } + + public function messages(): array + { + return [ + 'event_location.location_id.required_if' => __('A saved location must be selected for in-person occurrences'), ]; } } diff --git a/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php index 213038a74a..046faf1d9a 100644 --- a/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php +++ b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php @@ -16,15 +16,15 @@ class UpdateEventSettingsRequest extends BaseRequest public function rules(): array { return [ - 'post_checkout_message' => ['string', "nullable"], - 'pre_checkout_message' => ['string', "nullable"], - 'email_footer_message' => ['string', "nullable"], + 'post_checkout_message' => ['string', 'nullable'], + 'pre_checkout_message' => ['string', 'nullable'], + 'email_footer_message' => ['string', 'nullable'], 'continue_button_text' => ['string', 'nullable', 'max:100'], 'support_email' => ['email', 'nullable'], 'require_attendee_details' => ['boolean'], 'attendee_details_collection_method' => [Rule::in(AttendeeDetailsCollectionMethod::valuesArray())], - 'order_timeout_in_minutes' => ['numeric', "min:1", "max:120"], + 'order_timeout_in_minutes' => ['numeric', 'min:1', 'max:120'], 'homepage_background_color' => ['nullable', ...RulesHelper::HEX_COLOR], 'homepage_primary_color' => ['nullable', ...RulesHelper::HEX_COLOR], @@ -37,18 +37,6 @@ public function rules(): array 'website_url' => ['url', 'nullable'], 'maps_url' => ['url', 'nullable'], - 'location_details' => ['array'], - 'location_details.venue_name' => ['string', 'max:255', 'nullable'], - 'location_details.address_line_1' => ['required_with:location_details', 'string', 'max:255'], - 'location_details.address_line_2' => ['string', 'max:255', 'nullable'], - 'location_details.city' => ['required_with:location_details', 'string', 'max:85'], - 'location_details.state_or_region' => ['string', 'max:85', 'nullable'], - 'location_details.zip_or_postal_code' => ['required_with:location_details', 'string', 'max:85'], - 'location_details.country' => ['required_with:location_details', 'string', 'max:2'], - - 'is_online_event' => ['boolean'], - 'online_event_connection_details' => ['string', 'nullable'], - 'seo_title' => ['string', 'max:255', 'nullable'], 'seo_description' => ['string', 'max:255', 'nullable'], 'seo_keywords' => ['string', 'max:255', 'nullable'], @@ -63,7 +51,7 @@ public function rules(): array // Payment settings 'payment_providers' => ['array'], 'payment_providers.*' => ['string', Rule::in(PaymentProviders::valuesArray())], - 'offline_payment_instructions' => ['string', 'nullable', Rule::requiredIf(fn() => in_array(PaymentProviders::OFFLINE->name, $this->input('payment_providers', []), true))], + 'offline_payment_instructions' => ['string', 'nullable', Rule::requiredIf(fn () => in_array(PaymentProviders::OFFLINE->name, $this->input('payment_providers', []), true))], 'allow_orders_awaiting_offline_payment_to_check_in' => ['boolean'], // Invoice settings @@ -120,11 +108,6 @@ public function messages(): array 'homepage_link_color' => $colorMessage, 'homepage_product_widget_background_color' => $colorMessage, 'homepage_product_widget_text_color' => $colorMessage, - 'location_details.address_line_1.required_with' => __('The address line 1 field is required'), - 'location_details.city.required_with' => __('The city field is required'), - 'location_details.zip_or_postal_code.required_with' => __('The zip or postal code field is required'), - 'location_details.country.required_with' => __('The country field is required'), - 'location_details.country.max' => __('The country field should be a 2 character ISO 3166 code'), 'price_display_mode.in' => 'The price display mode must be either inclusive or exclusive.', // Payment messages diff --git a/backend/app/Http/Request/Location/UpsertLocationRequest.php b/backend/app/Http/Request/Location/UpsertLocationRequest.php new file mode 100644 index 0000000000..2e65787afd --- /dev/null +++ b/backend/app/Http/Request/Location/UpsertLocationRequest.php @@ -0,0 +1,61 @@ + ['nullable', 'string', 'max:255'], + 'structured_address' => ['required', 'array'], + 'structured_address.venue_name' => ['nullable', 'string', 'max:255'], + 'structured_address.address_line_1' => ['nullable', 'string', 'max:255'], + 'structured_address.address_line_2' => ['nullable', 'string', 'max:255'], + 'structured_address.city' => ['nullable', 'string', 'max:85'], + 'structured_address.state_or_region' => ['nullable', 'string', 'max:85'], + 'structured_address.zip_or_postal_code' => ['nullable', 'string', 'max:85'], + 'structured_address.country' => ['nullable', 'string', 'max:2'], + 'latitude' => ['nullable', 'numeric', 'between:-90,90'], + 'longitude' => ['nullable', 'numeric', 'between:-180,180'], + 'provider' => [ + 'nullable', + 'string', + Rule::in([GooglePlacesGeoProvider::PROVIDER_NAME]), + 'required_with:provider_place_id', + ], + 'provider_place_id' => [ + 'nullable', + 'string', + 'max:255', + 'required_with:provider', + ], + ]; + } + + public function withValidator($validator): void + { + $validator->after(function ($validator) { + $address = $this->input('structured_address', []); + $hasAny = false; + foreach (['venue_name', 'address_line_1', 'city', 'state_or_region', 'zip_or_postal_code', 'country'] as $key) { + if (! empty($address[$key] ?? null)) { + $hasAny = true; + break; + } + } + if (! $hasAny) { + $validator->errors()->add( + 'structured_address', + __('Provide at least one address field (venue, street, city, or country).'), + ); + } + }); + } +} diff --git a/backend/app/Http/Request/Organizer/Settings/PartialUpdateOrganizerSettingsRequest.php b/backend/app/Http/Request/Organizer/Settings/PartialUpdateOrganizerSettingsRequest.php index 5441436925..1b64811e65 100644 --- a/backend/app/Http/Request/Organizer/Settings/PartialUpdateOrganizerSettingsRequest.php +++ b/backend/app/Http/Request/Organizer/Settings/PartialUpdateOrganizerSettingsRequest.php @@ -91,16 +91,6 @@ public static function rules(): array 'website_url' => ['sometimes', 'nullable', 'url'], - // Location details - 'location_details' => ['sometimes', 'array'], - 'location_details.venue_name' => ['sometimes', 'nullable', 'string', 'max:255'], - 'location_details.address_line_1' => ['sometimes', 'nullable', 'string', 'max:255'], - 'location_details.address_line_2' => ['sometimes', 'nullable', 'string', 'max:255'], - 'location_details.city' => ['sometimes', 'nullable', 'string', 'max:85'], - 'location_details.state_or_region' => ['sometimes', 'nullable', 'string', 'max:85'], - 'location_details.zip_or_postal_code' => ['sometimes', 'nullable', 'string', 'max:85'], - 'location_details.country' => ['sometimes', 'nullable', 'string', 'max:2'], - // Homepage 'homepage_visibility' => ['nullable', Rule::in(OrganizerHomepageVisibility::valuesArray())], diff --git a/backend/app/Http/Request/Organizer/UpdateOrganizerLocationRequest.php b/backend/app/Http/Request/Organizer/UpdateOrganizerLocationRequest.php new file mode 100644 index 0000000000..5334a357d5 --- /dev/null +++ b/backend/app/Http/Request/Organizer/UpdateOrganizerLocationRequest.php @@ -0,0 +1,17 @@ + ['nullable', 'integer'], + ]; + } +} diff --git a/backend/app/Jobs/Occurrence/SendOccurrenceCancellationEmailJob.php b/backend/app/Jobs/Occurrence/SendOccurrenceCancellationEmailJob.php index e872cf2472..7a59fd5cac 100644 --- a/backend/app/Jobs/Occurrence/SendOccurrenceCancellationEmailJob.php +++ b/backend/app/Jobs/Occurrence/SendOccurrenceCancellationEmailJob.php @@ -3,8 +3,10 @@ namespace HiEvents\Jobs\Occurrence; use HiEvents\DomainObjects\AttendeeDomainObject; +use HiEvents\DomainObjects\EventLocationDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\Generated\AttendeeDomainObjectAbstract; +use HiEvents\DomainObjects\LocationDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; @@ -43,11 +45,18 @@ public function handle( Mailer $mailer, MailBuilderService $mailBuilderService, ): void { - $occurrence = $occurrenceRepository->findById($this->occurrenceId); + $occurrence = $occurrenceRepository + ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ], name: 'event_location')) + ->findById($this->occurrenceId); $event = $eventRepository ->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer')) ->loadRelation(new Relationship(EventSettingDomainObject::class)) + ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ], name: 'event_location')) ->findById($this->eventId); // Intentionally does NOT filter out CANCELLED attendees: diff --git a/backend/app/Mail/Attendee/AttendeeTicketMail.php b/backend/app/Mail/Attendee/AttendeeTicketMail.php index 5fb72d4357..54037d8fa4 100644 --- a/backend/app/Mail/Attendee/AttendeeTicketMail.php +++ b/backend/app/Mail/Attendee/AttendeeTicketMail.php @@ -4,11 +4,15 @@ use Carbon\Carbon; use HiEvents\DomainObjects\AttendeeDomainObject; +use HiEvents\DomainObjects\Enums\LocationType; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventLocationDomainObject; use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; +use HiEvents\DomainObjects\LocationDomainObject; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; +use HiEvents\Helper\AddressHelper; use HiEvents\Helper\StringHelper; use HiEvents\Helper\Url; use HiEvents\Mail\BaseMail; @@ -28,15 +32,14 @@ class AttendeeTicketMail extends BaseMail private readonly ?RenderedEmailTemplateDTO $renderedTemplate; public function __construct( - private readonly OrderDomainObject $order, - private readonly AttendeeDomainObject $attendee, - private readonly EventDomainObject $event, + private readonly OrderDomainObject $order, + private readonly AttendeeDomainObject $attendee, + private readonly EventDomainObject $event, private readonly EventSettingDomainObject $eventSettings, - private readonly OrganizerDomainObject $organizer, - ?RenderedEmailTemplateDTO $renderedTemplate = null, + private readonly OrganizerDomainObject $organizer, + ?RenderedEmailTemplateDTO $renderedTemplate = null, private readonly ?EventOccurrenceDomainObject $occurrence = null, - ) - { + ) { parent::__construct(); $this->renderedTemplate = $renderedTemplate; } @@ -44,7 +47,7 @@ public function __construct( public function envelope(): Envelope { $subject = $this->renderedTemplate?->subject ?? __('🎟️ Your Ticket for :event', [ - 'event' => Str::limit($this->event->getTitle(), 50) + 'event' => Str::limit($this->event->getTitle(), 50), ]); return new Envelope( @@ -66,7 +69,9 @@ public function content(): Content ); } - // If no template is provided, use the default blade template + $occurrence = $this->occurrence ?? $this->attendee->getEventOccurrence(); + $eventLocation = $occurrence?->getEventLocation() ?? $this->event->getEventLocation(); + return new Content( markdown: 'emails.orders.attendee-ticket', with: [ @@ -75,16 +80,64 @@ public function content(): Content 'eventSettings' => $this->eventSettings, 'organizer' => $this->organizer, 'order' => $this->order, - 'occurrence' => $this->occurrence ?? $this->attendee->getEventOccurrence(), + 'occurrence' => $occurrence, + 'eventLocation' => $eventLocation, + 'effectiveVenueName' => $this->venueNameFor($eventLocation), + 'effectiveAddressString' => $this->addressStringFor($eventLocation), 'ticketUrl' => sprintf( Url::getFrontEndUrlFromConfig(Url::ATTENDEE_TICKET), $this->event->getId(), $this->attendee->getShortId(), - ) + ), ] ); } + private function venueNameFor(?EventLocationDomainObject $eventLocation): ?string + { + $venue = $this->venueLocation($eventLocation); + if ($venue === null) { + return null; + } + + $name = $venue->getName(); + if ($name !== null && $name !== '') { + return $name; + } + + return $venue->getStructuredAddress()['venue_name'] ?? null; + } + + private function addressStringFor(?EventLocationDomainObject $eventLocation): ?string + { + $venue = $this->venueLocation($eventLocation); + if ($venue === null) { + return null; + } + + $address = $venue->getStructuredAddress(); + if (! is_array($address)) { + return null; + } + + $formatted = AddressHelper::formatAddress($address); + + return $formatted === '' ? null : $formatted; + } + + private function venueLocation(?EventLocationDomainObject $eventLocation): ?LocationDomainObject + { + if ($eventLocation === null) { + return null; + } + + if ($eventLocation->getType() !== LocationType::IN_PERSON->name) { + return null; + } + + return $eventLocation->getLocation(); + } + public function attachments(): array { $startDateRaw = $this->occurrence?->getStartDate() ?? $this->event->getStartDate(); @@ -99,12 +152,12 @@ public function attachments(): array $eventTitle = $this->event->getTitle(); if ($this->occurrence?->getLabel()) { - $eventTitle .= ' - ' . $this->occurrence->getLabel(); + $eventTitle .= ' - '.$this->occurrence->getLabel(); } $event = Event::create() ->name($eventTitle) - ->uniqueIdentifier('event-' . $this->attendee->getId()) + ->uniqueIdentifier('event-'.$this->attendee->getId()) ->startsAt($startDateTime) ->url($this->event->getEventUrl()) ->organizer($this->organizer->getEmail(), $this->organizer->getName()); @@ -113,8 +166,14 @@ public function attachments(): array $event->description(StringHelper::previewFromHtml($this->event->getDescription())); } - if ($this->eventSettings->getLocationDetails()) { - $event->address($this->eventSettings->getAddressString()); + $occurrence = $this->occurrence ?? $this->attendee->getEventOccurrence(); + $eventLocation = $occurrence?->getEventLocation() ?? $this->event->getEventLocation(); + $address = $this->addressStringFor($eventLocation); + if ($address !== null) { + $event->address($address); + } elseif ($eventLocation?->getType() === LocationType::ONLINE->name + && $eventLocation->getOnlineEventConnectionDetails() !== null) { + $event->address(__('Online event')); } if ($endDateTime) { @@ -126,8 +185,8 @@ public function attachments(): array ->get(); return [ - Attachment::fromData(static fn() => $calendar, 'event.ics') - ->withMime('text/calendar') + Attachment::fromData(static fn () => $calendar, 'event.ics') + ->withMime('text/calendar'), ]; } } diff --git a/backend/app/Models/Event.php b/backend/app/Models/Event.php index 3d4ec316a9..8cd19a3973 100644 --- a/backend/app/Models/Event.php +++ b/backend/app/Models/Event.php @@ -13,8 +13,8 @@ class Event extends BaseModel { - use SoftDeletes; use HasImages; + use SoftDeletes; public function account(): BelongsTo { @@ -86,6 +86,11 @@ public function event_occurrences(): HasMany return $this->hasMany(EventOccurrence::class); } + public function event_location(): BelongsTo + { + return $this->belongsTo(EventLocation::class, 'event_location_id'); + } + public static function boot(): void { parent::boot(); @@ -102,7 +107,6 @@ protected function getCastMap(): array { return [ EventDomainObjectAbstract::ATTRIBUTES => 'array', - EventDomainObjectAbstract::LOCATION_DETAILS => 'array', EventDomainObjectAbstract::RECURRENCE_RULE => 'array', ]; } diff --git a/backend/app/Models/EventLocation.php b/backend/app/Models/EventLocation.php new file mode 100644 index 0000000000..0bc6d389f9 --- /dev/null +++ b/backend/app/Models/EventLocation.php @@ -0,0 +1,30 @@ +belongsTo(Event::class); + } + + public function location(): BelongsTo + { + return $this->belongsTo(Location::class, 'location_id'); + } + + protected function getCastMap(): array + { + return []; + } +} diff --git a/backend/app/Models/EventOccurrence.php b/backend/app/Models/EventOccurrence.php index 469261d175..7107058601 100644 --- a/backend/app/Models/EventOccurrence.php +++ b/backend/app/Models/EventOccurrence.php @@ -20,6 +20,11 @@ public function event(): BelongsTo return $this->belongsTo(Event::class); } + public function event_location(): BelongsTo + { + return $this->belongsTo(EventLocation::class, 'event_location_id'); + } + public function order_items(): HasMany { return $this->hasMany(OrderItem::class, 'event_occurrence_id'); diff --git a/backend/app/Models/EventSetting.php b/backend/app/Models/EventSetting.php index 11cf5fbb74..b29d1af5f2 100644 --- a/backend/app/Models/EventSetting.php +++ b/backend/app/Models/EventSetting.php @@ -11,7 +11,6 @@ class EventSetting extends BaseModel protected function getCastMap(): array { return [ - 'location_details' => 'array', 'payment_providers' => 'array', 'ticket_design_settings' => 'array', 'homepage_theme_settings' => 'array', diff --git a/backend/app/Models/Location.php b/backend/app/Models/Location.php new file mode 100644 index 0000000000..7c9ff41297 --- /dev/null +++ b/backend/app/Models/Location.php @@ -0,0 +1,36 @@ +belongsTo(Account::class); + } + + public function organizer(): BelongsTo + { + return $this->belongsTo(Organizer::class); + } + + protected function getCastMap(): array + { + return [ + LocationDomainObjectAbstract::STRUCTURED_ADDRESS => 'array', + LocationDomainObjectAbstract::LATITUDE => 'float', + LocationDomainObjectAbstract::LONGITUDE => 'float', + LocationDomainObjectAbstract::RAW_PROVIDER_RESPONSE => 'array', + ]; + } +} diff --git a/backend/app/Models/Organizer.php b/backend/app/Models/Organizer.php index 5488bac90e..e1bcb039b0 100644 --- a/backend/app/Models/Organizer.php +++ b/backend/app/Models/Organizer.php @@ -42,4 +42,9 @@ public function organizer_configuration(): BelongsTo { return $this->belongsTo(OrganizerConfiguration::class, 'organizer_configuration_id'); } + + public function location_record(): BelongsTo + { + return $this->belongsTo(Location::class, 'location_id'); + } } diff --git a/backend/app/Models/OrganizerSetting.php b/backend/app/Models/OrganizerSetting.php index db72d958a2..90d457f855 100644 --- a/backend/app/Models/OrganizerSetting.php +++ b/backend/app/Models/OrganizerSetting.php @@ -13,7 +13,6 @@ public function getCastMap(): array return [ 'social_media_handles' => 'array', 'homepage_theme_settings' => 'array', - 'location_details' => 'array', 'tracking_pixels' => 'array', ]; } diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index 8f3b933a63..66118f6933 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -12,6 +12,10 @@ use HiEvents\Services\Infrastructure\CurrencyConversion\CurrencyConversionClientInterface; use HiEvents\Services\Infrastructure\CurrencyConversion\NoOpCurrencyConversionClient; use HiEvents\Services\Infrastructure\CurrencyConversion\OpenExchangeRatesCurrencyConversionClient; +use HiEvents\Services\Infrastructure\Geo\GeoProviderInterface; +use HiEvents\Services\Infrastructure\Geo\GooglePlacesGeoProvider; +use HiEvents\Services\Infrastructure\Geo\NoOpGeoProvider; +use Illuminate\Http\Client\Factory as HttpClient; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Facades\DB; @@ -29,6 +33,7 @@ public function register(): void $this->bindDoctrineConnection(); $this->bindStripeServices(); $this->bindCurrencyConversionClient(); + $this->bindGeoProvider(); } /** @@ -143,4 +148,28 @@ function () { } ); } + + private function bindGeoProvider(): void + { + $this->app->bind( + GeoProviderInterface::class, + function () { + $provider = config('services.geo.provider'); + $googleKey = config('services.geo.google.api_key'); + + if ($provider === 'google' && $googleKey) { + return new GooglePlacesGeoProvider( + apiKey: $googleKey, + http: $this->app->make(HttpClient::class), + logger: $this->app->make('log'), + cache: $this->app->make(\Illuminate\Contracts\Cache\Repository::class), + ); + } + + return new NoOpGeoProvider( + logger: $this->app->make('log'), + ); + } + ); + } } diff --git a/backend/app/Providers/RepositoryServiceProvider.php b/backend/app/Providers/RepositoryServiceProvider.php index 750af972a4..90d474beeb 100644 --- a/backend/app/Providers/RepositoryServiceProvider.php +++ b/backend/app/Providers/RepositoryServiceProvider.php @@ -16,6 +16,7 @@ use HiEvents\Repository\Eloquent\CheckInListRepository; use HiEvents\Repository\Eloquent\EmailTemplateRepository; use HiEvents\Repository\Eloquent\EventDailyStatisticRepository; +use HiEvents\Repository\Eloquent\EventLocationRepository; use HiEvents\Repository\Eloquent\EventOccurrenceRepository; use HiEvents\Repository\Eloquent\EventOccurrenceDailyStatisticRepository; use HiEvents\Repository\Eloquent\EventOccurrenceStatisticRepository; @@ -24,6 +25,7 @@ use HiEvents\Repository\Eloquent\EventStatisticRepository; use HiEvents\Repository\Eloquent\ImageRepository; use HiEvents\Repository\Eloquent\InvoiceRepository; +use HiEvents\Repository\Eloquent\LocationRepository; use HiEvents\Repository\Eloquent\MessageRepository; use HiEvents\Repository\Eloquent\OrderApplicationFeeRepository; use HiEvents\Repository\Eloquent\OrderAuditLogRepository; @@ -69,6 +71,7 @@ use HiEvents\Repository\Interfaces\CheckInListRepositoryInterface; use HiEvents\Repository\Interfaces\EmailTemplateRepositoryInterface; use HiEvents\Repository\Interfaces\EventDailyStatisticRepositoryInterface; +use HiEvents\Repository\Interfaces\EventLocationRepositoryInterface; use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; use HiEvents\Repository\Interfaces\EventOccurrenceDailyStatisticRepositoryInterface; use HiEvents\Repository\Interfaces\EventOccurrenceStatisticRepositoryInterface; @@ -77,6 +80,7 @@ use HiEvents\Repository\Interfaces\EventStatisticRepositoryInterface; use HiEvents\Repository\Interfaces\ImageRepositoryInterface; use HiEvents\Repository\Interfaces\InvoiceRepositoryInterface; +use HiEvents\Repository\Interfaces\LocationRepositoryInterface; use HiEvents\Repository\Interfaces\MessageRepositoryInterface; use HiEvents\Repository\Interfaces\OrderApplicationFeeRepositoryInterface; use HiEvents\Repository\Interfaces\OrderAuditLogRepositoryInterface; @@ -171,6 +175,8 @@ class RepositoryServiceProvider extends ServiceProvider EventOccurrenceDailyStatisticRepositoryInterface::class => EventOccurrenceDailyStatisticRepository::class, ProductOccurrenceVisibilityRepositoryInterface::class => ProductOccurrenceVisibilityRepository::class, ProductPriceOccurrenceOverrideRepositoryInterface::class => ProductPriceOccurrenceOverrideRepository::class, + LocationRepositoryInterface::class => LocationRepository::class, + EventLocationRepositoryInterface::class => EventLocationRepository::class, ]; public function register(): void diff --git a/backend/app/Repository/Eloquent/EventLocationRepository.php b/backend/app/Repository/Eloquent/EventLocationRepository.php new file mode 100644 index 0000000000..4f269dbf32 --- /dev/null +++ b/backend/app/Repository/Eloquent/EventLocationRepository.php @@ -0,0 +1,45 @@ + + */ +class EventLocationRepository extends BaseRepository implements EventLocationRepositoryInterface +{ + protected function getModel(): string + { + return EventLocation::class; + } + + public function getDomainObject(): string + { + return EventLocationDomainObject::class; + } + + public function isReferenced(int $eventLocationId): bool + { + $eventCount = DB::table('events') + ->where('event_location_id', $eventLocationId) + ->whereNull('deleted_at') + ->count(); + + if ($eventCount > 0) { + return true; + } + + $occurrenceCount = DB::table('event_occurrences') + ->where('event_location_id', $eventLocationId) + ->whereNull('deleted_at') + ->count(); + + return $occurrenceCount > 0; + } +} diff --git a/backend/app/Repository/Eloquent/LocationRepository.php b/backend/app/Repository/Eloquent/LocationRepository.php new file mode 100644 index 0000000000..6723afe6ee --- /dev/null +++ b/backend/app/Repository/Eloquent/LocationRepository.php @@ -0,0 +1,81 @@ + + */ +class LocationRepository extends BaseRepository implements LocationRepositoryInterface +{ + protected function getModel(): string + { + return Location::class; + } + + public function getDomainObject(): string + { + return LocationDomainObject::class; + } + + public function findByOrganizerId(int $organizerId, int $accountId, QueryParamsDTO $params): LengthAwarePaginator + { + $this->model = $this->model->newQuery()->orderBy( + column: $this->validateSortColumn($params->sort_by, LocationDomainObject::class), + direction: $this->validateSortDirection($params->sort_direction, LocationDomainObject::class), + ); + + if (! empty($params->filter_fields)) { + $this->applyFilterFields($params, LocationDomainObject::getAllowedFilterFields()); + } + + if (! empty($params->query)) { + $needle = '%'.strtolower($params->query).'%'; + $this->model = $this->model + ->where(function ($query) use ($needle) { + $query + ->whereRaw('LOWER('.LocationDomainObjectAbstract::NAME.') LIKE ?', [$needle]) + ->orWhereRaw("LOWER(structured_address->>'venue_name') LIKE ?", [$needle]) + ->orWhereRaw("LOWER(structured_address->>'address_line_1') LIKE ?", [$needle]) + ->orWhereRaw("LOWER(structured_address->>'city') LIKE ?", [$needle]); + }); + } + + return $this->paginateWhere( + where: [ + LocationDomainObjectAbstract::ORGANIZER_ID => $organizerId, + LocationDomainObjectAbstract::ACCOUNT_ID => $accountId, + ], + limit: $params->per_page, + page: $params->page, + ); + } + + public function isReferenced(int $locationId): bool + { + $eventLocationCount = DB::table('event_locations') + ->where('location_id', $locationId) + ->whereNull('deleted_at') + ->count(); + + if ($eventLocationCount > 0) { + return true; + } + + $organizerCount = DB::table('organizers') + ->where('location_id', $locationId) + ->whereNull('deleted_at') + ->count(); + + return $organizerCount > 0; + } +} diff --git a/backend/app/Repository/Interfaces/EventLocationRepositoryInterface.php b/backend/app/Repository/Interfaces/EventLocationRepositoryInterface.php new file mode 100644 index 0000000000..94cd386491 --- /dev/null +++ b/backend/app/Repository/Interfaces/EventLocationRepositoryInterface.php @@ -0,0 +1,15 @@ + + */ +interface EventLocationRepositoryInterface extends RepositoryInterface +{ + public function isReferenced(int $eventLocationId): bool; +} diff --git a/backend/app/Repository/Interfaces/LocationRepositoryInterface.php b/backend/app/Repository/Interfaces/LocationRepositoryInterface.php new file mode 100644 index 0000000000..f7164dd06e --- /dev/null +++ b/backend/app/Repository/Interfaces/LocationRepositoryInterface.php @@ -0,0 +1,19 @@ + + */ +interface LocationRepositoryInterface extends RepositoryInterface +{ + public function findByOrganizerId(int $organizerId, int $accountId, QueryParamsDTO $params): LengthAwarePaginator; + + public function isReferenced(int $locationId): bool; +} diff --git a/backend/app/Resources/Event/EventResource.php b/backend/app/Resources/Event/EventResource.php index 0273185ddd..c7d3ec5e7e 100644 --- a/backend/app/Resources/Event/EventResource.php +++ b/backend/app/Resources/Event/EventResource.php @@ -4,6 +4,7 @@ use HiEvents\DomainObjects\EventDomainObject; use HiEvents\Resources\BaseResource; +use HiEvents\Resources\EventLocation\EventLocationResource; use HiEvents\Resources\EventOccurrence\EventOccurrenceResource; use HiEvents\Resources\Image\ImageResource; use HiEvents\Resources\Organizer\OrganizerResource; @@ -33,32 +34,36 @@ public function toArray(Request $request): array 'currency' => $this->getCurrency(), 'timezone' => $this->getTimezone(), 'slug' => $this->getSlug(), + 'organizer_id' => $this->getOrganizerId(), 'products' => $this->when( - condition: (bool)$this->getProducts(), - value: fn() => ProductResource::collection($this->getProducts()), + condition: (bool) $this->getProducts(), + value: fn () => ProductResource::collection($this->getProducts()), ), 'product_categories' => $this->when( - condition: (bool)$this->getProductCategories(), - value: fn() => ProductCategoryResource::collection($this->getProductCategories()), + condition: (bool) $this->getProductCategories(), + value: fn () => ProductCategoryResource::collection($this->getProductCategories()), + ), + 'attributes' => $this->when((bool) $this->getAttributes(), fn () => $this->getAttributes()), + 'images' => $this->when((bool) $this->getImages(), fn () => ImageResource::collection($this->getImages())), + 'event_location' => $this->when( + condition: $this->getEventLocation() !== null, + value: fn () => new EventLocationResource($this->getEventLocation()), ), - 'attributes' => $this->when((bool)$this->getAttributes(), fn() => $this->getAttributes()), - 'images' => $this->when((bool)$this->getImages(), fn() => ImageResource::collection($this->getImages())), - 'location_details' => $this->when((bool)$this->getLocationDetails(), fn() => $this->getLocationDetails()), 'settings' => $this->when( - condition: !is_null($this->getEventSettings()), - value: fn() => new EventSettingsResource($this->getEventSettings()) + condition: ! is_null($this->getEventSettings()), + value: fn () => new EventSettingsResource($this->getEventSettings()) ), 'organizer' => $this->when( - condition: !is_null($this->getOrganizer()), - value: fn() => new OrganizerResource($this->getOrganizer()) + condition: ! is_null($this->getOrganizer()), + value: fn () => new OrganizerResource($this->getOrganizer()) ), 'statistics' => $this->when( - condition: !is_null($this->getEventStatistics()), - value: fn() => new EventStatisticsResource($this->getEventStatistics()) + condition: ! is_null($this->getEventStatistics()), + value: fn () => new EventStatisticsResource($this->getEventStatistics()) ), 'occurrences' => $this->when( - condition: !is_null($this->getEventOccurrences()) && $this->getEventOccurrences()->isNotEmpty(), - value: fn() => EventOccurrenceResource::collection($this->getEventOccurrences()), + condition: ! is_null($this->getEventOccurrences()) && $this->getEventOccurrences()->isNotEmpty(), + value: fn () => EventOccurrenceResource::collection($this->getEventOccurrences()), ), ]; } diff --git a/backend/app/Resources/Event/EventResourcePublic.php b/backend/app/Resources/Event/EventResourcePublic.php index 8c078c30c0..c3819d5076 100644 --- a/backend/app/Resources/Event/EventResourcePublic.php +++ b/backend/app/Resources/Event/EventResourcePublic.php @@ -6,6 +6,7 @@ use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\Resources\BaseResource; +use HiEvents\Resources\EventLocation\EventLocationResourcePublic; use HiEvents\Resources\EventOccurrence\EventOccurrenceResourcePublic; use HiEvents\Resources\Image\ImageResource; use HiEvents\Resources\Organizer\OrganizerResourcePublic; @@ -24,9 +25,8 @@ public function __construct( mixed $resource, mixed $includePostCheckoutData = false, ) { - // This is a hacky workaround to handle when this resource is instantiated - // internally within Laravel the second param is the collection key (numeric) - // When called normally, second param is includePostCheckoutData (boolean) + // Laravel passes a numeric collection key as the second arg during + // collection iteration; coerce to false unless the caller passed a bool. $this->includePostCheckoutData = is_bool($includePostCheckoutData) ? $includePostCheckoutData : false; @@ -53,7 +53,10 @@ public function toArray(Request $request): array 'status' => $this->getStatus(), 'lifecycle_status' => $this->getLifecycleStatus(), 'timezone' => $this->getTimezone(), - 'location_details' => $this->when((bool) $this->getLocationDetails(), fn () => $this->getLocationDetails()), + 'event_location' => $this->when( + condition: $this->getEventLocation() !== null, + value: fn () => new EventLocationResourcePublic($this->getEventLocation(), $this->includePostCheckoutData), + ), 'product_categories' => $this->when( condition: ! is_null($this->getProductCategories()) && $this->getProductCategories()->isNotEmpty(), value: fn () => ProductCategoryResourcePublic::collection($this->getProductCategories()), @@ -83,12 +86,8 @@ public function toArray(Request $request): array ), 'occurrences' => $this->when( condition: ! is_null($this->getEventOccurrences()) && $this->getEventOccurrences()->isNotEmpty(), - // Cap is enforced by GetPublicEventHandler before assignment so - // the requested occurrence (which the handler explicitly pushes - // even when its position is past the cap) survives to the - // payload. Re-capping here re-sorted by start_date and dropped - // it again, breaking shared/checkout links to occurrences past - // the first 200 upcoming. + // Cap is enforced by GetPublicEventHandler; do not re-cap here + // or shared/checkout links past the cap silently drop out. value: fn () => EventOccurrenceResourcePublic::collection( $this->getEventOccurrences() ->filter(fn (EventOccurrenceDomainObject $occ) => ! $occ->isCancelled() && (! $isRecurring || ! $occ->isPast())) diff --git a/backend/app/Resources/Event/EventSettingsResource.php b/backend/app/Resources/Event/EventSettingsResource.php index b61c69bf09..7f93ebacc4 100644 --- a/backend/app/Resources/Event/EventSettingsResource.php +++ b/backend/app/Resources/Event/EventSettingsResource.php @@ -34,10 +34,6 @@ public function toArray($request): array 'website_url' => $this->getWebsiteUrl(), 'maps_url' => $this->getMapsUrl(), - 'location_details' => $this->getLocationDetails(), - 'is_online_event' => $this->getIsOnlineEvent(), - 'online_event_connection_details' => $this->getOnlineEventConnectionDetails(), - 'seo_title' => $this->getSeoTitle(), 'seo_description' => $this->getSeoDescription(), 'seo_keywords' => $this->getSeoKeywords(), diff --git a/backend/app/Resources/Event/EventSettingsResourcePublic.php b/backend/app/Resources/Event/EventSettingsResourcePublic.php index 02ed37b5c2..f184db60ed 100644 --- a/backend/app/Resources/Event/EventSettingsResourcePublic.php +++ b/backend/app/Resources/Event/EventSettingsResourcePublic.php @@ -11,10 +11,9 @@ class EventSettingsResourcePublic extends JsonResource { public function __construct( - mixed $resource, + mixed $resource, private readonly bool $includePostCheckoutData = false, - ) - { + ) { parent::__construct($resource); } @@ -28,7 +27,6 @@ public function toArray($request): array // i.e. order->event->event_settings and not event->event_settings $this->mergeWhen($this->includePostCheckoutData, [ 'post_checkout_message' => $this->getPostCheckoutMessage(), - 'online_event_connection_details' => $this->getOnlineEventConnectionDetails(), ]), 'product_page_message' => $this->getProductPageMessage(), @@ -51,9 +49,6 @@ public function toArray($request): array 'website_url' => $this->getWebsiteUrl(), 'maps_url' => $this->getMapsUrl(), - 'location_details' => $this->getLocationDetails(), - 'is_online_event' => $this->getIsOnlineEvent(), - // Ticket design settings 'ticket_design_settings' => $this->getTicketDesignSettings(), diff --git a/backend/app/Resources/EventLocation/EventLocationResource.php b/backend/app/Resources/EventLocation/EventLocationResource.php new file mode 100644 index 0000000000..e99b17e689 --- /dev/null +++ b/backend/app/Resources/EventLocation/EventLocationResource.php @@ -0,0 +1,46 @@ +includeOnlineConnectionDetails = is_bool($includeOnlineConnectionDetails) + ? $includeOnlineConnectionDetails + : true; + + parent::__construct($resource); + } + + public function toArray(Request $request): array + { + return [ + 'id' => $this->getId(), + 'type' => $this->getType(), + 'location_id' => $this->getLocationId(), + 'online_event_connection_details' => $this->when( + condition: $this->includeOnlineConnectionDetails, + value: fn () => $this->getOnlineEventConnectionDetails(), + ), + 'location' => $this->when( + condition: $this->getLocation() !== null, + value: fn () => new LocationResource($this->getLocation()), + ), + ]; + } +} diff --git a/backend/app/Resources/EventLocation/EventLocationResourcePublic.php b/backend/app/Resources/EventLocation/EventLocationResourcePublic.php new file mode 100644 index 0000000000..ab57849eca --- /dev/null +++ b/backend/app/Resources/EventLocation/EventLocationResourcePublic.php @@ -0,0 +1,48 @@ +includeOnlineConnectionDetails = is_bool($includeOnlineConnectionDetails) + ? $includeOnlineConnectionDetails + : false; + + parent::__construct($resource); + } + + public function toArray(Request $request): array + { + return [ + 'type' => $this->getType(), + 'online_event_connection_details' => $this->when( + condition: $this->includeOnlineConnectionDetails, + value: fn () => $this->getOnlineEventConnectionDetails(), + ), + 'location' => $this->when( + condition: $this->getLocation() !== null, + value: fn () => new LocationPublicResource($this->getLocation()), + ), + ]; + } +} diff --git a/backend/app/Resources/EventOccurrence/EventOccurrenceResource.php b/backend/app/Resources/EventOccurrence/EventOccurrenceResource.php index abb0bcbcd3..f0dfca2849 100644 --- a/backend/app/Resources/EventOccurrence/EventOccurrenceResource.php +++ b/backend/app/Resources/EventOccurrence/EventOccurrenceResource.php @@ -4,6 +4,7 @@ use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\Resources\BaseResource; +use HiEvents\Resources\EventLocation\EventLocationResource; use Illuminate\Http\Request; /** @@ -30,7 +31,11 @@ public function toArray(Request $request): array 'is_past' => $this->isPast(), 'is_future' => $this->isFuture(), 'is_active' => $this->isActive(), - 'statistics' => $this->when($stats !== null, fn() => [ + 'event_location' => $this->when( + condition: $this->getEventLocation() !== null, + value: fn () => new EventLocationResource($this->getEventLocation()), + ), + 'statistics' => $this->when($stats !== null, fn () => [ 'total_gross_sales' => $stats->getSalesTotalGross() ?? 0, 'total_tax' => $stats->getTotalTax() ?? 0, 'total_fee' => $stats->getTotalFee() ?? 0, diff --git a/backend/app/Resources/EventOccurrence/EventOccurrenceResourcePublic.php b/backend/app/Resources/EventOccurrence/EventOccurrenceResourcePublic.php index de0671cab4..dfc76eddb0 100644 --- a/backend/app/Resources/EventOccurrence/EventOccurrenceResourcePublic.php +++ b/backend/app/Resources/EventOccurrence/EventOccurrenceResourcePublic.php @@ -4,6 +4,7 @@ use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\Resources\BaseResource; +use HiEvents\Resources\EventLocation\EventLocationResourcePublic; use Illuminate\Http\Request; /** @@ -26,6 +27,10 @@ public function toArray(Request $request): array 'is_past' => $this->isPast(), 'is_future' => $this->isFuture(), 'is_active' => $this->isActive(), + 'event_location' => $this->when( + condition: $this->getEventLocation() !== null, + value: fn () => new EventLocationResourcePublic($this->getEventLocation(), false), + ), ]; } } diff --git a/backend/app/Resources/Location/LocationPublicResource.php b/backend/app/Resources/Location/LocationPublicResource.php new file mode 100644 index 0000000000..ca11f56a81 --- /dev/null +++ b/backend/app/Resources/Location/LocationPublicResource.php @@ -0,0 +1,29 @@ + $this->getName(), + 'structured_address' => $this->getStructuredAddress(), + 'latitude' => $this->getLatitude(), + 'longitude' => $this->getLongitude(), + ]; + } +} diff --git a/backend/app/Resources/Location/LocationResource.php b/backend/app/Resources/Location/LocationResource.php new file mode 100644 index 0000000000..8cd7921618 --- /dev/null +++ b/backend/app/Resources/Location/LocationResource.php @@ -0,0 +1,31 @@ + $this->getId(), + 'organizer_id' => $this->getOrganizerId(), + 'name' => $this->getName(), + 'structured_address' => $this->getStructuredAddress(), + 'latitude' => $this->getLatitude(), + 'longitude' => $this->getLongitude(), + 'provider' => $this->getProvider(), + 'provider_place_id' => $this->getProviderPlaceId(), + 'created_at' => $this->getCreatedAt(), + 'updated_at' => $this->getUpdatedAt(), + ]; + } +} diff --git a/backend/app/Resources/Organizer/OrganizerResource.php b/backend/app/Resources/Organizer/OrganizerResource.php index ad439100d3..aac4dd994a 100644 --- a/backend/app/Resources/Organizer/OrganizerResource.php +++ b/backend/app/Resources/Organizer/OrganizerResource.php @@ -4,6 +4,7 @@ use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\Resources\Image\ImageResource; +use HiEvents\Resources\Location\LocationResource; use Illuminate\Http\Resources\Json\JsonResource; /** @@ -24,6 +25,11 @@ public function toArray($request): array 'currency' => $this->getCurrency(), 'slug' => $this->getSlug(), 'status' => $this->getStatus(), + 'location_id' => $this->getLocationId(), + 'location' => $this->when( + condition: $this->getLocationRecord() !== null, + value: fn() => new LocationResource($this->getLocationRecord()), + ), 'images' => $this->when( (bool)$this->getImages(), fn() => ImageResource::collection($this->getImages()) diff --git a/backend/app/Resources/Organizer/OrganizerResourcePublic.php b/backend/app/Resources/Organizer/OrganizerResourcePublic.php index 084d457ebb..7b22f23fee 100644 --- a/backend/app/Resources/Organizer/OrganizerResourcePublic.php +++ b/backend/app/Resources/Organizer/OrganizerResourcePublic.php @@ -5,6 +5,7 @@ use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\Resources\Event\EventResourcePublic; use HiEvents\Resources\Image\ImageResource; +use HiEvents\Resources\Location\LocationPublicResource; use Illuminate\Http\Resources\Json\JsonResource; /** @@ -21,17 +22,21 @@ public function toArray($request): array 'description' => $this->getDescription(), 'slug' => $this->getSlug(), 'status' => $this->getStatus(), + 'location' => $this->when( + condition: $this->getLocationRecord() !== null, + value: fn () => new LocationPublicResource($this->getLocationRecord()), + ), 'images' => $this->when( - (bool)$this->getImages(), - fn() => ImageResource::collection($this->getImages()) + (bool) $this->getImages(), + fn () => ImageResource::collection($this->getImages()) ), 'events' => $this->when( - condition: !is_null($this->getEvents()), - value: fn() => EventResourcePublic::collection($this->getEvents()) + condition: ! is_null($this->getEvents()), + value: fn () => EventResourcePublic::collection($this->getEvents()) ), 'settings' => $this->when( - condition: !is_null($this->getOrganizerSettings()), - value: fn() => new OrganizerSettingsPublicResource($this->getOrganizerSettings()) + condition: ! is_null($this->getOrganizerSettings()), + value: fn () => new OrganizerSettingsPublicResource($this->getOrganizerSettings()) ), ]; } diff --git a/backend/app/Resources/Organizer/OrganizerSettingsResource.php b/backend/app/Resources/Organizer/OrganizerSettingsResource.php index 439f9fe318..d297cf31ef 100644 --- a/backend/app/Resources/Organizer/OrganizerSettingsResource.php +++ b/backend/app/Resources/Organizer/OrganizerSettingsResource.php @@ -28,7 +28,6 @@ public function toArray($request): array 'seo_title' => $this->getSeoTitle(), 'seo_description' => $this->getSeoDescription(), 'allow_search_engine_indexing' => $this->getAllowSearchEngineIndexing(), - 'location_details' => $this->getLocationDetails(), 'tracking_pixels' => $this->getTrackingPixels(), 'tracking_consent_acknowledged' => $this->getTrackingConsentAcknowledged(), ]; diff --git a/backend/app/Services/Application/Handlers/Event/CreateEventHandler.php b/backend/app/Services/Application/Handlers/Event/CreateEventHandler.php index b1a8c7d257..15ceec0aa4 100644 --- a/backend/app/Services/Application/Handlers/Event/CreateEventHandler.php +++ b/backend/app/Services/Application/Handlers/Event/CreateEventHandler.php @@ -6,12 +6,15 @@ use HiEvents\DomainObjects\Enums\EventCategory; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\Generated\EventDomainObjectAbstract; use HiEvents\Exceptions\OrganizerNotFoundException; +use HiEvents\Jobs\Event\Webhook\DispatchEventWebhookJob; +use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Services\Application\Handlers\Event\DTO\CreateEventDTO; use HiEvents\Services\Domain\Event\CreateEventService; -use HiEvents\Services\Domain\ProductCategory\CreateProductCategoryService; +use HiEvents\Services\Domain\EventLocation\EventLocationUpserter; use HiEvents\Services\Domain\Organizer\OrganizerFetchService; -use HiEvents\Jobs\Event\Webhook\DispatchEventWebhookJob; +use HiEvents\Services\Domain\ProductCategory\CreateProductCategoryService; use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType; use Illuminate\Database\DatabaseManager; use Throwable; @@ -19,13 +22,13 @@ class CreateEventHandler { public function __construct( - private readonly CreateEventService $createEventService, - private readonly OrganizerFetchService $organizerFetchService, + private readonly CreateEventService $createEventService, + private readonly OrganizerFetchService $organizerFetchService, private readonly CreateProductCategoryService $createProductCategoryService, - private readonly DatabaseManager $databaseManager, - ) - { - } + private readonly EventLocationUpserter $eventLocationUpserter, + private readonly EventRepositoryInterface $eventRepository, + private readonly DatabaseManager $databaseManager, + ) {} /** * @throws OrganizerNotFoundException @@ -33,7 +36,7 @@ public function __construct( */ public function handle(CreateEventDTO $eventData): EventDomainObject { - return $this->databaseManager->transaction(fn() => $this->createEvent($eventData)); + return $this->databaseManager->transaction(fn () => $this->createEvent($eventData)); } /** @@ -47,7 +50,7 @@ private function createEvent(CreateEventDTO $eventData): EventDomainObject accountId: $eventData->account_id ); - $event = (new EventDomainObject()) + $event = (new EventDomainObject) ->setOrganizerId($eventData->organizer_id) ->setAccountId($eventData->account_id) ->setUserId($eventData->user_id) @@ -59,8 +62,7 @@ private function createEvent(CreateEventDTO $eventData): EventDomainObject ->setCategory($eventData->category?->value ?? EventCategory::OTHER->value) ->setStatus($eventData->status) ->setType($eventData->type?->name) - ->setEventSettings($eventData->event_settings) - ->setLocationDetails($eventData->location_details?->toArray()); + ->setEventSettings($eventData->event_settings); $newEvent = $this->createEventService->createEvent( eventData: $event, @@ -68,6 +70,26 @@ private function createEvent(CreateEventDTO $eventData): EventDomainObject endDate: $eventData->end_date, ); + if ($eventData->event_location !== null) { + $eventLocation = $this->eventLocationUpserter->createForEvent( + eventId: $newEvent->getId(), + accountId: $eventData->account_id, + data: $eventData->event_location, + ); + + $this->eventRepository->updateWhere( + attributes: [ + EventDomainObjectAbstract::EVENT_LOCATION_ID => $eventLocation->getId(), + ], + where: [ + 'id' => $newEvent->getId(), + ], + ); + + $newEvent->setEventLocationId($eventLocation->getId()); + $newEvent->setEventLocation($eventLocation); + } + $this->createProductCategoryService->createDefaultProductCategory($newEvent); DispatchEventWebhookJob::dispatch( diff --git a/backend/app/Services/Application/Handlers/Event/CreateEventImageHandler.php b/backend/app/Services/Application/Handlers/Event/CreateEventImageHandler.php index fdba6365f4..32bce28da3 100644 --- a/backend/app/Services/Application/Handlers/Event/CreateEventImageHandler.php +++ b/backend/app/Services/Application/Handlers/Event/CreateEventImageHandler.php @@ -11,9 +11,7 @@ class CreateEventImageHandler { public function __construct( private readonly CreateEventImageService $createEventImageService, - ) - { - } + ) {} /** * @throws Throwable diff --git a/backend/app/Services/Application/Handlers/Event/DTO/CreateEventDTO.php b/backend/app/Services/Application/Handlers/Event/DTO/CreateEventDTO.php index e0ea4b8aad..079c620ed1 100644 --- a/backend/app/Services/Application/Handlers/Event/DTO/CreateEventDTO.php +++ b/backend/app/Services/Application/Handlers/Event/DTO/CreateEventDTO.php @@ -2,7 +2,6 @@ namespace HiEvents\Services\Application\Handlers\Event\DTO; -use HiEvents\DataTransferObjects\AddressDTO; use HiEvents\DataTransferObjects\Attributes\CollectionOf; use HiEvents\DataTransferObjects\AttributesDTO; use HiEvents\DataTransferObjects\BaseDTO; @@ -10,30 +9,29 @@ use HiEvents\DomainObjects\Enums\EventType; use HiEvents\DomainObjects\Status\EventStatus; use HiEvents\Services\Application\Handlers\EventSettings\DTO\UpdateEventSettingsDTO; +use HiEvents\Services\Domain\EventLocation\EventLocationData; use Illuminate\Support\Collection; class CreateEventDTO extends BaseDTO { public function __construct( - public readonly string $title, - public readonly int $organizer_id, - public readonly int $account_id, - public readonly int $user_id, - public readonly ?int $id = null, - public readonly ?string $start_date = null, - public readonly ?string $end_date = null, - public readonly ?string $description = null, + public readonly string $title, + public readonly int $organizer_id, + public readonly int $account_id, + public readonly int $user_id, + public readonly ?int $id = null, + public readonly ?string $start_date = null, + public readonly ?string $end_date = null, + public readonly ?string $description = null, #[CollectionOf(AttributesDTO::class)] - public readonly ?Collection $attributes = null, - public readonly ?string $timezone = null, - public readonly ?string $currency = null, + public readonly ?Collection $attributes = null, + public readonly ?string $timezone = null, + public readonly ?string $currency = null, public readonly ?EventCategory $category = null, - public readonly ?AddressDTO $location_details = null, - public readonly ?string $status = EventStatus::DRAFT->name, - public readonly ?EventType $type = EventType::SINGLE, + public readonly ?EventLocationData $event_location = null, + public readonly ?string $status = EventStatus::DRAFT->name, + public readonly ?EventType $type = EventType::SINGLE, public ?UpdateEventSettingsDTO $event_settings = null - ) - { - } + ) {} } diff --git a/backend/app/Services/Application/Handlers/Event/DTO/CreateEventImageDTO.php b/backend/app/Services/Application/Handlers/Event/DTO/CreateEventImageDTO.php index b5e466d69d..da47521e53 100644 --- a/backend/app/Services/Application/Handlers/Event/DTO/CreateEventImageDTO.php +++ b/backend/app/Services/Application/Handlers/Event/DTO/CreateEventImageDTO.php @@ -9,11 +9,9 @@ class CreateEventImageDTO extends BaseDTO { public function __construct( - public readonly int $eventId, - public readonly int $accountId, + public readonly int $eventId, + public readonly int $accountId, public readonly UploadedFile $image, - public readonly ImageType $imageType, - ) - { - } + public readonly ImageType $imageType, + ) {} } diff --git a/backend/app/Services/Application/Handlers/Event/DTO/DeleteEventDTO.php b/backend/app/Services/Application/Handlers/Event/DTO/DeleteEventDTO.php index 8ef3d4a60a..9bbbe92b8e 100644 --- a/backend/app/Services/Application/Handlers/Event/DTO/DeleteEventDTO.php +++ b/backend/app/Services/Application/Handlers/Event/DTO/DeleteEventDTO.php @@ -9,7 +9,5 @@ class DeleteEventDTO extends BaseDTO public function __construct( public int $eventId, public int $accountId, - ) - { - } + ) {} } diff --git a/backend/app/Services/Application/Handlers/Event/DTO/DeleteEventImageDTO.php b/backend/app/Services/Application/Handlers/Event/DTO/DeleteEventImageDTO.php index 1c464d579b..b488231a2a 100644 --- a/backend/app/Services/Application/Handlers/Event/DTO/DeleteEventImageDTO.php +++ b/backend/app/Services/Application/Handlers/Event/DTO/DeleteEventImageDTO.php @@ -9,7 +9,5 @@ class DeleteEventImageDTO extends BaseDTO public function __construct( public int $eventId, public int $imageId, - ) - { - } + ) {} } diff --git a/backend/app/Services/Application/Handlers/Event/DTO/EventStatsRequestDTO.php b/backend/app/Services/Application/Handlers/Event/DTO/EventStatsRequestDTO.php index a308331df0..26882a9c20 100644 --- a/backend/app/Services/Application/Handlers/Event/DTO/EventStatsRequestDTO.php +++ b/backend/app/Services/Application/Handlers/Event/DTO/EventStatsRequestDTO.php @@ -7,12 +7,10 @@ class EventStatsRequestDTO extends BaseDTO { public function __construct( - public int $event_id, + public int $event_id, public ?string $start_date = null, public ?string $end_date = null, - public string $date_range_preset = 'month', - public ?int $occurrence_id = null, - ) - { - } + public string $date_range_preset = 'month', + public ?int $occurrence_id = null, + ) {} } diff --git a/backend/app/Services/Application/Handlers/Event/DTO/EventStatsResponseDTO.php b/backend/app/Services/Application/Handlers/Event/DTO/EventStatsResponseDTO.php index ef5e25dfd7..9ac95cd22a 100644 --- a/backend/app/Services/Application/Handlers/Event/DTO/EventStatsResponseDTO.php +++ b/backend/app/Services/Application/Handlers/Event/DTO/EventStatsResponseDTO.php @@ -4,7 +4,6 @@ use HiEvents\DataTransferObjects\Attributes\CollectionOf; use HiEvents\DataTransferObjects\BaseDTO; -use HiEvents\Services\Domain\Event\DTO\EventCheckInStatsResponseDTO; use HiEvents\Services\Domain\Event\DTO\EventDailyStatsResponseDTO; use Illuminate\Support\Collection; @@ -12,20 +11,18 @@ class EventStatsResponseDTO extends BaseDTO { public function __construct( #[CollectionOf(EventDailyStatsResponseDTO::class)] - public readonly Collection $daily_stats, - public readonly string $start_date, - public readonly string $end_date, + public readonly Collection $daily_stats, + public readonly string $start_date, + public readonly string $end_date, - public int $total_products_sold, - public int $total_attendees_registered, + public int $total_products_sold, + public int $total_attendees_registered, - public int $total_orders, - public float $total_gross_sales, - public float $total_fees, - public float $total_tax, - public float $total_views, - public float $total_refunded, - ) - { - } + public int $total_orders, + public float $total_gross_sales, + public float $total_fees, + public float $total_tax, + public float $total_views, + public float $total_refunded, + ) {} } diff --git a/backend/app/Services/Application/Handlers/Event/DTO/GetEventsDTO.php b/backend/app/Services/Application/Handlers/Event/DTO/GetEventsDTO.php index 4bf64a61ce..cb19e337de 100644 --- a/backend/app/Services/Application/Handlers/Event/DTO/GetEventsDTO.php +++ b/backend/app/Services/Application/Handlers/Event/DTO/GetEventsDTO.php @@ -10,7 +10,5 @@ class GetEventsDTO extends BaseDTO public function __construct( public int $accountId, public QueryParamsDTO $queryParams, - ) - { - } + ) {} } diff --git a/backend/app/Services/Application/Handlers/Event/DTO/GetPublicEventDTO.php b/backend/app/Services/Application/Handlers/Event/DTO/GetPublicEventDTO.php index cfbeb36f52..8854845de6 100644 --- a/backend/app/Services/Application/Handlers/Event/DTO/GetPublicEventDTO.php +++ b/backend/app/Services/Application/Handlers/Event/DTO/GetPublicEventDTO.php @@ -7,12 +7,10 @@ class GetPublicEventDTO extends BaseDTO { public function __construct( - public int $eventId, - public bool $isAuthenticated, + public int $eventId, + public bool $isAuthenticated, public ?string $ipAddress = null, public ?string $promoCode = null, - public ?int $eventOccurrenceId = null, - ) - { - } + public ?int $eventOccurrenceId = null, + ) {} } diff --git a/backend/app/Services/Application/Handlers/Event/DTO/GetPublicOrganizerEventsDTO.php b/backend/app/Services/Application/Handlers/Event/DTO/GetPublicOrganizerEventsDTO.php index 36a70a4900..0b1c5e30cb 100644 --- a/backend/app/Services/Application/Handlers/Event/DTO/GetPublicOrganizerEventsDTO.php +++ b/backend/app/Services/Application/Handlers/Event/DTO/GetPublicOrganizerEventsDTO.php @@ -8,10 +8,8 @@ class GetPublicOrganizerEventsDTO extends BaseDTO { public function __construct( - public int $organizerId, + public int $organizerId, public QueryParamsDTO $queryParams, - public ?int $authenticatedAccountId = null, - ) - { - } + public ?int $authenticatedAccountId = null, + ) {} } diff --git a/backend/app/Services/Application/Handlers/Event/DTO/UpdateEventDTO.php b/backend/app/Services/Application/Handlers/Event/DTO/UpdateEventDTO.php index 0f30609d07..de31e84ebe 100644 --- a/backend/app/Services/Application/Handlers/Event/DTO/UpdateEventDTO.php +++ b/backend/app/Services/Application/Handlers/Event/DTO/UpdateEventDTO.php @@ -2,7 +2,6 @@ namespace HiEvents\Services\Application\Handlers\Event\DTO; -use HiEvents\DataTransferObjects\AddressDTO; use HiEvents\DataTransferObjects\Attributes\CollectionOf; use HiEvents\DataTransferObjects\AttributesDTO; use HiEvents\DataTransferObjects\BaseDTO; @@ -13,21 +12,17 @@ class UpdateEventDTO extends BaseDTO { public function __construct( - public readonly string $title, + public readonly string $title, public readonly ?EventCategory $category, - public readonly int $account_id, - public readonly int $id, - public readonly ?string $start_date = null, - public readonly ?string $end_date = null, - public readonly ?string $description = null, + public readonly int $account_id, + public readonly int $id, + public readonly ?string $start_date = null, + public readonly ?string $end_date = null, + public readonly ?string $description = null, #[CollectionOf(AttributesDTO::class)] - public readonly ?Collection $attributes = null, - public readonly ?string $timezone = null, - public readonly ?string $currency = null, - public readonly ?string $location = null, - public readonly ?AddressDTO $location_details = null, - public readonly ?string $status = EventStatus::DRAFT->name, - ) - { - } + public readonly ?Collection $attributes = null, + public readonly ?string $timezone = null, + public readonly ?string $currency = null, + public readonly ?string $status = EventStatus::DRAFT->name, + ) {} } diff --git a/backend/app/Services/Application/Handlers/Event/DTO/UpdateEventLocationDTO.php b/backend/app/Services/Application/Handlers/Event/DTO/UpdateEventLocationDTO.php new file mode 100644 index 0000000000..b5926d774f --- /dev/null +++ b/backend/app/Services/Application/Handlers/Event/DTO/UpdateEventLocationDTO.php @@ -0,0 +1,18 @@ +eventRepository - ->loadRelation(new Relationship(EventOccurrenceDomainObject::class)) + ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ])) + ->loadRelation(new Relationship(domainObject: EventOccurrenceDomainObject::class, nested: [ + new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ]), + ])) ->loadRelation(new Relationship(ImageDomainObject::class)) ->loadRelation(new Relationship(EventSettingDomainObject::class)) ->loadRelation(new Relationship(EventStatisticDomainObject::class)) diff --git a/backend/app/Services/Application/Handlers/Event/GetPublicEventHandler.php b/backend/app/Services/Application/Handlers/Event/GetPublicEventHandler.php index 28d66a741d..eb5acb0da6 100644 --- a/backend/app/Services/Application/Handlers/Event/GetPublicEventHandler.php +++ b/backend/app/Services/Application/Handlers/Event/GetPublicEventHandler.php @@ -4,11 +4,13 @@ use HiEvents\DomainObjects\Enums\EventType; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventLocationDomainObject; use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\Generated\EventOccurrenceDomainObjectAbstract; use HiEvents\DomainObjects\Generated\PromoCodeDomainObjectAbstract; use HiEvents\DomainObjects\ImageDomainObject; +use HiEvents\DomainObjects\LocationDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\DomainObjects\OrganizerSettingDomainObject; use HiEvents\DomainObjects\ProductCategoryDomainObject; @@ -54,10 +56,14 @@ public function handle(GetPublicEventDTO $data): EventDomainObject ]) ) ->loadRelation(new Relationship(EventSettingDomainObject::class)) + ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ])) ->loadRelation(new Relationship(ImageDomainObject::class)) ->loadRelation(new Relationship(OrganizerDomainObject::class, nested: [ new Relationship(ImageDomainObject::class), new Relationship(OrganizerSettingDomainObject::class), + new Relationship(domainObject: LocationDomainObject::class, name: 'location_record'), ], name: 'organizer')) ->findById($data->eventId); @@ -72,13 +78,17 @@ public function handle(GetPublicEventDTO $data): EventDomainObject // +1 lets us detect overflow without loading the entire occurrence table for // long-running recurring events (e.g. daily over multiple years). - $occurrences = $this->occurrenceRepository->findWhere( - where: $occurrenceWhere, - orderAndDirections: [ - new OrderAndDirection(EventOccurrenceDomainObjectAbstract::START_DATE, 'asc'), - ], - limit: self::MAX_PUBLIC_OCCURRENCES + 1, - ); + $occurrences = $this->occurrenceRepository + ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ])) + ->findWhere( + where: $occurrenceWhere, + orderAndDirections: [ + new OrderAndDirection(EventOccurrenceDomainObjectAbstract::START_DATE, 'asc'), + ], + limit: self::MAX_PUBLIC_OCCURRENCES + 1, + ); // Resolve once: only honour the requested occurrence id if it actually // belongs to this event. The caller can supply any id, and downstream @@ -91,11 +101,15 @@ public function handle(GetPublicEventDTO $data): EventDomainObject fn (EventOccurrenceDomainObject $o) => $o->getId() === $data->eventOccurrenceId ); if ($verifiedOccurrence === null) { - $verifiedOccurrence = $this->occurrenceRepository->findFirstWhere([ - EventOccurrenceDomainObjectAbstract::ID => $data->eventOccurrenceId, - EventOccurrenceDomainObjectAbstract::EVENT_ID => $data->eventId, - [EventOccurrenceDomainObjectAbstract::STATUS, '!=', EventOccurrenceStatus::CANCELLED->name], - ]); + $verifiedOccurrence = $this->occurrenceRepository + ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ])) + ->findFirstWhere([ + EventOccurrenceDomainObjectAbstract::ID => $data->eventOccurrenceId, + EventOccurrenceDomainObjectAbstract::EVENT_ID => $data->eventId, + [EventOccurrenceDomainObjectAbstract::STATUS, '!=', EventOccurrenceStatus::CANCELLED->name], + ]); } // The fallback above only filters out CANCELLED — drop past dates // here too. Without this, a stale share/email link to a past date diff --git a/backend/app/Services/Application/Handlers/Event/GetPublicEventsHandler.php b/backend/app/Services/Application/Handlers/Event/GetPublicEventsHandler.php index af779c4b9c..445c97964b 100644 --- a/backend/app/Services/Application/Handlers/Event/GetPublicEventsHandler.php +++ b/backend/app/Services/Application/Handlers/Event/GetPublicEventsHandler.php @@ -20,11 +20,9 @@ class GetPublicEventsHandler { public function __construct( - private readonly EventRepositoryInterface $eventRepository, + private readonly EventRepositoryInterface $eventRepository, private readonly OrganizerRepositoryInterface $organizerRepository, - ) - { - } + ) {} public function handle(GetPublicOrganizerEventsDTO $dto): LengthAwarePaginator { diff --git a/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php b/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php index 9000b73c13..715d2be3ea 100644 --- a/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php +++ b/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php @@ -1,5 +1,7 @@ eventRepository->findFirstWhere([ 'id' => $eventData->id, @@ -54,34 +57,24 @@ private function fetchExistingEvent(UpdateEventDTO $eventData) if ($existingEvent === null) { throw new ResourceNotFoundException( - __('Event :id not found', ['id' => $eventData->id]) + __('Event :id not found', ['id' => $eventData->id]), ); } - return $existingEvent; - } - - /** - * @throws CannotChangeCurrencyException - */ - private function updateEventAttributes(UpdateEventDTO $eventData): void - { - $existingEvent = $this->fetchExistingEvent($eventData); - if ($eventData->currency !== null && $eventData->currency !== $existingEvent->getCurrency()) { $this->checkForCompletedOrders($eventData); } + $attributes = [ + 'title' => $eventData->title, + 'category' => $eventData->category?->value ?? $existingEvent->getCategory(), + 'description' => $this->purifier->purify($eventData->description), + 'timezone' => $eventData->timezone ?? $existingEvent->getTimezone(), + 'currency' => $eventData->currency ?? $existingEvent->getCurrency(), + ]; + $this->eventRepository->updateWhere( - attributes: [ - 'title' => $eventData->title, - 'category' => $eventData->category?->value ?? $existingEvent->getCategory(), - 'description' => $this->purifier->purify($eventData->description), - 'timezone' => $eventData->timezone ?? $existingEvent->getTimezone(), - 'currency' => $eventData->currency ?? $existingEvent->getCurrency(), - 'location' => $eventData->location, - 'location_details' => $eventData->location_details?->toArray(), - ], + attributes: $attributes, where: [ 'id' => $eventData->id, 'account_id' => $eventData->account_id, @@ -151,11 +144,10 @@ private function checkForCompletedOrders(UpdateEventDTO $eventData): void 'status' => OrderStatus::COMPLETED->name, ]); - if (!$orders->isNotEmpty()) { + if ($orders->isNotEmpty()) { throw new CannotChangeCurrencyException( __('You cannot change the currency of an event that has completed orders'), ); } } } - diff --git a/backend/app/Services/Application/Handlers/Event/UpdateEventLocationHandler.php b/backend/app/Services/Application/Handlers/Event/UpdateEventLocationHandler.php new file mode 100644 index 0000000000..e3144e908f --- /dev/null +++ b/backend/app/Services/Application/Handlers/Event/UpdateEventLocationHandler.php @@ -0,0 +1,76 @@ +databaseManager->transaction(function () use ($dto) { + $event = $this->eventRepository->findFirstWhere([ + 'id' => $dto->event_id, + 'account_id' => $dto->account_id, + ]); + + if ($event === null) { + throw new ResourceNotFoundException(__('Event :id not found', ['id' => $dto->event_id])); + } + + $previousEventLocationId = $event->getEventLocationId(); + + if ($dto->event_location !== null) { + if ($previousEventLocationId === null) { + $created = $this->eventLocationUpserter->createForEvent( + eventId: $dto->event_id, + accountId: $dto->account_id, + data: $dto->event_location, + ); + $this->eventRepository->updateWhere( + attributes: [EventDomainObjectAbstract::EVENT_LOCATION_ID => $created->getId()], + where: ['id' => $dto->event_id, 'account_id' => $dto->account_id], + ); + } else { + $this->eventLocationUpserter->updateInPlace( + eventLocationId: $previousEventLocationId, + eventId: $dto->event_id, + accountId: $dto->account_id, + data: $dto->event_location, + ); + } + } elseif ($dto->clear_event_location && $previousEventLocationId !== null) { + $this->eventRepository->updateWhere( + attributes: [EventDomainObjectAbstract::EVENT_LOCATION_ID => null], + where: ['id' => $dto->event_id, 'account_id' => $dto->account_id], + ); + $this->eventLocationCleaner->deleteIfOrphaned($previousEventLocationId); + } + + return $this->eventRepository->findFirstWhere([ + 'id' => $dto->event_id, + 'account_id' => $dto->account_id, + ]); + }); + } +} diff --git a/backend/app/Services/Application/Handlers/Event/UpdateEventStatusHandler.php b/backend/app/Services/Application/Handlers/Event/UpdateEventStatusHandler.php index 4d43879404..bbf29db4e5 100644 --- a/backend/app/Services/Application/Handlers/Event/UpdateEventStatusHandler.php +++ b/backend/app/Services/Application/Handlers/Event/UpdateEventStatusHandler.php @@ -3,12 +3,12 @@ namespace HiEvents\Services\Application\Handlers\Event; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\Status\EventStatus; use HiEvents\Exceptions\AccountNotVerifiedException; +use HiEvents\Jobs\Event\Webhook\DispatchEventWebhookJob; use HiEvents\Repository\Interfaces\AccountRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Services\Application\Handlers\Event\DTO\UpdateEventStatusDTO; -use HiEvents\DomainObjects\Status\EventStatus; -use HiEvents\Jobs\Event\Webhook\DispatchEventWebhookJob; use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType; use Illuminate\Database\DatabaseManager; use Psr\Log\LoggerInterface; @@ -17,13 +17,11 @@ readonly class UpdateEventStatusHandler { public function __construct( - private EventRepositoryInterface $eventRepository, + private EventRepositoryInterface $eventRepository, private AccountRepositoryInterface $accountRepository, - private LoggerInterface $logger, - private DatabaseManager $databaseManager, - ) - { - } + private LoggerInterface $logger, + private DatabaseManager $databaseManager, + ) {} /** * @throws AccountNotVerifiedException|Throwable @@ -60,7 +58,7 @@ private function updateEventStatus(UpdateEventStatusDTO $updateEventStatusDTO): $this->logger->info('Event status updated', [ 'eventId' => $updateEventStatusDTO->eventId, - 'status' => $updateEventStatusDTO->status + 'status' => $updateEventStatusDTO->status, ]); $event = $this->eventRepository->findFirstWhere([ diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/BulkUpdateOccurrencesHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/BulkUpdateOccurrencesHandler.php index 1ece2cffa4..fb4133a217 100644 --- a/backend/app/Services/Application/Handlers/EventOccurrence/BulkUpdateOccurrencesHandler.php +++ b/backend/app/Services/Application/Handlers/EventOccurrence/BulkUpdateOccurrencesHandler.php @@ -14,14 +14,18 @@ use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\DomainObjects\Status\EventOccurrenceStatus; use HiEvents\DomainObjects\Status\WaitlistEntryStatus; +use HiEvents\Exceptions\ResourceNotFoundException; use HiEvents\Jobs\Occurrence\BulkCancelOccurrencesJob; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; +use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\OrderItemRepositoryInterface; use HiEvents\Repository\Interfaces\WaitlistEntryRepositoryInterface; use HiEvents\Services\Application\Handlers\EventOccurrence\DTO\BulkUpdateOccurrencesDTO; use HiEvents\Services\Application\Handlers\EventOccurrence\DTO\BulkUpdateOccurrencesResultDTO; use HiEvents\Services\Domain\Event\RecurrenceRuleExclusionService; +use HiEvents\Services\Domain\EventLocation\EventLocationCleaner; +use HiEvents\Services\Domain\EventLocation\EventLocationUpserter; use Illuminate\Database\DatabaseManager; use Illuminate\Support\Collection; use Throwable; @@ -30,10 +34,13 @@ class BulkUpdateOccurrencesHandler { public function __construct( private readonly EventOccurrenceRepositoryInterface $occurrenceRepository, + private readonly EventRepositoryInterface $eventRepository, private readonly OrderItemRepositoryInterface $orderItemRepository, private readonly AttendeeRepositoryInterface $attendeeRepository, private readonly WaitlistEntryRepositoryInterface $waitlistEntryRepository, private readonly RecurrenceRuleExclusionService $exclusionService, + private readonly EventLocationUpserter $eventLocationUpserter, + private readonly EventLocationCleaner $eventLocationCleaner, private readonly DatabaseManager $databaseManager, ) {} @@ -43,6 +50,11 @@ public function __construct( public function handle(BulkUpdateOccurrencesDTO $dto): BulkUpdateOccurrencesResultDTO { return $this->databaseManager->transaction(function () use ($dto) { + $event = $this->eventRepository->findById($dto->event_id); + if ($event === null) { + throw new ResourceNotFoundException(__('Event :id not found', ['id' => $dto->event_id])); + } + $occurrences = $this->occurrenceRepository->findWhere( where: [ EventOccurrenceDomainObjectAbstract::EVENT_ID => $dto->event_id, @@ -54,7 +66,7 @@ public function handle(BulkUpdateOccurrencesDTO $dto): BulkUpdateOccurrencesResu return match ($dto->action) { BulkOccurrenceAction::CANCEL => $this->handleCancel($dto, $eligible), BulkOccurrenceAction::DELETE => $this->handleDelete($dto, $eligible), - BulkOccurrenceAction::UPDATE => $this->handleUpdate($dto, $eligible), + BulkOccurrenceAction::UPDATE => $this->handleUpdate($dto, $eligible, $event->getAccountId()), }; }); } @@ -104,10 +116,7 @@ private function handleDelete(BulkUpdateOccurrencesDTO $dto, Collection $eligibl return new BulkUpdateOccurrencesResultDTO(updated_count: 0, updated_ids: []); } - // Two batched lookups instead of 2N countWhere calls — bulk delete - // during regenerate can touch hundreds of occurrences. Mirrors the - // single-delete handler: attendees can exist without order items - // (imports, legacy data), so both checks must run. + // Attendees can exist without order items (imports/legacy), so both checks must run. $idsWithOrders = $this->orderItemRepository ->findWhereIn( field: OrderItemDomainObjectAbstract::EVENT_OCCURRENCE_ID, @@ -130,6 +139,7 @@ private function handleDelete(BulkUpdateOccurrencesDTO $dto, Collection $eligibl $deletableIds = []; $deletableStartDates = []; + $deletableEventLocationIds = []; foreach ($eligible as $occurrence) { $id = $occurrence->getId(); @@ -137,6 +147,9 @@ private function handleDelete(BulkUpdateOccurrencesDTO $dto, Collection $eligibl if (! isset($idsWithOrders[$id]) && ! isset($idsWithAttendees[$id])) { $deletableIds[] = $id; $deletableStartDates[] = $occurrence->getStartDate(); + if ($occurrence->getEventLocationId() !== null) { + $deletableEventLocationIds[] = $occurrence->getEventLocationId(); + } } } @@ -163,6 +176,10 @@ private function handleDelete(BulkUpdateOccurrencesDTO $dto, Collection $eligibl ]); $this->exclusionService->addExclusions($dto->event_id, $deletableStartDates); + + foreach (array_unique($deletableEventLocationIds) as $eventLocationId) { + $this->eventLocationCleaner->deleteIfOrphaned($eventLocationId); + } } return new BulkUpdateOccurrencesResultDTO( @@ -171,14 +188,17 @@ private function handleDelete(BulkUpdateOccurrencesDTO $dto, Collection $eligibl ); } - private function handleUpdate(BulkUpdateOccurrencesDTO $dto, Collection $eligible): BulkUpdateOccurrencesResultDTO + private function handleUpdate(BulkUpdateOccurrencesDTO $dto, Collection $eligible, int $accountId): BulkUpdateOccurrencesResultDTO { + $perRowEventLocation = $dto->event_location !== null || $dto->clear_event_location; + $requiresPerRow = $dto->start_time_shift !== null || $dto->end_time_shift !== null - || $dto->duration_minutes !== null; + || $dto->duration_minutes !== null + || $perRowEventLocation; if ($requiresPerRow) { - return $this->applyPerRowUpdate($dto, $eligible); + return $this->applyPerRowUpdate($dto, $eligible, $accountId); } return $this->applyUniformUpdate($dto, $eligible); @@ -194,9 +214,6 @@ private function applyUniformUpdate(BulkUpdateOccurrencesDTO $dto, Collection $e $capacityChanged = array_key_exists(EventOccurrenceDomainObjectAbstract::CAPACITY, $attributes); - // Capacity changes diverge the occurrence from the rule's defaults — - // flag overridden so the next regenerate doesn't reset them. Label-only - // edits don't pin against regenerate (parity with single-edit handler). if ($capacityChanged) { $attributes[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] = true; } @@ -214,12 +231,6 @@ private function applyUniformUpdate(BulkUpdateOccurrencesDTO $dto, Collection $e ], ); - // Mirror the single-edit handler's status reconciliation: capacity is - // uniform across the batch but each occurrence carries its own - // used_capacity, so SOLD_OUT/ACTIVE has to flip per row. Two scoped - // updateWheres avoid an N-row loop while still respecting the uniform - // path. CANCELLED is left untouched on either side — its lifecycle - // belongs to the dedicated cancel/reactivate handlers. if ($capacityChanged) { $this->reconcileStatusForUniformCapacity( ids: $ids, @@ -238,8 +249,6 @@ private function applyUniformUpdate(BulkUpdateOccurrencesDTO $dto, Collection $e */ private function reconcileStatusForUniformCapacity(array $ids, ?int $newCapacity): void { - // Re-open any sold-out occurrence that now has headroom (or is now - // unlimited). $reopenWhere = [ [EventOccurrenceDomainObjectAbstract::ID, 'in', $ids], [EventOccurrenceDomainObjectAbstract::STATUS, '=', EventOccurrenceStatus::SOLD_OUT->name], @@ -255,8 +264,6 @@ private function reconcileStatusForUniformCapacity(array $ids, ?int $newCapacity where: $reopenWhere, ); - // Mark sold-out any active occurrence that the new ceiling has now - // exceeded. Only meaningful when the new capacity is finite. if ($newCapacity !== null) { $this->occurrenceRepository->updateWhere( attributes: [ @@ -271,18 +278,35 @@ private function reconcileStatusForUniformCapacity(array $ids, ?int $newCapacity } } - private function applyPerRowUpdate(BulkUpdateOccurrencesDTO $dto, Collection $eligible): BulkUpdateOccurrencesResultDTO + private function applyPerRowUpdate(BulkUpdateOccurrencesDTO $dto, Collection $eligible, int $accountId): BulkUpdateOccurrencesResultDTO { $updatedIds = []; + $orphanCandidateIds = []; foreach ($eligible as $occurrence) { $attributes = $this->buildPerRowAttributes($dto, $occurrence); + $previousEventLocationId = $occurrence->getEventLocationId(); + + if ($dto->event_location !== null) { + $eventLocation = $this->eventLocationUpserter->createForEvent( + eventId: $dto->event_id, + accountId: $accountId, + data: $dto->event_location, + ); + $attributes[EventOccurrenceDomainObjectAbstract::EVENT_LOCATION_ID] = $eventLocation->getId(); + $attributes[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] = true; + + if ($previousEventLocationId !== null) { + $orphanCandidateIds[] = $previousEventLocationId; + } + } elseif ($dto->clear_event_location && $previousEventLocationId !== null) { + $attributes[EventOccurrenceDomainObjectAbstract::EVENT_LOCATION_ID] = null; + $attributes[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] = true; + $orphanCandidateIds[] = $previousEventLocationId; + } + if (! empty($attributes)) { - // Same SOLD_OUT/ACTIVE reconciliation as the single-edit and - // uniform-bulk paths — when the bulk operation also tweaks - // capacity (e.g. shift_times + clear_capacity in one save) the - // status has to follow the new ceiling per row. if (array_key_exists(EventOccurrenceDomainObjectAbstract::CAPACITY, $attributes)) { $reconciled = $this->reconcileCapacityStatus( currentStatus: $occurrence->getStatus(), @@ -302,17 +326,16 @@ private function applyPerRowUpdate(BulkUpdateOccurrencesDTO $dto, Collection $el } } + foreach (array_unique($orphanCandidateIds) as $eventLocationId) { + $this->eventLocationCleaner->deleteIfOrphaned($eventLocationId); + } + return new BulkUpdateOccurrencesResultDTO( updated_count: count($updatedIds), updated_ids: $updatedIds, ); } - /** - * Returns the status the occurrence should sit at after a capacity edit, - * or null if the existing status is correct. Mirrors UpdateEventOccurrenceHandler - * — keep both in sync. Never touches CANCELLED. - */ private function reconcileCapacityStatus( string $currentStatus, ?int $newCapacity, @@ -386,8 +409,6 @@ private function buildPerRowAttributes(BulkUpdateOccurrencesDTO $dto, EventOccur $startEndChanged = true; } - // Pin overridden so the next regenerate doesn't revert the shift. - // Capacity-only changes already flagged via buildUniformAttributes path. if ($startEndChanged || array_key_exists(EventOccurrenceDomainObjectAbstract::CAPACITY, $attributes) ) { diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/CreateEventOccurrenceHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/CreateEventOccurrenceHandler.php index 186363c99a..c703aa2e83 100644 --- a/backend/app/Services/Application/Handlers/EventOccurrence/CreateEventOccurrenceHandler.php +++ b/backend/app/Services/Application/Handlers/EventOccurrence/CreateEventOccurrenceHandler.php @@ -7,9 +7,12 @@ use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\Generated\EventOccurrenceDomainObjectAbstract; use HiEvents\DomainObjects\Status\EventOccurrenceStatus; +use HiEvents\Exceptions\ResourceNotFoundException; use HiEvents\Helper\IdHelper; use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; +use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Services\Application\Handlers\EventOccurrence\DTO\UpsertEventOccurrenceDTO; +use HiEvents\Services\Domain\EventLocation\EventLocationUpserter; use Illuminate\Database\DatabaseManager; use Throwable; @@ -17,6 +20,8 @@ class CreateEventOccurrenceHandler { public function __construct( private readonly EventOccurrenceRepositoryInterface $occurrenceRepository, + private readonly EventRepositoryInterface $eventRepository, + private readonly EventLocationUpserter $eventLocationUpserter, private readonly DatabaseManager $databaseManager, ) {} @@ -26,6 +31,22 @@ public function __construct( public function handle(UpsertEventOccurrenceDTO $dto): EventOccurrenceDomainObject { return $this->databaseManager->transaction(function () use ($dto) { + $eventLocationId = null; + + if ($dto->event_location !== null) { + $event = $this->eventRepository->findById($dto->event_id); + if ($event === null) { + throw new ResourceNotFoundException(__('Event :id not found', ['id' => $dto->event_id])); + } + + $eventLocation = $this->eventLocationUpserter->createForEvent( + eventId: $dto->event_id, + accountId: $event->getAccountId(), + data: $dto->event_location, + ); + $eventLocationId = $eventLocation->getId(); + } + return $this->occurrenceRepository->create([ EventOccurrenceDomainObjectAbstract::EVENT_ID => $dto->event_id, EventOccurrenceDomainObjectAbstract::SHORT_ID => IdHelper::shortId(IdHelper::OCCURRENCE_PREFIX), @@ -36,6 +57,7 @@ public function handle(UpsertEventOccurrenceDTO $dto): EventOccurrenceDomainObje EventOccurrenceDomainObjectAbstract::USED_CAPACITY => 0, EventOccurrenceDomainObjectAbstract::LABEL => $dto->label, EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN => $dto->is_overridden, + EventOccurrenceDomainObjectAbstract::EVENT_LOCATION_ID => $eventLocationId, ]); }); } diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/DTO/BulkUpdateOccurrencesDTO.php b/backend/app/Services/Application/Handlers/EventOccurrence/DTO/BulkUpdateOccurrencesDTO.php index c16ef596fb..0756dfbd01 100644 --- a/backend/app/Services/Application/Handlers/EventOccurrence/DTO/BulkUpdateOccurrencesDTO.php +++ b/backend/app/Services/Application/Handlers/EventOccurrence/DTO/BulkUpdateOccurrencesDTO.php @@ -4,6 +4,7 @@ use HiEvents\DataTransferObjects\BaseDataObject; use HiEvents\DomainObjects\Enums\BulkOccurrenceAction; +use HiEvents\Services\Domain\EventLocation\EventLocationData; class BulkUpdateOccurrencesDTO extends BaseDataObject { @@ -23,5 +24,7 @@ public function __construct( public readonly ?string $label = null, public readonly bool $clear_label = false, public readonly ?int $duration_minutes = null, + public readonly ?EventLocationData $event_location = null, + public readonly bool $clear_event_location = false, ) {} } diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/DTO/GenerateOccurrencesDTO.php b/backend/app/Services/Application/Handlers/EventOccurrence/DTO/GenerateOccurrencesDTO.php index 7d7ee10a7b..afe2f8f185 100644 --- a/backend/app/Services/Application/Handlers/EventOccurrence/DTO/GenerateOccurrencesDTO.php +++ b/backend/app/Services/Application/Handlers/EventOccurrence/DTO/GenerateOccurrencesDTO.php @@ -7,9 +7,7 @@ class GenerateOccurrencesDTO extends BaseDataObject { public function __construct( - public readonly int $event_id, + public readonly int $event_id, public readonly array $recurrence_rule, - ) - { - } + ) {} } diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/DTO/UpdateProductVisibilityDTO.php b/backend/app/Services/Application/Handlers/EventOccurrence/DTO/UpdateProductVisibilityDTO.php index 7af2394c6a..c5782cb7e3 100644 --- a/backend/app/Services/Application/Handlers/EventOccurrence/DTO/UpdateProductVisibilityDTO.php +++ b/backend/app/Services/Application/Handlers/EventOccurrence/DTO/UpdateProductVisibilityDTO.php @@ -7,10 +7,8 @@ class UpdateProductVisibilityDTO extends BaseDataObject { public function __construct( - public readonly int $event_id, - public readonly int $event_occurrence_id, + public readonly int $event_id, + public readonly int $event_occurrence_id, public readonly array $product_ids, - ) - { - } + ) {} } diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/DTO/UpsertEventOccurrenceDTO.php b/backend/app/Services/Application/Handlers/EventOccurrence/DTO/UpsertEventOccurrenceDTO.php index 8721e0a90e..6c10d99bd6 100644 --- a/backend/app/Services/Application/Handlers/EventOccurrence/DTO/UpsertEventOccurrenceDTO.php +++ b/backend/app/Services/Application/Handlers/EventOccurrence/DTO/UpsertEventOccurrenceDTO.php @@ -3,6 +3,7 @@ namespace HiEvents\Services\Application\Handlers\EventOccurrence\DTO; use HiEvents\DataTransferObjects\BaseDataObject; +use HiEvents\Services\Domain\EventLocation\EventLocationData; class UpsertEventOccurrenceDTO extends BaseDataObject { @@ -14,5 +15,7 @@ public function __construct( public readonly ?string $label = null, public readonly bool $is_overridden = false, public readonly ?int $id = null, + public readonly ?EventLocationData $event_location = null, + public readonly bool $clear_event_location = false, ) {} } diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/GenerateOccurrencesFromRuleHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/GenerateOccurrencesFromRuleHandler.php index c65f79c0bf..25481a918d 100644 --- a/backend/app/Services/Application/Handlers/EventOccurrence/GenerateOccurrencesFromRuleHandler.php +++ b/backend/app/Services/Application/Handlers/EventOccurrence/GenerateOccurrencesFromRuleHandler.php @@ -19,12 +19,10 @@ class GenerateOccurrencesFromRuleHandler { public function __construct( private readonly EventOccurrenceGeneratorService $generatorService, - private readonly EventRepositoryInterface $eventRepository, - private readonly RecurrenceRuleParserService $ruleParserService, - private readonly DatabaseManager $databaseManager, - ) - { - } + private readonly EventRepositoryInterface $eventRepository, + private readonly RecurrenceRuleParserService $ruleParserService, + private readonly DatabaseManager $databaseManager, + ) {} /** * @throws Throwable diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/GetEventOccurrenceHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/GetEventOccurrenceHandler.php index 998c326bc4..76d7d23477 100644 --- a/backend/app/Services/Application/Handlers/EventOccurrence/GetEventOccurrenceHandler.php +++ b/backend/app/Services/Application/Handlers/EventOccurrence/GetEventOccurrenceHandler.php @@ -4,30 +4,34 @@ namespace HiEvents\Services\Application\Handlers\EventOccurrence; +use HiEvents\DomainObjects\EventLocationDomainObject; use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\EventOccurrenceStatisticDomainObject; use HiEvents\DomainObjects\Generated\EventOccurrenceDomainObjectAbstract; -use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; +use HiEvents\DomainObjects\LocationDomainObject; use HiEvents\Exceptions\ResourceNotFoundException; +use HiEvents\Repository\Eloquent\Value\Relationship; +use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; class GetEventOccurrenceHandler { public function __construct( private readonly EventOccurrenceRepositoryInterface $occurrenceRepository, - ) - { - } + ) {} public function handle(int $eventId, int $occurrenceId): EventOccurrenceDomainObject { $occurrence = $this->occurrenceRepository ->loadRelation(EventOccurrenceStatisticDomainObject::class) + ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ])) ->findFirstWhere([ - EventOccurrenceDomainObjectAbstract::ID => $occurrenceId, - EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId, - ]); + EventOccurrenceDomainObjectAbstract::ID => $occurrenceId, + EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId, + ]); - if (!$occurrence) { + if (! $occurrence) { throw new ResourceNotFoundException( __('Occurrence :id not found for event :eventId', [ 'id' => $occurrenceId, diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/GetEventOccurrencesHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/GetEventOccurrencesHandler.php index 66f945a8c7..da4cb19f43 100644 --- a/backend/app/Services/Application/Handlers/EventOccurrence/GetEventOccurrencesHandler.php +++ b/backend/app/Services/Application/Handlers/EventOccurrence/GetEventOccurrencesHandler.php @@ -4,8 +4,11 @@ namespace HiEvents\Services\Application\Handlers\EventOccurrence; +use HiEvents\DomainObjects\EventLocationDomainObject; use HiEvents\DomainObjects\EventOccurrenceStatisticDomainObject; +use HiEvents\DomainObjects\LocationDomainObject; use HiEvents\Http\DTO\QueryParamsDTO; +use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; use Illuminate\Pagination\LengthAwarePaginator; @@ -17,7 +20,10 @@ public function __construct( public function handle(int $eventId, QueryParamsDTO $queryParams, bool $includeStats = true): LengthAwarePaginator { - $repository = $this->occurrenceRepository; + $repository = $this->occurrenceRepository + ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ])); if ($includeStats) { $repository = $repository->loadRelation(EventOccurrenceStatisticDomainObject::class); diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/GetProductVisibilityHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/GetProductVisibilityHandler.php index 8a3020cc22..ea60247046 100644 --- a/backend/app/Services/Application/Handlers/EventOccurrence/GetProductVisibilityHandler.php +++ b/backend/app/Services/Application/Handlers/EventOccurrence/GetProductVisibilityHandler.php @@ -15,10 +15,8 @@ class GetProductVisibilityHandler { public function __construct( private readonly ProductOccurrenceVisibilityRepositoryInterface $visibilityRepository, - private readonly EventOccurrenceRepositoryInterface $occurrenceRepository, - ) - { - } + private readonly EventOccurrenceRepositoryInterface $occurrenceRepository, + ) {} public function handle(int $eventId, int $occurrenceId): Collection { @@ -27,7 +25,7 @@ public function handle(int $eventId, int $occurrenceId): Collection EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId, ]); - if (!$occurrence) { + if (! $occurrence) { throw new ResourceNotFoundException( __('Occurrence :id not found for this event', ['id' => $occurrenceId]) ); diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/DTO/UpsertPriceOverrideDTO.php b/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/DTO/UpsertPriceOverrideDTO.php index e2a18c80b5..05dc936a7e 100644 --- a/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/DTO/UpsertPriceOverrideDTO.php +++ b/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/DTO/UpsertPriceOverrideDTO.php @@ -7,11 +7,9 @@ class UpsertPriceOverrideDTO extends BaseDataObject { public function __construct( - public readonly int $event_id, - public readonly int $event_occurrence_id, - public readonly int $product_price_id, + public readonly int $event_id, + public readonly int $event_occurrence_id, + public readonly int $product_price_id, public readonly float $price, - ) - { - } + ) {} } diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/DeletePriceOverrideHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/DeletePriceOverrideHandler.php index 84d5b417db..da7fde7832 100644 --- a/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/DeletePriceOverrideHandler.php +++ b/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/DeletePriceOverrideHandler.php @@ -16,11 +16,9 @@ class DeletePriceOverrideHandler { public function __construct( private readonly ProductPriceOccurrenceOverrideRepositoryInterface $overrideRepository, - private readonly EventOccurrenceRepositoryInterface $occurrenceRepository, - private readonly DatabaseManager $databaseManager, - ) - { - } + private readonly EventOccurrenceRepositoryInterface $occurrenceRepository, + private readonly DatabaseManager $databaseManager, + ) {} /** * @throws Throwable @@ -33,7 +31,7 @@ public function handle(int $eventId, int $occurrenceId, int $overrideId): void EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId, ]); - if (!$occurrence) { + if (! $occurrence) { throw new ResourceNotFoundException( __('Occurrence :id not found for event :eventId', [ 'id' => $occurrenceId, @@ -47,7 +45,7 @@ public function handle(int $eventId, int $occurrenceId, int $overrideId): void ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID => $occurrenceId, ]); - if (!$override) { + if (! $override) { throw new ResourceNotFoundException( __('Price override :id not found for occurrence :occurrenceId', [ 'id' => $overrideId, diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/GetPriceOverridesHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/GetPriceOverridesHandler.php index 3a63d9b187..ff9967eb6a 100644 --- a/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/GetPriceOverridesHandler.php +++ b/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/GetPriceOverridesHandler.php @@ -15,10 +15,8 @@ class GetPriceOverridesHandler { public function __construct( private readonly ProductPriceOccurrenceOverrideRepositoryInterface $overrideRepository, - private readonly EventOccurrenceRepositoryInterface $occurrenceRepository, - ) - { - } + private readonly EventOccurrenceRepositoryInterface $occurrenceRepository, + ) {} public function handle(int $eventId, int $occurrenceId): Collection { @@ -27,7 +25,7 @@ public function handle(int $eventId, int $occurrenceId): Collection EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId, ]); - if (!$occurrence) { + if (! $occurrence) { throw new ResourceNotFoundException( __('Occurrence :id not found for this event', ['id' => $occurrenceId]) ); diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/UpsertPriceOverrideHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/UpsertPriceOverrideHandler.php index 7bdee33228..9640e791e6 100644 --- a/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/UpsertPriceOverrideHandler.php +++ b/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/UpsertPriceOverrideHandler.php @@ -20,13 +20,11 @@ class UpsertPriceOverrideHandler { public function __construct( private readonly ProductPriceOccurrenceOverrideRepositoryInterface $overrideRepository, - private readonly EventOccurrenceRepositoryInterface $occurrenceRepository, - private readonly ProductPriceRepositoryInterface $productPriceRepository, - private readonly ProductRepositoryInterface $productRepository, - private readonly DatabaseManager $databaseManager, - ) - { - } + private readonly EventOccurrenceRepositoryInterface $occurrenceRepository, + private readonly ProductPriceRepositoryInterface $productPriceRepository, + private readonly ProductRepositoryInterface $productRepository, + private readonly DatabaseManager $databaseManager, + ) {} /** * @throws Throwable @@ -38,14 +36,14 @@ public function handle(UpsertPriceOverrideDTO $dto): ProductPriceOccurrenceOverr EventOccurrenceDomainObjectAbstract::EVENT_ID => $dto->event_id, ]); - if (!$occurrence) { + if (! $occurrence) { throw new ResourceNotFoundException( __('Occurrence :id not found for this event', ['id' => $dto->event_occurrence_id]) ); } $productPrice = $this->productPriceRepository->findFirst($dto->product_price_id); - if (!$productPrice) { + if (! $productPrice) { throw new ResourceNotFoundException( __('Product price :id not found', ['id' => $dto->product_price_id]) ); @@ -56,7 +54,7 @@ public function handle(UpsertPriceOverrideDTO $dto): ProductPriceOccurrenceOverr 'event_id' => $dto->event_id, ]); - if (!$product) { + if (! $product) { throw new ResourceNotFoundException( __('Product price :id does not belong to this event', ['id' => $dto->product_price_id]) ); diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/UpdateEventOccurrenceHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/UpdateEventOccurrenceHandler.php index 13780dcc39..0b8c290341 100644 --- a/backend/app/Services/Application/Handlers/EventOccurrence/UpdateEventOccurrenceHandler.php +++ b/backend/app/Services/Application/Handlers/EventOccurrence/UpdateEventOccurrenceHandler.php @@ -10,7 +10,10 @@ use HiEvents\DomainObjects\Status\EventOccurrenceStatus; use HiEvents\Exceptions\ResourceNotFoundException; use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; +use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Services\Application\Handlers\EventOccurrence\DTO\UpsertEventOccurrenceDTO; +use HiEvents\Services\Domain\EventLocation\EventLocationCleaner; +use HiEvents\Services\Domain\EventLocation\EventLocationUpserter; use Illuminate\Database\DatabaseManager; use Throwable; @@ -18,6 +21,9 @@ class UpdateEventOccurrenceHandler { public function __construct( private readonly EventOccurrenceRepositoryInterface $occurrenceRepository, + private readonly EventRepositoryInterface $eventRepository, + private readonly EventLocationUpserter $eventLocationUpserter, + private readonly EventLocationCleaner $eventLocationCleaner, private readonly DatabaseManager $databaseManager, ) {} @@ -41,32 +47,52 @@ public function handle(int $occurrenceId, UpsertEventOccurrenceDTO $dto): EventO ); } - // Only flag as overridden when a rule-determining field actually changes - // away from what the rule would produce. Label edits and no-op saves - // shouldn't pin the row against rule regenerates. Dates are normalized - // to a canonical parsed form — `DateHelper::convertToUTC` returns a - // different string format than the DB-hydrated getStartDate(), so - // string comparison alone would always register as changed. + $previousEventLocationId = $occurrence->getEventLocationId(); + $newEventLocationId = $previousEventLocationId; + $eventLocationChanged = false; + + if ($dto->event_location !== null) { + $event = $this->eventRepository->findById($dto->event_id); + if ($event === null) { + throw new ResourceNotFoundException(__('Event :id not found', ['id' => $dto->event_id])); + } + + if ($previousEventLocationId === null) { + $eventLocation = $this->eventLocationUpserter->createForEvent( + eventId: $dto->event_id, + accountId: $event->getAccountId(), + data: $dto->event_location, + ); + $newEventLocationId = $eventLocation->getId(); + $eventLocationChanged = true; + } else { + $this->eventLocationUpserter->updateInPlace( + eventLocationId: $previousEventLocationId, + eventId: $dto->event_id, + accountId: $event->getAccountId(), + data: $dto->event_location, + ); + } + } elseif ($dto->clear_event_location && $previousEventLocationId !== null) { + $newEventLocationId = null; + $eventLocationChanged = true; + } + + // `DateHelper::convertToUTC` normalizes to a different string than the + // DB-hydrated value, so string compare alone always reports a change. $isOverride = $occurrence->getIsOverridden() || $this->datesDiffer($dto->start_date, $occurrence->getStartDate()) || $this->datesDiffer($dto->end_date, $occurrence->getEndDate()) - || $dto->capacity !== $occurrence->getCapacity(); - - // CANCELLED is intentionally not writable here — lifecycle - // transitions (cancel / reactivate) live in their own handlers - // and fan out into refund / attendee / recurrence-exclusion side - // effects this handler does not perform. SOLD_OUT/ACTIVE on the - // other hand are capacity-derived: ProductQuantityUpdateService - // flips between them whenever used_capacity crosses capacity, so - // a capacity edit here has to do the same reconciliation or a - // sold-out date stays blocked after capacity is increased / cleared, - // and an over-capacity active date stays visually open. + || $dto->capacity !== $occurrence->getCapacity() + || $eventLocationChanged; + $attributes = [ EventOccurrenceDomainObjectAbstract::START_DATE => $dto->start_date, EventOccurrenceDomainObjectAbstract::END_DATE => $dto->end_date, EventOccurrenceDomainObjectAbstract::CAPACITY => $dto->capacity, EventOccurrenceDomainObjectAbstract::LABEL => $dto->label, EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN => $isOverride, + EventOccurrenceDomainObjectAbstract::EVENT_LOCATION_ID => $newEventLocationId, ]; $reconciledStatus = $this->reconcileCapacityStatus( @@ -78,19 +104,19 @@ public function handle(int $occurrenceId, UpsertEventOccurrenceDTO $dto): EventO $attributes[EventOccurrenceDomainObjectAbstract::STATUS] = $reconciledStatus; } - return $this->occurrenceRepository->updateFromArray( + $updated = $this->occurrenceRepository->updateFromArray( id: $occurrence->getId(), attributes: $attributes, ); + + if ($dto->clear_event_location && $previousEventLocationId !== null) { + $this->eventLocationCleaner->deleteIfOrphaned($previousEventLocationId); + } + + return $updated; }); } - /** - * Returns the status the occurrence should sit at after a capacity edit, - * or null if the existing status is correct. Only flips between ACTIVE - * and SOLD_OUT — never touches CANCELLED, which is owned by the dedicated - * cancel/reactivate handlers. - */ private function reconcileCapacityStatus( string $currentStatus, ?int $newCapacity, @@ -100,7 +126,6 @@ private function reconcileCapacityStatus( return null; } - // Unlimited (null) capacity can never be sold out. if ($newCapacity === null) { return $currentStatus === EventOccurrenceStatus::SOLD_OUT->name ? EventOccurrenceStatus::ACTIVE->name diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/UpdateProductVisibilityHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/UpdateProductVisibilityHandler.php index 84134e228a..965aebe735 100644 --- a/backend/app/Services/Application/Handlers/EventOccurrence/UpdateProductVisibilityHandler.php +++ b/backend/app/Services/Application/Handlers/EventOccurrence/UpdateProductVisibilityHandler.php @@ -20,12 +20,10 @@ class UpdateProductVisibilityHandler { public function __construct( private readonly ProductOccurrenceVisibilityRepositoryInterface $visibilityRepository, - private readonly ProductRepositoryInterface $productRepository, - private readonly EventOccurrenceRepositoryInterface $occurrenceRepository, - private readonly DatabaseManager $databaseManager, - ) - { - } + private readonly ProductRepositoryInterface $productRepository, + private readonly EventOccurrenceRepositoryInterface $occurrenceRepository, + private readonly DatabaseManager $databaseManager, + ) {} /** * @throws Throwable @@ -37,7 +35,7 @@ public function handle(UpdateProductVisibilityDTO $dto): Collection EventOccurrenceDomainObjectAbstract::EVENT_ID => $dto->event_id, ]); - if (!$occurrence) { + if (! $occurrence) { throw new ResourceNotFoundException( __('Occurrence :id not found for this event', ['id' => $dto->event_occurrence_id]) ); @@ -56,7 +54,7 @@ public function handle(UpdateProductVisibilityDTO $dto): Collection $selectedProductIds = collect($dto->product_ids)->sort()->values()->toArray(); $invalidIds = array_diff($selectedProductIds, $allProductIds); - if (!empty($invalidIds)) { + if (! empty($invalidIds)) { throw new ResourceNotFoundException( __('One or more product IDs do not belong to this event') ); diff --git a/backend/app/Services/Application/Handlers/EventSettings/DTO/GetPlatformFeePreviewDTO.php b/backend/app/Services/Application/Handlers/EventSettings/DTO/GetPlatformFeePreviewDTO.php index b9006e9ed1..6b803a4eb7 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/DTO/GetPlatformFeePreviewDTO.php +++ b/backend/app/Services/Application/Handlers/EventSettings/DTO/GetPlatformFeePreviewDTO.php @@ -7,8 +7,7 @@ class GetPlatformFeePreviewDTO extends BaseDataObject { public function __construct( - public readonly int $eventId, + public readonly int $eventId, public readonly float $price, - ) { - } + ) {} } diff --git a/backend/app/Services/Application/Handlers/EventSettings/DTO/PartialUpdateEventSettingsDTO.php b/backend/app/Services/Application/Handlers/EventSettings/DTO/PartialUpdateEventSettingsDTO.php index 315a23b7ae..ecd7f9134f 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/DTO/PartialUpdateEventSettingsDTO.php +++ b/backend/app/Services/Application/Handlers/EventSettings/DTO/PartialUpdateEventSettingsDTO.php @@ -7,10 +7,8 @@ class PartialUpdateEventSettingsDTO extends BaseDTO { public function __construct( - public readonly int $account_id, - public readonly int $event_id, + public readonly int $account_id, + public readonly int $event_id, public readonly array $settings, - ) - { - } + ) {} } diff --git a/backend/app/Services/Application/Handlers/EventSettings/DTO/PlatformFeePreviewResponseDTO.php b/backend/app/Services/Application/Handlers/EventSettings/DTO/PlatformFeePreviewResponseDTO.php index 5734673da0..01de620d6e 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/DTO/PlatformFeePreviewResponseDTO.php +++ b/backend/app/Services/Application/Handlers/EventSettings/DTO/PlatformFeePreviewResponseDTO.php @@ -7,14 +7,13 @@ class PlatformFeePreviewResponseDTO extends BaseDataObject { public function __construct( - public readonly string $eventCurrency, + public readonly string $eventCurrency, public readonly ?string $feeCurrency, - public readonly float $fixedFeeOriginal, - public readonly float $fixedFeeConverted, - public readonly float $percentageFee, - public readonly float $samplePrice, - public readonly float $platformFee, - public readonly float $total, - ) { - } + public readonly float $fixedFeeOriginal, + public readonly float $fixedFeeConverted, + public readonly float $percentageFee, + public readonly float $samplePrice, + public readonly float $platformFee, + public readonly float $total, + ) {} } diff --git a/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php b/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php index 6d3c3864f1..ccd5b94da6 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php +++ b/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php @@ -2,7 +2,6 @@ namespace HiEvents\Services\Application\Handlers\EventSettings\DTO; -use HiEvents\DataTransferObjects\AddressDTO; use HiEvents\DataTransferObjects\BaseDTO; use HiEvents\DomainObjects\Enums\AttendeeDetailsCollectionMethod; use HiEvents\DomainObjects\Enums\HomepageBackgroundType; @@ -13,90 +12,83 @@ class UpdateEventSettingsDTO extends BaseDTO { public function __construct( - public readonly int $account_id, + public readonly int $account_id, // event settings - public readonly int $event_id, - public readonly ?string $post_checkout_message, - public readonly ?string $pre_checkout_message, - public readonly ?string $email_footer_message, - public readonly ?string $continue_button_text, - public readonly ?string $support_email, - - public readonly ?string $homepage_background_color, - public readonly ?string $homepage_primary_color, - public readonly ?string $homepage_primary_text_color, - public readonly ?string $homepage_secondary_color, - public readonly ?string $homepage_secondary_text_color, - public readonly ?string $homepage_body_background_color, + public readonly int $event_id, + public readonly ?string $post_checkout_message, + public readonly ?string $pre_checkout_message, + public readonly ?string $email_footer_message, + public readonly ?string $continue_button_text, + public readonly ?string $support_email, + + public readonly ?string $homepage_background_color, + public readonly ?string $homepage_primary_color, + public readonly ?string $homepage_primary_text_color, + public readonly ?string $homepage_secondary_color, + public readonly ?string $homepage_secondary_text_color, + public readonly ?string $homepage_body_background_color, public readonly ?HomepageBackgroundType $homepage_background_type, - public readonly bool $require_attendee_details, + public readonly bool $require_attendee_details, public readonly AttendeeDetailsCollectionMethod $attendee_details_collection_method, - public readonly int $order_timeout_in_minutes, - public readonly ?string $website_url, - public readonly ?string $maps_url, - public readonly ?string $seo_title, - public readonly ?string $seo_description, - public readonly ?string $seo_keywords, + public readonly int $order_timeout_in_minutes, + public readonly ?string $website_url, + public readonly ?string $maps_url, + public readonly ?string $seo_title, + public readonly ?string $seo_description, + public readonly ?string $seo_keywords, - public readonly ?AddressDTO $location_details = null, - public readonly bool $is_online_event = false, - public readonly ?string $online_event_connection_details = null, + public readonly ?bool $allow_search_engine_indexing = true, - public readonly ?bool $allow_search_engine_indexing = true, + public readonly ?bool $notify_organizer_of_new_orders = null, - public readonly ?bool $notify_organizer_of_new_orders = null, + public readonly ?PriceDisplayMode $price_display_mode = PriceDisplayMode::INCLUSIVE, - public readonly ?PriceDisplayMode $price_display_mode = PriceDisplayMode::INCLUSIVE, - - public readonly ?bool $hide_getting_started_page = false, + public readonly ?bool $hide_getting_started_page = false, // Payment settings - public readonly array $payment_providers = [], - public readonly ?string $offline_payment_instructions = null, - public readonly bool $allow_orders_awaiting_offline_payment_to_check_in = false, + public readonly array $payment_providers = [], + public readonly ?string $offline_payment_instructions = null, + public readonly bool $allow_orders_awaiting_offline_payment_to_check_in = false, // Invoice settings - public readonly bool $enable_invoicing = false, - public readonly ?string $invoice_label = null, - public readonly ?string $invoice_prefix = null, - public readonly ?int $invoice_start_number = null, - public readonly bool $require_billing_address = true, - public readonly ?string $organization_name = null, - public readonly ?string $organization_address = null, - public readonly ?string $invoice_tax_details = null, - public readonly ?string $invoice_notes = null, - public readonly ?int $invoice_payment_terms_days = null, + public readonly bool $enable_invoicing = false, + public readonly ?string $invoice_label = null, + public readonly ?string $invoice_prefix = null, + public readonly ?int $invoice_start_number = null, + public readonly bool $require_billing_address = true, + public readonly ?string $organization_name = null, + public readonly ?string $organization_address = null, + public readonly ?string $invoice_tax_details = null, + public readonly ?string $invoice_notes = null, + public readonly ?int $invoice_payment_terms_days = null, // Ticket design settings - public readonly ?array $ticket_design_settings = null, + public readonly ?array $ticket_design_settings = null, // Marketing settings - public readonly bool $show_marketing_opt_in = true, + public readonly bool $show_marketing_opt_in = true, // Platform fee settings - public readonly bool $pass_platform_fee_to_buyer = false, + public readonly bool $pass_platform_fee_to_buyer = false, // Homepage theme settings - public readonly ?array $homepage_theme_settings = null, + public readonly ?array $homepage_theme_settings = null, // Self-service settings - public readonly bool $allow_attendee_self_edit = false, + public readonly bool $allow_attendee_self_edit = false, // Waitlist settings - public readonly ?bool $waitlist_auto_process = null, - public readonly ?int $waitlist_offer_timeout_minutes = null, - ) - { - } + public readonly ?bool $waitlist_auto_process = null, + public readonly ?int $waitlist_offer_timeout_minutes = null, + ) {} public static function createWithDefaults( - int $account_id, - int $event_id, + int $account_id, + int $event_id, OrganizerDomainObject $organizer, - ): self - { + ): self { return new self( account_id: $account_id, event_id: $event_id, @@ -120,9 +112,6 @@ public static function createWithDefaults( seo_title: null, seo_description: null, seo_keywords: null, - location_details: null, - is_online_event: false, - online_event_connection_details: null, allow_search_engine_indexing: true, notify_organizer_of_new_orders: null, price_display_mode: PriceDisplayMode::INCLUSIVE, @@ -172,4 +161,3 @@ public static function createWithDefaults( ); } } - diff --git a/backend/app/Services/Application/Handlers/EventSettings/GetPlatformFeePreviewHandler.php b/backend/app/Services/Application/Handlers/EventSettings/GetPlatformFeePreviewHandler.php index 2b9e301e9f..7286a9d2e9 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/GetPlatformFeePreviewHandler.php +++ b/backend/app/Services/Application/Handlers/EventSettings/GetPlatformFeePreviewHandler.php @@ -15,11 +15,9 @@ class GetPlatformFeePreviewHandler { public function __construct( - private readonly EventRepositoryInterface $eventRepository, + private readonly EventRepositoryInterface $eventRepository, private readonly CurrencyConversionClientInterface $currencyConversionClient, - ) - { - } + ) {} public function handle(GetPlatformFeePreviewDTO $dto): PlatformFeePreviewResponseDTO { diff --git a/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php b/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php index cbad542a81..7980b922cd 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php +++ b/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php @@ -2,7 +2,6 @@ namespace HiEvents\Services\Application\Handlers\EventSettings; -use HiEvents\DataTransferObjects\AddressDTO; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\Exceptions\RefundNotPossibleException; use HiEvents\Repository\Interfaces\EventSettingsRepositoryInterface; @@ -13,11 +12,9 @@ class PartialUpdateEventSettingsHandler { public function __construct( - private readonly UpdateEventSettingsHandler $eventSettingsHandler, + private readonly UpdateEventSettingsHandler $eventSettingsHandler, private readonly EventSettingsRepositoryInterface $eventSettingsRepository, - ) - { - } + ) {} /** * @throws Throwable @@ -28,17 +25,10 @@ public function handle(PartialUpdateEventSettingsDTO $eventSettingsDTO): EventSe 'event_id' => $eventSettingsDTO->event_id, ]); - if (!$existingSettings) { + if (! $existingSettings) { throw new RefundNotPossibleException('Event settings not found'); } - $locationDetails = AddressDTO::from($eventSettingsDTO->settings['location_details'] ?? $existingSettings->getLocationDetails()); - $isOnlineEvent = $eventSettingsDTO->settings['is_online_event'] ?? $existingSettings->getIsOnlineEvent(); - - if ($isOnlineEvent) { - $locationDetails = null; - } - return $this->eventSettingsHandler->handle( UpdateEventSettingsDTO::fromArray([ 'event_id' => $eventSettingsDTO->event_id, @@ -70,11 +60,6 @@ public function handle(PartialUpdateEventSettingsDTO $eventSettingsDTO): EventSe 'maps_url' => array_key_exists('maps_url', $eventSettingsDTO->settings) ? $eventSettingsDTO->settings['maps_url'] : $existingSettings->getMapsUrl(), - 'location_details' => $locationDetails, - 'is_online_event' => $eventSettingsDTO->settings['is_online_event'] ?? $existingSettings->getIsOnlineEvent(), - 'online_event_connection_details' => array_key_exists('online_event_connection_details', $eventSettingsDTO->settings) - ? $eventSettingsDTO->settings['online_event_connection_details'] - : $existingSettings->getOnlineEventConnectionDetails(), 'seo_title' => $eventSettingsDTO->settings['seo_title'] ?? $existingSettings->getSeoTitle(), 'seo_description' => $eventSettingsDTO->settings['seo_description'] ?? $existingSettings->getSeoDescription(), diff --git a/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php b/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php index de98ee5862..418b2b1f0d 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php +++ b/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php @@ -15,11 +15,9 @@ class UpdateEventSettingsHandler { public function __construct( private readonly EventSettingsRepositoryInterface $eventSettingsRepository, - private readonly HtmlPurifierService $purifier, - private readonly DatabaseManager $databaseManager, - ) - { - } + private readonly HtmlPurifierService $purifier, + private readonly DatabaseManager $databaseManager, + ) {} /** * @throws Throwable @@ -54,9 +52,6 @@ public function handle(UpdateEventSettingsDTO $settings): EventSettingDomainObje 'order_timeout_in_minutes' => $settings->order_timeout_in_minutes, 'website_url' => trim($settings->website_url), 'maps_url' => trim($settings->maps_url), - 'location_details' => $settings->location_details?->toArray(), - 'is_online_event' => $settings->is_online_event, - 'online_event_connection_details' => $this->purifier->purify($settings->online_event_connection_details), 'seo_title' => $settings->seo_title, 'seo_description' => $settings->seo_description, @@ -113,7 +108,7 @@ public function handle(UpdateEventSettingsDTO $settings): EventSettingDomainObje ]); }); - if ($settings->waitlist_auto_process && !$wasAutoProcessEnabled) { + if ($settings->waitlist_auto_process && ! $wasAutoProcessEnabled) { event(new CapacityChangedEvent( eventId: $settings->event_id, direction: CapacityChangeDirection::INCREASED, diff --git a/backend/app/Services/Application/Handlers/Location/CreateLocationHandler.php b/backend/app/Services/Application/Handlers/Location/CreateLocationHandler.php new file mode 100644 index 0000000000..e0b4b02c8d --- /dev/null +++ b/backend/app/Services/Application/Handlers/Location/CreateLocationHandler.php @@ -0,0 +1,61 @@ +databaseManager->transaction(function () use ($dto) { + if ($dto->provider !== null && $dto->provider_place_id !== null) { + $existing = $this->locationRepository->findFirstWhere([ + LocationDomainObjectAbstract::ORGANIZER_ID => $dto->organizer_id, + LocationDomainObjectAbstract::ACCOUNT_ID => $dto->account_id, + LocationDomainObjectAbstract::PROVIDER => $dto->provider, + LocationDomainObjectAbstract::PROVIDER_PLACE_ID => $dto->provider_place_id, + ]); + + // Reuse the saved row as-is. Mutating it here would silently + // rename or move locations already linked from other events or + // an organizer's saved address. Edits must go through + // UpdateLocationHandler with an explicit location ID. + if ($existing !== null) { + return $existing; + } + } + + return $this->locationRepository->create([ + LocationDomainObjectAbstract::SHORT_ID => IdHelper::shortId(IdHelper::LOCATION_PREFIX), + LocationDomainObjectAbstract::ACCOUNT_ID => $dto->account_id, + LocationDomainObjectAbstract::ORGANIZER_ID => $dto->organizer_id, + LocationDomainObjectAbstract::NAME => $this->sanitizer->sanitizeText($dto->name), + LocationDomainObjectAbstract::STRUCTURED_ADDRESS => $this->sanitizer->sanitizeAddress($dto->structured_address->toArray()), + LocationDomainObjectAbstract::LATITUDE => $dto->latitude, + LocationDomainObjectAbstract::LONGITUDE => $dto->longitude, + LocationDomainObjectAbstract::PROVIDER => $dto->provider, + LocationDomainObjectAbstract::PROVIDER_PLACE_ID => $dto->provider_place_id, + LocationDomainObjectAbstract::RAW_PROVIDER_RESPONSE => $this->sanitizer->fetchRawProviderResponse($dto->provider, $dto->provider_place_id), + ]); + }); + } +} diff --git a/backend/app/Services/Application/Handlers/Location/DTO/UpsertLocationDTO.php b/backend/app/Services/Application/Handlers/Location/DTO/UpsertLocationDTO.php new file mode 100644 index 0000000000..a80322c6d8 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Location/DTO/UpsertLocationDTO.php @@ -0,0 +1,22 @@ +databaseManager->transaction(function () use ($organizerId, $accountId, $locationId) { + $location = $this->locationRepository->findFirstWhere([ + LocationDomainObjectAbstract::ID => $locationId, + LocationDomainObjectAbstract::ORGANIZER_ID => $organizerId, + LocationDomainObjectAbstract::ACCOUNT_ID => $accountId, + ]); + + if ($location === null) { + throw new ResourceNotFoundException(__('Location not found')); + } + + if ($this->locationRepository->isReferenced($locationId)) { + throw new ResourceConflictException( + __('This location is referenced by one or more events or occurrences and cannot be deleted') + ); + } + + $this->locationRepository->deleteWhere([ + LocationDomainObjectAbstract::ID => $locationId, + ]); + }); + } +} diff --git a/backend/app/Services/Application/Handlers/Location/GeoAutocompleteHandler.php b/backend/app/Services/Application/Handlers/Location/GeoAutocompleteHandler.php new file mode 100644 index 0000000000..2cea1a683b --- /dev/null +++ b/backend/app/Services/Application/Handlers/Location/GeoAutocompleteHandler.php @@ -0,0 +1,23 @@ + + */ + public function handle(string $query, ?string $locale = null, ?string $country = null): array + { + return $this->geoProvider->autocomplete($query, $locale, $country); + } +} diff --git a/backend/app/Services/Application/Handlers/Location/GeoPlaceDetailsHandler.php b/backend/app/Services/Application/Handlers/Location/GeoPlaceDetailsHandler.php new file mode 100644 index 0000000000..2d63ccf474 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Location/GeoPlaceDetailsHandler.php @@ -0,0 +1,20 @@ +geoProvider->getPlaceDetails($providerPlaceId, $locale); + } +} diff --git a/backend/app/Services/Application/Handlers/Location/GetLocationsHandler.php b/backend/app/Services/Application/Handlers/Location/GetLocationsHandler.php new file mode 100644 index 0000000000..e4e3f4db21 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Location/GetLocationsHandler.php @@ -0,0 +1,21 @@ +locationRepository->findByOrganizerId($organizerId, $accountId, $params); + } +} diff --git a/backend/app/Services/Application/Handlers/Location/UpdateLocationHandler.php b/backend/app/Services/Application/Handlers/Location/UpdateLocationHandler.php new file mode 100644 index 0000000000..fae77e12ce --- /dev/null +++ b/backend/app/Services/Application/Handlers/Location/UpdateLocationHandler.php @@ -0,0 +1,51 @@ +databaseManager->transaction(function () use ($locationId, $dto) { + $location = $this->locationRepository->findFirstWhere([ + LocationDomainObjectAbstract::ID => $locationId, + LocationDomainObjectAbstract::ORGANIZER_ID => $dto->organizer_id, + LocationDomainObjectAbstract::ACCOUNT_ID => $dto->account_id, + ]); + + if ($location === null) { + throw new ResourceNotFoundException(__('Location not found')); + } + + return $this->locationRepository->updateFromArray($location->getId(), [ + LocationDomainObjectAbstract::NAME => $this->sanitizer->sanitizeText($dto->name), + LocationDomainObjectAbstract::STRUCTURED_ADDRESS => $this->sanitizer->sanitizeAddress($dto->structured_address->toArray()), + LocationDomainObjectAbstract::LATITUDE => $dto->latitude, + LocationDomainObjectAbstract::LONGITUDE => $dto->longitude, + LocationDomainObjectAbstract::PROVIDER => $dto->provider, + LocationDomainObjectAbstract::PROVIDER_PLACE_ID => $dto->provider_place_id, + LocationDomainObjectAbstract::RAW_PROVIDER_RESPONSE => $this->sanitizer->fetchRawProviderResponse($dto->provider, $dto->provider_place_id), + ]); + }); + } +} diff --git a/backend/app/Services/Application/Handlers/Organizer/DTO/PartialUpdateOrganizerSettingsDTO.php b/backend/app/Services/Application/Handlers/Organizer/DTO/PartialUpdateOrganizerSettingsDTO.php index 9bc1c94447..c28bbf80e6 100644 --- a/backend/app/Services/Application/Handlers/Organizer/DTO/PartialUpdateOrganizerSettingsDTO.php +++ b/backend/app/Services/Application/Handlers/Organizer/DTO/PartialUpdateOrganizerSettingsDTO.php @@ -2,7 +2,6 @@ namespace HiEvents\Services\Application\Handlers\Organizer\DTO; -use HiEvents\DataTransferObjects\AddressDTO; use HiEvents\DataTransferObjects\BaseDataObject; use HiEvents\DomainObjects\Enums\AttendeeDetailsCollectionMethod; use HiEvents\DomainObjects\Enums\OrganizerHomepageVisibility; @@ -52,9 +51,6 @@ public function __construct( // Website public readonly string|Optional|null $websiteUrl, - // Location details - public readonly AddressDTO|Optional|null $locationDetails, - // Homepage settings public readonly OrganizerHomepageVisibility|Optional|null $homepageVisibility, diff --git a/backend/app/Services/Application/Handlers/Organizer/DTO/UpdateOrganizerLocationDTO.php b/backend/app/Services/Application/Handlers/Organizer/DTO/UpdateOrganizerLocationDTO.php new file mode 100644 index 0000000000..7a9035e0a3 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Organizer/DTO/UpdateOrganizerLocationDTO.php @@ -0,0 +1,16 @@ +eventRepository - ->loadRelation(new Relationship(EventOccurrenceDomainObject::class)) + ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ])) + ->loadRelation(new Relationship(domainObject: EventOccurrenceDomainObject::class, nested: [ + new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ]), + ])) ->loadRelation(new Relationship(ImageDomainObject::class)) ->loadRelation(new Relationship(EventSettingDomainObject::class)) ->loadRelation(new Relationship( diff --git a/backend/app/Services/Application/Handlers/Organizer/GetPublicOrganizerHandler.php b/backend/app/Services/Application/Handlers/Organizer/GetPublicOrganizerHandler.php index 577796b904..0b07c4c145 100644 --- a/backend/app/Services/Application/Handlers/Organizer/GetPublicOrganizerHandler.php +++ b/backend/app/Services/Application/Handlers/Organizer/GetPublicOrganizerHandler.php @@ -3,7 +3,9 @@ namespace HiEvents\Services\Application\Handlers\Organizer; use HiEvents\DomainObjects\ImageDomainObject; +use HiEvents\DomainObjects\LocationDomainObject; use HiEvents\DomainObjects\OrganizerSettingDomainObject; +use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface; class GetPublicOrganizerHandler @@ -19,6 +21,7 @@ public function handle(int $organizerId) return $this->organizerRepository ->loadRelation(ImageDomainObject::class) ->loadRelation(OrganizerSettingDomainObject::class) + ->loadRelation(new Relationship(LocationDomainObject::class, name: 'location_record')) ->findById($organizerId); } } diff --git a/backend/app/Services/Application/Handlers/Organizer/Settings/PartialUpdateOrganizerSettingsHandler.php b/backend/app/Services/Application/Handlers/Organizer/Settings/PartialUpdateOrganizerSettingsHandler.php index cc97506a71..d38d1cce52 100644 --- a/backend/app/Services/Application/Handlers/Organizer/Settings/PartialUpdateOrganizerSettingsHandler.php +++ b/backend/app/Services/Application/Handlers/Organizer/Settings/PartialUpdateOrganizerSettingsHandler.php @@ -7,7 +7,6 @@ use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface; use HiEvents\Repository\Interfaces\OrganizerSettingsRepositoryInterface; use HiEvents\Services\Application\Handlers\Organizer\DTO\PartialUpdateOrganizerSettingsDTO; -use Spatie\LaravelData\Data; class PartialUpdateOrganizerSettingsHandler { @@ -31,16 +30,6 @@ public function handle(PartialUpdateOrganizerSettingsDTO $dto): OrganizerSetting 'organizer_id' => $organizer->getId(), ]); - $locationDetails = $dto->getProvided('locationDetails', $organizerSettings->getLocationDetails()); - - if ($locationDetails instanceof Data) { - $locationDetails = $locationDetails->toArray(); - } elseif (is_array($locationDetails)) { - $locationDetails = array_filter($locationDetails); - } else { - $locationDetails = []; - } - $this->organizerSettingsRepository->updateWhere([ 'default_attendee_details_collection_method' => $dto->getProvided( 'defaultAttendeeDetailsCollectionMethod', @@ -83,8 +72,6 @@ public function handle(PartialUpdateOrganizerSettingsDTO $dto): OrganizerSetting 'website_url' => $dto->getProvided('websiteUrl', $organizerSettings->getWebsiteUrl()), - 'location_details' => $locationDetails, - 'homepage_visibility' => $dto->getProvided('homepageVisibility', $organizerSettings->getHomepageVisibility()), 'homepage_theme_settings' => $dto->getProvided('homepageThemeSettings', $organizerSettings->getHomepageThemeSettings()), diff --git a/backend/app/Services/Application/Handlers/Organizer/UpdateOrganizerLocationHandler.php b/backend/app/Services/Application/Handlers/Organizer/UpdateOrganizerLocationHandler.php new file mode 100644 index 0000000000..1436c6056b --- /dev/null +++ b/backend/app/Services/Application/Handlers/Organizer/UpdateOrganizerLocationHandler.php @@ -0,0 +1,52 @@ +organizerRepository->findFirstWhere([ + 'id' => $dto->organizer_id, + 'account_id' => $dto->account_id, + ]); + + if ($existing === null) { + throw new ResourceNotFoundException( + __('Organizer :id not found', ['id' => $dto->organizer_id]), + ); + } + + $this->locationOwnershipValidator->assertOwnedBy( + $dto->location_id, + $dto->organizer_id, + $dto->account_id, + ); + + $this->organizerRepository->updateWhere( + attributes: ['location_id' => $dto->location_id], + where: [ + 'id' => $dto->organizer_id, + 'account_id' => $dto->account_id, + ], + ); + + return $this->organizerRepository->findFirstWhere([ + 'id' => $dto->organizer_id, + 'account_id' => $dto->account_id, + ]); + } +} diff --git a/backend/app/Services/Domain/Email/EmailTokenContextBuilder.php b/backend/app/Services/Domain/Email/EmailTokenContextBuilder.php index d9ea3e0d46..e561f50bbd 100644 --- a/backend/app/Services/Domain/Email/EmailTokenContextBuilder.php +++ b/backend/app/Services/Domain/Email/EmailTokenContextBuilder.php @@ -4,8 +4,10 @@ use Carbon\Carbon; use HiEvents\DomainObjects\AttendeeDomainObject; +use HiEvents\DomainObjects\Enums\LocationType; use HiEvents\DomainObjects\Enums\PaymentProviders; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventLocationDomainObject; use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\OrderDomainObject; @@ -20,33 +22,43 @@ class EmailTokenContextBuilder { + /** + * Callers must hydrate `$event->event_location` (with nested `location`) and + * `$occurrence->event_location` before invoking these builders. Reads happen + * directly off the domain objects with no lazy-load fallback — invoking from + * a tight loop without preloads will N+1. + */ public function buildOrderConfirmationContext( - OrderDomainObject $order, - EventDomainObject $event, - OrganizerDomainObject $organizer, + OrderDomainObject $order, + EventDomainObject $event, + OrganizerDomainObject $organizer, EventSettingDomainObject $eventSettings, ?EventOccurrenceDomainObject $occurrence = null, - ): array - { + ): array { $startDateRaw = $occurrence?->getStartDate() ?? $event->getStartDate(); $endDateRaw = $occurrence?->getEndDate() ?? $event->getEndDate(); $eventStartDate = $startDateRaw ? new Carbon(DateHelper::convertFromUTC($startDateRaw, $event->getTimezone())) : null; $eventEndDate = $endDateRaw ? new Carbon(DateHelper::convertFromUTC($endDateRaw, $event->getTimezone())) : null; + $eventLocation = $occurrence?->getEventLocation() ?? $event->getEventLocation(); + $structuredAddress = $this->extractStructuredAddress($eventLocation); + return [ 'event' => [ - 'title' => $event->getTitle() . ($occurrence?->getLabel() ? ' - ' . $occurrence->getLabel() : ''), + 'title' => $event->getTitle().($occurrence?->getLabel() ? ' - '.$occurrence->getLabel() : ''), 'date' => $eventStartDate?->format('F j, Y') ?? '', 'time' => $eventStartDate?->format('g:i A') ?? '', 'end_date' => $eventEndDate?->format('F j, Y') ?? '', 'end_time' => $eventEndDate?->format('g:i A') ?? '', - 'full_address' => $eventSettings->getLocationDetails() ? AddressHelper::formatAddress($eventSettings->getLocationDetails()) : '', - 'location_details' => $eventSettings->getLocationDetails(), + 'full_address' => $structuredAddress ? AddressHelper::formatAddress($structuredAddress) : '', + 'location_details' => $structuredAddress, 'description' => $event->getDescription() ?? '', 'timezone' => $event->getTimezone(), ], + 'event_location' => $this->buildLocationContext($eventLocation, $structuredAddress), + 'order' => [ 'url' => sprintf( Url::getFrontEndUrlFromConfig(Url::ORDER_SUMMARY), @@ -87,25 +99,23 @@ public function buildOrderConfirmationContext( } public function buildAttendeeTicketContext( - AttendeeDomainObject $attendee, - OrderDomainObject $order, - EventDomainObject $event, - OrganizerDomainObject $organizer, + AttendeeDomainObject $attendee, + OrderDomainObject $order, + EventDomainObject $event, + OrganizerDomainObject $organizer, EventSettingDomainObject $eventSettings, ?EventOccurrenceDomainObject $occurrence = null, - ): array - { + ): array { $baseContext = $this->buildOrderConfirmationContext($order, $event, $organizer, $eventSettings, $occurrence); /** @var OrderItemDomainObject $orderItem */ - $orderItem = $order->getOrderItems()->first(fn(OrderItemDomainObject $item) => $item->getProductPriceId() === $attendee->getProductPriceId()); + $orderItem = $order->getOrderItems()->first(fn (OrderItemDomainObject $item) => $item->getProductPriceId() === $attendee->getProductPriceId()); $ticketPrice = Currency::format($orderItem?->getPrice() ?? 0, $event->getCurrency()); $ticketName = $orderItem?->getItemName(); - // Add attendee and ticket objects $baseContext['attendee'] = [ - 'name' => $attendee->getFirstName() . ' ' . $attendee->getLastName(), + 'name' => $attendee->getFirstName().' '.$attendee->getLastName(), 'email' => $attendee->getEmail() ?? '', ]; @@ -128,23 +138,25 @@ public function buildOccurrenceCancellationContext( OrganizerDomainObject $organizer, EventSettingDomainObject $eventSettings, bool $refundOrders = false, - ): array - { + ): array { $startDateRaw = $occurrence->getStartDate(); $endDateRaw = $occurrence->getEndDate(); $eventStartDate = $startDateRaw ? new Carbon(DateHelper::convertFromUTC($startDateRaw, $event->getTimezone())) : null; $eventEndDate = $endDateRaw ? new Carbon(DateHelper::convertFromUTC($endDateRaw, $event->getTimezone())) : null; + $eventLocation = $occurrence->getEventLocation() ?? $event->getEventLocation(); + $structuredAddress = $this->extractStructuredAddress($eventLocation); + return [ 'event' => [ - 'title' => $event->getTitle() . ($occurrence->getLabel() ? ' - ' . $occurrence->getLabel() : ''), + 'title' => $event->getTitle().($occurrence->getLabel() ? ' - '.$occurrence->getLabel() : ''), 'date' => $eventStartDate?->format('F j, Y') ?? '', 'time' => $eventStartDate?->format('g:i A') ?? '', 'end_date' => $eventEndDate?->format('F j, Y') ?? '', 'end_time' => $eventEndDate?->format('g:i A') ?? '', - 'full_address' => $eventSettings->getLocationDetails() ? AddressHelper::formatAddress($eventSettings->getLocationDetails()) : '', - 'location_details' => $eventSettings->getLocationDetails(), + 'full_address' => $structuredAddress ? AddressHelper::formatAddress($structuredAddress) : '', + 'location_details' => $structuredAddress, 'description' => $event->getDescription() ?? '', 'timezone' => $event->getTimezone(), 'url' => sprintf( @@ -154,6 +166,8 @@ public function buildOccurrenceCancellationContext( ), ], + 'event_location' => $this->buildLocationContext($eventLocation, $structuredAddress), + 'occurrence' => [ 'start_date' => $eventStartDate?->format('F j, Y') ?? '', 'start_time' => $eventStartDate?->format('g:i A') ?? '', @@ -197,7 +211,7 @@ public function buildPreviewContext(string $templateType): array 'state_or_region' => 'Dublin 1', 'zip_or_postal_code' => 'D01 T0X4', 'country' => 'IE', - ] + ], ], 'order' => [ 'url' => 'https://example.com/order/ABC123', @@ -210,7 +224,7 @@ public function buildPreviewContext(string $templateType): array 'is_awaiting_offline_payment' => false, 'is_offline_payment' => false, 'locale' => Locale::EN->value, - 'currency' => 'USD' + 'currency' => 'USD', ], 'organizer' => [ 'name' => 'ACME Events Inc.', @@ -231,6 +245,18 @@ public function buildPreviewContext(string $templateType): array 'label' => 'Session A', ]; + $baseContext['event_location'] = [ + 'type' => LocationType::IN_PERSON->name, + 'is_online' => false, + 'online_connection_details' => null, + 'name' => '3 Arena', + 'label' => null, + 'formatted_address' => __('3 Arena, North Wall Quay, Dublin 1, Ireland'), + 'latitude' => 53.3478, + 'longitude' => -6.2289, + 'structured_address' => $baseContext['event']['location_details'], + ]; + if ($templateType === 'attendee_ticket') { $baseContext['attendee'] = [ 'name' => 'John Smith', @@ -252,4 +278,41 @@ public function buildPreviewContext(string $templateType): array return $baseContext; } + + private function extractStructuredAddress(?EventLocationDomainObject $eventLocation): ?array + { + if ($eventLocation === null) { + return null; + } + + if ($eventLocation->getType() !== LocationType::IN_PERSON->name) { + return null; + } + + $location = $eventLocation->getLocation(); + if ($location === null) { + return null; + } + + $address = $location->getStructuredAddress(); + + return is_array($address) ? $address : null; + } + + private function buildLocationContext(?EventLocationDomainObject $eventLocation, ?array $structuredAddress): array + { + $type = $eventLocation?->getType(); + $location = $eventLocation?->getLocation(); + + return [ + 'type' => $type, + 'is_online' => $type === LocationType::ONLINE->name, + 'online_connection_details' => $eventLocation?->getOnlineEventConnectionDetails(), + 'name' => $location?->getName(), + 'formatted_address' => $structuredAddress ? AddressHelper::formatAddress($structuredAddress) : '', + 'latitude' => $location?->getLatitude(), + 'longitude' => $location?->getLongitude(), + 'structured_address' => $structuredAddress, + ]; + } } diff --git a/backend/app/Services/Domain/Event/CreateEventService.php b/backend/app/Services/Domain/Event/CreateEventService.php index ed3f57ba8c..187e1b5366 100644 --- a/backend/app/Services/Domain/Event/CreateEventService.php +++ b/backend/app/Services/Domain/Event/CreateEventService.php @@ -124,7 +124,6 @@ private function handleEventCreate(EventDomainObject $eventData, ?string $startD 'timezone' => $eventData->getTimezone(), 'currency' => $eventData->getCurrency(), 'category' => $eventData->getCategory(), - 'location_details' => $eventData->getLocationDetails(), 'account_id' => $eventData->getAccountId(), 'user_id' => $eventData->getUserId(), 'status' => $eventData->getStatus(), diff --git a/backend/app/Services/Domain/EventLocation/EventLocationCleaner.php b/backend/app/Services/Domain/EventLocation/EventLocationCleaner.php new file mode 100644 index 0000000000..69f46c2346 --- /dev/null +++ b/backend/app/Services/Domain/EventLocation/EventLocationCleaner.php @@ -0,0 +1,27 @@ +eventLocationRepository->isReferenced($eventLocationId)) { + return; + } + + $this->eventLocationRepository->deleteById($eventLocationId); + } +} diff --git a/backend/app/Services/Domain/EventLocation/EventLocationData.php b/backend/app/Services/Domain/EventLocation/EventLocationData.php new file mode 100644 index 0000000000..252349eb55 --- /dev/null +++ b/backend/app/Services/Domain/EventLocation/EventLocationData.php @@ -0,0 +1,26 @@ +assertOwnership($eventId, $accountId, $data->location_id); + + return $this->eventLocationRepository->create([ + EventLocationDomainObjectAbstract::EVENT_ID => $eventId, + EventLocationDomainObjectAbstract::SHORT_ID => IdHelper::shortId(IdHelper::EVENT_LOCATION_PREFIX), + EventLocationDomainObjectAbstract::TYPE => $data->type->name, + EventLocationDomainObjectAbstract::LOCATION_ID => $data->type === LocationType::IN_PERSON ? $data->location_id : null, + EventLocationDomainObjectAbstract::ONLINE_EVENT_CONNECTION_DETAILS => $this->resolveConnectionDetails($data), + ]); + } + + /** + * @throws ResourceNotFoundException + */ + public function updateInPlace(int $eventLocationId, int $eventId, int $accountId, EventLocationData $data): EventLocationDomainObject + { + $this->assertOwnership($eventId, $accountId, $data->location_id); + + $existing = $this->eventLocationRepository->findFirstWhere([ + EventLocationDomainObjectAbstract::ID => $eventLocationId, + EventLocationDomainObjectAbstract::EVENT_ID => $eventId, + ]); + + if ($existing === null) { + throw new ResourceNotFoundException( + __('Event location :id not found for event :event', ['id' => $eventLocationId, 'event' => $eventId]), + ); + } + + return $this->eventLocationRepository->updateFromArray($eventLocationId, [ + EventLocationDomainObjectAbstract::TYPE => $data->type->name, + EventLocationDomainObjectAbstract::LOCATION_ID => $data->type === LocationType::IN_PERSON ? $data->location_id : null, + EventLocationDomainObjectAbstract::ONLINE_EVENT_CONNECTION_DETAILS => $this->resolveConnectionDetails($data), + ]); + } + + /** + * @throws ResourceNotFoundException + */ + private function assertOwnership(int $eventId, int $accountId, ?int $locationId): void + { + $event = $this->eventRepository->findFirstWhere([ + 'id' => $eventId, + 'account_id' => $accountId, + ]); + + if ($event === null) { + throw new ResourceNotFoundException(__('Event :id not found', ['id' => $eventId])); + } + + $this->locationOwnershipValidator->assertOwnedBy($locationId, $event->getOrganizerId(), $accountId); + } + + private function resolveConnectionDetails(EventLocationData $data): ?string + { + if ($data->type !== LocationType::ONLINE) { + return null; + } + + if ($data->online_event_connection_details === null) { + return null; + } + + return $this->purifier->purify($data->online_event_connection_details); + } +} diff --git a/backend/app/Services/Domain/Location/LocationDataSanitizer.php b/backend/app/Services/Domain/Location/LocationDataSanitizer.php new file mode 100644 index 0000000000..1b224c6522 --- /dev/null +++ b/backend/app/Services/Domain/Location/LocationDataSanitizer.php @@ -0,0 +1,69 @@ +purifier->purify($value)); + } + + public function sanitizeAddress(array $address): array + { + foreach ($address as $key => $value) { + if (is_string($value)) { + $address[$key] = $this->sanitizeText($value); + } + } + + return $address; + } + + /** + * Returns the provider's raw place response so we have a record of + * exactly what we saved this location from. Failures degrade to null + * — saving must not block on a flaky third party. + */ + public function fetchRawProviderResponse(?string $provider, ?string $providerPlaceId): ?array + { + if ($provider === null || $providerPlaceId === null) { + return null; + } + + try { + return $this->geoProvider->getPlaceDetails($providerPlaceId)?->raw_response; + } catch (GeoProviderException $e) { + $this->logger->warning('Geo provider lookup failed during location save; storing without raw response', [ + 'provider' => $provider, + 'provider_place_id' => $providerPlaceId, + 'error' => $e->getMessage(), + ]); + + return null; + } + } +} diff --git a/backend/app/Services/Domain/Location/LocationOwnershipValidator.php b/backend/app/Services/Domain/Location/LocationOwnershipValidator.php new file mode 100644 index 0000000000..5ee3a29b0c --- /dev/null +++ b/backend/app/Services/Domain/Location/LocationOwnershipValidator.php @@ -0,0 +1,37 @@ +locationRepository->findFirstWhere([ + 'id' => $locationId, + 'account_id' => $accountId, + 'organizer_id' => $organizerId, + ]); + + if ($location === null) { + throw new ResourceNotFoundException( + __('Location :id not found', ['id' => $locationId]), + ); + } + } +} diff --git a/backend/app/Services/Domain/Mail/SendOrderDetailsService.php b/backend/app/Services/Domain/Mail/SendOrderDetailsService.php index 6858b6ed45..0879c98836 100644 --- a/backend/app/Services/Domain/Mail/SendOrderDetailsService.php +++ b/backend/app/Services/Domain/Mail/SendOrderDetailsService.php @@ -4,9 +4,11 @@ use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventLocationDomainObject; use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\InvoiceDomainObject; +use HiEvents\DomainObjects\LocationDomainObject; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; @@ -40,6 +42,11 @@ public function sendOrderSummaryAndTicketEmails(OrderDomainObject $order): void nested: [ new Relationship( domainObject: EventOccurrenceDomainObject::class, + nested: [ + new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ]), + ], name: 'event_occurrence', ), ], @@ -49,6 +56,11 @@ public function sendOrderSummaryAndTicketEmails(OrderDomainObject $order): void nested: [ new Relationship( domainObject: EventOccurrenceDomainObject::class, + nested: [ + new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ]), + ], name: 'event_occurrence', ), new Relationship( @@ -63,7 +75,14 @@ public function sendOrderSummaryAndTicketEmails(OrderDomainObject $order): void $event = $this->eventRepository ->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer')) ->loadRelation(new Relationship(EventSettingDomainObject::class)) - ->loadRelation(new Relationship(EventOccurrenceDomainObject::class)) + ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ])) + ->loadRelation(new Relationship(EventOccurrenceDomainObject::class, nested: [ + new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ]), + ])) ->findById($order->getEventId()); if ($order->isOrderCompleted() || $order->isOrderAwaitingOfflinePayment()) { diff --git a/backend/app/Services/Domain/Order/MarkOrderAsPaidService.php b/backend/app/Services/Domain/Order/MarkOrderAsPaidService.php index 5878bbd498..9590f8e982 100644 --- a/backend/app/Services/Domain/Order/MarkOrderAsPaidService.php +++ b/backend/app/Services/Domain/Order/MarkOrderAsPaidService.php @@ -6,10 +6,12 @@ use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\Enums\PaymentProviders; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventLocationDomainObject; use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\Generated\OrderDomainObjectAbstract; use HiEvents\DomainObjects\InvoiceDomainObject; +use HiEvents\DomainObjects\LocationDomainObject; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\DomainObjects\OrganizerConfigurationDomainObject; @@ -72,7 +74,14 @@ public function markOrderAsPaid( $event = $this->eventRepository ->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer')) ->loadRelation(new Relationship(EventSettingDomainObject::class)) - ->loadRelation(new Relationship(EventOccurrenceDomainObject::class)) + ->loadRelation(new Relationship(domainObject: EventOccurrenceDomainObject::class, nested: [ + new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ]), + ])) + ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ])) ->findById($order->getEventId()); if ($order->getStatus() !== OrderStatus::AWAITING_OFFLINE_PAYMENT->name) { @@ -95,6 +104,11 @@ public function markOrderAsPaid( nested: [ new Relationship( domainObject: EventOccurrenceDomainObject::class, + nested: [ + new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ]), + ], name: 'event_occurrence', ), ], diff --git a/backend/app/Services/Domain/SelfService/SelfServiceEditOrderService.php b/backend/app/Services/Domain/SelfService/SelfServiceEditOrderService.php index 4a4634c86c..344c77ab4d 100644 --- a/backend/app/Services/Domain/SelfService/SelfServiceEditOrderService.php +++ b/backend/app/Services/Domain/SelfService/SelfServiceEditOrderService.php @@ -4,9 +4,11 @@ use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventLocationDomainObject; use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\InvoiceDomainObject; +use HiEvents\DomainObjects\LocationDomainObject; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; @@ -115,7 +117,14 @@ private function loadEventWithRelations(int $eventId): EventDomainObject return $this->eventRepository ->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer')) ->loadRelation(new Relationship(EventSettingDomainObject::class)) - ->loadRelation(new Relationship(EventOccurrenceDomainObject::class)) + ->loadRelation(new Relationship(domainObject: EventOccurrenceDomainObject::class, nested: [ + new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ]), + ])) + ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ])) ->findById($eventId); } @@ -127,11 +136,29 @@ private function sendConfirmationToNewEmail(int $orderId, EventDomainObject $eve nested: [ new Relationship( domainObject: EventOccurrenceDomainObject::class, + nested: [ + new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ]), + ], + name: 'event_occurrence', + ), + ], + )) + ->loadRelation(new Relationship( + domainObject: AttendeeDomainObject::class, + nested: [ + new Relationship( + domainObject: EventOccurrenceDomainObject::class, + nested: [ + new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ]), + ], name: 'event_occurrence', ), ], )) - ->loadRelation(AttendeeDomainObject::class) ->loadRelation(InvoiceDomainObject::class) ->findById($orderId); diff --git a/backend/app/Services/Domain/SelfService/SelfServiceResendEmailService.php b/backend/app/Services/Domain/SelfService/SelfServiceResendEmailService.php index f4673c14df..a6b456260f 100644 --- a/backend/app/Services/Domain/SelfService/SelfServiceResendEmailService.php +++ b/backend/app/Services/Domain/SelfService/SelfServiceResendEmailService.php @@ -4,9 +4,11 @@ use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\Enums\OrderAuditAction; +use HiEvents\DomainObjects\EventLocationDomainObject; use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\InvoiceDomainObject; +use HiEvents\DomainObjects\LocationDomainObject; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; @@ -42,6 +44,11 @@ public function resendAttendeeTicket( ], name: 'order')) ->loadRelation(new Relationship( domainObject: EventOccurrenceDomainObject::class, + nested: [ + new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ]), + ], name: 'event_occurrence', )) ->loadRelation(new Relationship( @@ -57,6 +64,9 @@ public function resendAttendeeTicket( $event = $this->eventRepository ->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer')) ->loadRelation(EventSettingDomainObject::class) + ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ])) ->findById($eventId); $this->sendAttendeeTicketService->send( @@ -89,11 +99,29 @@ public function resendOrderConfirmation( nested: [ new Relationship( domainObject: EventOccurrenceDomainObject::class, + nested: [ + new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ]), + ], + name: 'event_occurrence', + ), + ], + )) + ->loadRelation(new Relationship( + domainObject: AttendeeDomainObject::class, + nested: [ + new Relationship( + domainObject: EventOccurrenceDomainObject::class, + nested: [ + new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ]), + ], name: 'event_occurrence', ), ], )) - ->loadRelation(AttendeeDomainObject::class) ->loadRelation(InvoiceDomainObject::class) ->findFirstWhere([ 'id' => $orderId, @@ -103,7 +131,14 @@ public function resendOrderConfirmation( $event = $this->eventRepository ->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer')) ->loadRelation(new Relationship(EventSettingDomainObject::class)) - ->loadRelation(new Relationship(EventOccurrenceDomainObject::class)) + ->loadRelation(new Relationship(domainObject: EventOccurrenceDomainObject::class, nested: [ + new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ]), + ])) + ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ])) ->findById($eventId); $this->sendOrderDetailsService->sendCustomerOrderSummary( diff --git a/backend/app/Services/Infrastructure/Geo/DTO/GeoPlaceDTO.php b/backend/app/Services/Infrastructure/Geo/DTO/GeoPlaceDTO.php new file mode 100644 index 0000000000..6f4c0cae28 --- /dev/null +++ b/backend/app/Services/Infrastructure/Geo/DTO/GeoPlaceDTO.php @@ -0,0 +1,21 @@ + + */ + public function autocomplete(string $query, ?string $locale = null, ?string $country = null): array; + + public function getPlaceDetails(string $providerPlaceId, ?string $locale = null): ?GeoPlaceDTO; +} diff --git a/backend/app/Services/Infrastructure/Geo/GooglePlacesGeoProvider.php b/backend/app/Services/Infrastructure/Geo/GooglePlacesGeoProvider.php new file mode 100644 index 0000000000..e419fbbc57 --- /dev/null +++ b/backend/app/Services/Infrastructure/Geo/GooglePlacesGeoProvider.php @@ -0,0 +1,259 @@ + $query]; + if ($locale !== null && $locale !== '') { + $body['languageCode'] = $locale; + } + if ($country !== null && $country !== '') { + $body['regionCode'] = strtoupper($country); + $body['includedRegionCodes'] = [strtoupper($country)]; + } + + try { + $response = $this->http + ->withHeaders([ + 'X-Goog-Api-Key' => $this->apiKey, + 'X-Goog-FieldMask' => self::AUTOCOMPLETE_FIELD_MASK, + ]) + ->timeout(self::REQUEST_TIMEOUT_SECONDS) + ->post(self::AUTOCOMPLETE_URL, $body); + } catch (Throwable $e) { + $this->logger->error('Google Places autocomplete failed', ['error' => $e->getMessage()]); + + throw new GeoProviderException('Geo provider unavailable', previous: $e); + } + + if (! $response->successful()) { + $this->logger->error('Google Places autocomplete non-2xx', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + if ($this->isQuotaResponse($response->status(), $response->json())) { + throw new GeoProviderQuotaExceededException( + sprintf('Geo provider quota exceeded (HTTP %d)', $response->status()), + ); + } + + throw new GeoProviderException(sprintf('Geo provider returned HTTP %d', $response->status())); + } + + $suggestions = []; + foreach ($response->json('suggestions') ?? [] as $suggestion) { + $prediction = $suggestion['placePrediction'] ?? null; + if ($prediction === null) { + continue; + } + + $suggestions[] = new GeoSuggestionDTO( + provider_place_id: (string) ($prediction['placeId'] ?? ''), + primary_text: (string) ($prediction['structuredFormat']['mainText']['text'] ?? $prediction['text']['text'] ?? ''), + secondary_text: $prediction['structuredFormat']['secondaryText']['text'] ?? null, + ); + } + + return $suggestions; + } + + public function getPlaceDetails(string $providerPlaceId, ?string $locale = null): ?GeoPlaceDTO + { + if ($providerPlaceId === '') { + return null; + } + + // Place IDs are stable upstream — Google explicitly recommends caching + // place-details responses to cut API costs. Cache the parsed payload + // (not the DTO, since some fields are derived). + $cacheKey = $this->placeDetailsCacheKey($providerPlaceId, $locale); + $payload = $this->cache->get($cacheKey); + + if ($payload === null) { + $payload = $this->fetchPlaceDetailsPayload($providerPlaceId, $locale); + if ($payload === null) { + return null; + } + $this->cache->put($cacheKey, $payload, self::PLACE_DETAILS_CACHE_TTL_SECONDS); + } + + return $this->mapToGeoPlaceDTO($payload, $providerPlaceId); + } + + private function fetchPlaceDetailsPayload(string $providerPlaceId, ?string $locale): ?array + { + $url = self::PLACE_DETAILS_URL.rawurlencode($providerPlaceId); + + try { + $query = []; + if ($locale !== null && $locale !== '') { + $query['languageCode'] = $locale; + } + + $response = $this->http + ->withHeaders([ + 'X-Goog-Api-Key' => $this->apiKey, + 'X-Goog-FieldMask' => self::PLACE_DETAILS_FIELD_MASK, + ]) + ->timeout(self::REQUEST_TIMEOUT_SECONDS) + ->get($url, $query); + } catch (Throwable $e) { + $this->logger->error('Google Places details failed', ['error' => $e->getMessage(), 'place_id' => $providerPlaceId]); + + throw new GeoProviderException('Geo provider unavailable', previous: $e); + } + + if ($response->status() === 404) { + return null; + } + + if (! $response->successful()) { + $this->logger->error('Google Places details non-2xx', [ + 'status' => $response->status(), + 'place_id' => $providerPlaceId, + 'body' => $response->body(), + ]); + + if ($this->isQuotaResponse($response->status(), $response->json())) { + throw new GeoProviderQuotaExceededException( + sprintf('Geo provider quota exceeded (HTTP %d)', $response->status()), + ); + } + + throw new GeoProviderException(sprintf('Geo provider returned HTTP %d', $response->status())); + } + + $payload = $response->json(); + if (! is_array($payload)) { + return null; + } + + return $payload; + } + + private function placeDetailsCacheKey(string $providerPlaceId, ?string $locale): string + { + return 'geo:place_details:'.self::PROVIDER_NAME.':'.$providerPlaceId.':'.($locale ?? ''); + } + + private function isQuotaResponse(int $status, mixed $body): bool + { + if ($status === 429) { + return true; + } + + if (is_array($body)) { + $errorStatus = $body['error']['status'] ?? null; + + return $errorStatus === 'RESOURCE_EXHAUSTED'; + } + + return false; + } + + private function mapToGeoPlaceDTO(array $payload, string $fallbackPlaceId): GeoPlaceDTO + { + $components = $this->indexAddressComponents($payload['addressComponents'] ?? []); + + $streetNumber = $components['street_number']['short'] ?? null; + $route = $components['route']['short'] ?? null; + $addressLine1 = trim(implode(' ', array_filter([$streetNumber, $route]))); + + $city = $components['locality']['short'] + ?? $components['postal_town']['short'] + ?? $components['sublocality_level_1']['short'] + ?? null; + + $stateOrRegion = $components['administrative_area_level_1']['long'] + ?? $components['administrative_area_level_1']['short'] + ?? null; + $postalCode = $components['postal_code']['short'] ?? null; + $country = $components['country']['short'] ?? null; + $addressLine2 = $components['subpremise']['short'] ?? null; + + $types = $payload['types'] ?? []; + $isEstablishment = in_array('establishment', $types, true) || in_array('point_of_interest', $types, true); + $displayName = $payload['displayName']['text'] ?? null; + $venueName = ($isEstablishment && $displayName) ? $displayName : null; + + $address = new AddressDTO( + venue_name: $venueName, + address_line_1: $addressLine1 !== '' ? $addressLine1 : null, + address_line_2: $addressLine2, + city: $city, + state_or_region: $stateOrRegion, + zip_or_postal_code: $postalCode, + country: $country !== null ? strtoupper($country) : null, + ); + + $latitude = isset($payload['location']['latitude']) ? (float) $payload['location']['latitude'] : null; + $longitude = isset($payload['location']['longitude']) ? (float) $payload['location']['longitude'] : null; + + return new GeoPlaceDTO( + provider: self::PROVIDER_NAME, + provider_place_id: (string) ($payload['id'] ?? $fallbackPlaceId), + address: $address, + latitude: $latitude, + longitude: $longitude, + display_name: $displayName, + raw_response: $payload, + ); + } + + private function indexAddressComponents(array $components): array + { + $index = []; + foreach ($components as $component) { + foreach ($component['types'] ?? [] as $type) { + $index[$type] = [ + 'short' => $component['shortText'] ?? $component['longText'] ?? null, + 'long' => $component['longText'] ?? $component['shortText'] ?? null, + ]; + } + } + + return $index; + } +} diff --git a/backend/app/Services/Infrastructure/Geo/NoOpGeoProvider.php b/backend/app/Services/Infrastructure/Geo/NoOpGeoProvider.php new file mode 100644 index 0000000000..96b14382ea --- /dev/null +++ b/backend/app/Services/Infrastructure/Geo/NoOpGeoProvider.php @@ -0,0 +1,27 @@ +logger->warning('NoOpGeoProvider in use — autocomplete returned no results. Configure a real provider in services.geo.'); + + return []; + } + + public function getPlaceDetails(string $providerPlaceId, ?string $locale = null): ?GeoPlaceDTO + { + $this->logger->warning('NoOpGeoProvider in use — getPlaceDetails returned null. Configure a real provider in services.geo.'); + + return null; + } +} diff --git a/backend/app/Validators/EventRules.php b/backend/app/Validators/EventRules.php index ce4adfa0b0..7c1849d8d3 100644 --- a/backend/app/Validators/EventRules.php +++ b/backend/app/Validators/EventRules.php @@ -4,13 +4,14 @@ use HiEvents\DomainObjects\Enums\EventCategory; use HiEvents\DomainObjects\Enums\EventType; +use HiEvents\DomainObjects\Enums\LocationType; use Illuminate\Validation\Rule; trait EventRules { public function eventRules(): array { - $currencies = include __DIR__ . '/../../data/currencies.php'; + $currencies = include __DIR__.'/../../data/currencies.php'; return array_merge($this->minimalRules(), [ 'type' => ['nullable', Rule::in(EventType::valuesArray())], @@ -22,14 +23,16 @@ public function eventRules(): array 'attributes.*.name' => ['string', 'min:1', 'max:50', 'required'], 'attributes.*.value' => ['min:1', 'max:1000', 'required'], 'attributes.*.is_public' => ['boolean', 'required'], - 'location_details' => ['array'], - 'location_details.venue_name' => ['string', 'max:100'], - 'location_details.address_line_1' => ['required_with:location_details', 'string', 'max:255'], - 'location_details.address_line_2' => ['string', 'max:255', 'nullable'], - 'location_details.city' => ['required_with:location_details', 'string', 'max:85'], - 'location_details.state_or_region' => ['string', 'max:85'], - 'location_details.zip_or_postal_code' => ['required_with:location_details', 'string', 'max:85'], - 'location_details.country' => ['required_with:location_details', 'string', 'max:2'], + 'event_location' => ['nullable', 'array'], + 'event_location.type' => ['required_with:event_location', Rule::in(LocationType::valuesArray())], + 'event_location.location_id' => [ + 'nullable', 'integer', + 'required_if:event_location.type,'.LocationType::IN_PERSON->name, + ], + 'event_location.online_event_connection_details' => [ + 'nullable', 'string', 'max:10000', + 'required_if:event_location.type,'.LocationType::ONLINE->name, + ], ]); } @@ -43,7 +46,7 @@ public function minimalRules(): array 'start_date' => [ 'date', $isRecurring ? 'nullable' : 'required', - Rule::when($this->input('end_date') !== null, ['before_or_equal:end_date']) + Rule::when($this->input('end_date') !== null, ['before_or_equal:end_date']), ], 'end_date' => ['date', 'nullable'], ]; @@ -56,11 +59,7 @@ public function eventMessages(): array 'attributes.*.name.required' => __('The attribute name is required'), 'attributes.*.value.required' => __('The attribute value is required'), 'attributes.*.is_public.required' => __('The attribute is_public fields is required'), - 'location_details.address_line_1.required' => __('The address line 1 field is required'), - 'location_details.city.required' => __('The city field is required'), - 'location_details.zip_or_postal_code.required' => __('The zip or postal code field is required'), - 'location_details.country.required' => __('The country field is required'), - 'location_details.country.max' => __('The country field should be a 2 character ISO 3166 code'), + 'event_location.location_id.required_if' => __('A saved location must be selected for in-person events'), ]; } } diff --git a/backend/config/services.php b/backend/config/services.php index 44f123a1e7..c6b82eedba 100644 --- a/backend/config/services.php +++ b/backend/config/services.php @@ -52,4 +52,10 @@ 'open_exchange_rates' => [ 'app_id' => env('OPEN_EXCHANGE_RATES_APP_ID'), ], + 'geo' => [ + 'provider' => env('GEO_PROVIDER', 'google'), + 'google' => [ + 'api_key' => env('GOOGLE_MAPS_API_KEY'), + ], + ], ]; diff --git a/backend/database/migrations/2026_05_18_000001_create_locations_table.php b/backend/database/migrations/2026_05_18_000001_create_locations_table.php new file mode 100644 index 0000000000..d317e04323 --- /dev/null +++ b/backend/database/migrations/2026_05_18_000001_create_locations_table.php @@ -0,0 +1,40 @@ +id(); + $table->string('short_id', 32)->unique(); + $table->foreignId('account_id')->constrained('accounts')->cascadeOnDelete(); + $table->foreignId('organizer_id')->constrained('organizers')->cascadeOnDelete(); + $table->string('name', 255)->nullable(); + $table->jsonb('structured_address')->nullable(); + $table->decimal('latitude', 10, 7)->nullable(); + $table->decimal('longitude', 10, 7)->nullable(); + $table->string('provider', 32)->nullable(); + $table->string('provider_place_id', 255)->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index('account_id'); + $table->index('organizer_id'); + }); + + DB::statement(' + CREATE UNIQUE INDEX locations_provider_place_unique + ON locations (organizer_id, provider, provider_place_id) + WHERE provider_place_id IS NOT NULL AND deleted_at IS NULL + '); + } + + public function down(): void + { + Schema::dropIfExists('locations'); + } +}; diff --git a/backend/database/migrations/2026_05_19_000001_add_location_id_to_organizers_and_backfill.php b/backend/database/migrations/2026_05_19_000001_add_location_id_to_organizers_and_backfill.php new file mode 100644 index 0000000000..cbf713b95c --- /dev/null +++ b/backend/database/migrations/2026_05_19_000001_add_location_id_to_organizers_and_backfill.php @@ -0,0 +1,89 @@ +foreignId('location_id')->nullable() + ->after('organizer_configuration_id') + ->constrained('locations') + ->nullOnDelete(); + $table->index('location_id'); + }); + + DB::table('organizer_settings') + ->whereNotNull('location_details') + ->orderBy('id') + ->chunkById(200, function ($settings) { + foreach ($settings as $row) { + $address = $this->normaliseAddress($row->location_details); + if ($address === null) { + continue; + } + + $organizer = DB::table('organizers')->find($row->organizer_id); + if (! $organizer || $organizer->location_id !== null) { + continue; + } + + $locationId = DB::table('locations')->insertGetId([ + 'short_id' => IdHelper::shortId(IdHelper::LOCATION_PREFIX), + 'account_id' => $organizer->account_id, + 'organizer_id' => $organizer->id, + 'name' => $address['venue_name'] ?? null, + 'structured_address' => json_encode($address, JSON_UNESCAPED_UNICODE), + 'latitude' => null, + 'longitude' => null, + 'provider' => null, + 'provider_place_id' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('organizers') + ->where('id', $organizer->id) + ->update(['location_id' => $locationId]); + } + }); + + // Legacy organizer_settings.location_details is intentionally retained + // here. Dropping it is deferred to a follow-up migration once we've + // confirmed the new locations table is the only read path. + } + + public function down(): void + { + Schema::table('organizers', function (Blueprint $table) { + $table->dropForeign(['location_id']); + $table->dropIndex(['location_id']); + $table->dropColumn('location_id'); + }); + } + + private function normaliseAddress(mixed $raw): ?array + { + if ($raw === null) { + return null; + } + + $decoded = is_string($raw) ? json_decode($raw, true) : $raw; + if (! is_array($decoded) || $decoded === []) { + return null; + } + + foreach (['venue_name', 'address_line_1', 'city', 'state_or_region', 'zip_or_postal_code', 'country'] as $key) { + if (! empty($decoded[$key])) { + return $decoded; + } + } + + return null; + } +}; diff --git a/backend/database/migrations/2026_05_22_000001_create_event_locations_table.php b/backend/database/migrations/2026_05_22_000001_create_event_locations_table.php new file mode 100644 index 0000000000..5f2900548a --- /dev/null +++ b/backend/database/migrations/2026_05_22_000001_create_event_locations_table.php @@ -0,0 +1,28 @@ +id(); + $table->string('short_id', 20)->unique(); + $table->foreignId('event_id')->constrained('events')->cascadeOnDelete(); + $table->string('type', 20)->default('IN_PERSON'); + $table->foreignId('location_id')->nullable()->constrained('locations')->nullOnDelete(); + $table->text('online_event_connection_details')->nullable(); + $table->timestamps(); + $table->softDeletes(); + $table->index('event_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('event_locations'); + } +}; diff --git a/backend/database/migrations/2026_05_22_000002_link_events_and_occurrences_to_event_locations.php b/backend/database/migrations/2026_05_22_000002_link_events_and_occurrences_to_event_locations.php new file mode 100644 index 0000000000..36b536d475 --- /dev/null +++ b/backend/database/migrations/2026_05_22_000002_link_events_and_occurrences_to_event_locations.php @@ -0,0 +1,243 @@ +foreignId('event_location_id')->nullable()->after('organizer_id')->constrained('event_locations')->nullOnDelete(); + $table->index('event_location_id'); + }); + + Schema::table('event_occurrences', function (Blueprint $table) { + $table->foreignId('event_location_id')->nullable()->after('event_id')->constrained('event_locations')->nullOnDelete(); + $table->index('event_location_id'); + }); + + $this->backfill(); + } + + public function down(): void + { + Schema::table('event_occurrences', function (Blueprint $table) { + $table->dropForeign(['event_location_id']); + $table->dropIndex(['event_location_id']); + $table->dropColumn('event_location_id'); + }); + + Schema::table('events', function (Blueprint $table) { + $table->dropForeign(['event_location_id']); + $table->dropIndex(['event_location_id']); + $table->dropColumn('event_location_id'); + }); + } + + /** + * Public so feature tests can invoke the backfill against pretend + * pre-migration data without rerunning the schema changes. + */ + public function backfill(): int + { + $backfilled = 0; + + DB::table('events') + ->whereNull('event_location_id') + ->orderBy('id') + ->chunkById(200, function ($events) use (&$backfilled) { + foreach ($events as $event) { + if ($this->backfillEvent($event)) { + $backfilled++; + } + } + }); + + return $backfilled; + } + + private function backfillEvent(stdClass $event): bool + { + $settings = DB::table('event_settings') + ->where('event_id', $event->id) + ->first(); + + if ($this->isOnlineEvent($settings)) { + // Re-purify on the way through: the legacy column was sanitised + // when written, but online_event_connection_details is rendered + // post-checkout with dangerouslySetInnerHTML, so we don't want + // to trust historical data that may predate today's allowlist. + $eventLocationId = $this->createOnlineEventLocation( + eventId: (int) $event->id, + onlineDetails: $this->purify($settings->online_event_connection_details ?? null), + ); + $this->linkEvent((int) $event->id, $eventLocationId); + + return true; + } + + $address = $this->extractAddress($settings, $event); + if ($address === null) { + return false; + } + + $locationId = $this->createLocation( + accountId: (int) $event->account_id, + organizerId: (int) $event->organizer_id, + address: $address, + ); + $eventLocationId = $this->createInPersonEventLocation( + eventId: (int) $event->id, + locationId: $locationId, + ); + $this->linkEvent((int) $event->id, $eventLocationId); + + return true; + } + + private function isOnlineEvent(?stdClass $settings): bool + { + return $settings !== null && (bool) ($settings->is_online_event ?? false); + } + + private function extractAddress(?stdClass $settings, stdClass $event): ?array + { + $candidates = [ + $settings->location_details ?? null, + $event->location_details ?? null, + ]; + + foreach ($candidates as $raw) { + $address = $this->normaliseAddress($raw); + if ($address !== null) { + return $address; + } + } + + return null; + } + + private function normaliseAddress(mixed $raw): ?array + { + if ($raw === null) { + return null; + } + + $decoded = is_string($raw) ? json_decode($raw, true) : $raw; + if (! is_array($decoded) || $decoded === []) { + return null; + } + + foreach (['venue_name', 'address_line_1', 'city', 'state_or_region', 'zip_or_postal_code', 'country'] as $key) { + if (! empty($decoded[$key])) { + return $decoded; + } + } + + return null; + } + + private function createLocation(int $accountId, int $organizerId, array $address): int + { + $now = now(); + + return DB::table('locations')->insertGetId([ + 'short_id' => $this->shortId(self::LOCATION_SHORT_ID_PREFIX), + 'account_id' => $accountId, + 'organizer_id' => $organizerId, + 'name' => $address['venue_name'] ?? null, + 'structured_address' => json_encode($address, JSON_UNESCAPED_UNICODE), + 'latitude' => null, + 'longitude' => null, + 'provider' => null, + 'provider_place_id' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + private function createInPersonEventLocation(int $eventId, int $locationId): int + { + $now = now(); + + return DB::table('event_locations')->insertGetId([ + 'short_id' => $this->shortId(self::EVENT_LOCATION_SHORT_ID_PREFIX), + 'event_id' => $eventId, + 'type' => self::TYPE_IN_PERSON, + 'location_id' => $locationId, + 'online_event_connection_details' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + private function createOnlineEventLocation(int $eventId, ?string $onlineDetails): int + { + $now = now(); + + return DB::table('event_locations')->insertGetId([ + 'short_id' => $this->shortId(self::EVENT_LOCATION_SHORT_ID_PREFIX), + 'event_id' => $eventId, + 'type' => self::TYPE_ONLINE, + 'location_id' => null, + 'online_event_connection_details' => $onlineDetails, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + private function linkEvent(int $eventId, int $eventLocationId): void + { + DB::table('events') + ->where('id', $eventId) + ->update(['event_location_id' => $eventLocationId]); + } + + private function shortId(string $prefix): string + { + return sprintf('%s_%s', $prefix, Str::random(self::SHORT_ID_RANDOM_LENGTH)); + } + + private function purify(?string $html): ?string + { + if ($html === null) { + return null; + } + + return $this->purifier()->purify($html); + } + + private function purifier(): \HTMLPurifier + { + return $this->purifier ??= new \HTMLPurifier(\HTMLPurifier_Config::createDefault()); + } +}; diff --git a/backend/database/migrations/2026_05_22_000003_add_raw_provider_response_to_locations.php b/backend/database/migrations/2026_05_22_000003_add_raw_provider_response_to_locations.php new file mode 100644 index 0000000000..511d8552c7 --- /dev/null +++ b/backend/database/migrations/2026_05_22_000003_add_raw_provider_response_to_locations.php @@ -0,0 +1,22 @@ +jsonb('raw_provider_response')->nullable()->after('provider_place_id'); + }); + } + + public function down(): void + { + Schema::table('locations', function (Blueprint $table) { + $table->dropColumn('raw_provider_response'); + }); + } +}; diff --git a/backend/resources/views/emails/orders/attendee-ticket.blade.php b/backend/resources/views/emails/orders/attendee-ticket.blade.php index f0d469f43c..df55702afa 100644 --- a/backend/resources/views/emails/orders/attendee-ticket.blade.php +++ b/backend/resources/views/emails/orders/attendee-ticket.blade.php @@ -28,8 +28,8 @@ $endFormatted = $sameDay ? $formatTime($displayEnd) : $formatDateTime($displayEnd); } - $venueName = $eventSettings->getLocationDetails()['venue_name'] ?? null; - $addressString = $eventSettings->getIsOnlineEvent() ? null : $eventSettings->getAddressString(); + $venueName = $effectiveVenueName ?? null; + $addressString = $effectiveAddressString ?? null; $productTitle = $attendee->getProduct()?->getTitle(); @endphp diff --git a/backend/routes/api.php b/backend/routes/api.php index 4059bc9812..663bf973f9 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -2,15 +2,7 @@ use HiEvents\Http\Actions\Accounts\CreateAccountAction; use HiEvents\Http\Actions\Accounts\GetAccountAction; -use HiEvents\Http\Actions\Organizers\Stripe\CopyStripeConnectAccountAction; -use HiEvents\Http\Actions\Organizers\Stripe\CreateStripeConnectAccountAction; -use HiEvents\Http\Actions\Organizers\Stripe\GetStripeConnectAccountsAction; use HiEvents\Http\Actions\Accounts\UpdateAccountAction; -use HiEvents\Http\Actions\Organizers\Vat\GetOrganizerVatSettingAction; -use HiEvents\Http\Actions\Organizers\Vat\UpsertOrganizerVatSettingAction; -use HiEvents\Http\Actions\Admin\Organizers\AssignOrganizerConfigurationAction; -use HiEvents\Http\Actions\Admin\Organizers\UpdateOrganizerConfigurationAction; -use HiEvents\Http\Actions\Admin\Organizers\UpdateOrganizerVatSettingAction; use HiEvents\Http\Actions\Admin\Accounts\GetAccountAction as GetAdminAccountAction; use HiEvents\Http\Actions\Admin\Accounts\GetAllAccountsAction as GetAllAdminAccountsAction; use HiEvents\Http\Actions\Admin\Accounts\UpdateAccountMessagingTierAction; @@ -31,6 +23,9 @@ use HiEvents\Http\Actions\Admin\Messages\ApproveMessageAction; use HiEvents\Http\Actions\Admin\Messages\GetAllMessagesAction as GetAllAdminMessagesAction; use HiEvents\Http\Actions\Admin\Orders\GetAllOrdersAction; +use HiEvents\Http\Actions\Admin\Organizers\AssignOrganizerConfigurationAction; +use HiEvents\Http\Actions\Admin\Organizers\UpdateOrganizerConfigurationAction; +use HiEvents\Http\Actions\Admin\Organizers\UpdateOrganizerVatSettingAction; use HiEvents\Http\Actions\Admin\Stats\GetAdminDashboardDataAction; use HiEvents\Http\Actions\Admin\Stats\GetAdminStatsAction; use HiEvents\Http\Actions\Admin\Users\GetAllUsersAction; @@ -124,6 +119,13 @@ use HiEvents\Http\Actions\EventSettings\PartialEditEventSettingsAction; use HiEvents\Http\Actions\Images\CreateImageAction; use HiEvents\Http\Actions\Images\DeleteImageAction; +use HiEvents\Http\Actions\Locations\CreateLocationAction; +use HiEvents\Http\Actions\Locations\DeleteLocationAction; +use HiEvents\Http\Actions\Locations\GeoAutocompleteAction; +use HiEvents\Http\Actions\Locations\GeoPlaceDetailsAction; +use HiEvents\Http\Actions\Locations\GetGeoStatusAction; +use HiEvents\Http\Actions\Locations\GetLocationsAction; +use HiEvents\Http\Actions\Locations\UpdateLocationAction; use HiEvents\Http\Actions\Messages\CancelMessageAction; use HiEvents\Http\Actions\Messages\GetMessageRecipientsAction; use HiEvents\Http\Actions\Messages\GetMessagesAction; @@ -159,7 +161,13 @@ use HiEvents\Http\Actions\Organizers\Settings\GetOrganizerSettingsAction; use HiEvents\Http\Actions\Organizers\Settings\PartialUpdateOrganizerSettingsAction; use HiEvents\Http\Actions\Organizers\Stats\GetOrganizerStatsAction; +use HiEvents\Http\Actions\Organizers\Stripe\CopyStripeConnectAccountAction; +use HiEvents\Http\Actions\Organizers\Stripe\CreateStripeConnectAccountAction; +use HiEvents\Http\Actions\Organizers\Stripe\GetStripeConnectAccountsAction; +use HiEvents\Http\Actions\Organizers\UpdateOrganizerLocationAction; use HiEvents\Http\Actions\Organizers\UpdateOrganizerStatusAction; +use HiEvents\Http\Actions\Organizers\Vat\GetOrganizerVatSettingAction; +use HiEvents\Http\Actions\Organizers\Vat\UpsertOrganizerVatSettingAction; use HiEvents\Http\Actions\Organizers\Webhooks\CreateOrganizerWebhookAction; use HiEvents\Http\Actions\Organizers\Webhooks\DeleteOrganizerWebhookAction; use HiEvents\Http\Actions\Organizers\Webhooks\EditOrganizerWebhookAction; @@ -298,6 +306,7 @@ function (Router $router): void { $router->get('/organizers/{organizer_id}/orders', GetOrganizerOrdersAction::class); $router->get('/organizers/{organizer_id}/settings', GetOrganizerSettingsAction::class); $router->patch('/organizers/{organizer_id}/settings', PartialUpdateOrganizerSettingsAction::class); + $router->patch('/organizers/{organizer_id}/location', UpdateOrganizerLocationAction::class); $router->get('/organizers/{organizer_id}/reports/{report_type}', GetOrganizerReportAction::class); $router->get('/organizers/{organizer_id}/reports/{report_type}/export', ExportOrganizerReportAction::class); $router->post('/organizers/{organizer_id}/webhooks', CreateOrganizerWebhookAction::class); @@ -307,6 +316,18 @@ function (Router $router): void { $router->delete('/organizers/{organizer_id}/webhooks/{webhook_id}', DeleteOrganizerWebhookAction::class); $router->get('/organizers/{organizer_id}/webhooks/{webhook_id}/logs', GetOrganizerWebhookLogsAction::class); + // Locations - Organizer level + $router->get('/organizers/{organizer_id}/locations', GetLocationsAction::class); + $router->post('/organizers/{organizer_id}/locations', CreateLocationAction::class); + $router->get('/geo/status', GetGeoStatusAction::class); + $router->get('/organizers/{organizer_id}/locations/autocomplete', GeoAutocompleteAction::class) + ->middleware('throttle:60,1'); + $router->get('/organizers/{organizer_id}/locations/places/{place_id}', GeoPlaceDetailsAction::class) + ->where('place_id', '[A-Za-z0-9_\-]+') + ->middleware('throttle:60,1'); + $router->put('/organizers/{organizer_id}/locations/{location_id}', UpdateLocationAction::class); + $router->delete('/organizers/{organizer_id}/locations/{location_id}', DeleteLocationAction::class); + // Stripe Connect - Organizer level $router->get('/organizers/{organizerId}/stripe/connect_accounts', GetStripeConnectAccountsAction::class); $router->post('/organizers/{organizerId}/stripe/connect', CreateStripeConnectAccountAction::class); @@ -336,6 +357,7 @@ function (Router $router): void { $router->get('/events', GetEventsAction::class); $router->get('/events/{event_id}', GetEventAction::class); $router->put('/events/{event_id}', UpdateEventAction::class); + $router->patch('/events/{event_id}/event-location', \HiEvents\Http\Actions\Events\UpdateEventLocationAction::class); $router->put('/events/{event_id}/status', UpdateEventStatusAction::class); $router->delete('/events/{event_id}', DeleteEventAction::class); $router->get('/events/{event_id}/deletion-status', GetEventDeletionStatusAction::class); @@ -603,4 +625,4 @@ function (Router $router): void { } ); -include_once __DIR__.'/mail.php'; +include_once __DIR__ . '/mail.php'; diff --git a/backend/tests/Feature/Database/Migrations/LinkEventsToEventLocationsBackfillTest.php b/backend/tests/Feature/Database/Migrations/LinkEventsToEventLocationsBackfillTest.php new file mode 100644 index 0000000000..77d1961173 --- /dev/null +++ b/backend/tests/Feature/Database/Migrations/LinkEventsToEventLocationsBackfillTest.php @@ -0,0 +1,223 @@ +withAccount()->create(); + $this->userId = $user->id; + $this->accountId = $user->accounts()->first()->id; + $this->organizerId = $this->insertOrganizer(); + } + + public function test_creates_in_person_event_location_from_event_settings_address(): void + { + $eventId = $this->insertEvent(); + $this->insertEventSettings($eventId, locationDetails: [ + 'venue_name' => 'Settings Hall', + 'address_line_1' => '1 Settings Way', + 'city' => 'Dublin', + 'country' => 'IE', + ]); + + $count = $this->migration()->backfill(); + + $this->assertSame(1, $count); + + $event = DB::table('events')->where('id', $eventId)->first(); + $this->assertNotNull($event->event_location_id); + + $eventLocation = DB::table('event_locations')->where('id', $event->event_location_id)->first(); + $this->assertSame('IN_PERSON', $eventLocation->type); + $this->assertNotNull($eventLocation->location_id); + $this->assertNull($eventLocation->online_event_connection_details); + + $location = DB::table('locations')->where('id', $eventLocation->location_id)->first(); + $this->assertSame($this->organizerId, (int) $location->organizer_id); + $this->assertSame($this->accountId, (int) $location->account_id); + $this->assertSame('Settings Hall', $location->name); + $this->assertEquals([ + 'venue_name' => 'Settings Hall', + 'address_line_1' => '1 Settings Way', + 'city' => 'Dublin', + 'country' => 'IE', + ], json_decode($location->structured_address, true)); + } + + public function test_falls_back_to_events_location_details_when_event_settings_empty(): void + { + $eventId = $this->insertEvent(locationDetails: [ + 'venue_name' => 'Event Hall', + 'city' => 'Cork', + ]); + $this->insertEventSettings($eventId, locationDetails: null); + + $this->migration()->backfill(); + + $event = DB::table('events')->where('id', $eventId)->first(); + $eventLocation = DB::table('event_locations')->where('id', $event->event_location_id)->first(); + $location = DB::table('locations')->where('id', $eventLocation->location_id)->first(); + + $this->assertSame('Event Hall', $location->name); + } + + public function test_creates_online_event_location_when_is_online_event_true(): void + { + $eventId = $this->insertEvent(); + $this->insertEventSettings( + $eventId, + isOnlineEvent: true, + onlineDetails: '

Zoom: https://example.com/abc

', + ); + + $this->migration()->backfill(); + + $event = DB::table('events')->where('id', $eventId)->first(); + $eventLocation = DB::table('event_locations')->where('id', $event->event_location_id)->first(); + + $this->assertSame('ONLINE', $eventLocation->type); + $this->assertNull($eventLocation->location_id); + $this->assertSame('

Zoom: https://example.com/abc

', $eventLocation->online_event_connection_details); + } + + public function test_online_event_legacy_html_is_re_purified(): void + { + $eventId = $this->insertEvent(); + $this->insertEventSettings( + $eventId, + isOnlineEvent: true, + onlineDetails: '

Zoom: https://example.com/abc

', + ); + + $this->migration()->backfill(); + + $event = DB::table('events')->where('id', $eventId)->first(); + $eventLocation = DB::table('event_locations')->where('id', $event->event_location_id)->first(); + + $this->assertStringNotContainsString('online_event_connection_details); + $this->assertStringNotContainsString('alert(', $eventLocation->online_event_connection_details); + $this->assertStringContainsString('Zoom: https://example.com/abc', $eventLocation->online_event_connection_details); + } + + public function test_skips_events_with_no_legacy_data(): void + { + $eventId = $this->insertEvent(); + $this->insertEventSettings($eventId); + + $count = $this->migration()->backfill(); + + $this->assertSame(0, $count); + $event = DB::table('events')->where('id', $eventId)->first(); + $this->assertNull($event->event_location_id); + } + + public function test_is_idempotent_when_re_run(): void + { + $eventId = $this->insertEvent(); + $this->insertEventSettings($eventId, locationDetails: ['venue_name' => 'Hall']); + + $first = $this->migration()->backfill(); + $second = $this->migration()->backfill(); + + $this->assertSame(1, $first); + $this->assertSame(0, $second); + + $this->assertSame(1, DB::table('event_locations')->where('event_id', $eventId)->count()); + } + + public function test_skips_address_blobs_with_no_meaningful_fields(): void + { + $eventId = $this->insertEvent(); + $this->insertEventSettings($eventId, locationDetails: ['some_other_key' => 'noise']); + + $count = $this->migration()->backfill(); + + $this->assertSame(0, $count); + } + + private function migration(): Migration + { + // The migration is an anonymous-class instance — `require` the file + // each time to get a fresh one and call backfill() directly. + return require self::MIGRATION_PATH; + } + + private function insertOrganizer(): int + { + $now = now()->toDateTimeString(); + + return DB::table('organizers')->insertGetId([ + 'account_id' => $this->accountId, + 'name' => 'Backfill Organizer', + 'email' => 'org+'.uniqid().'@example.test', + 'currency' => 'USD', + 'timezone' => 'UTC', + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + private function insertEvent(?array $locationDetails = null): int + { + $now = now()->toDateTimeString(); + + return DB::table('events')->insertGetId([ + 'title' => 'Backfill Event '.uniqid(), + 'account_id' => $this->accountId, + 'user_id' => $this->userId, + 'organizer_id' => $this->organizerId, + 'currency' => 'USD', + 'timezone' => 'UTC', + 'short_id' => 'evt_'.uniqid(), + 'location_details' => $locationDetails !== null ? json_encode($locationDetails) : null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + private function insertEventSettings( + int $eventId, + ?array $locationDetails = null, + bool $isOnlineEvent = false, + ?string $onlineDetails = null, + ): void { + $now = now()->toDateTimeString(); + + DB::table('event_settings')->insert([ + 'event_id' => $eventId, + 'location_details' => $locationDetails !== null ? json_encode($locationDetails) : null, + 'is_online_event' => $isOnlineEvent, + 'online_event_connection_details' => $onlineDetails, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } +} diff --git a/backend/tests/Feature/Services/Domain/EventLocation/EventLocationCanaryTest.php b/backend/tests/Feature/Services/Domain/EventLocation/EventLocationCanaryTest.php new file mode 100644 index 0000000000..5c280c640d --- /dev/null +++ b/backend/tests/Feature/Services/Domain/EventLocation/EventLocationCanaryTest.php @@ -0,0 +1,366 @@ +eventRepo = $this->app->make(EventRepositoryInterface::class); + $this->occurrenceRepo = $this->app->make(EventOccurrenceRepositoryInterface::class); + $this->eventLocationRepo = $this->app->make(EventLocationRepositoryInterface::class); + + $user = User::factory()->withAccount()->create(); + $this->userId = $user->id; + $this->accountId = $user->accounts()->first()->id; + + $now = now()->toDateTimeString(); + + $this->organizerId = DB::table('organizers')->insertGetId([ + 'account_id' => $this->accountId, + 'name' => 'Canary Organizer', + 'email' => 'canary@example.test', + 'currency' => 'USD', + 'timezone' => 'UTC', + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + public function test_event_in_person_default_resolves_with_venue_via_event_location_relation(): void + { + $venueId = $this->createVenue('Main Venue', ['city' => 'Dublin']); + $eventLocationId = $this->createEventLocation( + eventId: $eventId = $this->createEvent('In-Person Event'), + type: LocationType::IN_PERSON, + locationId: $venueId, + ); + $this->linkEventLocationToEvent($eventId, $eventLocationId); + + $event = $this->eventRepo + ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ])) + ->findFirstWhere(['id' => $eventId]); + + $payload = (new EventResource($event))->toArray(new Request); + + $this->assertArrayHasKey('event_location', $payload); + $eventLocation = $payload['event_location']->toArray(new Request); + $this->assertSame(LocationType::IN_PERSON->name, $eventLocation['type']); + $this->assertArrayHasKey('location', $eventLocation); + $nestedLocation = $eventLocation['location']->toArray(new Request); + $this->assertSame($venueId, $nestedLocation['id']); + } + + public function test_occurrence_inherits_event_location_when_null(): void + { + // The inheritance walk lives in the email/service layer, so an + // occurrence with no override must not surface event_location here. + $venueId = $this->createVenue('Event Venue'); + $eventId = $this->createEvent('Event with Inherited Occurrence'); + $eventLocationId = $this->createEventLocation($eventId, LocationType::IN_PERSON, $venueId); + $this->linkEventLocationToEvent($eventId, $eventLocationId); + + $occurrenceId = $this->createOccurrence($eventId, eventLocationId: null); + + $occurrence = $this->occurrenceRepo + ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ])) + ->findFirstWhere([EventOccurrenceDomainObjectAbstract::ID => $occurrenceId]); + + $payload = (new EventOccurrenceResource($occurrence))->toArray(new Request); + + // Absent values come back as Laravel's MissingValue, not as null. + $this->assertTrue( + ! array_key_exists('event_location', $payload) + || $payload['event_location'] instanceof \Illuminate\Http\Resources\MissingValue, + 'Inheriting occurrence must not carry its own event_location on the resource', + ); + } + + public function test_occurrence_in_person_override_returned_in_resource(): void + { + $eventVenueId = $this->createVenue('Event Default Venue'); + $overrideVenueId = $this->createVenue('Override Venue'); + $eventId = $this->createEvent('Event with Override Occurrence'); + + $eventEventLocationId = $this->createEventLocation($eventId, LocationType::IN_PERSON, $eventVenueId); + $this->linkEventLocationToEvent($eventId, $eventEventLocationId); + + $overrideEventLocationId = $this->createEventLocation($eventId, LocationType::IN_PERSON, $overrideVenueId); + $occurrenceId = $this->createOccurrence($eventId, eventLocationId: $overrideEventLocationId); + + $occurrence = $this->occurrenceRepo + ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ])) + ->findFirstWhere([EventOccurrenceDomainObjectAbstract::ID => $occurrenceId]); + + $payload = (new EventOccurrenceResource($occurrence))->toArray(new Request); + + $this->assertArrayHasKey('event_location', $payload); + $embed = $payload['event_location']->toArray(new Request); + $this->assertSame(LocationType::IN_PERSON->name, $embed['type']); + $this->assertSame($overrideVenueId, $embed['location']->toArray(new Request)['id']); + } + + public function test_online_event_default_with_online_occurrence_override(): void + { + $eventId = $this->createEvent('Online Event'); + + $eventEventLocationId = $this->createEventLocation( + eventId: $eventId, + type: LocationType::ONLINE, + onlineDetails: '

Event default Zoom

', + ); + $this->linkEventLocationToEvent($eventId, $eventEventLocationId); + + $occurrenceEventLocationId = $this->createEventLocation( + eventId: $eventId, + type: LocationType::ONLINE, + onlineDetails: '

Occurrence override Zoom

', + ); + $occurrenceId = $this->createOccurrence($eventId, eventLocationId: $occurrenceEventLocationId); + + // Event-level + $event = $this->eventRepo + ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location')) + ->findFirstWhere(['id' => $eventId]); + + $eventPayload = (new EventResource($event))->toArray(new Request); + $eventEmbed = $eventPayload['event_location']->toArray(new Request); + $this->assertSame(LocationType::ONLINE->name, $eventEmbed['type']); + $this->assertSame('

Event default Zoom

', $eventEmbed['online_event_connection_details']); + + // Occurrence-level + $occurrence = $this->occurrenceRepo + ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location')) + ->findFirstWhere([EventOccurrenceDomainObjectAbstract::ID => $occurrenceId]); + + $occPayload = (new EventOccurrenceResource($occurrence))->toArray(new Request); + $occEmbed = $occPayload['event_location']->toArray(new Request); + $this->assertSame(LocationType::ONLINE->name, $occEmbed['type']); + $this->assertSame('

Occurrence override Zoom

', $occEmbed['online_event_connection_details']); + } + + public function test_clear_event_location_on_occurrence_orphans_cleaned_up(): void + { + // clear_event_location must null the FK AND soft-delete the now- + // unreferenced row via EventLocationCleaner. + $venueId = $this->createVenue('Override Venue'); + $eventId = $this->createEvent('Event with Cleanable Override'); + $occurrenceEventLocationId = $this->createEventLocation($eventId, LocationType::IN_PERSON, $venueId); + $occurrenceId = $this->createOccurrence($eventId, eventLocationId: $occurrenceEventLocationId); + + // Pre-condition: row exists and is not soft-deleted. + $this->assertDatabaseHas('event_locations', [ + 'id' => $occurrenceEventLocationId, + 'deleted_at' => null, + ]); + + $handler = $this->app->make(UpdateEventOccurrenceHandler::class); + $handler->handle( + $occurrenceId, + new UpsertEventOccurrenceDTO( + event_id: $eventId, + start_date: now()->addDay()->toDateTimeString(), + end_date: now()->addDay()->addHours(2)->toDateTimeString(), + clear_event_location: true, + ), + ); + + // Occurrence FK is now null. + $occurrence = $this->occurrenceRepo->findFirstWhere([EventOccurrenceDomainObjectAbstract::ID => $occurrenceId]); + $this->assertNull($occurrence->getEventLocationId()); + + // The orphaned row is soft-deleted. + $stillAlive = DB::table('event_locations') + ->where('id', $occurrenceEventLocationId) + ->whereNull('deleted_at') + ->exists(); + $this->assertFalse($stillAlive, 'Orphaned event_location row should be soft-deleted by EventLocationCleaner'); + } + + public function test_public_event_resource_hides_internal_location_fields(): void + { + // Public surface must never leak internal IDs, organizer scoping, + // provider names, place IDs or timestamps from the venue row. + $venueId = $this->createVenue('Public Venue', ['city' => 'Dublin']); + DB::table('locations')->where('id', $venueId)->update([ + 'provider' => 'google', + 'provider_place_id' => 'ChIJsecretplaceid', + ]); + $eventId = $this->createEvent('In-Person Public Event'); + $eventLocationId = $this->createEventLocation($eventId, LocationType::IN_PERSON, $venueId); + $this->linkEventLocationToEvent($eventId, $eventLocationId); + + $event = $this->eventRepo + ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ])) + ->findFirstWhere(['id' => $eventId]); + + $payload = (new EventResourcePublic($event, false))->toArray(new Request); + $eventLocation = $payload['event_location']->toArray(new Request); + $location = $eventLocation['location']->toArray(new Request); + + $this->assertSame('Public Venue', $location['name']); + $this->assertSame('Dublin', $location['structured_address']['city']); + + foreach (['id', 'organizer_id', 'provider', 'provider_place_id', 'created_at', 'updated_at'] as $sensitive) { + $this->assertArrayNotHasKey($sensitive, $location, "$sensitive must not appear in the public location payload"); + } + } + + public function test_public_event_resource_hides_online_connection_details_pre_checkout(): void + { + // online_event_connection_details is gated until the buyer completes + // their order — pre-checkout responses must omit it entirely. + $eventId = $this->createEvent('Online Public Event'); + $eventLocationId = $this->createEventLocation( + eventId: $eventId, + type: LocationType::ONLINE, + onlineDetails: '

Secret Zoom: https://zoom.example/secret

', + ); + $this->linkEventLocationToEvent($eventId, $eventLocationId); + + $event = $this->eventRepo + ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location')) + ->findFirstWhere(['id' => $eventId]); + + // Pre-checkout + $prePayload = (new EventResourcePublic($event, false))->toArray(new Request); + $preEmbed = $prePayload['event_location']->toArray(new Request); + $preAssertable = $this->resolveWhen($preEmbed); + $this->assertArrayNotHasKey('online_event_connection_details', $preAssertable); + + // Post-checkout + $postPayload = (new EventResourcePublic($event, true))->toArray(new Request); + $postEmbed = $postPayload['event_location']->toArray(new Request); + $postAssertable = $this->resolveWhen($postEmbed); + $this->assertArrayHasKey('online_event_connection_details', $postAssertable); + $this->assertSame('

Secret Zoom: https://zoom.example/secret

', $postAssertable['online_event_connection_details']); + } + + // Strips MissingValue instances so array_key_exists assertions work. + private function resolveWhen(array $payload): array + { + return array_filter( + $payload, + fn ($value) => ! ($value instanceof \Illuminate\Http\Resources\MissingValue), + ); + } + + private function createVenue(string $name, array $structuredAddress = []): int + { + return DB::table('locations')->insertGetId([ + 'short_id' => 'loc_'.uniqid(), + 'account_id' => $this->accountId, + 'organizer_id' => $this->organizerId, + 'name' => $name, + 'structured_address' => json_encode(array_merge(['venue_name' => $name], $structuredAddress)), + 'latitude' => 53.3478, + 'longitude' => -6.2289, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function createEvent(string $title): int + { + return DB::table('events')->insertGetId([ + 'title' => $title, + 'account_id' => $this->accountId, + 'user_id' => $this->userId, + 'organizer_id' => $this->organizerId, + 'currency' => 'USD', + 'timezone' => 'UTC', + 'short_id' => 'evt_'.uniqid(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function createEventLocation( + int $eventId, + LocationType $type, + ?int $locationId = null, + ?string $onlineDetails = null, + ): int { + return DB::table('event_locations')->insertGetId([ + 'short_id' => 'el_'.uniqid(), + 'event_id' => $eventId, + 'type' => $type->name, + 'location_id' => $locationId, + 'online_event_connection_details' => $onlineDetails, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function linkEventLocationToEvent(int $eventId, int $eventLocationId): void + { + DB::table('events')->where('id', $eventId)->update([ + 'event_location_id' => $eventLocationId, + ]); + } + + private function createOccurrence(int $eventId, ?int $eventLocationId): int + { + return DB::table('event_occurrences')->insertGetId([ + 'short_id' => 'occ_'.uniqid(), + 'event_id' => $eventId, + 'event_location_id' => $eventLocationId, + 'start_date' => now()->addDay()->toDateTimeString(), + 'end_date' => now()->addDay()->addHours(2)->toDateTimeString(), + 'status' => 'ACTIVE', + 'used_capacity' => 0, + 'is_overridden' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} diff --git a/backend/tests/Unit/Http/Request/Event/UpdateEventLocationRequestTest.php b/backend/tests/Unit/Http/Request/Event/UpdateEventLocationRequestTest.php new file mode 100644 index 0000000000..6796ab073e --- /dev/null +++ b/backend/tests/Unit/Http/Request/Event/UpdateEventLocationRequestTest.php @@ -0,0 +1,86 @@ +merge([ + 'event_location' => [ + 'type' => LocationType::IN_PERSON->name, + 'location_id' => null, + ], + ]); + + $validator = Validator::make($request->all(), $request->rules(), $request->messages()); + + $this->assertTrue($validator->fails()); + $this->assertTrue($validator->errors()->has('event_location.location_id')); + } + + public function test_in_person_passes_when_location_id_set(): void + { + $request = new UpdateEventLocationRequest; + $request->merge([ + 'event_location' => [ + 'type' => LocationType::IN_PERSON->name, + 'location_id' => 42, + ], + ]); + + $validator = Validator::make($request->all(), $request->rules(), $request->messages()); + + $this->assertFalse($validator->errors()->has('event_location.location_id')); + } + + public function test_online_does_not_require_location_id(): void + { + $request = new UpdateEventLocationRequest; + $request->merge([ + 'event_location' => [ + 'type' => LocationType::ONLINE->name, + 'online_event_connection_details' => 'Zoom: https://example.com/abc', + ], + ]); + + $validator = Validator::make($request->all(), $request->rules(), $request->messages()); + + $this->assertFalse($validator->errors()->has('event_location.location_id')); + } + + public function test_online_requires_connection_details(): void + { + $request = new UpdateEventLocationRequest; + $request->merge([ + 'event_location' => [ + 'type' => LocationType::ONLINE->name, + ], + ]); + + $validator = Validator::make($request->all(), $request->rules(), $request->messages()); + + $this->assertTrue($validator->errors()->has('event_location.online_event_connection_details')); + } + + public function test_null_event_location_is_allowed(): void + { + $request = new UpdateEventLocationRequest; + $request->merge([ + 'event_location' => null, + 'clear_event_location' => true, + ]); + + $validator = Validator::make($request->all(), $request->rules(), $request->messages()); + + $this->assertFalse($validator->fails()); + } +} diff --git a/backend/tests/Unit/Http/Request/EventOccurrence/BulkUpdateOccurrencesRequestTest.php b/backend/tests/Unit/Http/Request/EventOccurrence/BulkUpdateOccurrencesRequestTest.php new file mode 100644 index 0000000000..8178c7b15b --- /dev/null +++ b/backend/tests/Unit/Http/Request/EventOccurrence/BulkUpdateOccurrencesRequestTest.php @@ -0,0 +1,48 @@ +merge([ + 'action' => BulkOccurrenceAction::UPDATE->value, + 'apply_to_all' => true, + 'event_location' => [ + 'type' => LocationType::IN_PERSON->name, + 'location_id' => null, + ], + ]); + + $validator = Validator::make($request->all(), $request->rules(), $request->messages()); + + $this->assertTrue($validator->errors()->has('event_location.location_id')); + } + + public function test_in_person_passes_when_location_id_set(): void + { + $request = new BulkUpdateOccurrencesRequest; + $request->merge([ + 'action' => BulkOccurrenceAction::UPDATE->value, + 'apply_to_all' => true, + 'event_location' => [ + 'type' => LocationType::IN_PERSON->name, + 'location_id' => 99, + ], + ]); + + $validator = Validator::make($request->all(), $request->rules(), $request->messages()); + + $this->assertFalse($validator->errors()->has('event_location.location_id')); + } +} diff --git a/backend/tests/Unit/Http/Request/EventOccurrence/UpsertEventOccurrenceRequestTest.php b/backend/tests/Unit/Http/Request/EventOccurrence/UpsertEventOccurrenceRequestTest.php new file mode 100644 index 0000000000..d854fd9ddd --- /dev/null +++ b/backend/tests/Unit/Http/Request/EventOccurrence/UpsertEventOccurrenceRequestTest.php @@ -0,0 +1,45 @@ +merge([ + 'start_date' => now()->addDay()->toDateTimeString(), + 'event_location' => [ + 'type' => LocationType::IN_PERSON->name, + 'location_id' => null, + ], + ]); + + $validator = Validator::make($request->all(), $request->rules(), $request->messages()); + + $this->assertTrue($validator->errors()->has('event_location.location_id')); + } + + public function test_in_person_passes_when_location_id_set(): void + { + $request = new UpsertEventOccurrenceRequest; + $request->merge([ + 'start_date' => now()->addDay()->toDateTimeString(), + 'event_location' => [ + 'type' => LocationType::IN_PERSON->name, + 'location_id' => 7, + ], + ]); + + $validator = Validator::make($request->all(), $request->rules(), $request->messages()); + + $this->assertFalse($validator->errors()->has('event_location.location_id')); + } +} diff --git a/backend/tests/Unit/Http/Request/Location/UpsertLocationRequestTest.php b/backend/tests/Unit/Http/Request/Location/UpsertLocationRequestTest.php new file mode 100644 index 0000000000..aaecdcd31c --- /dev/null +++ b/backend/tests/Unit/Http/Request/Location/UpsertLocationRequestTest.php @@ -0,0 +1,79 @@ +merge([ + 'structured_address' => ['venue_name' => 'Foo Hall'], + 'provider' => 'foo', + 'provider_place_id' => 'some_id', + ]); + + $validator = Validator::make($request->all(), $request->rules()); + + $this->assertTrue($validator->errors()->has('provider')); + } + + public function test_accepts_google_provider(): void + { + $request = new UpsertLocationRequest; + $request->merge([ + 'structured_address' => ['venue_name' => 'Foo Hall'], + 'provider' => 'google', + 'provider_place_id' => 'ChIJsomething', + ]); + + $validator = Validator::make($request->all(), $request->rules()); + + $this->assertFalse($validator->errors()->has('provider')); + } + + public function test_provider_without_place_id_is_rejected(): void + { + $request = new UpsertLocationRequest; + $request->merge([ + 'structured_address' => ['venue_name' => 'Foo Hall'], + 'provider' => 'google', + ]); + + $validator = Validator::make($request->all(), $request->rules()); + + $this->assertTrue($validator->errors()->has('provider_place_id')); + } + + public function test_place_id_without_provider_is_rejected(): void + { + $request = new UpsertLocationRequest; + $request->merge([ + 'structured_address' => ['venue_name' => 'Foo Hall'], + 'provider_place_id' => 'ChIJsomething', + ]); + + $validator = Validator::make($request->all(), $request->rules()); + + $this->assertTrue($validator->errors()->has('provider')); + } + + public function test_both_null_is_allowed(): void + { + $request = new UpsertLocationRequest; + $request->merge([ + 'structured_address' => ['venue_name' => 'Foo Hall'], + ]); + + $validator = Validator::make($request->all(), $request->rules()); + + $this->assertFalse($validator->errors()->has('provider')); + $this->assertFalse($validator->errors()->has('provider_place_id')); + } +} diff --git a/backend/tests/Unit/Jobs/Occurrence/SendOccurrenceCancellationEmailJobTest.php b/backend/tests/Unit/Jobs/Occurrence/SendOccurrenceCancellationEmailJobTest.php index 5ee544b81d..e4a91982a2 100644 --- a/backend/tests/Unit/Jobs/Occurrence/SendOccurrenceCancellationEmailJobTest.php +++ b/backend/tests/Unit/Jobs/Occurrence/SendOccurrenceCancellationEmailJobTest.php @@ -70,8 +70,9 @@ private function setupCommon(array $attendees): void $occurrence = Mockery::mock(EventOccurrenceDomainObject::class); $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-15 14:00:00'); + $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf(); $this->occurrenceRepository->shouldReceive('findById')->with(10)->once()->andReturn($occurrence); - $this->eventRepository->shouldReceive('loadRelation')->twice()->andReturnSelf(); + $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf(); $this->eventRepository->shouldReceive('findById')->with(1)->once()->andReturn($this->makeEvent()); $this->attendeeRepository->shouldReceive('findWhere')->once()->andReturn(collect($attendees)); } @@ -135,8 +136,9 @@ public function test_handle_does_not_filter_out_cancelled_attendees(): void $occurrence = Mockery::mock(EventOccurrenceDomainObject::class); $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-15 14:00:00'); + $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf(); $this->occurrenceRepository->shouldReceive('findById')->once()->andReturn($occurrence); - $this->eventRepository->shouldReceive('loadRelation')->twice()->andReturnSelf(); + $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf(); $this->eventRepository->shouldReceive('findById')->once()->andReturn($this->makeEvent()); $this->attendeeRepository @@ -165,8 +167,9 @@ public function test_handle_returns_early_when_no_attendees(): void $occurrence = Mockery::mock(EventOccurrenceDomainObject::class); $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-15 14:00:00'); + $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf(); $this->occurrenceRepository->shouldReceive('findById')->once()->andReturn($occurrence); - $this->eventRepository->shouldReceive('loadRelation')->twice()->andReturnSelf(); + $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf(); $this->eventRepository->shouldReceive('findById')->once()->andReturn($this->makeEvent()); $this->attendeeRepository->shouldReceive('findWhere')->once()->andReturn(collect()); diff --git a/backend/tests/Unit/Repository/Eloquent/LocationRepositoryTest.php b/backend/tests/Unit/Repository/Eloquent/LocationRepositoryTest.php new file mode 100644 index 0000000000..c78da2c31b --- /dev/null +++ b/backend/tests/Unit/Repository/Eloquent/LocationRepositoryTest.php @@ -0,0 +1,132 @@ +repository = $this->app->make(LocationRepository::class); + + $user = User::factory()->withAccount()->create(); + $this->userId = $user->id; + $this->accountId = $user->accounts()->first()->id; + $this->organizerId = $this->insertOrganizer(); + } + + public function test_returns_true_when_location_is_referenced_by_active_event_location(): void + { + $locationId = $this->insertLocation(); + $eventId = $this->insertEvent(); + $this->insertEventLocation($eventId, $locationId); + + $this->assertTrue($this->repository->isReferenced($locationId)); + } + + public function test_returns_true_when_location_is_referenced_only_by_organizer(): void + { + $locationId = $this->insertLocation(); + DB::table('organizers') + ->where('id', $this->organizerId) + ->update(['location_id' => $locationId]); + + $this->assertTrue($this->repository->isReferenced($locationId)); + } + + public function test_returns_false_when_location_is_unreferenced(): void + { + $locationId = $this->insertLocation(); + + $this->assertFalse($this->repository->isReferenced($locationId)); + } + + public function test_soft_deleted_event_location_does_not_count_as_reference(): void + { + $locationId = $this->insertLocation(); + $eventId = $this->insertEvent(); + $this->insertEventLocation($eventId, $locationId, deletedAt: now()); + + $this->assertFalse($this->repository->isReferenced($locationId)); + } + + private function insertOrganizer(): int + { + $now = now()->toDateTimeString(); + + return DB::table('organizers')->insertGetId([ + 'account_id' => $this->accountId, + 'name' => 'Test Organizer', + 'email' => 'organizer+'.uniqid().'@example.test', + 'currency' => 'USD', + 'timezone' => 'UTC', + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + private function insertLocation(): int + { + $now = now()->toDateTimeString(); + + return DB::table('locations')->insertGetId([ + 'short_id' => 'loc_'.uniqid(), + 'account_id' => $this->accountId, + 'organizer_id' => $this->organizerId, + 'name' => 'Test Venue', + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + private function insertEvent(): int + { + $now = now()->toDateTimeString(); + + return DB::table('events')->insertGetId([ + 'title' => 'Test Event', + 'account_id' => $this->accountId, + 'user_id' => $this->userId, + 'organizer_id' => $this->organizerId, + 'currency' => 'USD', + 'timezone' => 'UTC', + 'short_id' => 'evt_'.uniqid(), + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + private function insertEventLocation(int $eventId, int $locationId, ?\DateTimeInterface $deletedAt = null): int + { + $now = now()->toDateTimeString(); + + return DB::table('event_locations')->insertGetId([ + 'short_id' => 'el_'.uniqid(), + 'event_id' => $eventId, + 'type' => 'IN_PERSON', + 'location_id' => $locationId, + 'created_at' => $now, + 'updated_at' => $now, + 'deleted_at' => $deletedAt, + ]); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/Event/GetPublicEventHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Event/GetPublicEventHandlerTest.php index 3cb83cc2dd..5c95c97d9f 100644 --- a/backend/tests/Unit/Services/Application/Handlers/Event/GetPublicEventHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/Event/GetPublicEventHandlerTest.php @@ -106,7 +106,8 @@ public function test_handle_loads_single_event_occurrence_without_future_filter( ->setProductCategories(collect()); $pastOccurrence = $this->makeOccurrence(10, '2024-01-01 10:00:00'); - $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf()->times(4); + $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf(); $this->eventRepository->shouldReceive('findById')->with($data->eventId)->andReturn($event); $this->occurrenceRepository ->shouldReceive('findWhere') @@ -199,7 +200,8 @@ public function test_handle_keeps_requested_occurrence_outside_public_cap(): voi ->map(fn (int $id) => $this->makeOccurrence($id, '2026-01-01 10:00:00')); $linkedOccurrence = $this->makeOccurrence($linkedOccurrenceId, '2027-01-01 10:00:00'); - $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf()->times(4); + $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf(); $this->eventRepository->shouldReceive('findById')->with($data->eventId)->andReturn($event); // findWhere has signature ($where, $columns, $orderAndDirections, $limit) // — production passes the limit by name, but Mockery sees them as @@ -250,7 +252,8 @@ public function test_handle_keeps_requested_occurrence_outside_public_cap(): voi private function setupEventRepositoryMock($event, $eventId): void { - $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf()->times(4); + $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf(); $this->eventRepository->shouldReceive('findById')->with($eventId)->andReturn($event); $this->occurrenceRepository->shouldReceive('findWhere')->andReturn(collect()); } diff --git a/backend/tests/Unit/Services/Application/Handlers/Event/UpdateEventHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Event/UpdateEventHandlerTest.php new file mode 100644 index 0000000000..2dbf802074 --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Event/UpdateEventHandlerTest.php @@ -0,0 +1,175 @@ +eventRepository = Mockery::mock(EventRepositoryInterface::class); + $this->orderRepository = Mockery::mock(OrderRepositoryInterface::class); + $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class); + $this->purifier = Mockery::mock(HtmlPurifierService::class); + $this->dispatcher = Mockery::mock(Dispatcher::class); + + $databaseManager = Mockery::mock(DatabaseManager::class); + $databaseManager->shouldReceive('transaction')->andReturnUsing(fn ($cb) => $cb()); + + $this->handler = new UpdateEventHandler( + $this->eventRepository, + $this->dispatcher, + $databaseManager, + $this->orderRepository, + $this->purifier, + $this->occurrenceRepository, + ); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + public function test_throws_when_changing_currency_with_completed_orders(): void + { + $existing = Mockery::mock(EventDomainObject::class); + $existing->shouldReceive('getCurrency')->andReturn('USD'); + + $this->eventRepository + ->shouldReceive('findFirstWhere') + ->andReturn($existing); + + $completedOrder = Mockery::mock(OrderDomainObject::class); + $this->orderRepository + ->shouldReceive('findWhere') + ->with([ + 'event_id' => 1, + 'status' => OrderStatus::COMPLETED->name, + ]) + ->andReturn(new Collection([$completedOrder])); + + $this->expectException(CannotChangeCurrencyException::class); + + $this->handler->handle(new UpdateEventDTO( + title: 'Event', + category: null, + account_id: 5, + id: 1, + currency: 'EUR', + )); + } + + public function test_allows_currency_change_when_no_completed_orders(): void + { + $existing = Mockery::mock(EventDomainObject::class); + $existing->shouldReceive('getCurrency')->andReturn('USD'); + $existing->shouldReceive('getCategory')->andReturn('OTHER'); + $existing->shouldReceive('getTimezone')->andReturn('UTC'); + $existing->shouldReceive('getType')->andReturn('OTHER_NOT_SINGLE'); + + $reloaded = Mockery::mock(EventDomainObject::class); + $reloaded->shouldReceive('getId')->andReturn(1); + + $this->eventRepository + ->shouldReceive('findFirstWhere') + ->andReturn($existing, $reloaded); + + $this->orderRepository + ->shouldReceive('findWhere') + ->with([ + 'event_id' => 1, + 'status' => OrderStatus::COMPLETED->name, + ]) + ->andReturn(new Collection); + + $this->purifier->shouldReceive('purify')->andReturn(null); + + $this->eventRepository + ->shouldReceive('updateWhere') + ->once() + ->with( + Mockery::on(fn ($attrs) => ($attrs['currency'] ?? null) === 'EUR'), + ['id' => 1, 'account_id' => 5], + ); + + $this->dispatcher->shouldReceive('dispatchEvent')->once(); + + $result = $this->handler->handle(new UpdateEventDTO( + title: 'Event', + category: null, + account_id: 5, + id: 1, + currency: 'EUR', + )); + + $this->assertSame($reloaded, $result); + } + + public function test_skips_completed_orders_check_when_currency_unchanged(): void + { + $existing = Mockery::mock(EventDomainObject::class); + $existing->shouldReceive('getCurrency')->andReturn('USD'); + $existing->shouldReceive('getCategory')->andReturn('OTHER'); + $existing->shouldReceive('getTimezone')->andReturn('UTC'); + $existing->shouldReceive('getType')->andReturn('OTHER_NOT_SINGLE'); + + $reloaded = Mockery::mock(EventDomainObject::class); + $reloaded->shouldReceive('getId')->andReturn(1); + + $this->eventRepository + ->shouldReceive('findFirstWhere') + ->andReturn($existing, $reloaded); + + $this->orderRepository->shouldNotReceive('findWhere'); + $this->purifier->shouldReceive('purify')->andReturn(null); + $this->eventRepository->shouldReceive('updateWhere')->once(); + $this->dispatcher->shouldReceive('dispatchEvent')->once(); + + $result = $this->handler->handle(new UpdateEventDTO( + title: 'Event', + category: null, + account_id: 5, + id: 1, + currency: 'USD', + )); + + $this->assertSame($reloaded, $result); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/Event/UpdateEventLocationHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Event/UpdateEventLocationHandlerTest.php new file mode 100644 index 0000000000..2561d9be1b --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Event/UpdateEventLocationHandlerTest.php @@ -0,0 +1,171 @@ +eventRepository = Mockery::mock(EventRepositoryInterface::class); + $this->eventLocationUpserter = Mockery::mock(EventLocationUpserter::class); + $this->eventLocationCleaner = Mockery::mock(EventLocationCleaner::class); + + $databaseManager = Mockery::mock(DatabaseManager::class); + $databaseManager->shouldReceive('transaction')->andReturnUsing(fn ($callback) => $callback()); + + $this->handler = new UpdateEventLocationHandler( + $this->eventRepository, + $this->eventLocationUpserter, + $this->eventLocationCleaner, + $databaseManager, + ); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + public function test_creates_event_location_when_event_has_none(): void + { + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getEventLocationId')->andReturn(null); + + $reloaded = Mockery::mock(EventDomainObject::class); + + $this->eventRepository + ->shouldReceive('findFirstWhere') + ->andReturn($event, $reloaded); + + $created = Mockery::mock(EventLocationDomainObject::class); + $created->shouldReceive('getId')->andReturn(42); + + $data = new EventLocationData(type: LocationType::IN_PERSON, location_id: 7); + + $this->eventLocationUpserter + ->shouldReceive('createForEvent') + ->once() + ->with(1, 5, $data) + ->andReturn($created); + + $this->eventRepository + ->shouldReceive('updateWhere') + ->once() + ->with(['event_location_id' => 42], ['id' => 1, 'account_id' => 5]); + + $this->eventLocationCleaner->shouldNotReceive('deleteIfOrphaned'); + + $dto = new UpdateEventLocationDTO(event_id: 1, account_id: 5, event_location: $data); + + $this->assertSame($reloaded, $this->handler->handle($dto)); + } + + public function test_updates_existing_event_location_in_place(): void + { + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getEventLocationId')->andReturn(99); + + $reloaded = Mockery::mock(EventDomainObject::class); + + $this->eventRepository + ->shouldReceive('findFirstWhere') + ->andReturn($event, $reloaded); + + $data = new EventLocationData(type: LocationType::IN_PERSON, location_id: 7); + + $this->eventLocationUpserter + ->shouldReceive('updateInPlace') + ->once() + ->with(99, 1, 5, $data) + ->andReturn(Mockery::mock(EventLocationDomainObject::class)); + + $this->eventRepository->shouldNotReceive('updateWhere'); + $this->eventLocationCleaner->shouldNotReceive('deleteIfOrphaned'); + + $dto = new UpdateEventLocationDTO(event_id: 1, account_id: 5, event_location: $data); + + $this->assertSame($reloaded, $this->handler->handle($dto)); + } + + public function test_clear_nulls_fk_and_cleans_orphan(): void + { + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getEventLocationId')->andReturn(99); + + $reloaded = Mockery::mock(EventDomainObject::class); + + $this->eventRepository + ->shouldReceive('findFirstWhere') + ->andReturn($event, $reloaded); + + $this->eventRepository + ->shouldReceive('updateWhere') + ->once() + ->with(['event_location_id' => null], ['id' => 1, 'account_id' => 5]); + + $this->eventLocationCleaner + ->shouldReceive('deleteIfOrphaned') + ->once() + ->with(99); + + $dto = new UpdateEventLocationDTO(event_id: 1, account_id: 5, clear_event_location: true); + + $this->assertSame($reloaded, $this->handler->handle($dto)); + } + + public function test_clear_noop_when_event_has_no_location(): void + { + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getEventLocationId')->andReturn(null); + + $this->eventRepository + ->shouldReceive('findFirstWhere') + ->andReturn($event, $event); + + $this->eventRepository->shouldNotReceive('updateWhere'); + $this->eventLocationCleaner->shouldNotReceive('deleteIfOrphaned'); + $this->eventLocationUpserter->shouldNotReceive('createForEvent'); + $this->eventLocationUpserter->shouldNotReceive('updateInPlace'); + + $dto = new UpdateEventLocationDTO(event_id: 1, account_id: 5, clear_event_location: true); + + $this->assertSame($event, $this->handler->handle($dto)); + } + + public function test_throws_when_event_not_found(): void + { + $this->eventRepository + ->shouldReceive('findFirstWhere') + ->andReturn(null); + + $this->expectException(ResourceNotFoundException::class); + + $this->handler->handle(new UpdateEventLocationDTO(event_id: 999, account_id: 5)); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/BulkUpdateOccurrencesHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/BulkUpdateOccurrencesHandlerTest.php index 67652975ef..9d760c3a64 100644 --- a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/BulkUpdateOccurrencesHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/BulkUpdateOccurrencesHandlerTest.php @@ -4,6 +4,9 @@ use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\Enums\BulkOccurrenceAction; +use HiEvents\DomainObjects\Enums\LocationType; +use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventLocationDomainObject; use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\Generated\AttendeeDomainObjectAbstract; use HiEvents\DomainObjects\Generated\EventOccurrenceDomainObjectAbstract; @@ -13,11 +16,15 @@ use HiEvents\Jobs\Occurrence\BulkCancelOccurrencesJob; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; +use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\OrderItemRepositoryInterface; use HiEvents\Repository\Interfaces\WaitlistEntryRepositoryInterface; use HiEvents\Services\Application\Handlers\EventOccurrence\BulkUpdateOccurrencesHandler; use HiEvents\Services\Application\Handlers\EventOccurrence\DTO\BulkUpdateOccurrencesDTO; use HiEvents\Services\Domain\Event\RecurrenceRuleExclusionService; +use HiEvents\Services\Domain\EventLocation\EventLocationCleaner; +use HiEvents\Services\Domain\EventLocation\EventLocationData; +use HiEvents\Services\Domain\EventLocation\EventLocationUpserter; use Illuminate\Database\DatabaseManager; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Bus; @@ -29,6 +36,8 @@ class BulkUpdateOccurrencesHandlerTest extends TestCase { private EventOccurrenceRepositoryInterface|MockInterface $occurrenceRepository; + private EventRepositoryInterface|MockInterface $eventRepository; + private OrderItemRepositoryInterface|MockInterface $orderItemRepository; private AttendeeRepositoryInterface|MockInterface $attendeeRepository; @@ -37,21 +46,40 @@ class BulkUpdateOccurrencesHandlerTest extends TestCase private RecurrenceRuleExclusionService|MockInterface $exclusionService; + private EventLocationUpserter|MockInterface $eventLocationUpserter; + + private EventLocationCleaner|MockInterface $eventLocationCleaner; + private DatabaseManager|MockInterface $databaseManager; private BulkUpdateOccurrencesHandler $handler; + private EventDomainObject|MockInterface $event; + protected function setUp(): void { parent::setUp(); $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class); + $this->eventRepository = Mockery::mock(EventRepositoryInterface::class); $this->orderItemRepository = Mockery::mock(OrderItemRepositoryInterface::class); $this->attendeeRepository = Mockery::mock(AttendeeRepositoryInterface::class); $this->waitlistEntryRepository = Mockery::mock(WaitlistEntryRepositoryInterface::class); $this->exclusionService = Mockery::mock(RecurrenceRuleExclusionService::class); + $this->eventLocationUpserter = Mockery::mock(EventLocationUpserter::class); + $this->eventLocationCleaner = Mockery::mock(EventLocationCleaner::class); $this->databaseManager = Mockery::mock(DatabaseManager::class); + // The handler always fetches the event up front; default-mock it to a + // mock with accountId 7 so all tests get a consistent event lookup. + $this->event = Mockery::mock(EventDomainObject::class); + $this->event->shouldReceive('getAccountId')->andReturn(7)->byDefault(); + + $this->eventRepository + ->shouldReceive('findById') + ->andReturn($this->event) + ->byDefault(); + // Default: no attendees on any occurrence. Delete-path tests that need // to assert the attendee guard override this expectation. $this->attendeeRepository @@ -89,14 +117,23 @@ protected function setUp(): void $this->handler = new BulkUpdateOccurrencesHandler( $this->occurrenceRepository, + $this->eventRepository, $this->orderItemRepository, $this->attendeeRepository, $this->waitlistEntryRepository, $this->exclusionService, + $this->eventLocationUpserter, + $this->eventLocationCleaner, $this->databaseManager, ); } + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + public function test_handle_updates_capacity_for_future_non_overridden_occurrences(): void { $dto = new BulkUpdateOccurrencesDTO( @@ -582,10 +619,13 @@ public function test_handle_skips_deletion_for_occurrences_with_attendees_but_no $this->handler = new BulkUpdateOccurrencesHandler( $this->occurrenceRepository, + $this->eventRepository, $this->orderItemRepository, $this->attendeeRepository, $this->waitlistEntryRepository, $this->exclusionService, + $this->eventLocationUpserter, + $this->eventLocationCleaner, $this->databaseManager, ); @@ -604,6 +644,171 @@ public function test_handle_skips_deletion_for_occurrences_with_attendees_but_no $this->assertEquals(1, $result->updated_count); } + public function test_per_occurrence_fork_when_event_location_supplied(): void + { + // Fork-per-override: every targeted occurrence gets its OWN freshly-created + // EventLocation row, even if some already had overrides. Three occurrences → + // three calls to createForEvent (no shared rows across the batch). + $locationData = new EventLocationData( + type: LocationType::IN_PERSON, + location_id: 42, + ); + + $dto = new BulkUpdateOccurrencesDTO( + event_id: 1, + action: BulkOccurrenceAction::UPDATE, + timezone: 'UTC', + future_only: false, + skip_overridden: false, + apply_to_all: true, + event_location: $locationData, + ); + + $occ1 = $this->createOccurrenceMock(10, false, false); + $occ2 = $this->createOccurrenceMock(11, false, false, eventLocationId: 200); + $occ3 = $this->createOccurrenceMock(12, false, false); + + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->once() + ->andReturn(new Collection([$occ1, $occ2, $occ3])); + + // Fork-per-override: createForEvent must be called THREE times. + $newLocations = [ + $this->makeEventLocationMock(501), + $this->makeEventLocationMock(502), + $this->makeEventLocationMock(503), + ]; + + $this->eventLocationUpserter + ->shouldReceive('createForEvent') + ->times(3) + ->with(1, 7, $locationData) + ->andReturn($newLocations[0], $newLocations[1], $newLocations[2]); + + // updateInPlace must NOT be called even though one occurrence already had + // an FK — fork-per-override semantics. + $this->eventLocationUpserter->shouldNotReceive('updateInPlace'); + + // Each row gets its own per-row update with its new FK. + $this->occurrenceRepository + ->shouldReceive('updateWhere') + ->times(3) + ->with( + Mockery::on(fn (array $attrs) => array_key_exists(EventOccurrenceDomainObjectAbstract::EVENT_LOCATION_ID, $attrs) + && $attrs[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] === true), + Mockery::any(), + ); + + // Occ 11's previous FK (200) is now orphan-candidate → cleaner runs for it. + $this->eventLocationCleaner + ->shouldReceive('deleteIfOrphaned') + ->once() + ->with(200); + + $result = $this->handler->handle($dto); + + $this->assertEquals(3, $result->updated_count); + } + + public function test_clears_overrides_and_cleans_up_orphans(): void + { + // clear_event_location on 3 occurrences, 2 of which had FKs → 2 cleanup + // calls. + $dto = new BulkUpdateOccurrencesDTO( + event_id: 1, + action: BulkOccurrenceAction::UPDATE, + timezone: 'UTC', + future_only: false, + skip_overridden: false, + apply_to_all: true, + clear_event_location: true, + ); + + $occWithFk1 = $this->createOccurrenceMock(10, false, false, eventLocationId: 200); + $occWithFk2 = $this->createOccurrenceMock(11, false, false, eventLocationId: 201); + $occNoFk = $this->createOccurrenceMock(12, false, false, eventLocationId: null); + + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->once() + ->andReturn(new Collection([$occWithFk1, $occWithFk2, $occNoFk])); + + $this->eventLocationUpserter->shouldNotReceive('createForEvent'); + $this->eventLocationUpserter->shouldNotReceive('updateInPlace'); + + // Two FK-bearing rows hit the clear branch and get updateWhere(...EVENT_LOCATION_ID=null...). + $this->occurrenceRepository + ->shouldReceive('updateWhere') + ->times(2) + ->with( + Mockery::on(fn (array $attrs) => array_key_exists(EventOccurrenceDomainObjectAbstract::EVENT_LOCATION_ID, $attrs) + && $attrs[EventOccurrenceDomainObjectAbstract::EVENT_LOCATION_ID] === null + && $attrs[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] === true), + Mockery::any(), + ); + + $this->eventLocationCleaner + ->shouldReceive('deleteIfOrphaned') + ->twice() + ->with(Mockery::on(fn ($id) => $id === 200 || $id === 201)); + + $result = $this->handler->handle($dto); + + // Only the 2 FK-bearing rows produced an update; the no-FK occurrence + // contributes no attributes so it's skipped. + $this->assertEquals(2, $result->updated_count); + } + + public function test_no_op_when_no_location_keys_in_payload(): void + { + // No event_location and no clear_event_location → no upserter or cleaner + // calls. (Capacity update still runs through the uniform path.) + $dto = new BulkUpdateOccurrencesDTO( + event_id: 1, + action: BulkOccurrenceAction::UPDATE, + timezone: 'UTC', + capacity: 50, + future_only: false, + skip_overridden: false, + apply_to_all: true, + ); + + $occurrence = $this->createOccurrenceMock(10, false, false); + + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->once() + ->andReturn(new Collection([$occurrence])); + + $this->eventLocationUpserter->shouldNotReceive('createForEvent'); + $this->eventLocationUpserter->shouldNotReceive('updateInPlace'); + $this->eventLocationCleaner->shouldNotReceive('deleteIfOrphaned'); + + $this->occurrenceRepository + ->shouldReceive('updateWhere') + ->once() + ->with( + [ + EventOccurrenceDomainObjectAbstract::CAPACITY => 50, + EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN => true, + ], + [[EventOccurrenceDomainObjectAbstract::ID, 'in', [10]]], + ); + + $result = $this->handler->handle($dto); + + $this->assertEquals(1, $result->updated_count); + } + + private function makeEventLocationMock(int $id): EventLocationDomainObject|MockInterface + { + $mock = Mockery::mock(EventLocationDomainObject::class); + $mock->shouldReceive('getId')->andReturn($id); + + return $mock; + } + private function createOccurrenceMock( int $id, bool $isPast, @@ -611,6 +816,7 @@ private function createOccurrenceMock( string $startDate = '2026-03-01 09:00:00', ?string $endDate = '2026-03-01 11:00:00', string $status = 'ACTIVE', + ?int $eventLocationId = null, ): EventOccurrenceDomainObject|MockInterface { $occurrence = Mockery::mock(EventOccurrenceDomainObject::class); $occurrence->shouldReceive('isPast')->andReturn($isPast); @@ -619,13 +825,10 @@ private function createOccurrenceMock( $occurrence->shouldReceive('getStatus')->andReturn($status); $occurrence->shouldReceive('getStartDate')->andReturn($startDate); $occurrence->shouldReceive('getEndDate')->andReturn($endDate); + $occurrence->shouldReceive('getEventLocationId')->andReturn($eventLocationId); + $occurrence->shouldReceive('getUsedCapacity')->andReturn(0)->byDefault(); + $occurrence->shouldReceive('getCapacity')->andReturn(null)->byDefault(); return $occurrence; } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } } diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/CreateEventOccurrenceHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/CreateEventOccurrenceHandlerTest.php index 1c70a0aba5..8f7b059488 100644 --- a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/CreateEventOccurrenceHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/CreateEventOccurrenceHandlerTest.php @@ -2,12 +2,18 @@ namespace Tests\Unit\Services\Application\Handlers\EventOccurrence; +use HiEvents\DomainObjects\Enums\LocationType; +use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventLocationDomainObject; use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\Generated\EventOccurrenceDomainObjectAbstract; use HiEvents\DomainObjects\Status\EventOccurrenceStatus; use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; +use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Services\Application\Handlers\EventOccurrence\CreateEventOccurrenceHandler; use HiEvents\Services\Application\Handlers\EventOccurrence\DTO\UpsertEventOccurrenceDTO; +use HiEvents\Services\Domain\EventLocation\EventLocationData; +use HiEvents\Services\Domain\EventLocation\EventLocationUpserter; use Illuminate\Database\DatabaseManager; use Mockery; use Mockery\MockInterface; @@ -16,7 +22,13 @@ class CreateEventOccurrenceHandlerTest extends TestCase { private EventOccurrenceRepositoryInterface|MockInterface $occurrenceRepository; + + private EventRepositoryInterface|MockInterface $eventRepository; + + private EventLocationUpserter|MockInterface $eventLocationUpserter; + private DatabaseManager|MockInterface $databaseManager; + private CreateEventOccurrenceHandler $handler; protected function setUp(): void @@ -24,34 +36,48 @@ protected function setUp(): void parent::setUp(); $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class); + $this->eventRepository = Mockery::mock(EventRepositoryInterface::class); + $this->eventLocationUpserter = Mockery::mock(EventLocationUpserter::class); $this->databaseManager = Mockery::mock(DatabaseManager::class); $this->databaseManager->shouldReceive('transaction') - ->andReturnUsing(fn($callback) => $callback()); + ->andReturnUsing(fn ($callback) => $callback()); $this->handler = new CreateEventOccurrenceHandler( $this->occurrenceRepository, + $this->eventRepository, + $this->eventLocationUpserter, $this->databaseManager, ); } - public function testHandleSuccessfullyCreatesOccurrence(): void + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + public function test_creates_occurrence_inheriting_event_location_when_no_event_location_payload(): void { + // No event_location override on the DTO — the occurrence should inherit + // by carrying a null event_location_id. The upserter must not be touched. $dto = new UpsertEventOccurrenceDTO( event_id: 1, start_date: '2026-06-01 10:00:00', end_date: '2026-06-01 18:00:00', capacity: 100, label: 'Morning Session', - is_overridden: false, ); $expectedOccurrence = Mockery::mock(EventOccurrenceDomainObject::class); + $this->eventLocationUpserter->shouldNotReceive('createForEvent'); + $this->eventRepository->shouldNotReceive('findById'); + $this->occurrenceRepository ->shouldReceive('create') ->once() - ->with(Mockery::on(function ($attrs) { + ->with(Mockery::on(function (array $attrs) { return $attrs[EventOccurrenceDomainObjectAbstract::EVENT_ID] === 1 && $attrs[EventOccurrenceDomainObjectAbstract::START_DATE] === '2026-06-01 10:00:00' && $attrs[EventOccurrenceDomainObjectAbstract::END_DATE] === '2026-06-01 18:00:00' @@ -59,6 +85,7 @@ public function testHandleSuccessfullyCreatesOccurrence(): void && $attrs[EventOccurrenceDomainObjectAbstract::CAPACITY] === 100 && $attrs[EventOccurrenceDomainObjectAbstract::USED_CAPACITY] === 0 && $attrs[EventOccurrenceDomainObjectAbstract::LABEL] === 'Morning Session' + && $attrs[EventOccurrenceDomainObjectAbstract::EVENT_LOCATION_ID] === null && str_starts_with($attrs[EventOccurrenceDomainObjectAbstract::SHORT_ID], 'oc_'); })) ->andReturn($expectedOccurrence); @@ -68,29 +95,45 @@ public function testHandleSuccessfullyCreatesOccurrence(): void $this->assertSame($expectedOccurrence, $result); } - public function testHandleAlwaysCreatesOccurrencesAsActive(): void + public function test_creates_occurrence_with_in_person_override(): void { - // Status is no longer client-controllable on create — it always starts - // ACTIVE. Cancellation goes through CancelOccurrenceHandler, SOLD_OUT - // is computed by ProductQuantityUpdateService. + $locationData = new EventLocationData( + type: LocationType::IN_PERSON, + location_id: 42, + ); + $dto = new UpsertEventOccurrenceDTO( - event_id: 2, - start_date: '2026-07-01 09:00:00', - end_date: null, - capacity: null, + event_id: 1, + start_date: '2026-06-01 10:00:00', + event_location: $locationData, ); + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getAccountId')->andReturn(7); + + $createdEventLocation = Mockery::mock(EventLocationDomainObject::class); + $createdEventLocation->shouldReceive('getId')->andReturn(99); + + $this->eventRepository + ->shouldReceive('findById') + ->once() + ->with(1) + ->andReturn($event); + + $this->eventLocationUpserter + ->shouldReceive('createForEvent') + ->once() + ->with(1, 7, $locationData) + ->andReturn($createdEventLocation); + $expectedOccurrence = Mockery::mock(EventOccurrenceDomainObject::class); $this->occurrenceRepository ->shouldReceive('create') ->once() - ->with(Mockery::on(function ($attrs) { - return $attrs[EventOccurrenceDomainObjectAbstract::EVENT_ID] === 2 - && $attrs[EventOccurrenceDomainObjectAbstract::START_DATE] === '2026-07-01 09:00:00' - && $attrs[EventOccurrenceDomainObjectAbstract::END_DATE] === null - && $attrs[EventOccurrenceDomainObjectAbstract::STATUS] === EventOccurrenceStatus::ACTIVE->name - && str_starts_with($attrs[EventOccurrenceDomainObjectAbstract::SHORT_ID], 'oc_'); + ->with(Mockery::on(function (array $attrs) { + return $attrs[EventOccurrenceDomainObjectAbstract::EVENT_LOCATION_ID] === 99 + && $attrs[EventOccurrenceDomainObjectAbstract::EVENT_ID] === 1; })) ->andReturn($expectedOccurrence); @@ -99,9 +142,80 @@ public function testHandleAlwaysCreatesOccurrencesAsActive(): void $this->assertSame($expectedOccurrence, $result); } - protected function tearDown(): void + public function test_creates_occurrence_with_online_override(): void { - Mockery::close(); - parent::tearDown(); + $locationData = new EventLocationData( + type: LocationType::ONLINE, + online_event_connection_details: '

Zoom link

', + ); + + $dto = new UpsertEventOccurrenceDTO( + event_id: 1, + start_date: '2026-06-01 10:00:00', + event_location: $locationData, + ); + + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getAccountId')->andReturn(7); + + $createdEventLocation = Mockery::mock(EventLocationDomainObject::class); + $createdEventLocation->shouldReceive('getId')->andReturn(123); + + $this->eventRepository + ->shouldReceive('findById') + ->once() + ->with(1) + ->andReturn($event); + + $this->eventLocationUpserter + ->shouldReceive('createForEvent') + ->once() + ->with(1, 7, $locationData) + ->andReturn($createdEventLocation); + + $expectedOccurrence = Mockery::mock(EventOccurrenceDomainObject::class); + + $this->occurrenceRepository + ->shouldReceive('create') + ->once() + ->with(Mockery::on(function (array $attrs) { + return $attrs[EventOccurrenceDomainObjectAbstract::EVENT_LOCATION_ID] === 123; + })) + ->andReturn($expectedOccurrence); + + $result = $this->handler->handle($dto); + + $this->assertSame($expectedOccurrence, $result); + } + + public function test_throws_when_event_not_found_for_override(): void + { + // event_location is set but the event lookup throws — the upserter + // never runs and the occurrence never gets created. Note the handler + // also has an explicit `=== null` guard which is unreachable in + // production because `findById`'s contract is non-nullable + // (BaseRepository throws ModelNotFoundException), so the realistic + // failure surface is the bubble-up. + $dto = new UpsertEventOccurrenceDTO( + event_id: 999, + start_date: '2026-06-01 10:00:00', + event_location: new EventLocationData( + type: LocationType::IN_PERSON, + location_id: 42, + ), + ); + + $this->eventRepository + ->shouldReceive('findById') + ->once() + ->with(999) + ->andThrow(new \Illuminate\Database\Eloquent\ModelNotFoundException); + + $this->eventLocationUpserter->shouldNotReceive('createForEvent'); + $this->occurrenceRepository->shouldNotReceive('create'); + + $this->expectException(\Illuminate\Database\Eloquent\ModelNotFoundException::class); + + $this->handler->handle($dto); } } diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GenerateOccurrencesFromRuleHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GenerateOccurrencesFromRuleHandlerTest.php index b3926878cc..d392ae2a8f 100644 --- a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GenerateOccurrencesFromRuleHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GenerateOccurrencesFromRuleHandlerTest.php @@ -18,9 +18,13 @@ class GenerateOccurrencesFromRuleHandlerTest extends TestCase { private EventOccurrenceGeneratorService|Mockery\MockInterface $generatorService; + private EventRepositoryInterface|Mockery\MockInterface $eventRepository; + private RecurrenceRuleParserService|Mockery\MockInterface $ruleParserService; + private DatabaseManager|Mockery\MockInterface $databaseManager; + private GenerateOccurrencesFromRuleHandler $handler; protected function setUp(): void @@ -33,7 +37,7 @@ protected function setUp(): void $this->databaseManager = Mockery::mock(DatabaseManager::class); $this->databaseManager->shouldReceive('transaction') - ->andReturnUsing(fn($callback) => $callback()); + ->andReturnUsing(fn ($callback) => $callback()); $this->handler = new GenerateOccurrencesFromRuleHandler( $this->generatorService, @@ -43,7 +47,7 @@ protected function setUp(): void ); } - public function testHandleGeneratesOccurrencesAndUpdatesEventType(): void + public function test_handle_generates_occurrences_and_updates_event_type(): void { $rule = ['frequency' => 'weekly', 'range' => ['type' => 'count', 'count' => 10]]; $dto = new GenerateOccurrencesDTO(event_id: 1, recurrence_rule: $rule); @@ -78,7 +82,7 @@ public function testHandleGeneratesOccurrencesAndUpdatesEventType(): void $this->assertSame($generatedOccurrences, $result); } - public function testHandleThrowsValidationExceptionWhenTooManyOccurrences(): void + public function test_handle_throws_validation_exception_when_too_many_occurrences(): void { $rule = ['frequency' => 'daily', 'range' => ['type' => 'count', 'count' => 2000]]; $dto = new GenerateOccurrencesDTO(event_id: 1, recurrence_rule: $rule); @@ -100,7 +104,7 @@ public function testHandleThrowsValidationExceptionWhenTooManyOccurrences(): voi $this->handler->handle($dto); } - public function testHandleUsesUtcWhenEventHasNoTimezone(): void + public function test_handle_uses_utc_when_event_has_no_timezone(): void { $rule = ['frequency' => 'weekly']; $dto = new GenerateOccurrencesDTO(event_id: 1, recurrence_rule: $rule); diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetEventOccurrenceHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetEventOccurrenceHandlerTest.php index 4da7f1d134..11f3c29038 100644 --- a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetEventOccurrenceHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetEventOccurrenceHandlerTest.php @@ -3,17 +3,17 @@ namespace Tests\Unit\Services\Application\Handlers\EventOccurrence; use HiEvents\DomainObjects\EventOccurrenceDomainObject; -use HiEvents\DomainObjects\EventOccurrenceStatisticDomainObject; use HiEvents\DomainObjects\Generated\EventOccurrenceDomainObjectAbstract; +use HiEvents\Exceptions\ResourceNotFoundException; use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; use HiEvents\Services\Application\Handlers\EventOccurrence\GetEventOccurrenceHandler; use Mockery; -use HiEvents\Exceptions\ResourceNotFoundException; use Tests\TestCase; class GetEventOccurrenceHandlerTest extends TestCase { private EventOccurrenceRepositoryInterface|Mockery\MockInterface $occurrenceRepository; + private GetEventOccurrenceHandler $handler; protected function setUp(): void @@ -24,14 +24,12 @@ protected function setUp(): void $this->handler = new GetEventOccurrenceHandler($this->occurrenceRepository); } - public function testHandleReturnsOccurrenceWithStats(): void + public function test_handle_returns_occurrence_with_stats(): void { $occurrence = Mockery::mock(EventOccurrenceDomainObject::class); $this->occurrenceRepository ->shouldReceive('loadRelation') - ->with(EventOccurrenceStatisticDomainObject::class) - ->once() ->andReturnSelf(); $this->occurrenceRepository @@ -48,11 +46,10 @@ public function testHandleReturnsOccurrenceWithStats(): void $this->assertSame($occurrence, $result); } - public function testHandleThrowsWhenOccurrenceNotFound(): void + public function test_handle_throws_when_occurrence_not_found(): void { $this->occurrenceRepository ->shouldReceive('loadRelation') - ->once() ->andReturnSelf(); $this->occurrenceRepository diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetEventOccurrencesHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetEventOccurrencesHandlerTest.php index e5e208e1d2..e7e2f3ef1b 100644 --- a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetEventOccurrencesHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetEventOccurrencesHandlerTest.php @@ -2,7 +2,6 @@ namespace Tests\Unit\Services\Application\Handlers\EventOccurrence; -use HiEvents\DomainObjects\EventOccurrenceStatisticDomainObject; use HiEvents\Http\DTO\QueryParamsDTO; use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; use HiEvents\Services\Application\Handlers\EventOccurrence\GetEventOccurrencesHandler; @@ -13,6 +12,7 @@ class GetEventOccurrencesHandlerTest extends TestCase { private EventOccurrenceRepositoryInterface|Mockery\MockInterface $occurrenceRepository; + private GetEventOccurrencesHandler $handler; protected function setUp(): void @@ -23,15 +23,13 @@ protected function setUp(): void $this->handler = new GetEventOccurrencesHandler($this->occurrenceRepository); } - public function testHandleReturnsPaginatedOccurrencesWithStats(): void + public function test_handle_returns_paginated_occurrences_with_stats(): void { $queryParams = Mockery::mock(QueryParamsDTO::class); $paginator = Mockery::mock(LengthAwarePaginator::class); $this->occurrenceRepository ->shouldReceive('loadRelation') - ->with(EventOccurrenceStatisticDomainObject::class) - ->once() ->andReturnSelf(); $this->occurrenceRepository diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetProductVisibilityHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetProductVisibilityHandlerTest.php index 7f82080cc5..4e15821055 100644 --- a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetProductVisibilityHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetProductVisibilityHandlerTest.php @@ -16,7 +16,9 @@ class GetProductVisibilityHandlerTest extends TestCase { private ProductOccurrenceVisibilityRepositoryInterface|Mockery\MockInterface $visibilityRepository; + private EventOccurrenceRepositoryInterface|Mockery\MockInterface $occurrenceRepository; + private GetProductVisibilityHandler $handler; protected function setUp(): void @@ -28,7 +30,7 @@ protected function setUp(): void $this->handler = new GetProductVisibilityHandler($this->visibilityRepository, $this->occurrenceRepository); } - public function testHandleReturnsVisibilityRecords(): void + public function test_handle_returns_visibility_records(): void { $occurrence = Mockery::mock(EventOccurrenceDomainObject::class); @@ -51,7 +53,7 @@ public function testHandleReturnsVisibilityRecords(): void $this->assertCount(1, $result); } - public function testHandleThrowsWhenOccurrenceNotFound(): void + public function test_handle_throws_when_occurrence_not_found(): void { $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn(null); diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/DeletePriceOverrideHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/DeletePriceOverrideHandlerTest.php index b3589ae332..f2f96a5b0c 100644 --- a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/DeletePriceOverrideHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/DeletePriceOverrideHandlerTest.php @@ -18,8 +18,11 @@ class DeletePriceOverrideHandlerTest extends TestCase { private ProductPriceOccurrenceOverrideRepositoryInterface|MockInterface $overrideRepository; + private EventOccurrenceRepositoryInterface|MockInterface $occurrenceRepository; + private DatabaseManager|MockInterface $databaseManager; + private DeletePriceOverrideHandler $handler; protected function setUp(): void @@ -31,7 +34,7 @@ protected function setUp(): void $this->databaseManager = Mockery::mock(DatabaseManager::class); $this->databaseManager->shouldReceive('transaction') - ->andReturnUsing(fn($callback) => $callback()); + ->andReturnUsing(fn ($callback) => $callback()); $this->handler = new DeletePriceOverrideHandler( $this->overrideRepository, @@ -40,7 +43,7 @@ protected function setUp(): void ); } - public function testHandleSuccessfullyDeletesOverrideScopedToOccurrence(): void + public function test_handle_successfully_deletes_override_scoped_to_occurrence(): void { $eventId = 1; $occurrenceId = 10; @@ -79,7 +82,7 @@ public function testHandleSuccessfullyDeletesOverrideScopedToOccurrence(): void $this->assertTrue(true); } - public function testHandleThrowsExceptionWhenOccurrenceDoesNotBelongToEvent(): void + public function test_handle_throws_exception_when_occurrence_does_not_belong_to_event(): void { $eventId = 1; $occurrenceId = 10; @@ -102,7 +105,7 @@ public function testHandleThrowsExceptionWhenOccurrenceDoesNotBelongToEvent(): v $this->handler->handle($eventId, $occurrenceId, $overrideId); } - public function testHandleThrowsExceptionWhenOverrideNotFound(): void + public function test_handle_throws_exception_when_override_not_found(): void { $eventId = 1; $occurrenceId = 10; @@ -131,7 +134,7 @@ public function testHandleThrowsExceptionWhenOverrideNotFound(): void $this->handler->handle($eventId, $occurrenceId, $overrideId); } - public function testHandleScopesLookupToOccurrenceId(): void + public function test_handle_scopes_lookup_to_occurrence_id(): void { $eventId = 1; $occurrenceId = 42; @@ -158,7 +161,7 @@ public function testHandleScopesLookupToOccurrenceId(): void $this->handler->handle($eventId, $occurrenceId, $overrideId); } - public function testHandleDeletesOnlyTheSpecifiedOverride(): void + public function test_handle_deletes_only_the_specified_override(): void { $eventId = 1; $occurrenceId = 10; diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/GetPriceOverridesHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/GetPriceOverridesHandlerTest.php index b747358f6a..6d46da489e 100644 --- a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/GetPriceOverridesHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/GetPriceOverridesHandlerTest.php @@ -16,7 +16,9 @@ class GetPriceOverridesHandlerTest extends TestCase { private ProductPriceOccurrenceOverrideRepositoryInterface|MockInterface $overrideRepository; + private EventOccurrenceRepositoryInterface|MockInterface $occurrenceRepository; + private GetPriceOverridesHandler $handler; protected function setUp(): void @@ -35,7 +37,7 @@ private function mockOccurrenceOwnership(): void ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class)); } - public function testHandleReturnsCollectionOfOverridesForOccurrence(): void + public function test_handle_returns_collection_of_overrides_for_occurrence(): void { $this->mockOccurrenceOwnership(); @@ -55,21 +57,21 @@ public function testHandleReturnsCollectionOfOverridesForOccurrence(): void $this->assertSame($expectedCollection, $result); } - public function testHandleReturnsEmptyCollectionWhenNoneExist(): void + public function test_handle_returns_empty_collection_when_none_exist(): void { $this->mockOccurrenceOwnership(); $this->overrideRepository ->shouldReceive('findWhere') ->once() - ->andReturn(new Collection()); + ->andReturn(new Collection); $result = $this->handler->handle(1, 99); $this->assertTrue($result->isEmpty()); } - public function testHandleThrowsWhenOccurrenceDoesNotBelongToEvent(): void + public function test_handle_throws_when_occurrence_does_not_belong_to_event(): void { $this->occurrenceRepository ->shouldReceive('findFirstWhere') diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/UpsertPriceOverrideHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/UpsertPriceOverrideHandlerTest.php index d92dca0fb7..72a90ddaa4 100644 --- a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/UpsertPriceOverrideHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/UpsertPriceOverrideHandlerTest.php @@ -22,10 +22,15 @@ class UpsertPriceOverrideHandlerTest extends TestCase { private ProductPriceOccurrenceOverrideRepositoryInterface|MockInterface $overrideRepository; + private EventOccurrenceRepositoryInterface|MockInterface $occurrenceRepository; + private ProductPriceRepositoryInterface|MockInterface $productPriceRepository; + private ProductRepositoryInterface|MockInterface $productRepository; + private DatabaseManager|MockInterface $databaseManager; + private UpsertPriceOverrideHandler $handler; protected function setUp(): void @@ -39,7 +44,7 @@ protected function setUp(): void $this->databaseManager = Mockery::mock(DatabaseManager::class); $this->databaseManager->shouldReceive('transaction') - ->andReturnUsing(fn($callback) => $callback()); + ->andReturnUsing(fn ($callback) => $callback()); $this->handler = new UpsertPriceOverrideHandler( $this->overrideRepository, @@ -67,7 +72,7 @@ private function mockOwnershipChecks(): void ->andReturn(Mockery::mock(ProductDomainObject::class)); } - public function testHandleCreatesNewOverrideWhenNoneExists(): void + public function test_handle_creates_new_override_when_none_exists(): void { $this->mockOwnershipChecks(); @@ -104,7 +109,7 @@ public function testHandleCreatesNewOverrideWhenNoneExists(): void $this->assertSame($expectedOverride, $result); } - public function testHandleUpdatesExistingOverride(): void + public function test_handle_updates_existing_override(): void { $this->mockOwnershipChecks(); @@ -145,7 +150,7 @@ public function testHandleUpdatesExistingOverride(): void $this->assertSame($updatedOverride, $result); } - public function testHandlePassesCorrectEventOccurrenceId(): void + public function test_handle_passes_correct_event_occurrence_id(): void { $occurrenceId = 42; $this->mockOwnershipChecks(); @@ -180,7 +185,7 @@ public function testHandlePassesCorrectEventOccurrenceId(): void $this->assertSame($expectedOverride, $result); } - public function testHandlePassesCorrectProductPriceId(): void + public function test_handle_passes_correct_product_price_id(): void { $priceId = 77; $this->mockOwnershipChecks(); @@ -215,7 +220,7 @@ public function testHandlePassesCorrectProductPriceId(): void $this->assertSame($expectedOverride, $result); } - public function testHandlePassesCorrectPrice(): void + public function test_handle_passes_correct_price(): void { $price = 199.50; $this->mockOwnershipChecks(); diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/UpdateEventOccurrenceHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/UpdateEventOccurrenceHandlerTest.php index b4d8d1f75b..e100ec4126 100644 --- a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/UpdateEventOccurrenceHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/UpdateEventOccurrenceHandlerTest.php @@ -2,13 +2,20 @@ namespace Tests\Unit\Services\Application\Handlers\EventOccurrence; +use HiEvents\DomainObjects\Enums\LocationType; +use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventLocationDomainObject; use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\Generated\EventOccurrenceDomainObjectAbstract; use HiEvents\DomainObjects\Status\EventOccurrenceStatus; use HiEvents\Exceptions\ResourceNotFoundException; use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; +use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Services\Application\Handlers\EventOccurrence\DTO\UpsertEventOccurrenceDTO; use HiEvents\Services\Application\Handlers\EventOccurrence\UpdateEventOccurrenceHandler; +use HiEvents\Services\Domain\EventLocation\EventLocationCleaner; +use HiEvents\Services\Domain\EventLocation\EventLocationData; +use HiEvents\Services\Domain\EventLocation\EventLocationUpserter; use Illuminate\Database\DatabaseManager; use Mockery; use Mockery\MockInterface; @@ -18,6 +25,12 @@ class UpdateEventOccurrenceHandlerTest extends TestCase { private EventOccurrenceRepositoryInterface|MockInterface $occurrenceRepository; + private EventRepositoryInterface|MockInterface $eventRepository; + + private EventLocationUpserter|MockInterface $eventLocationUpserter; + + private EventLocationCleaner|MockInterface $eventLocationCleaner; + private DatabaseManager|MockInterface $databaseManager; private UpdateEventOccurrenceHandler $handler; @@ -27,6 +40,9 @@ protected function setUp(): void parent::setUp(); $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class); + $this->eventRepository = Mockery::mock(EventRepositoryInterface::class); + $this->eventLocationUpserter = Mockery::mock(EventLocationUpserter::class); + $this->eventLocationCleaner = Mockery::mock(EventLocationCleaner::class); $this->databaseManager = Mockery::mock(DatabaseManager::class); $this->databaseManager->shouldReceive('transaction') @@ -34,6 +50,9 @@ protected function setUp(): void $this->handler = new UpdateEventOccurrenceHandler( $this->occurrenceRepository, + $this->eventRepository, + $this->eventLocationUpserter, + $this->eventLocationCleaner, $this->databaseManager, ); } @@ -56,6 +75,7 @@ private function existingOccurrence( bool $isOverridden = false, string $status = EventOccurrenceStatus::ACTIVE->name, int $usedCapacity = 0, + ?int $eventLocationId = null, ): MockInterface { $occ = Mockery::mock(EventOccurrenceDomainObject::class); $occ->shouldReceive('getId')->andReturn($id); @@ -64,9 +84,8 @@ private function existingOccurrence( $occ->shouldReceive('getCapacity')->andReturn($capacity); $occ->shouldReceive('getIsOverridden')->andReturn($isOverridden); $occ->shouldReceive('getStatus')->andReturn($status); - // SOLD_OUT/ACTIVE reconciliation in the handler reads used_capacity to - // decide whether the new ceiling has headroom. $occ->shouldReceive('getUsedCapacity')->andReturn($usedCapacity); + $occ->shouldReceive('getEventLocationId')->andReturn($eventLocationId); return $occ; } @@ -289,11 +308,6 @@ public function test_handle_does_not_write_status_when_capacity_unchanged_and_st public function test_handle_reactivates_sold_out_occurrence_when_capacity_increases_above_used(): void { - // ProductQuantityUpdateService::increaseOccurrenceUsedCapacity flips - // ACTIVE → SOLD_OUT when usage crosses the ceiling. The reverse path - // only runs from decreaseOccurrenceUsedCapacity, so a capacity edit - // that raises the ceiling above current usage is the only place the - // generic update handler can re-open a sold-out date. $occurrenceId = 10; $eventId = 1; @@ -329,7 +343,6 @@ public function test_handle_reactivates_sold_out_occurrence_when_capacity_increa public function test_handle_reactivates_sold_out_occurrence_when_capacity_cleared_to_unlimited(): void { - // Unlimited capacity (null) can never be sold out. $occurrenceId = 10; $eventId = 1; @@ -436,7 +449,213 @@ public function test_handle_does_not_write_status_for_cancelled_occurrence_even_ $this->assertNotNull($result); } - public function test_handle_throws_exception_when_occurrence_not_found(): void + public function test_gains_override_calls_create_for_event(): void + { + // Occurrence had no event_location override (event_location_id: null); + // DTO supplies new event_location → upserter creates a fresh row, + // FK gets set, and override flag is pinned. + $occurrenceId = 10; + $eventId = 1; + + $existing = $this->existingOccurrence(id: $occurrenceId, eventLocationId: null); + + $locationData = new EventLocationData( + type: LocationType::IN_PERSON, + location_id: 42, + ); + + $dto = new UpsertEventOccurrenceDTO( + event_id: $eventId, + start_date: '2026-06-01 10:00:00', + end_date: '2026-06-01 18:00:00', + capacity: 100, + event_location: $locationData, + ); + + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getAccountId')->andReturn(7); + + $createdEventLocation = Mockery::mock(EventLocationDomainObject::class); + $createdEventLocation->shouldReceive('getId')->andReturn(500); + + $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing); + + $this->eventRepository + ->shouldReceive('findById') + ->once() + ->with($eventId) + ->andReturn($event); + + $this->eventLocationUpserter + ->shouldReceive('createForEvent') + ->once() + ->with($eventId, 7, $locationData) + ->andReturn($createdEventLocation); + + $this->eventLocationUpserter->shouldNotReceive('updateInPlace'); + $this->eventLocationCleaner->shouldNotReceive('deleteIfOrphaned'); + + $this->occurrenceRepository + ->shouldReceive('updateFromArray') + ->once() + ->with( + $occurrenceId, + Mockery::on(fn (array $attrs) => $attrs[EventOccurrenceDomainObjectAbstract::EVENT_LOCATION_ID] === 500 + && $attrs[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] === true), + ) + ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class)); + + $result = $this->handler->handle($occurrenceId, $dto); + $this->assertNotNull($result); + } + + public function test_edits_existing_override_calls_update_in_place(): void + { + // Occurrence already has an event_location_id; DTO supplies a new + // event_location payload → upserter updates the existing row in place, + // FK stays the same. + $occurrenceId = 10; + $eventId = 1; + $existingEventLocationId = 5; + + $existing = $this->existingOccurrence( + id: $occurrenceId, + eventLocationId: $existingEventLocationId, + ); + + $locationData = new EventLocationData( + type: LocationType::IN_PERSON, + location_id: 42, + ); + + $dto = new UpsertEventOccurrenceDTO( + event_id: $eventId, + start_date: '2026-06-01 10:00:00', + end_date: '2026-06-01 18:00:00', + capacity: 100, + event_location: $locationData, + ); + + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getAccountId')->andReturn(7); + + $updatedEventLocation = Mockery::mock(EventLocationDomainObject::class); + + $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing); + + $this->eventRepository + ->shouldReceive('findById') + ->once() + ->with($eventId) + ->andReturn($event); + + $this->eventLocationUpserter + ->shouldReceive('updateInPlace') + ->once() + ->with($existingEventLocationId, $eventId, 7, $locationData) + ->andReturn($updatedEventLocation); + + $this->eventLocationUpserter->shouldNotReceive('createForEvent'); + $this->eventLocationCleaner->shouldNotReceive('deleteIfOrphaned'); + + $this->occurrenceRepository + ->shouldReceive('updateFromArray') + ->once() + ->with( + $occurrenceId, + // FK stays pinned to the existing row — updateInPlace mutates the + // row, doesn't create a new one. + Mockery::on(fn (array $attrs) => $attrs[EventOccurrenceDomainObjectAbstract::EVENT_LOCATION_ID] === $existingEventLocationId), + ) + ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class)); + + $result = $this->handler->handle($occurrenceId, $dto); + $this->assertNotNull($result); + } + + public function test_clear_event_location_clears_fk_and_cleans_up(): void + { + // Occurrence has an override; DTO requests clear_event_location → FK + // gets nulled and the cleaner runs to soft-delete if the row is no + // longer referenced. + $occurrenceId = 10; + $eventId = 1; + $existingEventLocationId = 5; + + $existing = $this->existingOccurrence( + id: $occurrenceId, + eventLocationId: $existingEventLocationId, + ); + + $dto = new UpsertEventOccurrenceDTO( + event_id: $eventId, + start_date: '2026-06-01 10:00:00', + end_date: '2026-06-01 18:00:00', + capacity: 100, + clear_event_location: true, + ); + + $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing); + + $this->eventLocationUpserter->shouldNotReceive('createForEvent'); + $this->eventLocationUpserter->shouldNotReceive('updateInPlace'); + + $this->occurrenceRepository + ->shouldReceive('updateFromArray') + ->once() + ->with( + $occurrenceId, + Mockery::on(fn (array $attrs) => $attrs[EventOccurrenceDomainObjectAbstract::EVENT_LOCATION_ID] === null + && $attrs[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] === true), + ) + ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class)); + + $this->eventLocationCleaner + ->shouldReceive('deleteIfOrphaned') + ->once() + ->with($existingEventLocationId); + + $result = $this->handler->handle($occurrenceId, $dto); + $this->assertNotNull($result); + } + + public function test_clear_event_location_noop_when_no_existing_fk(): void + { + // No existing override → clear_event_location is a no-op for both the + // upserter and the cleaner. Just a regular update. + $occurrenceId = 10; + $eventId = 1; + + $existing = $this->existingOccurrence(id: $occurrenceId, eventLocationId: null); + + $dto = new UpsertEventOccurrenceDTO( + event_id: $eventId, + start_date: '2026-06-01 10:00:00', + end_date: '2026-06-01 18:00:00', + capacity: 100, + clear_event_location: true, + ); + + $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing); + + $this->eventLocationUpserter->shouldNotReceive('createForEvent'); + $this->eventLocationUpserter->shouldNotReceive('updateInPlace'); + $this->eventLocationCleaner->shouldNotReceive('deleteIfOrphaned'); + + $this->occurrenceRepository + ->shouldReceive('updateFromArray') + ->once() + ->with( + $occurrenceId, + Mockery::on(fn (array $attrs) => $attrs[EventOccurrenceDomainObjectAbstract::EVENT_LOCATION_ID] === null), + ) + ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class)); + + $result = $this->handler->handle($occurrenceId, $dto); + $this->assertNotNull($result); + } + + public function test_throws_when_occurrence_not_found(): void { $occurrenceId = 999; $eventId = 1; @@ -459,7 +678,6 @@ public function test_handle_throws_exception_when_occurrence_not_found(): void $this->expectException(ResourceNotFoundException::class); - $result = $this->handler->handle($occurrenceId, $dto); - $this->assertNotNull($result); + $this->handler->handle($occurrenceId, $dto); } } diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/UpdateProductVisibilityHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/UpdateProductVisibilityHandlerTest.php index 4112178c89..965028db00 100644 --- a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/UpdateProductVisibilityHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/UpdateProductVisibilityHandlerTest.php @@ -6,7 +6,6 @@ use HiEvents\DomainObjects\Generated\EventOccurrenceDomainObjectAbstract; use HiEvents\DomainObjects\Generated\ProductDomainObjectAbstract; use HiEvents\DomainObjects\Generated\ProductOccurrenceVisibilityDomainObjectAbstract; -use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\DomainObjects\ProductOccurrenceVisibilityDomainObject; use HiEvents\Exceptions\ResourceNotFoundException; use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; @@ -22,9 +21,13 @@ class UpdateProductVisibilityHandlerTest extends TestCase { private ProductOccurrenceVisibilityRepositoryInterface|Mockery\MockInterface $visibilityRepository; + private ProductRepositoryInterface|Mockery\MockInterface $productRepository; + private EventOccurrenceRepositoryInterface|Mockery\MockInterface $occurrenceRepository; + private DatabaseManager|Mockery\MockInterface $databaseManager; + private UpdateProductVisibilityHandler $handler; protected function setUp(): void @@ -37,7 +40,7 @@ protected function setUp(): void $this->databaseManager = Mockery::mock(DatabaseManager::class); $this->databaseManager->shouldReceive('transaction') - ->andReturnUsing(fn($callback) => $callback()); + ->andReturnUsing(fn ($callback) => $callback()); $this->handler = new UpdateProductVisibilityHandler( $this->visibilityRepository, @@ -50,15 +53,24 @@ protected function setUp(): void private function makeProductCollection(array $ids): Collection { return collect(array_map(function ($id) { - return new class($id) { + return new class($id) + { public function __construct(public readonly int $id) {} - public function offsetGet($key) { return $this->$key; } - public function offsetExists($key): bool { return isset($this->$key); } + + public function offsetGet($key) + { + return $this->$key; + } + + public function offsetExists($key): bool + { + return isset($this->$key); + } }; }, $ids)); } - public function testHandleCreatesVisibilityRecordsForSelectedProducts(): void + public function test_handle_creates_visibility_records_for_selected_products(): void { $dto = new UpdateProductVisibilityDTO( event_id: 1, @@ -101,7 +113,7 @@ public function testHandleCreatesVisibilityRecordsForSelectedProducts(): void $this->assertCount(1, $result); } - public function testHandleReturnsEmptyWhenAllProductsSelected(): void + public function test_handle_returns_empty_when_all_products_selected(): void { $dto = new UpdateProductVisibilityDTO( event_id: 1, @@ -124,7 +136,7 @@ public function testHandleReturnsEmptyWhenAllProductsSelected(): void $this->assertEmpty($result); } - public function testHandleThrowsWhenOccurrenceNotFound(): void + public function test_handle_throws_when_occurrence_not_found(): void { $dto = new UpdateProductVisibilityDTO( event_id: 1, @@ -139,7 +151,7 @@ public function testHandleThrowsWhenOccurrenceNotFound(): void $this->handler->handle($dto); } - public function testHandleThrowsWhenProductIdDoesNotBelongToEvent(): void + public function test_handle_throws_when_product_id_does_not_belong_to_event(): void { $dto = new UpdateProductVisibilityDTO( event_id: 1, diff --git a/backend/tests/Unit/Services/Application/Handlers/Organizer/UpdateOrganizerLocationHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Organizer/UpdateOrganizerLocationHandlerTest.php new file mode 100644 index 0000000000..5995ec2037 --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Organizer/UpdateOrganizerLocationHandlerTest.php @@ -0,0 +1,144 @@ +organizerRepository = Mockery::mock(OrganizerRepositoryInterface::class); + $this->locationRepository = Mockery::mock(LocationRepositoryInterface::class); + $this->handler = new UpdateOrganizerLocationHandler( + $this->organizerRepository, + new LocationOwnershipValidator($this->locationRepository), + ); + } + + public function test_happy_path_sets_location_id(): void + { + $dto = new UpdateOrganizerLocationDTO(organizer_id: 10, account_id: 5, location_id: 99); + $organizer = Mockery::mock(OrganizerDomainObject::class); + + $this->organizerRepository + ->shouldReceive('findFirstWhere') + ->with(['id' => 10, 'account_id' => 5]) + ->andReturn($organizer, $organizer); + + $this->locationRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(['id' => 99, 'account_id' => 5, 'organizer_id' => 10]) + ->andReturn(Mockery::mock(LocationDomainObject::class)); + + $this->organizerRepository + ->shouldReceive('updateWhere') + ->once() + ->with( + ['location_id' => 99], + ['id' => 10, 'account_id' => 5], + ); + + $this->assertSame($organizer, $this->handler->handle($dto)); + } + + public function test_null_location_id_clears_the_relation_without_validator_lookup(): void + { + $dto = new UpdateOrganizerLocationDTO(organizer_id: 10, account_id: 5, location_id: null); + $organizer = Mockery::mock(OrganizerDomainObject::class); + + $this->organizerRepository + ->shouldReceive('findFirstWhere') + ->andReturn($organizer); + + // No location lookup should happen when clearing. + $this->locationRepository->shouldNotReceive('findFirstWhere'); + + $this->organizerRepository + ->shouldReceive('updateWhere') + ->once() + ->with(['location_id' => null], ['id' => 10, 'account_id' => 5]); + + $this->assertSame($organizer, $this->handler->handle($dto)); + } + + public function test_throws_when_organizer_not_found(): void + { + $dto = new UpdateOrganizerLocationDTO(organizer_id: 10, account_id: 5, location_id: null); + + $this->organizerRepository + ->shouldReceive('findFirstWhere') + ->andReturn(null); + + $this->expectException(ResourceNotFoundException::class); + $this->handler->handle($dto); + } + + public function test_throws_when_location_belongs_to_a_different_organizer_in_same_account(): void + { + // The IDOR vector this fix was written for: caller supplies a location_id + // they don't own. The exists:locations,id rule on the request would let + // it through; the validator must reject it. + $dto = new UpdateOrganizerLocationDTO(organizer_id: 10, account_id: 5, location_id: 99); + + $this->organizerRepository + ->shouldReceive('findFirstWhere') + ->andReturn(Mockery::mock(OrganizerDomainObject::class)); + + $this->locationRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(['id' => 99, 'account_id' => 5, 'organizer_id' => 10]) + ->andReturn(null); + + $this->organizerRepository->shouldNotReceive('updateWhere'); + + $this->expectException(ResourceNotFoundException::class); + $this->handler->handle($dto); + } + + public function test_throws_when_location_belongs_to_a_different_account(): void + { + // Cross-tenant attack: the location with this id exists, but on + // another account. The validator filter on account_id + organizer_id + // is what stops it. + $dto = new UpdateOrganizerLocationDTO(organizer_id: 10, account_id: 5, location_id: 99); + + $this->organizerRepository + ->shouldReceive('findFirstWhere') + ->andReturn(Mockery::mock(OrganizerDomainObject::class)); + + $this->locationRepository + ->shouldReceive('findFirstWhere') + ->once() + ->andReturn(null); + + $this->expectException(ResourceNotFoundException::class); + $this->handler->handle($dto); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Services/Domain/Email/EmailTokenContextBuilderTest.php b/backend/tests/Unit/Services/Domain/Email/EmailTokenContextBuilderTest.php index 94ee72dd9b..3dc6b8ba70 100644 --- a/backend/tests/Unit/Services/Domain/Email/EmailTokenContextBuilderTest.php +++ b/backend/tests/Unit/Services/Domain/Email/EmailTokenContextBuilderTest.php @@ -3,12 +3,16 @@ namespace Tests\Unit\Services\Domain\Email; use HiEvents\DomainObjects\AttendeeDomainObject; +use HiEvents\DomainObjects\Enums\LocationType; +use HiEvents\DomainObjects\Enums\PaymentProviders; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventLocationDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; +use HiEvents\DomainObjects\LocationDomainObject; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; -use HiEvents\DomainObjects\Enums\PaymentProviders; use HiEvents\Services\Domain\Email\EmailTokenContextBuilder; use Illuminate\Support\Collection; use Mockery; @@ -21,7 +25,13 @@ class EmailTokenContextBuilderTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->contextBuilder = new EmailTokenContextBuilder(); + $this->contextBuilder = new EmailTokenContextBuilder; + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); } public function test_builds_order_confirmation_context(): void @@ -46,7 +56,7 @@ public function test_builds_order_confirmation_context(): void // Test order context $this->assertEquals('ORD-123456', $context['order']['number']); - $this->assertEquals('$9,999.00', $context['order']['total']); // Updated expected format + $this->assertEquals('$9,999.00', $context['order']['total']); $this->assertEquals('John', $context['order']['first_name']); $this->assertEquals('Doe', $context['order']['last_name']); $this->assertEquals('john@example.com', $context['order']['email']); @@ -92,11 +102,11 @@ public function test_builds_attendee_ticket_context(): void // Test ticket context $this->assertEquals('General Admission', $context['ticket']['name']); - $this->assertEquals('$4,999.00', $context['ticket']['price']); // Updated expected format + $this->assertEquals('$4,999.00', $context['ticket']['price']); // Test event context $this->assertEquals('Amazing Event', $context['event']['title']); - + // Test organizer context $this->assertEquals('Great Organizer', $context['organizer']['name']); } @@ -115,13 +125,11 @@ public function test_whitelists_only_allowed_tokens_for_order_confirmation(): vo $eventSettings ); - // Test that expected nested structure exists $this->assertArrayHasKey('order', $context); $this->assertArrayHasKey('event', $context); $this->assertArrayHasKey('organizer', $context); $this->assertArrayHasKey('settings', $context); - - // Test that expected properties are included + $this->assertArrayHasKey('number', $context['order']); $this->assertArrayHasKey('first_name', $context['order']); $this->assertArrayHasKey('title', $context['event']); @@ -143,13 +151,11 @@ public function test_whitelists_only_allowed_tokens_for_attendee_ticket(): void $eventSettings ); - // Test that expected nested structure exists $this->assertArrayHasKey('attendee', $context); $this->assertArrayHasKey('ticket', $context); $this->assertArrayHasKey('event', $context); $this->assertArrayHasKey('organizer', $context); - - // Test that expected properties are included + $this->assertArrayHasKey('name', $context['attendee']); $this->assertArrayHasKey('name', $context['ticket']); $this->assertArrayHasKey('title', $context['event']); @@ -191,12 +197,222 @@ public function test_occurrence_tokens_empty_when_no_occurrence(): void $this->assertEquals('', $context['occurrence']['label']); } + public function test_event_in_person_location_in_token_context(): void + { + // Event has an in-person EventLocation with a venue → the location + // section of the context should reflect IN_PERSON with structured + // address and no online connection details. + $order = $this->createMockOrder(); + $organizer = $this->createMockOrganizer(); + $eventSettings = $this->createMockEventSettings(); + + $venue = $this->makeVenueLocation( + name: 'The Arena', + structuredAddress: [ + 'venue_name' => 'The Arena', + 'address_line_1' => '1 Stadium Way', + 'city' => 'Dublin', + 'country' => 'IE', + ], + latitude: 53.3478, + longitude: -6.2289, + ); + $eventLocation = $this->makeEventLocation( + type: LocationType::IN_PERSON, + location: $venue, + label: 'Main entrance', + ); + + $event = $this->createMockEventWithLocation($eventLocation); + + $context = $this->contextBuilder->buildOrderConfirmationContext( + $order, $event, $organizer, $eventSettings + ); + + $this->assertArrayHasKey('event_location', $context); + $this->assertSame(LocationType::IN_PERSON->name, $context['event_location']['type']); + $this->assertFalse($context['event_location']['is_online']); + $this->assertNull($context['event_location']['online_connection_details']); + $this->assertSame('The Arena', $context['event_location']['name']); + $this->assertIsArray($context['event_location']['structured_address']); + $this->assertNotEmpty($context['event_location']['formatted_address']); + + // The legacy event.location_details token is still expected to surface + // the same structured address. + $this->assertSame( + $context['event_location']['structured_address'], + $context['event']['location_details'], + ); + } + + public function test_event_online_location_in_token_context(): void + { + // Online event → is_online flips true and online_connection_details + // surfaces. No structured address. + $order = $this->createMockOrder(); + $organizer = $this->createMockOrganizer(); + $eventSettings = $this->createMockEventSettings(); + + $eventLocation = $this->makeEventLocation( + type: LocationType::ONLINE, + location: null, + onlineDetails: '

Zoom: example.zoom.us/j/123

', + ); + + $event = $this->createMockEventWithLocation($eventLocation); + + $context = $this->contextBuilder->buildOrderConfirmationContext( + $order, $event, $organizer, $eventSettings + ); + + $this->assertSame(LocationType::ONLINE->name, $context['event_location']['type']); + $this->assertTrue($context['event_location']['is_online']); + $this->assertSame( + '

Zoom: example.zoom.us/j/123

', + $context['event_location']['online_connection_details'], + ); + $this->assertNull($context['event_location']['structured_address']); + $this->assertNull($context['event']['location_details']); + } + + public function test_occurrence_event_location_overrides_event_event_location(): void + { + // Occurrence has its own EventLocation override → the resolver walk + // (`$occurrence->getEventLocation() ?? $event->getEventLocation()`) + // must pick the occurrence's, not the event's. + $order = $this->createMockOrder(); + $organizer = $this->createMockOrganizer(); + $eventSettings = $this->createMockEventSettings(); + + $eventVenue = $this->makeVenueLocation( + name: 'Event Default Venue', + structuredAddress: ['venue_name' => 'Event Default Venue'], + ); + $eventEventLocation = $this->makeEventLocation( + type: LocationType::IN_PERSON, + location: $eventVenue, + ); + $event = $this->createMockEventWithLocation($eventEventLocation); + + $occVenue = $this->makeVenueLocation( + name: 'Occurrence Override Venue', + structuredAddress: ['venue_name' => 'Occurrence Override Venue'], + ); + $occEventLocation = $this->makeEventLocation( + type: LocationType::IN_PERSON, + location: $occVenue, + ); + $occurrence = $this->createMockOccurrenceWithLocation($occEventLocation); + + $context = $this->contextBuilder->buildOrderConfirmationContext( + $order, $event, $organizer, $eventSettings, $occurrence + ); + + $this->assertSame('Occurrence Override Venue', $context['event_location']['name']); + $this->assertSame( + ['venue_name' => 'Occurrence Override Venue'], + $context['event_location']['structured_address'], + ); + } + + public function test_occurrence_inherits_event_event_location_when_null(): void + { + // Occurrence has no event_location override → the resolver walk falls + // through to the event's EventLocation. + $order = $this->createMockOrder(); + $organizer = $this->createMockOrganizer(); + $eventSettings = $this->createMockEventSettings(); + + $eventVenue = $this->makeVenueLocation( + name: 'Event Default Venue', + structuredAddress: ['venue_name' => 'Event Default Venue'], + ); + $eventEventLocation = $this->makeEventLocation( + type: LocationType::IN_PERSON, + location: $eventVenue, + ); + $event = $this->createMockEventWithLocation($eventEventLocation); + + $occurrence = $this->createMockOccurrenceWithLocation(null); + + $context = $this->contextBuilder->buildOrderConfirmationContext( + $order, $event, $organizer, $eventSettings, $occurrence + ); + + $this->assertSame('Event Default Venue', $context['event_location']['name']); + } + + public function test_safe_fallback_when_event_location_and_occurrence_location_both_null(): void + { + // Both event and occurrence have no EventLocation → the builder + // emits a context with nullable fields rather than crashing. + $order = $this->createMockOrder(); + $organizer = $this->createMockOrganizer(); + $eventSettings = $this->createMockEventSettings(); + + $event = $this->createMockEventWithLocation(null); + $occurrence = $this->createMockOccurrenceWithLocation(null); + + $context = $this->contextBuilder->buildOrderConfirmationContext( + $order, $event, $organizer, $eventSettings, $occurrence + ); + + $this->assertArrayHasKey('event_location', $context); + $this->assertNull($context['event_location']['type']); + $this->assertFalse($context['event_location']['is_online']); + $this->assertNull($context['event_location']['online_connection_details']); + $this->assertNull($context['event_location']['name']); + $this->assertNull($context['event_location']['structured_address']); + $this->assertNull($context['event']['location_details']); + } + + private function makeVenueLocation( + string $name = 'A Venue', + ?array $structuredAddress = null, + ?float $latitude = null, + ?float $longitude = null, + ): LocationDomainObject { + $loc = Mockery::mock(LocationDomainObject::class); + $loc->shouldReceive('getName')->andReturn($name); + $loc->shouldReceive('getStructuredAddress')->andReturn($structuredAddress); + $loc->shouldReceive('getLatitude')->andReturn($latitude); + $loc->shouldReceive('getLongitude')->andReturn($longitude); + + return $loc; + } + + private function makeEventLocation( + LocationType $type, + ?LocationDomainObject $location = null, + ?string $onlineDetails = null, + ?string $label = null, + ): EventLocationDomainObject { + $el = Mockery::mock(EventLocationDomainObject::class); + $el->shouldReceive('getType')->andReturn($type->name); + $el->shouldReceive('getLocation')->andReturn($location); + $el->shouldReceive('getOnlineEventConnectionDetails')->andReturn($onlineDetails); + $el->shouldReceive('getLabel')->andReturn($label); + + return $el; + } + private function createMockOccurrence(): Mockery\MockInterface { - return Mockery::mock(\HiEvents\DomainObjects\EventOccurrenceDomainObject::class, [ + return Mockery::mock(EventOccurrenceDomainObject::class, [ + 'getStartDate' => '2024-07-20 14:00:00', + 'getEndDate' => '2024-07-20 18:00:00', + 'getLabel' => 'Afternoon Show', + 'getEventLocation' => null, + ]); + } + + private function createMockOccurrenceWithLocation(?EventLocationDomainObject $eventLocation): Mockery\MockInterface + { + return Mockery::mock(EventOccurrenceDomainObject::class, [ 'getStartDate' => '2024-07-20 14:00:00', 'getEndDate' => '2024-07-20 18:00:00', 'getLabel' => 'Afternoon Show', + 'getEventLocation' => $eventLocation, ]); } @@ -236,6 +452,21 @@ private function createMockEvent(): EventDomainObject 'getTimezone' => 'America/New_York', 'getCurrency' => 'USD', 'getId' => 1, + 'getEventLocation' => null, + ]); + } + + private function createMockEventWithLocation(?EventLocationDomainObject $eventLocation): EventDomainObject + { + return Mockery::mock(EventDomainObject::class, [ + 'getTitle' => 'Amazing Event', + 'getDescription' => 'This is an amazing event', + 'getStartDate' => '2024-02-15 19:00:00', + 'getEndDate' => '2024-02-15 22:00:00', + 'getTimezone' => 'America/New_York', + 'getCurrency' => 'USD', + 'getId' => 1, + 'getEventLocation' => $eventLocation, ]); } @@ -253,7 +484,6 @@ private function createMockEventSettings(): EventSettingDomainObject 'getSupportEmail' => 'support@event.com', 'getOfflinePaymentInstructions' => 'Pay by bank transfer', 'getPostCheckoutMessage' => 'Thank you for your purchase!', - 'getLocationDetails' => null, ]); } @@ -267,4 +497,4 @@ private function createMockAttendee(): AttendeeDomainObject 'getShortId' => 'ATT123', ]); } -} \ No newline at end of file +} diff --git a/backend/tests/Unit/Services/Domain/EventLocation/EventLocationUpserterTest.php b/backend/tests/Unit/Services/Domain/EventLocation/EventLocationUpserterTest.php new file mode 100644 index 0000000000..beececb256 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/EventLocation/EventLocationUpserterTest.php @@ -0,0 +1,275 @@ +eventRepository = Mockery::mock(EventRepositoryInterface::class); + $this->eventLocationRepository = Mockery::mock(EventLocationRepositoryInterface::class); + $this->ownershipValidator = Mockery::mock(LocationOwnershipValidator::class); + $this->purifier = Mockery::mock(HtmlPurifierService::class); + + $this->upserter = new EventLocationUpserter( + $this->eventRepository, + $this->eventLocationRepository, + $this->ownershipValidator, + $this->purifier, + ); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + public function test_create_for_event_in_person_validates_ownership_and_creates_row(): void + { + // IN_PERSON with a location_id → ownership is validated against the + // owning event's organizer/account, then the row is persisted with a + // short_id minted via IdHelper. + $data = new EventLocationData( + type: LocationType::IN_PERSON, + location_id: 42, + ); + + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getOrganizerId')->andReturn(3); + + $this->eventRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(['id' => 1, 'account_id' => 7]) + ->andReturn($event); + + $this->ownershipValidator + ->shouldReceive('assertOwnedBy') + ->once() + ->with(42, 3, 7); + + $created = Mockery::mock(EventLocationDomainObject::class); + + $this->eventLocationRepository + ->shouldReceive('create') + ->once() + ->with(Mockery::on(function (array $attrs) { + return $attrs[EventLocationDomainObjectAbstract::EVENT_ID] === 1 + && $attrs[EventLocationDomainObjectAbstract::TYPE] === LocationType::IN_PERSON->name + && $attrs[EventLocationDomainObjectAbstract::LOCATION_ID] === 42 + && $attrs[EventLocationDomainObjectAbstract::ONLINE_EVENT_CONNECTION_DETAILS] === null + && str_starts_with($attrs[EventLocationDomainObjectAbstract::SHORT_ID], 'el_'); + })) + ->andReturn($created); + + $result = $this->upserter->createForEvent(1, 7, $data); + + $this->assertSame($created, $result); + } + + public function test_create_for_event_online_nulls_location_id_and_purifies_details(): void + { + $data = new EventLocationData( + type: LocationType::ONLINE, + location_id: null, + online_event_connection_details: '

Zoom

', + ); + + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getOrganizerId')->andReturn(3); + + $this->eventRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(['id' => 1, 'account_id' => 7]) + ->andReturn($event); + + $this->ownershipValidator + ->shouldReceive('assertOwnedBy') + ->once() + ->with(null, 3, 7); + + $this->purifier + ->shouldReceive('purify') + ->once() + ->with('

Zoom

') + ->andReturn('

Zoom

'); + + $created = Mockery::mock(EventLocationDomainObject::class); + + $this->eventLocationRepository + ->shouldReceive('create') + ->once() + ->with(Mockery::on(function (array $attrs) { + return $attrs[EventLocationDomainObjectAbstract::TYPE] === LocationType::ONLINE->name + && $attrs[EventLocationDomainObjectAbstract::LOCATION_ID] === null + && $attrs[EventLocationDomainObjectAbstract::ONLINE_EVENT_CONNECTION_DETAILS] === '

Zoom

'; + })) + ->andReturn($created); + + $result = $this->upserter->createForEvent(1, 7, $data); + + $this->assertSame($created, $result); + } + + public function test_create_for_event_throws_when_event_missing(): void + { + // Ownership validation needs the event to derive the organizer scope; + // if the event lookup misses, surface ResourceNotFoundException. + $data = new EventLocationData( + type: LocationType::IN_PERSON, + location_id: 42, + ); + + $this->eventRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(['id' => 999, 'account_id' => 7]) + ->andReturn(null); + + $this->ownershipValidator->shouldNotReceive('assertOwnedBy'); + $this->eventLocationRepository->shouldNotReceive('create'); + + $this->expectException(ResourceNotFoundException::class); + + $this->upserter->createForEvent(999, 7, $data); + } + + public function test_create_for_event_throws_when_location_not_owned_by_organizer(): void + { + // Ownership validator bubbles up ResourceNotFoundException for + // foreign-organizer or foreign-account locations. + $data = new EventLocationData( + type: LocationType::IN_PERSON, + location_id: 999, + ); + + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getOrganizerId')->andReturn(3); + + $this->eventRepository + ->shouldReceive('findFirstWhere') + ->once() + ->andReturn($event); + + $this->ownershipValidator + ->shouldReceive('assertOwnedBy') + ->once() + ->with(999, 3, 7) + ->andThrow(new ResourceNotFoundException(__('Location :id not found', ['id' => 999]))); + + $this->eventLocationRepository->shouldNotReceive('create'); + + $this->expectException(ResourceNotFoundException::class); + + $this->upserter->createForEvent(1, 7, $data); + } + + public function test_update_in_place_updates_existing_row(): void + { + $data = new EventLocationData( + type: LocationType::IN_PERSON, + location_id: 42, + ); + + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getOrganizerId')->andReturn(3); + + $this->eventRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(['id' => 1, 'account_id' => 7]) + ->andReturn($event); + + $this->ownershipValidator + ->shouldReceive('assertOwnedBy') + ->once() + ->with(42, 3, 7); + + $this->eventLocationRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with([ + EventLocationDomainObjectAbstract::ID => 5, + EventLocationDomainObjectAbstract::EVENT_ID => 1, + ]) + ->andReturn(Mockery::mock(EventLocationDomainObject::class)); + + $updated = Mockery::mock(EventLocationDomainObject::class); + + $this->eventLocationRepository + ->shouldReceive('updateFromArray') + ->once() + ->with( + 5, + Mockery::on(function (array $attrs) { + return $attrs[EventLocationDomainObjectAbstract::TYPE] === LocationType::IN_PERSON->name + && $attrs[EventLocationDomainObjectAbstract::LOCATION_ID] === 42 + && $attrs[EventLocationDomainObjectAbstract::ONLINE_EVENT_CONNECTION_DETAILS] === null + && ! array_key_exists(EventLocationDomainObjectAbstract::SHORT_ID, $attrs); + }), + ) + ->andReturn($updated); + + $result = $this->upserter->updateInPlace(5, 1, 7, $data); + + $this->assertSame($updated, $result); + } + + public function test_update_in_place_throws_when_event_location_id_belongs_to_another_event(): void + { + $data = new EventLocationData(type: LocationType::IN_PERSON, location_id: null); + + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getOrganizerId')->andReturn(3); + + $this->eventRepository + ->shouldReceive('findFirstWhere') + ->andReturn($event); + + $this->ownershipValidator->shouldReceive('assertOwnedBy'); + + $this->eventLocationRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with([ + EventLocationDomainObjectAbstract::ID => 99, + EventLocationDomainObjectAbstract::EVENT_ID => 1, + ]) + ->andReturn(null); + + $this->eventLocationRepository->shouldNotReceive('updateFromArray'); + + $this->expectException(ResourceNotFoundException::class); + + $this->upserter->updateInPlace(99, 1, 7, $data); + } +} diff --git a/backend/tests/Unit/Services/Domain/SelfService/SelfServiceResendEmailServiceTest.php b/backend/tests/Unit/Services/Domain/SelfService/SelfServiceResendEmailServiceTest.php index a083973ac3..5ee56caa5d 100644 --- a/backend/tests/Unit/Services/Domain/SelfService/SelfServiceResendEmailServiceTest.php +++ b/backend/tests/Unit/Services/Domain/SelfService/SelfServiceResendEmailServiceTest.php @@ -84,7 +84,6 @@ public function test_resend_attendee_ticket_successfully(): void // date, venue, and ticket type. $this->attendeeRepository ->shouldReceive('loadRelation') - ->times(3) ->with(Mockery::type(Relationship::class)) ->andReturnSelf(); @@ -100,7 +99,6 @@ public function test_resend_attendee_ticket_successfully(): void $this->eventRepository ->shouldReceive('loadRelation') - ->twice() ->andReturnSelf(); $this->eventRepository @@ -166,7 +164,6 @@ public function test_resend_order_confirmation_successfully(): void $this->orderRepository ->shouldReceive('loadRelation') - ->times(3) ->andReturnSelf(); $this->orderRepository @@ -183,7 +180,6 @@ public function test_resend_order_confirmation_successfully(): void // orders where the primary-occurrence resolver returns null. $this->eventRepository ->shouldReceive('loadRelation') - ->times(3) ->andReturnSelf(); $this->eventRepository @@ -245,7 +241,6 @@ public function test_resend_attendee_ticket_loads_correct_relationships(): void // the resend email can show the occurrence date and the ticket type. $this->attendeeRepository ->shouldReceive('loadRelation') - ->times(3) ->with(Mockery::type(Relationship::class)) ->andReturnSelf(); @@ -256,17 +251,6 @@ public function test_resend_attendee_ticket_loads_correct_relationships(): void $this->eventRepository ->shouldReceive('loadRelation') - ->once() - ->with(Mockery::on(function ($relationship) { - return $relationship instanceof Relationship - && $relationship->getDomainObject() === OrganizerDomainObject::class; - })) - ->andReturnSelf(); - - $this->eventRepository - ->shouldReceive('loadRelation') - ->once() - ->with(EventSettingDomainObject::class) ->andReturnSelf(); $this->eventRepository @@ -310,21 +294,6 @@ public function test_resend_order_confirmation_loads_correct_relationships(): vo $this->orderRepository ->shouldReceive('loadRelation') - ->times(3) - ->with(Mockery::on(function ($arg) { - // OrderItem is now passed as a Relationship with a nested - // event_occurrence load so the summary email can show the - // occurrence date. Attendee and Invoice are still plain class - // strings. - if ($arg instanceof \HiEvents\Repository\Eloquent\Value\Relationship) { - return true; - } - - return in_array($arg, [ - AttendeeDomainObject::class, - InvoiceDomainObject::class, - ], true); - })) ->andReturnSelf(); $this->orderRepository @@ -334,7 +303,6 @@ public function test_resend_order_confirmation_loads_correct_relationships(): vo $this->eventRepository ->shouldReceive('loadRelation') - ->times(3) ->andReturnSelf(); $this->eventRepository diff --git a/backend/tests/Unit/Services/Infrastructure/Geo/GooglePlacesGeoProviderTest.php b/backend/tests/Unit/Services/Infrastructure/Geo/GooglePlacesGeoProviderTest.php new file mode 100644 index 0000000000..8c48f832f7 --- /dev/null +++ b/backend/tests/Unit/Services/Infrastructure/Geo/GooglePlacesGeoProviderTest.php @@ -0,0 +1,194 @@ +logger = new NullLogger; + $this->cache = new CacheRepository(new ArrayStore); + } + + private function makeProvider(): GooglePlacesGeoProvider + { + return new GooglePlacesGeoProvider('test-key', app(HttpClient::class), $this->logger, $this->cache); + } + + public function test_autocomplete_maps_response_to_suggestion_dtos(): void + { + Http::fake([ + 'places.googleapis.com/v1/places:autocomplete' => Http::response([ + 'suggestions' => [ + [ + 'placePrediction' => [ + 'placeId' => 'ChIJ123', + 'structuredFormat' => [ + 'mainText' => ['text' => 'Some Venue'], + 'secondaryText' => ['text' => 'Dublin, Ireland'], + ], + ], + ], + ], + ], 200), + ]); + + $provider = $this->makeProvider(); + $results = $provider->autocomplete('some venue', locale: 'en', country: 'IE'); + + $this->assertCount(1, $results); + $this->assertSame('ChIJ123', $results[0]->provider_place_id); + $this->assertSame('Some Venue', $results[0]->primary_text); + $this->assertSame('Dublin, Ireland', $results[0]->secondary_text); + } + + public function test_autocomplete_returns_empty_on_blank_query(): void + { + $provider = $this->makeProvider(); + $this->assertSame([], $provider->autocomplete(' ')); + } + + public function test_get_place_details_maps_establishment_to_address_dto(): void + { + Http::fake([ + 'places.googleapis.com/v1/places/*' => Http::response([ + 'id' => 'ChIJ123', + 'formattedAddress' => '3 Arena, North Wall Quay, Dublin 1, Ireland', + 'displayName' => ['text' => '3 Arena'], + 'types' => ['establishment', 'point_of_interest'], + 'location' => ['latitude' => 53.3478, 'longitude' => -6.2289], + 'addressComponents' => [ + ['types' => ['street_number'], 'shortText' => '3', 'longText' => '3'], + ['types' => ['route'], 'shortText' => 'North Wall Quay', 'longText' => 'North Wall Quay'], + ['types' => ['locality'], 'shortText' => 'Dublin', 'longText' => 'Dublin'], + ['types' => ['administrative_area_level_1'], 'shortText' => 'Dublin 1', 'longText' => 'Dublin 1'], + ['types' => ['postal_code'], 'shortText' => 'D01 T0X4', 'longText' => 'D01 T0X4'], + ['types' => ['country'], 'shortText' => 'IE', 'longText' => 'Ireland'], + ], + ], 200), + ]); + + $provider = $this->makeProvider(); + $place = $provider->getPlaceDetails('ChIJ123'); + + $this->assertNotNull($place); + $this->assertSame('google', $place->provider); + $this->assertSame('ChIJ123', $place->provider_place_id); + $this->assertSame('3 Arena', $place->address->venue_name); + $this->assertSame('3 North Wall Quay', $place->address->address_line_1); + $this->assertSame('Dublin', $place->address->city); + $this->assertSame('Dublin 1', $place->address->state_or_region); + $this->assertSame('D01 T0X4', $place->address->zip_or_postal_code); + $this->assertSame('IE', $place->address->country); + $this->assertEqualsWithDelta(53.3478, $place->latitude, 0.0001); + $this->assertEqualsWithDelta(-6.2289, $place->longitude, 0.0001); + } + + public function test_get_place_details_skips_venue_name_for_street_address(): void + { + Http::fake([ + 'places.googleapis.com/v1/places/*' => Http::response([ + 'id' => 'ChIJ456', + 'displayName' => ['text' => '123 Main St'], + 'types' => ['street_address'], + 'addressComponents' => [ + ['types' => ['street_number'], 'shortText' => '123', 'longText' => '123'], + ['types' => ['route'], 'shortText' => 'Main St', 'longText' => 'Main Street'], + ['types' => ['locality'], 'shortText' => 'Springfield', 'longText' => 'Springfield'], + ['types' => ['country'], 'shortText' => 'US', 'longText' => 'United States'], + ], + ], 200), + ]); + + $provider = $this->makeProvider(); + $place = $provider->getPlaceDetails('ChIJ456'); + + $this->assertNotNull($place); + $this->assertNull($place->address->venue_name); + $this->assertSame('123 Main St', $place->address->address_line_1); + } + + public function test_get_place_details_returns_null_on_404(): void + { + Http::fake([ + 'places.googleapis.com/v1/places/*' => Http::response(['error' => 'not found'], 404), + ]); + + $provider = $this->makeProvider(); + $this->assertNull($provider->getPlaceDetails('ChIJ-bad')); + } + + public function test_get_place_details_throws_on_5xx(): void + { + Http::fake([ + 'places.googleapis.com/v1/places/*' => Http::response(['error' => 'boom'], 503), + ]); + + $provider = $this->makeProvider(); + + $this->expectException(\HiEvents\Services\Infrastructure\Geo\Exception\GeoProviderException::class); + $provider->getPlaceDetails('ChIJ-503'); + } + + public function test_autocomplete_throws_on_5xx(): void + { + Http::fake([ + 'places.googleapis.com/v1/places:autocomplete' => Http::response(['error' => 'boom'], 502), + ]); + + $provider = $this->makeProvider(); + + $this->expectException(\HiEvents\Services\Infrastructure\Geo\Exception\GeoProviderException::class); + $provider->autocomplete('something'); + } + + public function test_autocomplete_throws_quota_exception_on_429(): void + { + Http::fake([ + 'places.googleapis.com/v1/places:autocomplete' => Http::response(['error' => ['status' => 'RESOURCE_EXHAUSTED']], 429), + ]); + + $provider = $this->makeProvider(); + + $this->expectException(\HiEvents\Services\Infrastructure\Geo\Exception\GeoProviderQuotaExceededException::class); + $provider->autocomplete('something'); + } + + public function test_get_place_details_caches_responses(): void + { + Http::fake([ + 'places.googleapis.com/v1/places/*' => Http::response([ + 'id' => 'ChIJ-cached', + 'displayName' => ['text' => 'Cached Place'], + 'types' => ['establishment'], + 'addressComponents' => [ + ['types' => ['country'], 'shortText' => 'IE', 'longText' => 'Ireland'], + ], + ], 200), + ]); + + $provider = $this->makeProvider(); + + $first = $provider->getPlaceDetails('ChIJ-cached'); + $second = $provider->getPlaceDetails('ChIJ-cached'); + + $this->assertNotNull($first); + $this->assertNotNull($second); + // One upstream call, not two — the second resolves from cache. + Http::assertSentCount(1); + } +} diff --git a/frontend/src/api/event.client.ts b/frontend/src/api/event.client.ts index c440647a75..6088045910 100644 --- a/frontend/src/api/event.client.ts +++ b/frontend/src/api/event.client.ts @@ -10,6 +10,7 @@ import { Image, ImageType, QueryFilters, + UpsertEventLocationPayload, } from "../types"; import {publicApi} from "./public-client.ts"; import {queryParamsHelper} from "../utilites/queryParamsHelper.ts"; @@ -88,6 +89,14 @@ export const eventsClient = { return response.data; }, + updateEventLocation: async ( + eventId: IdParam, + payload: { event_location?: UpsertEventLocationPayload | null; clear_event_location?: boolean }, + ) => { + const response = await api.patch>('events/' + eventId + '/event-location', payload); + return response.data; + }, + updateEventStatus: async (eventId: IdParam, status: string) => { const response = await api.put>('events/' + eventId + '/status', { status diff --git a/frontend/src/api/location.client.ts b/frontend/src/api/location.client.ts new file mode 100644 index 0000000000..5d6623a33c --- /dev/null +++ b/frontend/src/api/location.client.ts @@ -0,0 +1,73 @@ +import {api} from "./client"; +import { + GenericDataResponse, + GenericPaginatedResponse, + GeoPlace, + GeoSuggestion, + IdParam, + Location, + QueryFilters, +} from "../types"; +import {queryParamsHelper} from "../utilites/queryParamsHelper.ts"; + +export type UpsertLocationPayload = Partial & { + structured_address: Location["structured_address"]; +}; + +export const locationClient = { + all: async (organizerId: IdParam, pagination?: QueryFilters) => { + const response = await api.get>( + "organizers/" + organizerId + "/locations" + queryParamsHelper.buildQueryString(pagination ?? {}), + ); + return response.data; + }, + + create: async (organizerId: IdParam, payload: UpsertLocationPayload) => { + const response = await api.post>( + "organizers/" + organizerId + "/locations", + payload, + ); + return response.data; + }, + + update: async (organizerId: IdParam, locationId: IdParam, payload: UpsertLocationPayload) => { + const response = await api.put>( + "organizers/" + organizerId + "/locations/" + locationId, + payload, + ); + return response.data; + }, + + delete: async (organizerId: IdParam, locationId: IdParam) => { + const response = await api.delete( + "organizers/" + organizerId + "/locations/" + locationId, + ); + return response.data; + }, + + geoStatus: async () => { + const response = await api.get>("geo/status"); + return response.data; + }, + + autocomplete: async (organizerId: IdParam, query: string, opts: {locale?: string; country?: string} = {}) => { + const params = new URLSearchParams(); + params.set("query", query); + if (opts.locale) params.set("locale", opts.locale); + if (opts.country) params.set("country", opts.country); + const response = await api.get<{data: GeoSuggestion[]}>( + "organizers/" + organizerId + "/locations/autocomplete?" + params.toString(), + ); + return response.data; + }, + + placeDetails: async (organizerId: IdParam, placeId: string, opts: {locale?: string} = {}) => { + const params = new URLSearchParams(); + if (opts.locale) params.set("locale", opts.locale); + const qs = params.toString(); + const response = await api.get<{data: GeoPlace}>( + "organizers/" + organizerId + "/locations/places/" + encodeURIComponent(placeId) + (qs ? "?" + qs : ""), + ); + return response.data; + }, +}; diff --git a/frontend/src/api/organizer.client.ts b/frontend/src/api/organizer.client.ts index 5a9f88f2f5..8b9e58ca7f 100644 --- a/frontend/src/api/organizer.client.ts +++ b/frontend/src/api/organizer.client.ts @@ -51,6 +51,13 @@ export const organizerClient = { return response.data; }, + updateLocation: async (organizerId: IdParam, locationId: IdParam | null) => { + const response = await api.patch>('organizers/' + organizerId + '/location', { + location_id: locationId, + }); + return response.data; + }, + findEventsByOrganizerId: async (organizerId: IdParam, pagination: QueryFilters) => { const response = await api.get>( 'organizers/' + organizerId + '/events' + queryParamsHelper.buildQueryString(pagination) diff --git a/frontend/src/components/common/AddEventToCalendarButton/index.tsx b/frontend/src/components/common/AddEventToCalendarButton/index.tsx index 5148fc5bf9..119c350b12 100644 --- a/frontend/src/components/common/AddEventToCalendarButton/index.tsx +++ b/frontend/src/components/common/AddEventToCalendarButton/index.tsx @@ -1,16 +1,17 @@ import {ActionIcon, Tooltip} from '@mantine/core'; import {IconCalendarPlus} from '@tabler/icons-react'; import {t} from "@lingui/macro"; -import {Event} from "../../../types.ts"; +import {Event, EventOccurrence} from "../../../types.ts"; import {CalendarOptionsPopover} from "../CalendarOptionsPopover"; interface AddToCalendarProps { event: Event; + occurrence?: EventOccurrence; } -export const AddToEventCalendarButton = ({event}: AddToCalendarProps) => { +export const AddToEventCalendarButton = ({event, occurrence}: AddToCalendarProps) => { return ( - + diff --git a/frontend/src/components/common/AddressAutocomplete/index.tsx b/frontend/src/components/common/AddressAutocomplete/index.tsx new file mode 100644 index 0000000000..9eafc17aee --- /dev/null +++ b/frontend/src/components/common/AddressAutocomplete/index.tsx @@ -0,0 +1,113 @@ +import {t} from "@lingui/macro"; +import {Combobox, Loader, TextInput, useCombobox} from "@mantine/core"; +import {useRef, useState} from "react"; +import {useDebouncedValue} from "@mantine/hooks"; +import {GeoPlace, IdParam} from "../../../types.ts"; +import {useGeoAutocomplete} from "../../../queries/useGeoAutocomplete.ts"; +import {useGeoStatus} from "../../../queries/useGeoStatus.ts"; +import {useResolveGeoPlace} from "../../../mutations/useResolveGeoPlace.ts"; +import {showError} from "../../../utilites/notifications.tsx"; + +interface AddressAutocompleteProps { + organizerId: IdParam; + label?: string; + placeholder?: string; + description?: string; + country?: string; + locale?: string; + onPlaceSelected: (place: GeoPlace) => void; +} + +export const AddressAutocomplete = ({ + organizerId, + label, + placeholder, + description, + country, + locale, + onPlaceSelected, +}: AddressAutocompleteProps) => { + const combobox = useCombobox(); + const [value, setValue] = useState(""); + const [debounced] = useDebouncedValue(value, 250); + const justSelectedRef = useRef(false); + const geoStatus = useGeoStatus(); + const geoAvailable = geoStatus.data?.data?.available === true; + + const suggestionsQuery = useGeoAutocomplete(organizerId, debounced, { + country, + locale, + enabled: geoAvailable && debounced.length >= 3 && !justSelectedRef.current, + }); + const resolvePlace = useResolveGeoPlace(); + + if (!geoAvailable) { + return null; + } + + const suggestions = suggestionsQuery.data?.data ?? []; + + const handleSelect = async (placeId: string) => { + const match = suggestions.find((s) => s.provider_place_id === placeId); + if (!match) return; + justSelectedRef.current = true; + combobox.closeDropdown(); + const label = match.secondary_text ? `${match.primary_text}, ${match.secondary_text}` : match.primary_text; + setValue(label); + try { + const response = await resolvePlace.mutateAsync({organizerId, placeId, locale}); + onPlaceSelected(response.data); + } catch (error) { + showError(t`Could not retrieve address details`); + } + }; + + const handleChange = (event: React.ChangeEvent) => { + const next = event.currentTarget.value; + justSelectedRef.current = false; + setValue(next); + if (next.trim().length >= 3) { + combobox.openDropdown(); + } else { + combobox.closeDropdown(); + } + }; + + const handleFocus = () => { + if (!justSelectedRef.current && value.trim().length >= 3 && suggestions.length > 0) { + combobox.openDropdown(); + } + }; + + return ( + + + combobox.closeDropdown()} + rightSection={suggestionsQuery.isFetching || resolvePlace.isPending ? : null} + /> + + + + {suggestions.length === 0 && ( + {t`No suggestions`} + )} + {suggestions.map((s) => ( + +
+ {s.primary_text} + {s.secondary_text &&
{s.secondary_text}
} +
+
+ ))} +
+
+
+ ); +}; diff --git a/frontend/src/components/common/AttendeeTicket/index.tsx b/frontend/src/components/common/AttendeeTicket/index.tsx index 3f81413b22..748beb0d0a 100644 --- a/frontend/src/components/common/AttendeeTicket/index.tsx +++ b/frontend/src/components/common/AttendeeTicket/index.tsx @@ -4,9 +4,10 @@ import {formatCurrency} from "../../../utilites/currency.ts"; import {t} from "@lingui/macro"; import QRCode from "react-qr-code"; import {IconCopy, IconPrinter, IconLock, IconX} from "@tabler/icons-react"; -import {Address, Attendee, Event, EventOccurrence, Product} from "../../../types.ts"; +import {Attendee, Event, EventOccurrence, LocationType, Product} from "../../../types.ts"; import classes from './AttendeeTicket.module.scss'; import {imageUrl} from "../../../utilites/urlHelper.ts"; +import {resolveEventLocation} from "../../../utilites/effectiveLocation.ts"; import {formatAddress} from "../../../utilites/addressUtilities.ts"; import {PoweredByFooter} from "../PoweredByFooter"; import {EventDateRange} from "../EventDateRange"; @@ -33,7 +34,16 @@ export const AttendeeTicket = ({ // rather than the event's aggregated range. const ticketOccurrence = attendee.event_occurrence ?? occurrence; const productPrice = getAttendeeProductPrice(attendee, product); - const hasVenue = event?.settings?.location_details?.venue_name || event?.settings?.location_details?.address_line_1; + const eventLocation = resolveEventLocation(event, ticketOccurrence); + const venueName = eventLocation?.type === LocationType.InPerson + ? (eventLocation.location?.name || eventLocation.location?.structured_address?.venue_name || null) + : null; + const formattedAddress = eventLocation?.type === LocationType.InPerson && eventLocation.location?.structured_address + ? formatAddress(eventLocation.location.structured_address) + : ''; + const locationLine = [venueName, formattedAddress].filter(Boolean).join(', '); + const isInPerson = eventLocation?.type === LocationType.InPerson && locationLine.length > 0; + const isOnline = eventLocation?.type === LocationType.Online; const ticketDesignSettings = event?.settings?.ticket_design_settings; const accentColor = ticketDesignSettings?.accent_color || '#6B46C1'; @@ -95,11 +105,20 @@ export const AttendeeTicket = ({ )} - {hasVenue && ( + {isInPerson && (
{t`Location`}
- {formatAddress(event?.settings?.location_details as Address)} + {locationLine} +
+
+ )} + + {isOnline && ( +
+
{t`Location`}
+
+ {t`Online event`}
)} diff --git a/frontend/src/components/common/Editor/Controls/InsertLiquidVariableControl.tsx b/frontend/src/components/common/Editor/Controls/InsertLiquidVariableControl.tsx index 84db1cd813..8a5716d07d 100644 --- a/frontend/src/components/common/Editor/Controls/InsertLiquidVariableControl.tsx +++ b/frontend/src/components/common/Editor/Controls/InsertLiquidVariableControl.tsx @@ -23,6 +23,15 @@ const OCCURRENCE_VARIABLES: TemplateVariable[] = [ {label: t`Occurrence Label`, value: 'occurrence.label', description: t`Label for the occurrence`, category: t`Occurrence`}, ]; +const LOCATION_VARIABLES: TemplateVariable[] = [ + {label: t`Location Name`, value: 'event_location.name', description: t`Resolved location or venue name`, category: t`Location`}, + {label: t`Location Formatted Address`, value: 'event_location.formatted_address', description: t`Full resolved address`, category: t`Location`}, + {label: t`Location Latitude`, value: 'event_location.latitude', description: t`Latitude of the resolved location`, category: t`Location`}, + {label: t`Location Longitude`, value: 'event_location.longitude', description: t`Longitude of the resolved location`, category: t`Location`}, + {label: t`Location Mode`, value: 'event_location.mode', description: t`in_person, online, unset, or mixed`, category: t`Location`}, + {label: t`Online Connection Details`, value: 'event_location.online_connection_details', description: t`Online event connection details`, category: t`Location`}, +]; + const TEMPLATE_VARIABLES: Record = { order_confirmation: [ // Order Information @@ -47,6 +56,8 @@ const TEMPLATE_VARIABLES: Record = { {label: t`Event Timezone`, value: 'event.timezone', description: t`Event timezone`, category: t`Event`}, {label: t`Event Venue`, value: 'event.location_details.venue_name', description: t`The event venue`, category: t`Event`}, + ...LOCATION_VARIABLES, + ...OCCURRENCE_VARIABLES, // Organization @@ -82,6 +93,8 @@ const TEMPLATE_VARIABLES: Record = { {label: t`Event Timezone`, value: 'event.timezone', description: t`Event timezone`, category: t`Event`}, {label: t`Event Venue`, value: 'event.location_details.venue_name', description: t`The event venue`, category: t`Event`}, + ...LOCATION_VARIABLES, + ...OCCURRENCE_VARIABLES, // Organization @@ -103,6 +116,8 @@ const TEMPLATE_VARIABLES: Record = { {label: t`Event Venue`, value: 'event.location_details.venue_name', description: t`The event venue`, category: t`Event`}, {label: t`Event URL`, value: 'event.url', description: t`Link to event homepage`, category: t`Event`}, + ...LOCATION_VARIABLES, + ...OCCURRENCE_VARIABLES, {label: t`Refund Issued`, value: 'cancellation.refund_issued', description: t`Whether refunds are being processed`, category: t`Cancellation`}, diff --git a/frontend/src/components/common/EventCard/index.tsx b/frontend/src/components/common/EventCard/index.tsx index fdc362fa24..850b824290 100644 --- a/frontend/src/components/common/EventCard/index.tsx +++ b/frontend/src/components/common/EventCard/index.tsx @@ -1,5 +1,5 @@ import {ActionIcon, Tooltip} from '@mantine/core'; -import {Event, EventType, IdParam, Product} from "../../../types.ts"; +import {Event, EventType, IdParam, LocationType, Product} from "../../../types.ts"; import classes from "./EventCard.module.scss"; import {NavLink, useNavigate} from "react-router"; import { @@ -23,6 +23,8 @@ import {formatCurrency} from "../../../utilites/currency.ts"; import {formatNumber} from "../../../utilites/helpers.ts"; import {formatDateWithLocale, relativeDate} from "../../../utilites/dates.ts"; import {Card} from "../Card"; +import {resolveEventLocation} from "../../../utilites/effectiveLocation.ts"; +import {formatAddress} from "../../../utilites/addressUtilities.ts"; const placeholderGradients = [ 'linear-gradient(135deg, var(--mantine-color-violet-5) 0%, var(--mantine-color-indigo-5) 100%)', @@ -91,11 +93,30 @@ export function EventCard({event}: EventCardProps) { }; const getLocationText = () => { - if (event.settings?.is_online_event) return t`Online`; - const location = event.settings?.location_details; - if (location?.venue_name) return location.venue_name; - if (location?.city) return location.city; - return null; + const occurrences = event.occurrences ?? []; + const resolvedList = occurrences.length > 0 + ? occurrences.map(o => resolveEventLocation(event, o)) + : [resolveEventLocation(event, null)]; + + const types = new Set(); + const locationIds = new Set(); + for (const r of resolvedList) { + if (r) { + types.add(r.type); + if (r.location_id != null) locationIds.add(String(r.location_id)); + } + } + + const first = resolvedList[0]; + if (!first) return null; + if (types.size > 1) return t`Online & in-person`; + if (first.type === LocationType.Online) return t`Online`; + if (locationIds.size > 1) return t`Multiple locations`; + if (first.type !== LocationType.InPerson) return null; + const city = first.location?.structured_address?.city; + const venueName = first.location?.name || first.location?.structured_address?.venue_name; + const formatted = first.location?.structured_address ? formatAddress(first.location.structured_address) : ''; + return venueName ?? city ?? (formatted ? formatted : null); }; const getTicketAvailability = () => { diff --git a/frontend/src/components/common/EventDocumentHead/index.tsx b/frontend/src/components/common/EventDocumentHead/index.tsx index 7bb2bacd07..f47efcfe37 100644 --- a/frontend/src/components/common/EventDocumentHead/index.tsx +++ b/frontend/src/components/common/EventDocumentHead/index.tsx @@ -1,8 +1,10 @@ /* eslint-disable lingui/no-unlocalized-strings */ import {Helmet} from "react-helmet-async"; -import {Event} from "../../../types"; +import {Event, LocationType} from "../../../types"; import {eventCoverImageUrl, eventHomepageUrl} from "../../../utilites/urlHelper.ts"; import {utcToTz} from "../../../utilites/dates.ts"; +import {resolveEventLocation} from "../../../utilites/effectiveLocation.ts"; +import {formatAddress} from "../../../utilites/addressUtilities.ts"; interface EventDocumentHeadProps { event: Event; @@ -19,26 +21,63 @@ export const EventDocumentHead = ({event}: EventDocumentHeadProps) => { const startDate = utcToTz(new Date(event.start_date), event.timezone); const endDate = event.end_date ? utcToTz(new Date(event.end_date), event.timezone) : undefined; - const address = { + const occurrences = event.occurrences ?? []; + const resolvedList = occurrences.length > 0 + ? occurrences.map(o => resolveEventLocation(event, o)) + : [resolveEventLocation(event, null)]; + const types = new Set(); + for (const r of resolvedList) { + if (r) types.add(r.type); + } + const effective = resolvedList[0]; + const hasMixedModes = types.size > 1; + const structuredAddress = effective?.type === LocationType.InPerson ? effective.location?.structured_address : null; + + const address = structuredAddress ? { "@type": "http://schema.org/PostalAddress", - streetAddress: eventSettings?.location_details?.address_line_1, - addressLocality: eventSettings?.location_details?.city, - addressRegion: eventSettings?.location_details?.state_or_region, - postalCode: eventSettings?.location_details?.zip_or_postal_code, - addressCountry: eventSettings?.location_details?.country - }; + streetAddress: structuredAddress.address_line_1, + addressLocality: structuredAddress.city, + addressRegion: structuredAddress.state_or_region, + postalCode: structuredAddress.zip_or_postal_code, + addressCountry: structuredAddress.country + } : null; + + if (address) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + Object.keys(address).forEach(key => address[key] === undefined && delete address[key]); + } - // Filter out undefined address properties - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - Object.keys(address).forEach(key => address[key] === undefined && delete address[key]); + const latitude = effective?.type === LocationType.InPerson ? effective.location?.latitude : null; + const longitude = effective?.type === LocationType.InPerson ? effective.location?.longitude : null; + const geo = latitude != null && longitude != null ? { + "@type": "http://schema.org/GeoCoordinates", + latitude, + longitude, + } : null; - const location = eventSettings?.location_details && Object.keys(address).length > 1 ? { + const venueName = effective?.type === LocationType.InPerson + ? (effective.location?.name || effective.location?.structured_address?.venue_name || null) + : null; + const placeName = venueName + ?? (effective?.type === LocationType.InPerson && structuredAddress ? formatAddress(structuredAddress) : null); + + const includePlace = effective?.type === LocationType.InPerson && address && Object.keys(address).length > 1; + const location = includePlace ? { "@type": "http://schema.org/Place", - name: event.location_details?.venue_name, - address + name: placeName, + address, + ...(geo ? {geo} : {}), } : {}; + const eventAttendanceMode = hasMixedModes + ? "https://schema.org/MixedEventAttendanceMode" + : effective?.type === LocationType.Online + ? "https://schema.org/OnlineEventAttendanceMode" + : effective?.type === LocationType.InPerson + ? "https://schema.org/OfflineEventAttendanceMode" + : undefined; + const schemaOrgJSONLD = { "@context": "http://schema.org", "@type": "http://schema.org/Event", @@ -56,7 +95,7 @@ export const EventDocumentHead = ({event}: EventDocumentHeadProps) => { }, url, eventStatus: 'https://schema.org/EventScheduled', - eventAttendanceMode: event.settings?.is_online_event ? "https://schema.org/OnlineEventAttendanceMode" : "https://schema.org/OfflineEventAttendanceMode", + eventAttendanceMode, currency: event.currency, offers: products.map(product => ({ "@type": "http://schema.org/Offer", diff --git a/frontend/src/components/common/InlineOrderSummary/index.tsx b/frontend/src/components/common/InlineOrderSummary/index.tsx index 3c70f2aeda..325a527f35 100644 --- a/frontend/src/components/common/InlineOrderSummary/index.tsx +++ b/frontend/src/components/common/InlineOrderSummary/index.tsx @@ -3,9 +3,10 @@ import {Collapse, Popover} from "@mantine/core"; import {IconCalendarEvent, IconChevronDown, IconInfoCircle, IconShieldCheck, IconTag} from "@tabler/icons-react"; import {t} from "@lingui/macro"; import classNames from "classnames"; -import {Event, Order} from "../../../types.ts"; +import {Event, LocationType, Order} from "../../../types.ts"; import {formatCurrency} from "../../../utilites/currency.ts"; import {prettyDate} from "../../../utilites/dates.ts"; +import {resolveEventLocation} from "../../../utilites/effectiveLocation.ts"; import classes from './InlineOrderSummary.module.scss'; interface InlineOrderSummaryProps { @@ -28,9 +29,15 @@ export const InlineOrderSummary = ({ : order.total_gross; const coverImage = event?.images?.find((image) => image.type === 'EVENT_COVER'); - const location = event?.settings?.location_details?.city || - event?.settings?.location_details?.venue_name || - null; + const orderOccurrence = order.order_items?.[0]?.event_occurrence; + const effective = resolveEventLocation(event, orderOccurrence); + const venueName = effective?.type === LocationType.InPerson + ? (effective.location?.name || effective.location?.structured_address?.venue_name || null) + : null; + const city = effective?.type === LocationType.InPerson + ? effective.location?.structured_address?.city ?? null + : null; + const location = city || venueName || null; const totalFee = order.taxes_and_fees_rollup?.fees?.reduce((sum, fee) => sum + fee.value, 0) || 0; const totalTax = order.taxes_and_fees_rollup?.taxes?.reduce((sum, tax) => sum + tax.value, 0) || 0; diff --git a/frontend/src/components/common/OnlineEventDetails/index.tsx b/frontend/src/components/common/OnlineEventDetails/index.tsx index b428316cc4..0daba5041e 100644 --- a/frontend/src/components/common/OnlineEventDetails/index.tsx +++ b/frontend/src/components/common/OnlineEventDetails/index.tsx @@ -1,17 +1,28 @@ import {t} from "@lingui/macro"; import {Card} from "../Card"; -import {EventSettings} from "../../../types.ts"; +import {Event, EventOccurrence, LocationType} from "../../../types.ts"; +import {resolveEventLocation} from "../../../utilites/effectiveLocation.ts"; -export const OnlineEventDetails = (props: { eventSettings: EventSettings }) => { - return <> - {(props.eventSettings.is_online_event && props.eventSettings.online_event_connection_details) && ( -
-

{t`Online Event Details`}

- -
- -
- )} - ; +interface OnlineEventDetailsProps { + event?: Event | null; + occurrence?: EventOccurrence | null; } + +export const OnlineEventDetails = (props: OnlineEventDetailsProps) => { + if (!props.event) return null; + const eventLocation = resolveEventLocation(props.event, props.occurrence ?? null); + + if (eventLocation?.type !== LocationType.Online || !eventLocation.online_event_connection_details) { + return null; + } + const details = eventLocation.online_event_connection_details; + + return ( +
+

{t`Online Event Details`}

+ +
+ +
+ ); +}; diff --git a/frontend/src/components/common/OrganizerDocumentHead/index.tsx b/frontend/src/components/common/OrganizerDocumentHead/index.tsx index fee5250e81..0630934f7d 100644 --- a/frontend/src/components/common/OrganizerDocumentHead/index.tsx +++ b/frontend/src/components/common/OrganizerDocumentHead/index.tsx @@ -17,13 +17,14 @@ export const OrganizerDocumentHead = ({organizer}: OrganizerDocumentHeadProps) = const image = coverImage || logoImage; const url = organizerHomepageUrl(organizer); - const address = organizerSettings?.location_details ? { + const structuredAddress = organizer.location?.structured_address; + const address = structuredAddress ? { "@type": "http://schema.org/PostalAddress", - streetAddress: organizerSettings.location_details.address_line_1, - addressLocality: organizerSettings.location_details.city, - addressRegion: organizerSettings.location_details.state_or_region, - postalCode: organizerSettings.location_details.zip_or_postal_code, - addressCountry: organizerSettings.location_details.country + streetAddress: structuredAddress.address_line_1, + addressLocality: structuredAddress.city, + addressRegion: structuredAddress.state_or_region, + postalCode: structuredAddress.zip_or_postal_code, + addressCountry: structuredAddress.country } : undefined; // Filter out undefined address properties @@ -39,7 +40,7 @@ export const OrganizerDocumentHead = ({organizer}: OrganizerDocumentHeadProps) = const location = address && Object.keys(address).length > 1 ? { "@type": "http://schema.org/Place", - name: organizerSettings?.location_details?.venue_name, + name: organizer.location?.name ?? structuredAddress?.venue_name, address } : undefined; diff --git a/frontend/src/components/layouts/Event/index.tsx b/frontend/src/components/layouts/Event/index.tsx index b141af0da4..67bc818fe0 100644 --- a/frontend/src/components/layouts/Event/index.tsx +++ b/frontend/src/components/layouts/Event/index.tsx @@ -30,6 +30,7 @@ import {t} from "@lingui/macro"; import {useGetEvent} from "../../../queries/useGetEvent"; import {useGetEventSettings} from "../../../queries/useGetEventSettings"; import {useGetEventStats} from "../../../queries/useGetEventStats"; +import {useGeoStatus} from "../../../queries/useGeoStatus.ts"; import Truncate from "../../common/Truncate"; import {BreadcrumbItem, NavItem} from "../AppLayout/types.ts"; import AppLayout from "../AppLayout"; @@ -69,6 +70,7 @@ const EventLayout = () => { const resendEmailConfirmationMutation = useResendEmailConfirmation(); const [emailConfirmationResent, setEmailConfirmationResent] = useState(false); + useGeoStatus(); const occurrenceIdFromUrl = useMemo(() => { const match = location.pathname.match(/\/occurrences\/(\d+)$/); diff --git a/frontend/src/components/layouts/EventHomepage/index.tsx b/frontend/src/components/layouts/EventHomepage/index.tsx index bdb1d21c48..1a9a8d328b 100644 --- a/frontend/src/components/layouts/EventHomepage/index.tsx +++ b/frontend/src/components/layouts/EventHomepage/index.tsx @@ -4,7 +4,7 @@ import "../../../styles/widget/default.scss"; import React, {useEffect, useRef, useState} from "react"; import {EventDocumentHead} from "../../common/EventDocumentHead"; import {eventCoverImage, eventHomepageUrl, imageUrl, organizerHomepageUrl} from "../../../utilites/urlHelper.ts"; -import {Event, EventOccurrenceStatus, EventType, OrganizerStatus} from "../../../types.ts"; +import {Event, EventOccurrenceStatus, EventType, LocationType, OrganizerStatus} from "../../../types.ts"; import {EventNotAvailable} from "./EventNotAvailable"; import { IconArrowUpRight, @@ -28,9 +28,9 @@ import {socialMediaConfig} from "../../../constants/socialMediaConfig"; import { formatAddress, getGoogleMapsUrl, - getShortLocationDisplay, - isAddressSet + getShortLocationDisplay } from "../../../utilites/addressUtilities.ts"; +import {resolveEventLocation} from "../../../utilites/effectiveLocation.ts"; import {StatusToggle} from "../../common/StatusToggle"; import {getConfig} from "../../../utilites/config.ts"; import {computeThemeVariables, validateThemeSettings} from "../../../utilites/themeUtils.ts"; @@ -136,11 +136,39 @@ const EventHomepage = ({...loaderData}: EventHomepageProps) => { const organizer = event.organizer!; const organizerSocials = organizer?.settings?.social_media_handles; const organizerLogo = imageUrl('ORGANIZER_LOGO', organizer?.images); - const organizerLocation = organizer?.settings?.location_details; + const organizerLocation = organizer?.location?.structured_address; const websiteUrl = organizer?.website; - const locationDetails = event.settings?.location_details; - const isOnlineEvent = event.settings?.is_online_event; - const hasLocation = isAddressSet(locationDetails) && !isOnlineEvent; + const occurrences = event.occurrences ?? []; + const resolvedList = occurrences.length > 0 + ? occurrences.map(o => resolveEventLocation(event, o)) + : [resolveEventLocation(event, null)]; + const types = new Set(); + const locationIds = new Set(); + for (const r of resolvedList) { + if (r) { + types.add(r.type); + if (r.location_id != null) locationIds.add(String(r.location_id)); + } + } + const effective = resolvedList[0]; + const hasMixedModes = types.size > 1; + const hasMultipleLocations = locationIds.size > 1; + const isOnlineEvent = effective?.type === LocationType.Online; + const venueName = effective?.type === LocationType.InPerson + ? (effective.location?.name || effective.location?.structured_address?.venue_name || null) + : null; + const formattedAddress = effective?.type === LocationType.InPerson && effective.location?.structured_address + ? formatAddress(effective.location.structured_address) + : ''; + const locationDetails = effective?.type === LocationType.InPerson + ? effective.location?.structured_address ?? null + : null; + const hasLocation = !hasMixedModes + && !hasMultipleLocations + && effective?.type === LocationType.InPerson + && Boolean(locationDetails); + const multipleLocationsLabel = hasMultipleLocations ? t`Multiple locations` : null; + const mixedModesLabel = hasMixedModes ? t`Online & in-person — see schedule` : null; const socialLinks = organizerSocials ? Object.entries(organizerSocials) .filter(([platform, handle]) => handle && socialMediaConfig[platform as keyof typeof socialMediaConfig]) @@ -173,7 +201,15 @@ const EventHomepage = ({...loaderData}: EventHomepageProps) => { const statusBadge = getStatusBadge(); - const mapUrl = event.settings?.maps_url || (locationDetails ? getGoogleMapsUrl(locationDetails) : null); + const mapUrl = (() => { + if (effective?.type !== LocationType.InPerson || !effective.location) return null; + const {latitude, longitude, structured_address} = effective.location; + if (latitude != null && longitude != null) { + return `https://www.google.com/maps?q=${latitude},${longitude}`; + } + if (structured_address) return getGoogleMapsUrl(structured_address) || null; + return null; + })(); return ( <> @@ -400,19 +436,50 @@ const EventHomepage = ({...loaderData}: EventHomepageProps) => {
)} - {/* Location */} - {hasLocation && locationDetails && ( + {/* Mixed modes */} + {mixedModesLabel && (
-
- {locationDetails.venue_name} -
+
{mixedModesLabel}
+
+
+ )} + + {/* Multiple locations */} + {multipleLocationsLabel && ( +
+
+ +
+
+
{multipleLocationsLabel}
- {formatAddress(locationDetails)} + {t`See schedule`}
+
+
+ )} + + {/* Location */} + {hasLocation && ( +
+
+ +
+
+ {venueName && ( +
+ {venueName} +
+ )} + {formattedAddress && ( +
+ {formattedAddress} +
+ )} {mapUrl && ( { )} {/* Location Section (with map) */} - {hasLocation && locationDetails && ( + {hasLocation && (

{t`Location`}

-
- {locationDetails.venue_name} -
-
- {formatAddress(locationDetails)} -
+ {venueName && ( +
+ {venueName} +
+ )} + {formattedAddress && ( +
+ {formattedAddress} +
+ )} {mapUrl && (
= ({event, primaryColor = '#8b5 const endDay = event.end_date ? formatDateWithLocale(event.end_date, "dayOfMonth", event.timezone) : null; const coverImage = event.images?.find(img => img.type === 'EVENT_COVER'); - const location = event?.settings?.location_details?.city || event?.settings?.location_details?.venue_name; - const isOnlineEvent = event.settings?.is_online_event; + const occurrences = event.occurrences ?? []; + const resolvedList = occurrences.length > 0 + ? occurrences.map(o => resolveEventLocation(event, o)) + : [resolveEventLocation(event, null)]; + const types = new Set(); + const locationIds = new Set(); + for (const r of resolvedList) { + if (r) { + types.add(r.type); + if (r.location_id != null) locationIds.add(String(r.location_id)); + } + } + const effective = resolvedList[0]; + const hasMixedModes = types.size > 1; + const hasMultipleLocations = locationIds.size > 1; + const isOnlineEvent = effective?.type === LocationType.Online; + const locationLabel: string | null = (() => { + if (hasMixedModes) return t`Online & in-person`; + if (isOnlineEvent) return t`Online`; + if (hasMultipleLocations) return t`Multiple locations`; + if (effective?.type !== LocationType.InPerson) return null; + const city = effective.location?.structured_address?.city; + const venueName = effective.location?.name || effective.location?.structured_address?.venue_name; + const formatted = effective.location?.structured_address ? formatAddress(effective.location.structured_address) : ''; + return venueName ?? city ?? (formatted ? formatted : null); + })(); + const location = !isOnlineEvent ? locationLabel : null; // Check if event is live const now = dayjs(); diff --git a/frontend/src/components/layouts/OrganizerHomepage/index.tsx b/frontend/src/components/layouts/OrganizerHomepage/index.tsx index 18c1920f32..f353703055 100644 --- a/frontend/src/components/layouts/OrganizerHomepage/index.tsx +++ b/frontend/src/components/layouts/OrganizerHomepage/index.tsx @@ -192,16 +192,16 @@ export const OrganizerHomepage = ({

{organizer?.name}

- {getShortLocationDisplay(organizer?.settings?.location_details) && ( + {getShortLocationDisplay(organizer?.location?.structured_address) && ( diff --git a/frontend/src/components/layouts/OrganizerLayout/index.tsx b/frontend/src/components/layouts/OrganizerLayout/index.tsx index f65e1c92cc..5ecea5d021 100644 --- a/frontend/src/components/layouts/OrganizerLayout/index.tsx +++ b/frontend/src/components/layouts/OrganizerLayout/index.tsx @@ -9,6 +9,7 @@ import { IconExternalLink, IconEye, IconEyeOff, + IconMapPin, IconPaint, IconSettings, IconShare, @@ -21,6 +22,7 @@ import AppLayout from "../AppLayout"; import { NavLink, useLocation, useParams } from "react-router"; import { Button, Modal, Stack, Text } from "@mantine/core"; import { useGetOrganizer } from "../../../queries/useGetOrganizer.ts"; +import { useGeoStatus } from "../../../queries/useGeoStatus.ts"; import { useState } from "react"; import { CreateEventModal } from "../../modals/CreateEventModal"; import { TopBarButton } from "../../common/TopBarButton"; @@ -44,6 +46,7 @@ const OrganizerLayout = () => { const { organizerId } = useParams(); const location = useLocation(); const { data: organizer } = useGetOrganizer(organizerId); + useGeoStatus(); const [showCreateEventModal, setShowCreateEventModal] = useState(false); const [showCreateOrganizerModal, setShowCreateOrganizerModal] = useState(false); const [createModalOpen, { open: openCreateModal, close: closeCreateModal }] = useDisclosure(false); @@ -100,6 +103,9 @@ const OrganizerLayout = () => { { label: t`Tools` }, { link: 'organizer-homepage-designer', label: t`Homepage Designer`, icon: IconPaint }, + { label: t`Library` }, + { link: 'locations', label: t`Locations`, icon: IconMapPin }, + { label: t`Integrations` }, { link: 'webhooks', label: t`Webhooks`, icon: IconWebhook }, ]; diff --git a/frontend/src/components/modals/LocationEditModal/index.tsx b/frontend/src/components/modals/LocationEditModal/index.tsx new file mode 100644 index 0000000000..fe9b80970b --- /dev/null +++ b/frontend/src/components/modals/LocationEditModal/index.tsx @@ -0,0 +1,138 @@ +import {t} from "@lingui/macro"; +import {Button, Select, TextInput} from "@mantine/core"; +import {useForm} from "@mantine/form"; +import {useState} from "react"; +import {Modal} from "../../common/Modal"; +import {InputGroup} from "../../common/InputGroup"; +import {AddressAutocomplete} from "../../common/AddressAutocomplete"; +import countries from "../../../../data/countries.json"; +import {GeoPlace, IdParam, Location, VenueAddress} from "../../../types.ts"; +import {useCreateLocation} from "../../../mutations/useCreateLocation.ts"; +import {useUpdateLocation} from "../../../mutations/useUpdateLocation.ts"; +import {showError, showSuccess} from "../../../utilites/notifications.tsx"; +import {useFormErrorResponseHandler} from "../../../hooks/useFormErrorResponseHandler.tsx"; + +interface LocationEditModalProps { + onClose: () => void; + organizerId: IdParam; + location: Location | null; +} + +export const LocationEditModal = ({onClose, organizerId, location}: LocationEditModalProps) => { + const isEditing = !!location?.id; + const createMutation = useCreateLocation(); + const updateMutation = useUpdateLocation(); + const formErrorHandle = useFormErrorResponseHandler(); + const [latLng, setLatLng] = useState<{lat: number | null; lng: number | null; provider: string | null; placeId: string | null}>({ + lat: location?.latitude ?? null, + lng: location?.longitude ?? null, + provider: location?.provider ?? null, + placeId: location?.provider_place_id ?? null, + }); + + const form = useForm<{name: string; structured_address: VenueAddress}>({ + initialValues: { + name: location?.name ?? "", + structured_address: { + venue_name: location?.structured_address?.venue_name ?? "", + address_line_1: location?.structured_address?.address_line_1 ?? "", + address_line_2: location?.structured_address?.address_line_2 ?? "", + city: location?.structured_address?.city ?? "", + state_or_region: location?.structured_address?.state_or_region ?? "", + zip_or_postal_code: location?.structured_address?.zip_or_postal_code ?? "", + country: location?.structured_address?.country ?? "", + }, + }, + }); + + const handlePlaceSelected = (place: GeoPlace) => { + form.setValues({ + name: place.display_name ?? place.address.venue_name ?? form.values.name, + structured_address: { + venue_name: place.address.venue_name ?? "", + address_line_1: place.address.address_line_1 ?? "", + address_line_2: place.address.address_line_2 ?? "", + city: place.address.city ?? "", + state_or_region: place.address.state_or_region ?? "", + zip_or_postal_code: place.address.zip_or_postal_code ?? "", + country: place.address.country ?? "", + }, + }); + setLatLng({ + lat: place.latitude ?? null, + lng: place.longitude ?? null, + provider: place.provider, + placeId: place.provider_place_id, + }); + }; + + const handleSubmit = async (values: {name: string; structured_address: VenueAddress}) => { + const payload = { + name: values.name || null, + structured_address: values.structured_address, + latitude: latLng.lat, + longitude: latLng.lng, + provider: latLng.provider, + provider_place_id: latLng.placeId, + }; + try { + if (isEditing && location?.id) { + await updateMutation.mutateAsync({organizerId, locationId: location.id, payload}); + showSuccess(t`Location updated`); + } else { + await createMutation.mutateAsync({organizerId, payload}); + showSuccess(t`Location saved`); + } + onClose(); + } catch (error) { + showError(t`Could not save location`); + formErrorHandle(form, error); + } + }; + + return ( + +
+
+ + + + + + + + + + + + + + ({ + value: String(loc.id), + label: loc.name || loc.structured_address?.venue_name || formatAddress(loc.structured_address ?? {}) || t`Unnamed location`, + }))} + searchable + value={form.values.saved_location_id} + onChange={(value) => form.setFieldValue('saved_location_id', value)} + /> + ) : ( + <> + {organizerId && ( + { + form.setFieldValue('override_address', { + venue_name: place.address.venue_name || '', + address_line_1: place.address.address_line_1 || '', + address_line_2: place.address.address_line_2 || '', + city: place.address.city || '', + state_or_region: place.address.state_or_region || '', + zip_or_postal_code: place.address.zip_or_postal_code || '', + country: place.address.country || '', + }); + form.setFieldValue('override_latlng', { + lat: place.latitude ?? null, + lng: place.longitude ?? null, + provider: place.provider, + placeId: place.provider_place_id, + }); + }} + /> + )} + + + + + + + + + + + + + + + )} + + )} + + {form.values.location_mode === 'online' && ( + form.setFieldValue('online_event_connection_details', value)} + /> + )} + + {form.values.location_mode === 'clear' && ( + }> + {t`Clearing removes any per-date override. Affected dates will fall back to the event's default location.`} + + )} + + )} + diff --git a/frontend/src/components/routes/event/OccurrencesTab/OccurrenceEditModal/index.tsx b/frontend/src/components/routes/event/OccurrencesTab/OccurrenceEditModal/index.tsx index 521a62d275..dab34bfdd2 100644 --- a/frontend/src/components/routes/event/OccurrencesTab/OccurrenceEditModal/index.tsx +++ b/frontend/src/components/routes/event/OccurrencesTab/OccurrenceEditModal/index.tsx @@ -1,5 +1,5 @@ import {t} from "@lingui/macro"; -import {Button, Checkbox, NumberInput, Tabs, Text, TextInput, UnstyledButton} from "@mantine/core"; +import {Button, Checkbox, NumberInput, Radio, SegmentedControl, Select, Tabs, Text, TextInput, UnstyledButton} from "@mantine/core"; import {useForm} from "@mantine/form"; import {modals} from "@mantine/modals"; import {useParams} from "react-router"; @@ -10,6 +10,7 @@ import { IconCalendar, IconEdit, IconInfoCircle, + IconMapPin, IconShoppingCart, IconTag, IconTrash, @@ -22,10 +23,18 @@ import { EventOccurrence, EventOccurrenceStatus, GenericModalProps, + GeoPlace, IdParam, + LocationType, UpsertEventOccurrenceRequest, + VenueAddress, } from "../../../../../types.ts"; +import {AddressAutocomplete} from "../../../../common/AddressAutocomplete"; +import {Editor} from "../../../../common/Editor"; +import {useCreateLocation} from "../../../../../mutations/useCreateLocation.ts"; import {useGetEventOccurrence} from "../../../../../queries/useGetEventOccurrence.ts"; +import {useGetOrganizerLocations} from "../../../../../queries/useGetOrganizerLocations.ts"; +import {formatAddress} from "../../../../../utilites/addressUtilities.ts"; import {useCreateEventOccurrence} from "../../../../../mutations/useCreateEventOccurrence.ts"; import {useUpdateEventOccurrence} from "../../../../../mutations/useUpdateEventOccurrence.ts"; import {useCancelOccurrence} from "../../../../../mutations/useCancelOccurrence.ts"; @@ -33,6 +42,7 @@ import {useDeleteEventOccurrence} from "../../../../../mutations/useDeleteEventO import {useGetEvent} from "../../../../../queries/useGetEvent.ts"; import {utcToTz} from "../../../../../utilites/dates.ts"; import {showSuccess, showError} from "../../../../../utilites/notifications.tsx"; +import {isEmptyHtml} from "../../../../../utilites/helpers.ts"; import {useFormErrorResponseHandler} from "../../../../../hooks/useFormErrorResponseHandler.tsx"; import {OccurrenceProductSettings} from "../PriceOverrideForm"; import {SendMessageModal} from "../../../../modals/SendMessageModal"; @@ -57,13 +67,46 @@ export const OccurrenceEditModal = ({onClose, occurrenceId, duplicateFrom, defau const updateMutation = useUpdateEventOccurrence(); const cancelMutation = useCancelOccurrence(); const deleteMutation = useDeleteEventOccurrence(); + const createLocationMutation = useCreateLocation(); + const organizerId = event?.organizer?.id ?? event?.organizer_id; + const savedLocationsQuery = useGetOrganizerLocations(organizerId, undefined, !!organizerId); + const savedLocations = savedLocationsQuery.data?.data ?? []; - const form = useForm({ + type LocationPickerMode = 'saved' | 'new'; + + type OccurrenceFormShape = { + start_date: string; + end_date: string; + capacity: number | null; + label: string; + online_event_connection_details: string; + location_mode: 'inherit' | 'override' | 'online'; + location_picker: LocationPickerMode; + saved_location_id: string | null; + override_address: VenueAddress; + override_latlng: {lat: number | null; lng: number | null; provider: string | null; placeId: string | null}; + }; + + const form = useForm({ initialValues: { start_date: '', end_date: '', capacity: null, label: '', + online_event_connection_details: '', + location_mode: 'inherit', + location_picker: 'saved', + saved_location_id: null, + override_address: { + venue_name: '', + address_line_1: '', + address_line_2: '', + city: '', + state_or_region: '', + zip_or_postal_code: '', + country: '', + }, + override_latlng: {lat: null, lng: null, provider: null, placeId: null}, }, validate: { start_date: (value) => !value ? t`Start date is required` : null, @@ -79,16 +122,50 @@ export const OccurrenceEditModal = ({onClose, occurrenceId, duplicateFrom, defau } return null; }, + online_event_connection_details: (value, values) => { + if (values.location_mode === 'online' && (!value || isEmptyHtml(value))) { + return t`Connection details are required for online dates`; + } + return null; + }, }, }); useEffect(() => { if (occurrence && event) { + const eventLocation = occurrence.event_location; + const occType = eventLocation?.type; + const mode: 'inherit' | 'override' | 'online' = (() => { + if (occType === LocationType.Online) return 'online'; + if (occType === LocationType.InPerson) return 'override'; + return 'inherit'; + })(); + const occLocation = eventLocation?.location; + const occLocationId = eventLocation?.location_id ?? null; form.setValues({ start_date: utcToTz(occurrence.start_date, event.timezone) || '', end_date: utcToTz(occurrence.end_date, event.timezone) || '', capacity: occurrence.capacity ?? null, label: occurrence.label || '', + online_event_connection_details: eventLocation?.online_event_connection_details ?? '', + location_mode: mode, + location_picker: occLocationId ? 'saved' : 'new', + saved_location_id: occLocationId ? String(occLocationId) : null, + override_address: occLocation?.structured_address ?? { + venue_name: '', + address_line_1: '', + address_line_2: '', + city: '', + state_or_region: '', + zip_or_postal_code: '', + country: '', + }, + override_latlng: { + lat: occLocation?.latitude ?? null, + lng: occLocation?.longitude ?? null, + provider: occLocation?.provider ?? null, + placeId: occLocation?.provider_place_id ?? null, + }, }); } }, [occurrence, event]); @@ -118,34 +195,95 @@ export const OccurrenceEditModal = ({onClose, occurrenceId, duplicateFrom, defau newEndDate: string | null | undefined; } | null>(null); - const submit = (values: UpsertEventOccurrenceRequest, notifyAfterSave: boolean) => { - const onSuccess = () => { - showSuccess(isEditing - ? t`Date updated successfully` - : t`Date created successfully` - ); - if (notifyAfterSave && isEditing && occurrence) { - // The edit modal hides (opened={!pendingNotification}) and - // SendMessageModal takes over — one visible modal at a time. - setPendingNotification({ - occurrence, - newStartDate: values.start_date, - newEndDate: values.end_date, - }); - return; + type OccurrenceFormValues = OccurrenceFormShape; + + const submit = async (values: OccurrenceFormValues, notifyAfterSave: boolean) => { + try { + let eventLocationPayload: UpsertEventOccurrenceRequest['event_location'] = undefined; + let clearEventLocation = false; + + if (values.location_mode === 'inherit') { + clearEventLocation = true; + } else if (values.location_mode === 'online') { + eventLocationPayload = { + type: LocationType.Online, + online_event_connection_details: values.online_event_connection_details || null, + }; + } else if (values.location_mode === 'override') { + if (!organizerId) { + throw new Error('No organizer context available'); + } + let locationIdForOccurrence: number | null = null; + if (values.location_picker === 'saved') { + if (!values.saved_location_id) { + throw new Error('Pick a saved location or switch to "New location"'); + } + locationIdForOccurrence = Number(values.saved_location_id); + } else { + if (!values.override_address?.address_line_1 && !values.override_address?.venue_name && !values.override_address?.city) { + throw new Error('Provide at least one address field for the override location'); + } + const created = await createLocationMutation.mutateAsync({ + organizerId, + payload: { + name: values.override_address.venue_name || null, + structured_address: values.override_address, + latitude: values.override_latlng.lat, + longitude: values.override_latlng.lng, + provider: values.override_latlng.provider, + provider_place_id: values.override_latlng.placeId, + }, + }); + locationIdForOccurrence = (created.data.id as number | undefined) ?? null; + } + eventLocationPayload = { + type: LocationType.InPerson, + location_id: locationIdForOccurrence, + }; + } + + const normalisedEnd = values.end_date && values.end_date > values.start_date + ? values.end_date + : undefined; + + const payload: UpsertEventOccurrenceRequest = { + start_date: values.start_date, + end_date: normalisedEnd, + capacity: values.capacity, + label: values.label, + ...(eventLocationPayload ? {event_location: eventLocationPayload} : {}), + ...(clearEventLocation ? {clear_event_location: true} : {}), + }; + + const onSuccess = () => { + showSuccess(isEditing + ? t`Date updated successfully` + : t`Date created successfully` + ); + if (notifyAfterSave && isEditing && occurrence) { + setPendingNotification({ + occurrence, + newStartDate: values.start_date, + newEndDate: values.end_date, + }); + return; + } + onClose(); + }; + const onError = (error: any) => errorHandler(form, error); + + if (isEditing) { + updateMutation.mutate({eventId, occurrenceId, data: payload}, {onSuccess, onError}); + } else { + createMutation.mutate({eventId, data: payload}, {onSuccess, onError}); } - onClose(); - }; - const onError = (error: any) => errorHandler(form, error); - - if (isEditing) { - updateMutation.mutate({eventId, occurrenceId, data: values}, {onSuccess, onError}); - } else { - createMutation.mutate({eventId, data: values}, {onSuccess, onError}); + } catch (error: any) { + showError(error?.message || t`Could not save date`); + errorHandler(form, error); } }; - const handleSubmit = (values: UpsertEventOccurrenceRequest) => { + const handleSubmit = (values: OccurrenceFormValues) => { // Only warn when editing an existing occurrence whose date/time actually // changed AND someone has already registered. const attendeeCount = occurrence?.statistics?.attendees_registered ?? 0; @@ -333,6 +471,106 @@ export const OccurrenceEditModal = ({onClose, occurrenceId, duplicateFrom, defau )}
+
+
+
+ {t`Location`} +
+ form.setFieldValue('location_mode', value as 'inherit' | 'override' | 'online')} + > + + + + + + {form.values.location_mode === 'override' && ( +
+ form.setFieldValue('location_picker', value as LocationPickerMode)} + /> + {form.values.location_picker === 'saved' ? ( + { )} - ); -} +}; diff --git a/frontend/src/components/routes/event/TicketDesigner/TicketDesignerPrint.tsx b/frontend/src/components/routes/event/TicketDesigner/TicketDesignerPrint.tsx index 64950ae88f..47484b7b3b 100644 --- a/frontend/src/components/routes/event/TicketDesigner/TicketDesignerPrint.tsx +++ b/frontend/src/components/routes/event/TicketDesigner/TicketDesignerPrint.tsx @@ -7,6 +7,8 @@ import {AttendeeTicket} from '../../../common/AttendeeTicket'; import {PoweredByFooter} from '../../../common/PoweredByFooter'; import {t} from '@lingui/macro'; import {useEffect} from "react"; +import {resolveEventLocation} from "../../../../utilites/effectiveLocation.ts"; +import {LocationType} from "../../../../types.ts"; import classes from '../../../routes/product-widget/PrintOrder/PrintOrder.module.scss'; const TicketDesignerPrint = () => { @@ -70,16 +72,24 @@ const TicketDesignerPrint = () => { } }; - // Merge the ticket design settings and images into the event + const fallbackLocationDetails = { + venue_name: t`Sample Venue`, + address_line_1: t`123 Sample Street`, + }; + const resolved = resolveEventLocation(event, null); + const resolvedEventLocation = resolved?.type === LocationType.InPerson && resolved.location + ? resolved + : { + id: 0, + type: LocationType.InPerson, + location: {name: fallbackLocationDetails.venue_name, structured_address: fallbackLocationDetails}, + }; const eventWithDesignSettings = { ...event, + event_location: resolvedEventLocation, settings: { ...event.settings, ticket_design_settings: settings.ticket_design_settings, - location_details: event.settings?.location_details || { - venue_name: t`Sample Venue`, - address_line_1: t`123 Sample Street`, - } }, images: images || [] }; diff --git a/frontend/src/components/routes/event/TicketDesigner/TicketPreview.tsx b/frontend/src/components/routes/event/TicketDesigner/TicketPreview.tsx index 26da7b28b0..4e0589bc01 100644 --- a/frontend/src/components/routes/event/TicketDesigner/TicketPreview.tsx +++ b/frontend/src/components/routes/event/TicketDesigner/TicketPreview.tsx @@ -1,10 +1,10 @@ import {useGetEvent} from "../../../../queries/useGetEvent.ts"; import {useGetMe} from "../../../../queries/useGetMe.ts"; import {t} from "@lingui/macro"; -import {IdParam} from "../../../../types.ts"; +import {IdParam, LocationType} from "../../../../types.ts"; import {AttendeeTicket} from "../../../common/AttendeeTicket"; +import {resolveEventLocation} from "../../../../utilites/effectiveLocation.ts"; import classes from './TicketPreview.module.scss'; -import {useGetEventSettings} from "../../../../queries/useGetEventSettings.ts"; interface TicketDesignSettings { accent_color: string; @@ -22,11 +22,9 @@ interface TicketPreviewProps { export const TicketPreview = ({settings, eventId, logoUrl}: TicketPreviewProps) => { const eventQuery = useGetEvent(eventId); const meQuery = useGetMe(); - const eventSettingsQuery = useGetEventSettings(eventId); const event = eventQuery.data; const user = meQuery.data; - const eventSettings = eventSettingsQuery.data; if (!event || !user) { return ( @@ -74,8 +72,21 @@ export const TicketPreview = ({settings, eventId, logoUrl}: TicketPreviewProps) } }; + const fallbackLocationDetails = { + venue_name: t`Sample Venue`, + address_line_1: t`123 Sample Street`, + }; + const resolved = resolveEventLocation(event, null); + const resolvedEventLocation = resolved?.type === LocationType.InPerson && resolved.location + ? resolved + : { + id: 0, + type: LocationType.InPerson, + location: {name: fallbackLocationDetails.venue_name, structured_address: fallbackLocationDetails}, + }; const eventWithDesignSettings = { ...event, + event_location: resolvedEventLocation, settings: { ...event.settings, ticket_design_settings: { @@ -84,10 +95,6 @@ export const TicketPreview = ({settings, eventId, logoUrl}: TicketPreviewProps) footer_text: settings.footer_text, enabled: settings.enabled }, - location_details: eventSettings?.location_details || { - venue_name: t`Sample Venue`, - address_line_1: t`123 Sample Street`, - } }, images: logoUrl && settings.logo_image_id ? [ ...((event.images || []).filter(img => img.type !== 'TICKET_LOGO')), diff --git a/frontend/src/components/routes/my-tickets/index.tsx b/frontend/src/components/routes/my-tickets/index.tsx index 37a94d8d58..a4ae7355b9 100644 --- a/frontend/src/components/routes/my-tickets/index.tsx +++ b/frontend/src/components/routes/my-tickets/index.tsx @@ -17,6 +17,7 @@ import {useState} from "react"; import {useGetOrdersByLookupToken} from "../../../queries/useGetOrdersByLookupToken.ts"; import {useSendTicketLookupEmail} from "../../../mutations/useSendTicketLookupEmail.ts"; import {dateToBrowserTz} from "../../../utilites/dates.ts"; +import {resolveEventLocation} from "../../../utilites/effectiveLocation.ts"; import {formatAddress} from "../../../utilites/addressUtilities.ts"; import {showError} from "../../../utilites/notifications.tsx"; @@ -26,7 +27,7 @@ import {PoweredByFooter} from "../../common/PoweredByFooter"; import {EventDateRange} from "../../common/EventDateRange"; import {CheckoutContent} from "../../layouts/Checkout/CheckoutContent"; -import {Event, Order} from "../../../types.ts"; +import {Event, LocationType, Order} from "../../../types.ts"; import classes from './MyTickets.module.scss'; const OrderStatusBadge = () => ( @@ -37,7 +38,23 @@ const OrderStatusBadge = () => ( const OrderCard = ({order}: { order: Order }) => { const event = order.event as Event; - const location = event?.settings?.location_details ? formatAddress(event.settings.location_details) : null; + const occurrenceId = order.attendees?.[0]?.event_occurrence_id + ?? order.order_items?.[0]?.event_occurrence_id; + const occurrence = occurrenceId != null + ? event?.occurrences?.find((o) => o.id === occurrenceId) ?? null + : null; + const effective = resolveEventLocation(event, occurrence); + const venueName = effective?.type === LocationType.InPerson + ? (effective.location?.name || effective.location?.structured_address?.venue_name || null) + : null; + const formattedAddress = effective?.type === LocationType.InPerson && effective.location?.structured_address + ? formatAddress(effective.location.structured_address) + : ''; + const locationLabel = effective?.type === LocationType.InPerson + ? [venueName, formattedAddress].filter(Boolean).join(', ') || null + : effective?.type === LocationType.Online + ? t`Online` + : null; const ticketCount = order.attendees?.length || 0; const orderUrl = `/checkout/${event?.id}/${order.short_id}/summary`; const printUrl = `/order/${event?.id}/${order.short_id}/print`; @@ -65,13 +82,13 @@ const OrderCard = ({order}: { order: Order }) => {
- {location && ( + {locationLabel && (
{t`Location`} - {location} + {locationLabel}
diff --git a/frontend/src/components/routes/organizer/Locations/index.tsx b/frontend/src/components/routes/organizer/Locations/index.tsx new file mode 100644 index 0000000000..eb29111b36 --- /dev/null +++ b/frontend/src/components/routes/organizer/Locations/index.tsx @@ -0,0 +1,152 @@ +import {t} from "@lingui/macro"; +import {ActionIcon, Box, Card, Group, Stack, Table, Text} from "@mantine/core"; +import {modals} from "@mantine/modals"; +import {IconMapPin, IconPencil, IconTrash} from "@tabler/icons-react"; +import {useDisclosure} from "@mantine/hooks"; +import {useState} from "react"; +import {useParams} from "react-router"; +import {useGetOrganizerLocations} from "../../../../queries/useGetOrganizerLocations.ts"; +import {useDeleteLocation} from "../../../../mutations/useDeleteLocation.ts"; +import {IdParam, Location} from "../../../../types.ts"; +import {PageBody} from "../../../common/PageBody"; +import {PageTitle} from "../../../common/PageTitle"; +import {TableSkeleton} from "../../../common/TableSkeleton"; +import {formatAddress} from "../../../../utilites/addressUtilities.ts"; +import {showError, showSuccess} from "../../../../utilites/notifications.tsx"; +import {LocationEditModal} from "../../../modals/LocationEditModal"; + +export default function Locations() { + const {organizerId} = useParams(); + const locationsQuery = useGetOrganizerLocations(organizerId as IdParam); + const deleteMutation = useDeleteLocation(); + const [editModalOpen, {open: openEditModal, close: closeEditModal}] = useDisclosure(false); + const [editTarget, setEditTarget] = useState(null); + + const locations = locationsQuery.data?.data ?? []; + + const handleEdit = (location: Location | null) => { + setEditTarget(location); + openEditModal(); + }; + + const handleDelete = (location: Location) => { + modals.openConfirmModal({ + title: t`Delete location`, + children: ( + + {t`Delete "${location.name ?? location.structured_address?.venue_name ?? formatAddress(location.structured_address ?? {})}"? Locations referenced by events, occurrences, or set as an organizer's address can't be deleted.`} + + ), + labels: {confirm: t`Delete`, cancel: t`Cancel`}, + confirmProps: {color: "red"}, + onConfirm: () => { + deleteMutation.mutate( + {organizerId: organizerId as IdParam, locationId: location.id as IdParam}, + { + onSuccess: () => showSuccess(t`Location deleted`), + onError: (error: any) => + showError(error?.response?.data?.message ?? t`Could not delete location`), + }, + ); + }, + }); + }; + + return ( + + {t`Locations`} + + + + + {t`Saved Locations`} + + {t`Reusable venues for your events. Locations created from the autocomplete are saved here automatically.`} + + + handleEdit(null)} + aria-label={t`Add location`} + > + + + + + + + {!locationsQuery.isLoading && locations.length === 0 && ( + + {t`No saved locations yet. They'll appear here as you create events with addresses.`} + + )} + + {!locationsQuery.isLoading && locations.length > 0 && ( + + + + {t`Name`} + {t`Address`} + {t`Provider`} + {t`Actions`} + + + + {locations.map((loc) => ( + + + + {loc.name ?? loc.structured_address?.venue_name ?? t`Unnamed`} + + + + + {loc.structured_address ? formatAddress(loc.structured_address) : ""} + + + + + {loc.provider ?? t`Manual`} + + + + + handleEdit(loc)} + aria-label={t`Edit location`} + > + + + handleDelete(loc)} + aria-label={t`Delete location`} + > + + + + + + ))} + +
+ )} +
+
+ + {editModalOpen && ( + { + setEditTarget(null); + closeEditModal(); + }} + organizerId={organizerId as IdParam} + location={editTarget} + /> + )} +
+ ); +} diff --git a/frontend/src/components/routes/organizer/Settings/Sections/AddressSettings/index.tsx b/frontend/src/components/routes/organizer/Settings/Sections/AddressSettings/index.tsx index acae497356..f8c1158a44 100644 --- a/frontend/src/components/routes/organizer/Settings/Sections/AddressSettings/index.tsx +++ b/frontend/src/components/routes/organizer/Settings/Sections/AddressSettings/index.tsx @@ -2,23 +2,34 @@ import {t} from "@lingui/macro"; import {Button, Select, TextInput} from "@mantine/core"; import {useForm} from "@mantine/form"; import {useParams} from "react-router"; -import {useEffect} from "react"; +import {useEffect, useState} from "react"; import {Card} from "../../../../../common/Card"; -import {showSuccess} from "../../../../../../utilites/notifications.tsx"; +import {showError, showSuccess} from "../../../../../../utilites/notifications.tsx"; import {useFormErrorResponseHandler} from "../../../../../../hooks/useFormErrorResponseHandler.tsx"; -import {useUpdateOrganizerSettings} from "../../../../../../mutations/useUpdateOrganizerSettings.ts"; import countries from "../../../../../../../data/countries.json"; -import {useGetOrganizerSettings} from "../../../../../../queries/useGetOrganizerSettings.ts"; +import {useGetOrganizer} from "../../../../../../queries/useGetOrganizer.ts"; import {InputGroup} from "../../../../../common/InputGroup"; import {HeadingWithDescription} from "../../../../../common/Card/CardHeading"; +import {AddressAutocomplete} from "../../../../../common/AddressAutocomplete"; +import {useCreateLocation} from "../../../../../../mutations/useCreateLocation.ts"; +import {useUpdateOrganizerLocation} from "../../../../../../mutations/useUpdateOrganizerLocation.ts"; +import {GeoPlace, IdParam, VenueAddress} from "../../../../../../types.ts"; export const AddressSettings = () => { const {organizerId} = useParams(); - const organizerSettingsQuery = useGetOrganizerSettings(organizerId); - const updateMutation = useUpdateOrganizerSettings(); + const organizerQuery = useGetOrganizer(organizerId); + const createLocationMutation = useCreateLocation(); + const updateOrganizerLocationMutation = useUpdateOrganizerLocation(); + const [latLng, setLatLng] = useState<{lat: number | null; lng: number | null; provider: string | null; placeId: string | null}>({ + lat: null, + lng: null, + provider: null, + placeId: null, + }); + const form = useForm({ initialValues: { - location_details: { + structured_address: { venue_name: '', address_line_1: '', address_line_2: '', @@ -26,40 +37,114 @@ export const AddressSettings = () => { state_or_region: '', zip_or_postal_code: '', country: '', - }, + } as VenueAddress, }, }); const formErrorHandle = useFormErrorResponseHandler(); useEffect(() => { - if (organizerSettingsQuery?.isFetched && organizerSettingsQuery.data) { + if (organizerQuery?.isFetched && organizerQuery.data) { + const source = organizerQuery.data.location?.structured_address; form.setValues({ - location_details: { - venue_name: organizerSettingsQuery.data.location_details?.venue_name || '', - address_line_1: organizerSettingsQuery.data.location_details?.address_line_1 || '', - address_line_2: organizerSettingsQuery.data.location_details?.address_line_2 || '', - city: organizerSettingsQuery.data.location_details?.city || '', - state_or_region: organizerSettingsQuery.data.location_details?.state_or_region || '', - zip_or_postal_code: organizerSettingsQuery.data.location_details?.zip_or_postal_code || '', - country: organizerSettingsQuery.data.location_details?.country || '', + structured_address: { + venue_name: source?.venue_name || '', + address_line_1: source?.address_line_1 || '', + address_line_2: source?.address_line_2 || '', + city: source?.city || '', + state_or_region: source?.state_or_region || '', + zip_or_postal_code: source?.zip_or_postal_code || '', + country: source?.country || '', }, }); + setLatLng({ + lat: organizerQuery.data.location?.latitude ?? null, + lng: organizerQuery.data.location?.longitude ?? null, + provider: organizerQuery.data.location?.provider ?? null, + placeId: organizerQuery.data.location?.provider_place_id ?? null, + }); } - }, [organizerSettingsQuery.isFetched]); + }, [organizerQuery.isFetched]); - const handleSubmit = (values: any) => { - updateMutation.mutate({ - organizerSettings: values, - organizerId: organizerId, - }, { - onSuccess: () => { - showSuccess(t`Successfully Updated Address`); + const handlePlaceSelected = (place: GeoPlace) => { + form.setValues({ + ...form.values, + structured_address: { + venue_name: place.address.venue_name || '', + address_line_1: place.address.address_line_1 || '', + address_line_2: place.address.address_line_2 || '', + city: place.address.city || '', + state_or_region: place.address.state_or_region || '', + zip_or_postal_code: place.address.zip_or_postal_code || '', + country: place.address.country || '', }, - onError: (error) => { - formErrorHandle(form, error); - } }); - } + setLatLng({ + lat: place.latitude ?? null, + lng: place.longitude ?? null, + provider: place.provider, + placeId: place.provider_place_id, + }); + }; + + const handleSubmit = async (values: {structured_address: VenueAddress}) => { + if (!organizerId) { + return; + } + const address = values.structured_address; + const hasAnything = Boolean( + address.venue_name || address.address_line_1 || address.city + || address.state_or_region || address.zip_or_postal_code || address.country, + ); + const existingLocationId = organizerQuery.data?.location_id ?? null; + + try { + if (!hasAnything && existingLocationId === null) { + showSuccess(t`Successfully Updated Address`); + return; + } + + const existingAddress = organizerQuery.data?.location?.structured_address ?? null; + const addressUnchanged = hasAnything && existingAddress !== null + && (existingAddress.venue_name || "") === (address.venue_name || "") + && (existingAddress.address_line_1 || "") === (address.address_line_1 || "") + && (existingAddress.address_line_2 || "") === (address.address_line_2 || "") + && (existingAddress.city || "") === (address.city || "") + && (existingAddress.state_or_region || "") === (address.state_or_region || "") + && (existingAddress.zip_or_postal_code || "") === (address.zip_or_postal_code || "") + && (existingAddress.country || "") === (address.country || ""); + + if (addressUnchanged) { + showSuccess(t`Successfully Updated Address`); + return; + } + + let locationId: IdParam | null = null; + if (hasAnything) { + const created = await createLocationMutation.mutateAsync({ + organizerId, + payload: { + name: address.venue_name || null, + structured_address: address, + latitude: latLng.lat, + longitude: latLng.lng, + provider: latLng.provider, + provider_place_id: latLng.placeId, + }, + }); + locationId = created.data.id ?? null; + } + + await updateOrganizerLocationMutation.mutateAsync({ + organizerId, + locationId, + }); + + showSuccess(t`Successfully Updated Address`); + } catch (error) { + showError(t`Could not save address`); + formErrorHandle(form, error); + } + }; return ( @@ -68,51 +153,58 @@ export const AddressSettings = () => { description={t`Your organizer address`} />
-
+
+ {organizerId && ( + + )}