From 6a7484c8f7123a2ba218d5338feab647cb797fa1 Mon Sep 17 00:00:00 2001 From: Dave Earley Date: Sun, 30 Jun 2024 22:35:36 -0700 Subject: [PATCH 1/9] Add ability to duplicate events --- backend/app/DataTransferObjects/BaseDTO.php | 56 ++++- .../app/DomainObjects/EventDomainObject.php | 14 ++ .../Actions/Events/DuplicateEventAction.php | 33 ++- .../Request/Event/DuplicateEventRequest.php | 30 +++ .../UpdateEventSettingsRequest.php | 1 + backend/app/Models/Event.php | 5 + .../Domain/Event/CreateEventService.php | 144 +++++++++++++ .../Domain/Event/DTO/CreateEventDTO.php | 13 ++ .../Event/DTO/DuplicateEventDataDTO.php | 23 +++ .../Domain/Event/DTO/EventSettingsDTO.php | 10 + .../Domain/Event/DuplicateEventService.php | 191 ++++++++++++++++++ .../Order/OrderItemProcessingService.php | 2 +- .../Organizer/OrganizerFetchService.php | 35 ++++ .../PromoCode/CreatePromoCodeService.php | 71 +++++++ .../Domain/Question/CreateQuestionService.php | 38 ++++ .../Domain/Ticket/CreateTicketService.php | 117 +++++++++++ .../Domain/Ticket/DTO/CreateTicketDTO.php | 10 + .../Ticket/TicketPriceCreateService.php | 55 ++--- .../Handlers/Event/CreateEventHandler.php | 130 +++--------- .../Handlers/Event/DTO/CreateEventDTO.php | 3 + .../Handlers/Event/DuplicateEventHandler.php | 36 ++++ .../DTO/UpdateEventSettingsDTO.php | 39 ++++ .../PromoCode/CreatePromoCodeHandler.php | 55 ++--- .../Question/CreateQuestionHandler.php | 31 +-- .../Handlers/Ticket/CreateTicketHandler.php | 107 ++++------ backend/app/Validators/EventRules.php | 20 +- backend/routes/api.php | 2 + frontend/src/api/event.client.ts | 7 +- .../src/components/common/EventCard/index.tsx | 18 +- .../modals/DuplicateEventModal/index.tsx | 114 +++++++++++ frontend/src/mutations/useDuplicateEvent.ts | 12 ++ frontend/src/types.ts | 19 +- 32 files changed, 1152 insertions(+), 289 deletions(-) create mode 100644 backend/app/Http/Request/Event/DuplicateEventRequest.php create mode 100644 backend/app/Services/Domain/Event/CreateEventService.php create mode 100644 backend/app/Services/Domain/Event/DTO/CreateEventDTO.php create mode 100644 backend/app/Services/Domain/Event/DTO/DuplicateEventDataDTO.php create mode 100644 backend/app/Services/Domain/Event/DTO/EventSettingsDTO.php create mode 100644 backend/app/Services/Domain/Event/DuplicateEventService.php create mode 100644 backend/app/Services/Domain/Organizer/OrganizerFetchService.php create mode 100644 backend/app/Services/Domain/PromoCode/CreatePromoCodeService.php create mode 100644 backend/app/Services/Domain/Question/CreateQuestionService.php create mode 100644 backend/app/Services/Domain/Ticket/CreateTicketService.php create mode 100644 backend/app/Services/Domain/Ticket/DTO/CreateTicketDTO.php create mode 100644 backend/app/Services/Handlers/Event/DuplicateEventHandler.php create mode 100644 frontend/src/components/modals/DuplicateEventModal/index.tsx create mode 100644 frontend/src/mutations/useDuplicateEvent.ts diff --git a/backend/app/DataTransferObjects/BaseDTO.php b/backend/app/DataTransferObjects/BaseDTO.php index fc4fa207..687a3dbb 100644 --- a/backend/app/DataTransferObjects/BaseDTO.php +++ b/backend/app/DataTransferObjects/BaseDTO.php @@ -2,12 +2,14 @@ namespace HiEvents\DataTransferObjects; +use BackedEnum; +use HiEvents\DataTransferObjects\Attributes\CollectionOf; use Illuminate\Support\Collection; use ReflectionClass; use ReflectionProperty; use RuntimeException; use Throwable; -use HiEvents\DataTransferObjects\Attributes\CollectionOf; +use UnitEnum; abstract class BaseDTO { @@ -54,6 +56,58 @@ public static function collectionFromArray(array $items): Collection return collect(array_map([static::class, 'fromArray'], $items)); } + /** + * Convert the DTO to a flattened array. Converts all enums to string and DTOs to arrays. + * + * It ain't pretty, but it works. + * + * @param array $without + * @return array + */ + public function toFlattenedArray(array $without = []): array + { + $data = []; + $properties = get_object_vars($this); + + foreach ($properties as $key => $value) { + if (in_array($key, $without, true)) { + continue; + } + + if ($value instanceof self) { + $data[$key] = $value->toFlattenedArray(); + } elseif ($value instanceof UnitEnum) { + $data[$key] = $value instanceof BackedEnum ? $value->value : $value->name; + } elseif ($value instanceof Collection) { + $data[$key] = $value->map(function ($item) { + if ($item instanceof BaseDTO) { + return $item->toFlattenedArray(); + } + + if ($item instanceof UnitEnum) { + return $item instanceof BackedEnum ? $item->value : $item->name; + } + return $item; + })->toArray(); + } elseif (is_array($value)) { + $data[$key] = array_map(static function ($item) { + if ($item instanceof BaseDTO) { + return $item->toFlattenedArray(); + } + + if ($item instanceof UnitEnum) { + return $item instanceof BackedEnum ? $item->value : $item->name; + } + return $item; + }, $value); + } else { + $data[$key] = $value; + } + } + + return $data; + } + /** * Hydrate objects from properties based on property to object map. * diff --git a/backend/app/DomainObjects/EventDomainObject.php b/backend/app/DomainObjects/EventDomainObject.php index b7f59961..af764deb 100644 --- a/backend/app/DomainObjects/EventDomainObject.php +++ b/backend/app/DomainObjects/EventDomainObject.php @@ -19,6 +19,8 @@ class EventDomainObject extends Generated\EventDomainObjectAbstract implements I private ?Collection $images = null; + private ?Collection $promoCodes = null; + private ?EventSettingDomainObject $settings = null; private ?OrganizerDomainObject $organizer = null; @@ -190,4 +192,16 @@ public function getLifecycleStatus(): string return EventLifecycleStatus::ENDED->name; } + + public function getPromoCodes(): ?Collection + { + return $this->promoCodes; + } + + public function setPromoCodes(?Collection $promoCodes): self + { + $this->promoCodes = $promoCodes; + + return $this; + } } diff --git a/backend/app/Http/Actions/Events/DuplicateEventAction.php b/backend/app/Http/Actions/Events/DuplicateEventAction.php index 63a2b609..337b10e5 100644 --- a/backend/app/Http/Actions/Events/DuplicateEventAction.php +++ b/backend/app/Http/Actions/Events/DuplicateEventAction.php @@ -2,12 +2,41 @@ namespace HiEvents\Http\Actions\Events; +use HiEvents\DomainObjects\EventDomainObject; use HiEvents\Http\Actions\BaseAction; +use HiEvents\Http\Request\Event\DuplicateEventRequest; +use HiEvents\Resources\Event\EventResource; +use HiEvents\Services\Domain\Event\DTO\DuplicateEventDataDTO; +use HiEvents\Services\Handlers\Event\DuplicateEventHandler; +use Illuminate\Http\JsonResponse; +use Throwable; class DuplicateEventAction extends BaseAction { - public function __invoke(): void + public function __construct(private readonly DuplicateEventHandler $handler) { - // todo + } + + /** + * @throws Throwable + */ + public function __invoke(int $eventId, DuplicateEventRequest $request): JsonResponse + { + $this->isActionAuthorized($eventId, EventDomainObject::class); + + $event = $this->handler->handle(new DuplicateEventDataDTO( + eventId: $eventId, + accountId: $this->getAuthenticatedAccountId(), + title: $request->validated('title'), + startDate: $request->validated('start_date'), + duplicateTickets: $request->validated('duplicate_tickets'), + duplicateQuestions: $request->validated('duplicate_questions'), + duplicateSettings: $request->validated('duplicate_settings'), + duplicatePromoCodes: $request->validated('duplicate_promo_codes'), + description: $request->validated('description'), + endDate: $request->validated('end_date'), + )); + + return $this->resourceResponse(EventResource::class, $event); } } diff --git a/backend/app/Http/Request/Event/DuplicateEventRequest.php b/backend/app/Http/Request/Event/DuplicateEventRequest.php new file mode 100644 index 00000000..62b7ce63 --- /dev/null +++ b/backend/app/Http/Request/Event/DuplicateEventRequest.php @@ -0,0 +1,30 @@ +minimalRules(); + + $duplicateValidations = [ + 'duplicate_tickets' => ['boolean', 'required'], + 'duplicate_questions' => ['boolean', 'required'], + 'duplicate_settings' => ['boolean', 'required'], + 'duplicate_promo_codes' => ['boolean', 'required'], + ]; + + return array_merge($eventValidations, $duplicateValidations); + } + + public function messages(): array + { + return $this->eventMessages(); + } +} diff --git a/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php index 821ea3df..4b1ad922 100644 --- a/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php +++ b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php @@ -10,6 +10,7 @@ class UpdateEventSettingsRequest extends BaseRequest { + // @todo these should all be required for the update request. They should only be nullable for the PATCH request public function rules(): array { return [ diff --git a/backend/app/Models/Event.php b/backend/app/Models/Event.php index 57a41e9f..5fe2e610 100644 --- a/backend/app/Models/Event.php +++ b/backend/app/Models/Event.php @@ -39,6 +39,11 @@ public function event_settings(): HasOne return $this->hasOne(EventSetting::class); } + public function promo_codes(): HasMany + { + return $this->hasMany(PromoCode::class); + } + public static function boot() { parent::boot(); diff --git a/backend/app/Services/Domain/Event/CreateEventService.php b/backend/app/Services/Domain/Event/CreateEventService.php new file mode 100644 index 00000000..dbc23330 --- /dev/null +++ b/backend/app/Services/Domain/Event/CreateEventService.php @@ -0,0 +1,144 @@ +databaseManager->beginTransaction(); + + $organizer = $this->getOrganizer( + organizerId: $eventData->getOrganizerId(), + accountId: $eventData->getAccountId() + ); + + $event = $this->handleEventCreate($eventData); + + $this->createEventSettings( + eventSettings: $eventSettings, + event: $event, + organizer: $organizer + ); + + $this->createEventStatistics($event); + + $this->databaseManager->commit(); + + return $event; + } + + /** + * @throws OrganizerNotFoundException + */ + private function getOrganizer(int $organizerId, int $accountId): OrganizerDomainObject + { + $organizer = $this->organizerRepository->findFirstWhere([ + 'id' => $organizerId, + 'account_id' => $accountId, + ]); + + if ($organizer === null) { + throw new OrganizerNotFoundException( + __('Organizer :id not found', ['id' => $organizerId]) + ); + } + + return $organizer; + } + + private function handleEventCreate(EventDomainObject $eventData): EventDomainObject + { + return $this->eventRepository->create([ + 'title' => $eventData->getTitle(), + 'organizer_id' => $eventData->getOrganizerId(), + 'start_date' => DateHelper::convertToUTC($eventData->getStartDate(), $eventData->getTimezone()), + 'end_date' => $eventData->getEndDate() + ? DateHelper::convertToUTC($eventData->getEndDate(), $eventData->getTimezone()) + : null, + 'description' => $eventData->getDescription(), + 'timezone' => $eventData->getTimezone(), + 'currency' => $eventData->getCurrency(), + 'location_details' => $eventData->getLocationDetails(), + 'account_id' => $eventData->getAccountId(), + 'user_id' => $eventData->getUserId(), + 'status' => $eventData->getStatus(), + 'short_id' => IdHelper::randomPrefixedId(IdHelper::EVENT_PREFIX), + 'attributes' => $eventData->getAttributes(), + ]); + } + + private function createEventStatistics(EventDomainObject $event): void + { + $this->eventStatisticsRepository->create([ + 'event_id' => $event->getId(), + 'tickets_sold' => 0, + 'sales_total_gross' => 0, + 'sales_total_before_additions' => 0, + 'total_tax' => 0, + 'total_fee' => 0, + 'orders_created' => 0, + ]); + } + + private function createEventSettings( + ?EventSettingDomainObject $eventSettings, + EventDomainObject $event, + OrganizerDomainObject $organizer + ): void + { + if ($eventSettings !== null) { + $eventSettings->setEventId($event->getId()); + $eventSettingsArray = $eventSettings->toArray(); + + unset($eventSettingsArray['id']); + + $this->eventSettingsRepository->create($eventSettingsArray); + + return; + } + + $this->eventSettingsRepository->create([ + 'event_id' => $event->getId(), + 'homepage_background_color' => '#ffffff', + 'homepage_primary_text_color' => '#000000', + 'homepage_primary_color' => '#7b5db8', + 'homepage_secondary_text_color' => '#ffffff', + 'homepage_secondary_color' => '#7b5eb9', + 'homepage_background_type' => HomepageBackgroundType::COLOR->name, + 'homepage_body_background_color' => '#7a5eb9', + 'continue_button_text' => __('Continue'), + 'support_email' => $organizer->getEmail(), + ]); + } +} diff --git a/backend/app/Services/Domain/Event/DTO/CreateEventDTO.php b/backend/app/Services/Domain/Event/DTO/CreateEventDTO.php new file mode 100644 index 00000000..1fc88d3d --- /dev/null +++ b/backend/app/Services/Domain/Event/DTO/CreateEventDTO.php @@ -0,0 +1,13 @@ +getEventWithRelations($eventId, $accountId); + + $event + ->setTitle($title) + ->setStartDate($startDate) + ->setEndDate($endDate) + ->setDescription($description); + + $newEvent = $this->cloneExistingEvent( + event: $event, + cloneEventSettings: $duplicateSettings, + ); + + if ($duplicateTickets) { + $this->cloneExistingTickets( + event: $event, + newEventId: $newEvent->getId(), + duplicateQuestions: $duplicateQuestions, + duplicatePromoCodes: $duplicatePromoCodes, + ); + } + + return $this->getEventWithRelations($newEvent->getId(), $newEvent->getAccountId()); + } + + /** + * @param EventDomainObject $event + * @param bool $cloneEventSettings + * @return EventDomainObject + * @throws Throwable + */ + private function cloneExistingEvent(EventDomainObject $event, bool $cloneEventSettings): EventDomainObject + { + return $this->createEventService->createEvent( + eventData: (new EventDomainObject()) + ->setOrganizerId($event->getOrganizerId()) + ->setAccountId($event->getAccountId()) + ->setUserId($event->getUserId()) + ->setTitle($event->getTitle()) + ->setStartDate($event->getStartDate()) + ->setEndDate($event->getEndDate()) + ->setDescription($event->getDescription()) + ->setAttributes($event->getAttributes()) + ->setTimezone($event->getTimezone()) + ->setCurrency($event->getCurrency()) + ->setStatus($event->getStatus()), + eventSettings: $cloneEventSettings + ? $event->getEventSettings() + : null, + ); + } + + /** + * @throws Throwable + */ + private function cloneExistingTickets( + EventDomainObject $event, + int $newEventId, + bool $duplicateQuestions, + bool $duplicatePromoCodes, + ): void + { + $oldTicketToNewTicketMap = []; + + $tickets = $event->getTickets(); + foreach ($tickets as $ticket) { + $ticket->setEventId($newEventId); + $newTicket = $this->createTicketService->createTicket( + $ticket, + $event->getAccountId(), + ); + + $oldTicketToNewTicketMap[$ticket->getId()] = $newTicket->getId(); + } + + if ($duplicateQuestions) { + /** @var Collection $questions */ + $questions = $event->getQuestions(); + + foreach ($questions as $question) { + $this->createQuestionService->createQuestion( + (new QuestionDomainObject()) + ->setTitle($question->getTitle()) + ->setEventId($newEventId) + ->setBelongsTo($question->getBelongsTo()) + ->setType($question->getType()) + ->setRequired($question->getRequired()) + ->setOptions($question->getOptions()) + ->setIsHidden($question->getIsHidden()), + array_map( + static fn(TicketDomainObject $ticket) => $oldTicketToNewTicketMap[$ticket->getId()], + $question->getTickets()?->all(), + ), + ); + } + } + + if ($duplicatePromoCodes) { + /** @var Collection $promoCodes */ + $promoCodes = $event->getPromoCodes(); + + foreach ($promoCodes as $promoCode) { + $this->createPromoCodeService->createPromoCode( + (new PromoCodeDomainObject()) + ->setCode($promoCode->getCode()) + ->setEventId($newEventId) + ->setApplicableTicketIds(array_map( + static fn($ticketId) => $oldTicketToNewTicketMap[$ticketId], + $promoCode->getApplicableTicketIds() ?? [], + )) + ->setDiscountType($promoCode->getDiscountType()) + ->setDiscount($promoCode->getDiscount()) + ->setExpiryDate($promoCode->getExpiryDate()) + ->setMaxAllowedUsages($promoCode->getMaxAllowedUsages()), + ); + } + } + } + + private function getEventWithRelations(string $eventId, string $accountId): EventDomainObject + { + return $this->eventRepository + ->loadRelation(EventSettingDomainObject::class) + ->loadRelation(TicketDomainObject::class) + ->loadRelation(PromoCodeDomainObject::class) + ->loadRelation(new Relationship( + domainObject: QuestionDomainObject::class, + nested: [ + new Relationship( + domainObject: TicketDomainObject::class, + ), + ])) + ->loadRelation(new Relationship( + domainObject: TicketDomainObject::class, + nested: [ + new Relationship( + domainObject: TicketPriceDomainObject::class, + ), + ])) + ->findFirstWhere([ + 'id' => $eventId, + 'account_id' => $accountId, + ]); + } +} diff --git a/backend/app/Services/Domain/Order/OrderItemProcessingService.php b/backend/app/Services/Domain/Order/OrderItemProcessingService.php index 0759ad09..3ab93a6e 100644 --- a/backend/app/Services/Domain/Order/OrderItemProcessingService.php +++ b/backend/app/Services/Domain/Order/OrderItemProcessingService.php @@ -108,7 +108,7 @@ private function calculateOrderItemData( ]; } - public function getOrderItemLabel(TicketDomainObject $ticket, int $priceId): string + private function getOrderItemLabel(TicketDomainObject $ticket, int $priceId): string { if ($ticket->isTieredType()) { return $ticket->getTitle() . ' - ' . $ticket->getTicketPrices() diff --git a/backend/app/Services/Domain/Organizer/OrganizerFetchService.php b/backend/app/Services/Domain/Organizer/OrganizerFetchService.php new file mode 100644 index 00000000..84b418ba --- /dev/null +++ b/backend/app/Services/Domain/Organizer/OrganizerFetchService.php @@ -0,0 +1,35 @@ +organizerRepository->findFirstWhere([ + 'id' => $organizerId, + 'account_id' => $accountId, + ]); + + if ($organizer === null) { + throw new OrganizerNotFoundException( + __('Organizer :id not found', ['id' => $organizerId]) + ); + } + + return $organizer; + } +} diff --git a/backend/app/Services/Domain/PromoCode/CreatePromoCodeService.php b/backend/app/Services/Domain/PromoCode/CreatePromoCodeService.php new file mode 100644 index 00000000..6cde42a5 --- /dev/null +++ b/backend/app/Services/Domain/PromoCode/CreatePromoCodeService.php @@ -0,0 +1,71 @@ +checkForDuplicateCode($promoCode); + + $this->eventTicketValidationService->validateTicketIds( + ticketIds: $promoCode->getApplicableTicketIds(), + eventId: $promoCode->getEventId() + ); + + $event = $this->eventRepository->findById($promoCode->getEventId()); + + return $this->promoCodeRepository->create([ + PromoCodeDomainObjectAbstract::EVENT_ID => $promoCode->getEventId(), + PromoCodeDomainObjectAbstract::CODE => $promoCode->getCode(), + PromoCodeDomainObjectAbstract::DISCOUNT => $promoCode->getDiscountType() === PromoCodeDiscountTypeEnum::NONE->name + ? 0.00 + : $promoCode->getDiscount(), + PromoCodeDomainObjectAbstract::DISCOUNT_TYPE => $promoCode->getDiscountType(), + PromoCodeDomainObjectAbstract::EXPIRY_DATE => $promoCode->getExpiryDate() + ? DateHelper::convertToUTC($promoCode->getExpiryDate(), $event->getTimezone()) + : null, + PromoCodeDomainObjectAbstract::MAX_ALLOWED_USAGES => $promoCode->getMaxAllowedUsages(), + PromoCodeDomainObjectAbstract::APPLICABLE_TICKET_IDS => $promoCode->getApplicableTicketIds(), + ]); + } + + /** + * @throws ResourceConflictException + */ + private function checkForDuplicateCode(PromoCodeDomainObject $promoCode): void + { + $existingPromoCode = $this->promoCodeRepository->findFirstWhere([ + PromoCodeDomainObjectAbstract::EVENT_ID => $promoCode->getEventId(), + PromoCodeDomainObjectAbstract::CODE => $promoCode->getCode(), + ]); + + if ($existingPromoCode !== null) { + throw new ResourceConflictException( + __('Promo code :code already exists', ['code' => $promoCode->getCode()]), + ); + } + } +} diff --git a/backend/app/Services/Domain/Question/CreateQuestionService.php b/backend/app/Services/Domain/Question/CreateQuestionService.php new file mode 100644 index 00000000..f419b658 --- /dev/null +++ b/backend/app/Services/Domain/Question/CreateQuestionService.php @@ -0,0 +1,38 @@ +databaseManager->transaction(fn() => $this->questionRepository->create([ + QuestionDomainObjectAbstract::TITLE => $question->getTitle(), + QuestionDomainObjectAbstract::EVENT_ID => $question->getEventId(), + QuestionDomainObjectAbstract::BELONGS_TO => $question->getBelongsTo(), + QuestionDomainObjectAbstract::TYPE => $question->getType(), + QuestionDomainObjectAbstract::REQUIRED => $question->getRequired(), + QuestionDomainObjectAbstract::OPTIONS => $question->getOptions(), + QuestionDomainObjectAbstract::IS_HIDDEN => $question->getIsHidden(), + ], $ticketIds)); + } +} diff --git a/backend/app/Services/Domain/Ticket/CreateTicketService.php b/backend/app/Services/Domain/Ticket/CreateTicketService.php new file mode 100644 index 00000000..065e9b78 --- /dev/null +++ b/backend/app/Services/Domain/Ticket/CreateTicketService.php @@ -0,0 +1,117 @@ +databaseManager->transaction(function () use ($accountId, $taxAndFeeIds, $ticket) { + $persistedTicket = $this->persistTicket($ticket); + + if ($taxAndFeeIds) { + $this->associateTaxesAndFees($persistedTicket, $taxAndFeeIds, $accountId); + } + + return $this->createTicketPrices($persistedTicket, $ticket); + }); + } + + private function persistTicket(TicketDomainObject $ticketsData): TicketDomainObject + { + $event = $this->eventRepository->findById($ticketsData->getEventId()); + + return $this->ticketRepository->create([ + 'title' => $ticketsData->getTitle(), + 'type' => $ticketsData->getType(), + 'order' => $ticketsData->getOrder(), + 'sale_start_date' => $ticketsData->getSaleStartDate() + ? DateHelper::convertToUTC($ticketsData->getSaleStartDate(), $event->getTimezone()) + : null, + 'sale_end_date' => $ticketsData->getSaleEndDate() + ? DateHelper::convertToUTC($ticketsData->getSaleEndDate(), $event->getTimezone()) + : null, + 'max_per_order' => $ticketsData->getMaxPerOrder(), + 'description' => $this->purifier->purify($ticketsData->getDescription()), + 'min_per_order' => $ticketsData->getMinPerOrder(), + 'is_hidden' => $ticketsData->getIsHidden(), + 'hide_before_sale_start_date' => $ticketsData->getHideBeforeSaleStartDate(), + 'hide_after_sale_end_date' => $ticketsData->getHideAfterSaleEndDate(), + 'hide_when_sold_out' => $ticketsData->getHideWhenSoldOut(), + 'show_quantity_remaining' => $ticketsData->getShowQuantityRemaining(), + 'is_hidden_without_promo_code' => $ticketsData->getIsHiddenWithoutPromoCode(), + 'event_id' => $ticketsData->getEventId(), + ]); + } + + /** + * @throws Exception + */ + private function createTicketTaxesAndFees( + TicketDomainObject $ticket, + array $taxAndFeeIds, + int $accountId, + ): Collection + { + return $this->taxAndTicketAssociationService->addTaxesToTicket( + new TaxAndTicketAssociateParams( + ticketId: $ticket->getId(), + accountId: $accountId, + taxAndFeeIds: $taxAndFeeIds, + ), + ); + } + + /** + * @throws Exception + */ + private function associateTaxesAndFees(TicketDomainObject $persistedTicket, array $taxAndFeeIds, int $accountId): void + { + $persistedTicket->setTaxAndFees($this->createTicketTaxesAndFees( + ticket: $persistedTicket, + taxAndFeeIds: $taxAndFeeIds, + accountId: $accountId, + )); + } + + private function createTicketPrices(TicketDomainObject $persistedTicket, TicketDomainObject $ticket): TicketDomainObject + { + $prices = $this->priceCreateService->createPrices( + ticketId: $persistedTicket->getId(), + prices: $ticket->getTicketPrices(), + event: $this->eventRepository->findById($ticket->getEventId()), + ); + + return $persistedTicket->setTicketPrices($prices); + } +} diff --git a/backend/app/Services/Domain/Ticket/DTO/CreateTicketDTO.php b/backend/app/Services/Domain/Ticket/DTO/CreateTicketDTO.php new file mode 100644 index 00000000..d2008ba4 --- /dev/null +++ b/backend/app/Services/Domain/Ticket/DTO/CreateTicketDTO.php @@ -0,0 +1,10 @@ +type !== TicketType::TIERED) { - $prices = new Collection([new TicketPriceDTO( - price: $ticketsData->type === TicketType::FREE ? 0.00 : $ticketsData->price, - label: null, - sale_start_date: null, - sale_end_date: null, - initial_quantity_available: $ticketsData->initial_quantity_available, - is_hidden: $ticketsData->is_hidden, - )]); - } else { - $prices = $ticketsData->prices; - } - - return $ticket->setTicketPrices(new Collection($prices->map(fn(TicketPriceDTO $price, int $index) => $this->ticketPriceRepository->create([ - 'ticket_id' => $ticket->getId(), - 'price' => $price->price, - 'label' => $price->label, - 'sale_start_date' => $price->sale_start_date - ? DateHelper::convertToUTC($price->sale_start_date, $event->getTimezone()) - : null, - 'sale_end_date' => $price->sale_end_date - ? DateHelper::convertToUTC($price->sale_end_date, $event->getTimezone()) - : null, - 'initial_quantity_available' => $price->initial_quantity_available, - 'is_hidden' => $price->is_hidden, - 'order' => $index + 1, - ]))) - ); + return (new Collection($prices->map(fn(TicketPriceDomainObject $price, int $index) => $this->ticketPriceRepository->create([ + 'ticket_id' => $ticketId, + 'price' => $price->getPrice(), + 'label' => $price->getLabel(), + 'sale_start_date' => $price->getSaleStartDate() + ? DateHelper::convertToUTC($price->getSaleStartDate(), $event->getTimezone()) + : null, + 'sale_end_date' => $price->getSaleEndDate() + ? DateHelper::convertToUTC($price->getSaleEndDate(), $event->getTimezone()) + : null, + 'initial_quantity_available' => $price->getInitialQuantityAvailable(), + 'is_hidden' => $price->getIsHidden(), + 'order' => $index + 1, + ])))); } } diff --git a/backend/app/Services/Handlers/Event/CreateEventHandler.php b/backend/app/Services/Handlers/Event/CreateEventHandler.php index 38e1b5a5..b734a956 100644 --- a/backend/app/Services/Handlers/Event/CreateEventHandler.php +++ b/backend/app/Services/Handlers/Event/CreateEventHandler.php @@ -4,29 +4,18 @@ namespace HiEvents\Services\Handlers\Event; -use HiEvents\DataTransferObjects\AttributesDTO; -use HiEvents\DomainObjects\Enums\HomepageBackgroundType; use HiEvents\DomainObjects\EventDomainObject; -use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\Exceptions\OrganizerNotFoundException; -use HiEvents\Helper\DateHelper; -use HiEvents\Helper\IdHelper; -use HiEvents\Repository\Interfaces\EventRepositoryInterface; -use HiEvents\Repository\Interfaces\EventSettingsRepositoryInterface; -use HiEvents\Repository\Interfaces\EventStatisticRepositoryInterface; -use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface; +use HiEvents\Services\Domain\Event\CreateEventService; +use HiEvents\Services\Domain\Organizer\OrganizerFetchService; use HiEvents\Services\Handlers\Event\DTO\CreateEventDTO; -use Illuminate\Database\DatabaseManager; use Throwable; -readonly class CreateEventHandler +class CreateEventHandler { public function __construct( - private EventRepositoryInterface $eventRepository, - private EventSettingsRepositoryInterface $eventSettingsRepository, - private OrganizerRepositoryInterface $organizerRepository, - private DatabaseManager $databaseManager, - private EventStatisticRepositoryInterface $eventStatisticsRepository, + private readonly CreateEventService $createEventService, + private readonly OrganizerFetchService $organizerFetchService ) { } @@ -37,97 +26,26 @@ public function __construct( */ public function handle(CreateEventDTO $eventData): EventDomainObject { - return $this->databaseManager->transaction(fn() => $this->createEvent( - eventData: $eventData, - organizer: $this->getOrganizer($eventData) - )); - } - - private function getEventDataWithDefaults( - CreateEventDTO $eventData, - OrganizerDomainObject $organizer - ): CreateEventDTO - { - return new CreateEventDTO( - title: $eventData->title, - organizer_id: $eventData->organizer_id, - account_id: $eventData->account_id, - user_id: $eventData->user_id, - start_date: $eventData->start_date, - end_date: $eventData->end_date, - description: $eventData->description, - attributes: $eventData->attributes, - timezone: $eventData->timezone ?? $organizer->getTimezone(), - currency: $eventData->currency ?? $organizer->getCurrency(), - location_details: $eventData->location_details, - status: $eventData->status, + $organizer = $this->organizerFetchService->fetchOrganizer( + organizerId: $eventData->organizer_id, + accountId: $eventData->account_id ); - } - - /** - * @throws OrganizerNotFoundException - */ - private function getOrganizer(CreateEventDTO $eventData): OrganizerDomainObject - { - $organizer = $this->organizerRepository->findFirstWhere([ - 'id' => $eventData->organizer_id, - 'account_id' => $eventData->account_id, - ]); - - if ($organizer === null) { - throw new OrganizerNotFoundException( - __('Organizer :id not found', ['id' => $eventData->organizer_id]) - ); - } - - return $organizer; - } - - private function createEvent(CreateEventDTO $eventData, OrganizerDomainObject $organizer): EventDomainObject - { - $eventData = $this->getEventDataWithDefaults($eventData, $organizer); - - $event = $this->eventRepository->create([ - 'title' => $eventData->title, - 'organizer_id' => $eventData->organizer_id, - 'start_date' => DateHelper::convertToUTC($eventData->start_date, $eventData->timezone), - 'end_date' => $eventData->end_date - ? DateHelper::convertToUTC($eventData->end_date, $eventData->timezone) - : null, - 'description' => $eventData->description, - 'timezone' => $eventData->timezone, - 'currency' => $eventData->currency, - 'location_details' => $eventData->location_details?->toArray(), - 'account_id' => $eventData->account_id, - 'user_id' => $eventData->user_id, - 'status' => $eventData->status, - 'short_id' => IdHelper::randomPrefixedId(IdHelper::EVENT_PREFIX), - 'attributes' => $eventData->attributes?->map(fn(AttributesDTO $attr) => $attr->toArray())->toArray(), - ]); - - $this->eventSettingsRepository->create([ - 'event_id' => $event->getId(), - 'homepage_background_color' => '#ffffff', - 'homepage_primary_text_color' => '#000000', - 'homepage_primary_color' => '#7b5db8', - 'homepage_secondary_text_color' => '#ffffff', - 'homepage_secondary_color' => '#7b5eb9', - 'homepage_background_type' => HomepageBackgroundType::COLOR->name, - 'homepage_body_background_color' => '#7a5eb9', - 'continue_button_text' => __('Continue'), - 'support_email' => $organizer->getEmail(), - ]); - - $this->eventStatisticsRepository->create([ - 'event_id' => $event->getId(), - 'tickets_sold' => 0, - 'sales_total_gross' => 0, - 'sales_total_before_additions' => 0, - 'total_tax' => 0, - 'total_fee' => 0, - 'orders_created' => 0, - ]); - return $event; + $event = (new EventDomainObject()) + ->setOrganizerId($eventData->organizer_id) + ->setAccountId($eventData->account_id) + ->setUserId($eventData->user_id) + ->setTitle($eventData->title) + ->setStartDate($eventData->start_date) + ->setEndDate($eventData->end_date) + ->setDescription($eventData->description) + ->setAttributes($eventData->attributes?->toArray()) + ->setTimezone($eventData->timezone) + ->setCurrency($eventData->currency ?? $organizer->getCurrency()) + ->setStatus($eventData->status) + ->setEventSettings($eventData->event_settings) + ->setLocationDetails($eventData->location_details?->toArray()); + + return $this->createEventService->createEvent($event); } } diff --git a/backend/app/Services/Handlers/Event/DTO/CreateEventDTO.php b/backend/app/Services/Handlers/Event/DTO/CreateEventDTO.php index 7d109ce6..bda05d1a 100644 --- a/backend/app/Services/Handlers/Event/DTO/CreateEventDTO.php +++ b/backend/app/Services/Handlers/Event/DTO/CreateEventDTO.php @@ -7,6 +7,7 @@ use HiEvents\DataTransferObjects\AttributesDTO; use HiEvents\DataTransferObjects\BaseDTO; use HiEvents\DomainObjects\Status\EventStatus; +use HiEvents\Services\Domain\Event\DTO\EventSettingsDTO; use Illuminate\Support\Collection; class CreateEventDTO extends BaseDTO @@ -26,6 +27,8 @@ public function __construct( public readonly ?string $currency = null, public readonly ?AddressDTO $location_details = null, public readonly ?string $status = EventStatus::DRAFT->name, + + public ?EventSettingsDTO $event_settings = null ) { } diff --git a/backend/app/Services/Handlers/Event/DuplicateEventHandler.php b/backend/app/Services/Handlers/Event/DuplicateEventHandler.php new file mode 100644 index 00000000..fa6f54fb --- /dev/null +++ b/backend/app/Services/Handlers/Event/DuplicateEventHandler.php @@ -0,0 +1,36 @@ +duplicateEventService->duplicateEvent( + eventId: $data->eventId, + accountId: $data->accountId, + title: $data->title, + startDate: $data->startDate, + duplicateTickets: $data->duplicateTickets, + duplicateQuestions: $data->duplicateQuestions, + duplicateSettings: $data->duplicateSettings, + duplicatePromoCodes: $data->duplicatePromoCodes, + description: $data->description, + endDate: $data->endDate, + ); + } +} diff --git a/backend/app/Services/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php b/backend/app/Services/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php index 45d56ff3..422e9120 100644 --- a/backend/app/Services/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php +++ b/backend/app/Services/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php @@ -6,6 +6,7 @@ use HiEvents\DataTransferObjects\BaseDTO; use HiEvents\DomainObjects\Enums\HomepageBackgroundType; use HiEvents\DomainObjects\Enums\PriceDisplayMode; +use HiEvents\DomainObjects\OrganizerDomainObject; class UpdateEventSettingsDTO extends BaseDTO { @@ -50,4 +51,42 @@ public function __construct( ) { } + + public static function createWithDefaults( + int $account_id, + int $event_id, + OrganizerDomainObject $organizer, + ): self + { + return new self( + account_id: $account_id, + event_id: $event_id, + post_checkout_message: null, + pre_checkout_message: null, + email_footer_message: null, + continue_button_text: __('Continue'), + support_email: $organizer->getEmail(), + homepage_background_color: '#ffffff', + homepage_primary_color: '#7b5db8', + homepage_primary_text_color: '#000000', + homepage_secondary_color: '#7b5eb9', + homepage_secondary_text_color: '#ffffff', + homepage_body_background_color: '#7a5eb9', + homepage_background_type: HomepageBackgroundType::COLOR, + require_attendee_details: false, + order_timeout_in_minutes: 0, + website_url: null, + maps_url: null, + 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, + hide_getting_started_page: false, + ); + } } diff --git a/backend/app/Services/Handlers/PromoCode/CreatePromoCodeHandler.php b/backend/app/Services/Handlers/PromoCode/CreatePromoCodeHandler.php index da79c154..84e954a3 100644 --- a/backend/app/Services/Handlers/PromoCode/CreatePromoCodeHandler.php +++ b/backend/app/Services/Handlers/PromoCode/CreatePromoCodeHandler.php @@ -2,23 +2,16 @@ namespace HiEvents\Services\Handlers\PromoCode; -use HiEvents\DomainObjects\Enums\PromoCodeDiscountTypeEnum; -use HiEvents\DomainObjects\Generated\PromoCodeDomainObjectAbstract; use HiEvents\DomainObjects\PromoCodeDomainObject; use HiEvents\Exceptions\ResourceConflictException; -use HiEvents\Helper\DateHelper; -use HiEvents\Repository\Interfaces\EventRepositoryInterface; -use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface; -use HiEvents\Services\Domain\Ticket\EventTicketValidationService; +use HiEvents\Services\Domain\PromoCode\CreatePromoCodeService; use HiEvents\Services\Domain\Ticket\Exception\UnrecognizedTicketIdException; use HiEvents\Services\Handlers\PromoCode\DTO\UpsertPromoCodeDTO; readonly class CreatePromoCodeHandler { public function __construct( - private PromoCodeRepositoryInterface $promoCodeRepository, - private EventTicketValidationService $eventTicketValidationService, - private EventRepositoryInterface $eventRepository, + private CreatePromoCodeService $createPromoCodeService, ) { } @@ -29,39 +22,15 @@ public function __construct( */ public function handle(int $eventId, UpsertPromoCodeDTO $promoCodeDTO): PromoCodeDomainObject { - $this->checkForDuplicateCode($promoCodeDTO->code, $eventId); - $this->eventTicketValidationService->validateTicketIds($promoCodeDTO->applicable_ticket_ids, $eventId); - $event = $this->eventRepository->findById($eventId); - - return $this->promoCodeRepository->create([ - PromoCodeDomainObjectAbstract::EVENT_ID => $eventId, - PromoCodeDomainObjectAbstract::CODE => $promoCodeDTO->code, - PromoCodeDomainObjectAbstract::DISCOUNT => $promoCodeDTO->discount_type === PromoCodeDiscountTypeEnum::NONE - ? 0.00 - : (float)$promoCodeDTO->discount, - PromoCodeDomainObjectAbstract::DISCOUNT_TYPE => $promoCodeDTO->discount_type?->name, - PromoCodeDomainObjectAbstract::EXPIRY_DATE => $promoCodeDTO->expiry_date - ? DateHelper::convertToUTC($promoCodeDTO->expiry_date, $event->getTimezone()) - : null, - PromoCodeDomainObjectAbstract::MAX_ALLOWED_USAGES => $promoCodeDTO->max_allowed_usages, - PromoCodeDomainObjectAbstract::APPLICABLE_TICKET_IDS => $promoCodeDTO->applicable_ticket_ids, - ]); - } - - /** - * @throws ResourceConflictException - */ - private function checkForDuplicateCode(string $code, int $eventId): void - { - $existingPromoCode = $this->promoCodeRepository->findFirstWhere([ - PromoCodeDomainObjectAbstract::EVENT_ID => $eventId, - PromoCodeDomainObjectAbstract::CODE => $code, - ]); - - if ($existingPromoCode !== null) { - throw new ResourceConflictException( - __('Promo code :code already exists', ['code' => $code]) - ); - } + return $this->createPromoCodeService->createPromoCode( + (new PromoCodeDomainObject()) + ->setEventId($eventId) + ->setCode($promoCodeDTO->code) + ->setDiscountType($promoCodeDTO->discount_type->name) + ->setDiscount($promoCodeDTO->discount) + ->setExpiryDate($promoCodeDTO->expiry_date) + ->setMaxAllowedUsages($promoCodeDTO->max_allowed_usages) + ->setApplicableTicketIds($promoCodeDTO->applicable_ticket_ids) + ); } } diff --git a/backend/app/Services/Handlers/Question/CreateQuestionHandler.php b/backend/app/Services/Handlers/Question/CreateQuestionHandler.php index ee7c317f..4a36738f 100644 --- a/backend/app/Services/Handlers/Question/CreateQuestionHandler.php +++ b/backend/app/Services/Handlers/Question/CreateQuestionHandler.php @@ -2,18 +2,16 @@ namespace HiEvents\Services\Handlers\Question; -use HiEvents\DomainObjects\Generated\QuestionDomainObjectAbstract; use HiEvents\DomainObjects\QuestionDomainObject; -use HiEvents\Repository\Interfaces\QuestionRepositoryInterface; +use HiEvents\Services\Domain\Question\CreateQuestionService; use HiEvents\Services\Handlers\Question\DTO\UpsertQuestionDTO; -use Illuminate\Database\DatabaseManager; use Throwable; -readonly class CreateQuestionHandler +class CreateQuestionHandler { public function __construct( - private QuestionRepositoryInterface $questionRepository, - private DatabaseManager $databaseManager) + private readonly CreateQuestionService $createQuestionService, + ) { } @@ -22,15 +20,18 @@ public function __construct( */ public function handle(UpsertQuestionDTO $createQuestionDTO): QuestionDomainObject { - return $this->databaseManager->transaction(fn() => $this->questionRepository->create([ - QuestionDomainObjectAbstract::TITLE => $createQuestionDTO->title, - QuestionDomainObjectAbstract::EVENT_ID => $createQuestionDTO->event_id, - QuestionDomainObjectAbstract::BELONGS_TO => $createQuestionDTO->belongs_to->name, - QuestionDomainObjectAbstract::TYPE => $createQuestionDTO->type->name, - QuestionDomainObjectAbstract::REQUIRED => $createQuestionDTO->required, - QuestionDomainObjectAbstract::OPTIONS => $createQuestionDTO->options, - QuestionDomainObjectAbstract::IS_HIDDEN => $createQuestionDTO->is_hidden, + $question = (new QuestionDomainObject()) + ->setTitle($createQuestionDTO->title) + ->setEventId($createQuestionDTO->event_id) + ->setBelongsTo($createQuestionDTO->belongs_to->name) + ->setType($createQuestionDTO->type->name) + ->setRequired($createQuestionDTO->required) + ->setOptions($createQuestionDTO->options) + ->setIsHidden($createQuestionDTO->is_hidden); - ], $createQuestionDTO->ticket_ids)); + return $this->createQuestionService->createQuestion( + $question, + $createQuestionDTO->ticket_ids, + ); } } diff --git a/backend/app/Services/Handlers/Ticket/CreateTicketHandler.php b/backend/app/Services/Handlers/Ticket/CreateTicketHandler.php index 09015158..eaf261be 100644 --- a/backend/app/Services/Handlers/Ticket/CreateTicketHandler.php +++ b/backend/app/Services/Handlers/Ticket/CreateTicketHandler.php @@ -4,28 +4,19 @@ namespace HiEvents\Services\Handlers\Ticket; -use Exception; +use HiEvents\DomainObjects\Enums\TicketType; +use HiEvents\DomainObjects\Generated\TicketPriceDomainObjectAbstract; use HiEvents\DomainObjects\TicketDomainObject; -use HiEvents\Helper\DateHelper; -use HiEvents\Repository\Interfaces\EventRepositoryInterface; -use HiEvents\Repository\Interfaces\TicketRepositoryInterface; -use HiEvents\Services\Domain\Tax\DTO\TaxAndTicketAssociateParams; -use HiEvents\Services\Domain\Tax\TaxAndTicketAssociationService; -use HiEvents\Services\Domain\Ticket\TicketPriceCreateService; +use HiEvents\DomainObjects\TicketPriceDomainObject; +use HiEvents\Services\Domain\Ticket\DTO\TicketPriceDTO; +use HiEvents\Services\Domain\Ticket\CreateTicketService; use HiEvents\Services\Handlers\Ticket\DTO\UpsertTicketDTO; -use HTMLPurifier; -use Illuminate\Database\DatabaseManager; use Throwable; -readonly class CreateTicketHandler +class CreateTicketHandler { public function __construct( - private TicketRepositoryInterface $ticketRepository, - private DatabaseManager $databaseManager, - private TaxAndTicketAssociationService $taxAndTicketAssociationService, - private TicketPriceCreateService $priceCreateService, - private HTMLPurifier $purifier, - private EventRepositoryInterface $eventRepository, + private readonly CreateTicketService $ticketCreateService, ) { } @@ -35,61 +26,35 @@ public function __construct( */ public function handle(UpsertTicketDTO $ticketsData): TicketDomainObject { - return $this->databaseManager->transaction(function () use ($ticketsData) { - $ticket = $this->createTicket($ticketsData); - - if ($ticketsData->tax_and_fee_ids) { - $ticket = $this->handleTaxes($ticket, $ticketsData); - } - - return $this->priceCreateService->createPrices( - ticket: $ticket, - ticketsData: $ticketsData, - event: $this->eventRepository->findById($ticketsData->event_id) - ); - }); - } - - private function createTicket(UpsertTicketDTO $ticketsData): TicketDomainObject - { - $event = $this->eventRepository->findById($ticketsData->event_id); - - return $this->ticketRepository->create([ - 'title' => $ticketsData->title, - 'type' => $ticketsData->type->name, - 'order' => $ticketsData->order, - 'sale_start_date' => $ticketsData->sale_start_date - ? DateHelper::convertToUTC($ticketsData->sale_start_date, $event->getTimezone()) - : null, - 'sale_end_date' => $ticketsData->sale_end_date - ? DateHelper::convertToUTC($ticketsData->sale_end_date, $event->getTimezone()) - : null, - 'max_per_order' => $ticketsData->max_per_order, - 'description' => $this->purifier->purify($ticketsData->description), - 'min_per_order' => $ticketsData->min_per_order, - 'is_hidden' => $ticketsData->is_hidden, - 'hide_before_sale_start_date' => $ticketsData->hide_before_sale_start_date, - 'hide_after_sale_end_date' => $ticketsData->hide_after_sale_end_date, - 'hide_when_sold_out' => $ticketsData->hide_when_sold_out, - 'show_quantity_remaining' => $ticketsData->show_quantity_remaining, - 'is_hidden_without_promo_code' => $ticketsData->is_hidden_without_promo_code, - 'event_id' => $ticketsData->event_id, - ]); - } - - /** - * @throws Exception - */ - private function handleTaxes(TicketDomainObject $ticket, UpsertTicketDTO $ticketsData): TicketDomainObject - { - return $ticket->setTaxAndFees( - $this->taxAndTicketAssociationService->addTaxesToTicket( - new TaxAndTicketAssociateParams( - ticketId: $ticket->getId(), - accountId: $ticketsData->account_id, - taxAndFeeIds: $ticketsData->tax_and_fee_ids, - ) - ) + $ticketPrices = $ticketsData->prices->map(fn(TicketPriceDTO $price) => TicketPriceDomainObject::hydrateFromArray([ + TicketPriceDomainObjectAbstract::PRICE => $ticketsData->type === TicketType::FREE ? 0.00 : $price->price, + TicketPriceDomainObjectAbstract::LABEL => $price->label, + TicketPriceDomainObjectAbstract::SALE_START_DATE => $price->sale_start_date, + TicketPriceDomainObjectAbstract::SALE_END_DATE => $price->sale_end_date, + TicketPriceDomainObjectAbstract::INITIAL_QUANTITY_AVAILABLE => $price->initial_quantity_available, + TicketPriceDomainObjectAbstract::IS_HIDDEN => $price->is_hidden, + ])); + + return $this->ticketCreateService->createTicket( + ticket: (new TicketDomainObject()) + ->setTitle($ticketsData->title) + ->setType($ticketsData->type->name) + ->setOrder($ticketsData->order) + ->setSaleStartDate($ticketsData->sale_start_date) + ->setSaleEndDate($ticketsData->sale_end_date) + ->setMaxPerOrder($ticketsData->max_per_order) + ->setDescription($ticketsData->description) + ->setMinPerOrder($ticketsData->min_per_order) + ->setIsHidden($ticketsData->is_hidden) + ->setHideBeforeSaleStartDate($ticketsData->hide_before_sale_start_date) + ->setHideAfterSaleEndDate($ticketsData->hide_after_sale_end_date) + ->setHideWhenSoldOut($ticketsData->hide_when_sold_out) + ->setShowQuantityRemaining($ticketsData->show_quantity_remaining) + ->setIsHiddenWithoutPromoCode($ticketsData->is_hidden_without_promo_code) + ->setTicketPrices($ticketPrices) + ->setEventId($ticketsData->event_id), + accountId: $ticketsData->account_id, + taxAndFeeIds: $ticketsData->tax_and_fee_ids, ); } } diff --git a/backend/app/Validators/EventRules.php b/backend/app/Validators/EventRules.php index 3435079a..8528f1c9 100644 --- a/backend/app/Validators/EventRules.php +++ b/backend/app/Validators/EventRules.php @@ -10,17 +10,11 @@ public function eventRules(): array { $currencies = include __DIR__ . '/../../data/currencies.php'; - return [ - 'title' => ['string', 'required', 'max:150', 'min:1'], - 'end_date' => ['date', 'nullable'], - 'start_date' => ['date', 'required', - Rule::when($this->input('end_date') !== null, - ['before_or_equal:end_date'])], + return array_merge($this->minimalRules(), [ 'timezone' => ['timezone:all'], 'organizer_id' => ['required', 'integer'], 'currency' => [Rule::in(array_values($currencies))], // todo - Revisit the 50k character limit - 'description' => ['string', 'min:1', 'max:50000', 'nullable'], 'attributes.*.name' => ['string', 'min:1', 'max:50', 'required'], 'attributes.*.value' => ['min:1', 'max:1000', 'required'], 'attributes.*.is_public' => ['boolean', 'required'], @@ -32,6 +26,18 @@ public function eventRules(): array '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'], + ]); + } + + public function minimalRules(): array + { + return [ + 'title' => ['string', 'required', 'max:150', 'min:1'], + 'description' => ['string', 'min:1', 'max:50000', 'nullable'], + 'start_date' => ['date', 'required', + Rule::when($this->input('end_date') !== null, + ['before_or_equal:end_date'])], + 'end_date' => ['date', 'nullable'], ]; } diff --git a/backend/routes/api.php b/backend/routes/api.php index 577bddc8..5cf492ba 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -23,6 +23,7 @@ use HiEvents\Http\Actions\Auth\ValidateResetPasswordTokenAction; use HiEvents\Http\Actions\Common\Webhooks\StripeIncomingWebhookAction; use HiEvents\Http\Actions\Events\CreateEventAction; +use HiEvents\Http\Actions\Events\DuplicateEventAction; use HiEvents\Http\Actions\Events\GetEventAction; use HiEvents\Http\Actions\Events\GetEventPublicAction; use HiEvents\Http\Actions\Events\GetEventsAction; @@ -154,6 +155,7 @@ function (Router $router): void { $router->get('/events/{event_id}', GetEventAction::class); $router->put('/events/{event_id}', UpdateEventAction::class); $router->put('/events/{event_id}/status', UpdateEventStatusAction::class); + $router->post('/events/{event_id}/duplicate', DuplicateEventAction::class); $router->post('/events/{event_id}/tickets', CreateTicketAction::class); $router->post('/events/{event_id}/tickets/sort', SortTicketsAction::class); diff --git a/frontend/src/api/event.client.ts b/frontend/src/api/event.client.ts index 0725ab08..41ad35a2 100644 --- a/frontend/src/api/event.client.ts +++ b/frontend/src/api/event.client.ts @@ -1,7 +1,7 @@ import {api} from "./client"; import { CheckInStats, - Event, + Event, EventDuplicatePayload, EventStats, GenericDataResponse, GenericPaginatedResponse, @@ -72,6 +72,11 @@ export const eventsClient = { return response.data; }, + duplicate: async (eventId: IdParam, event: EventDuplicatePayload) => { + const response = await api.post>('events/' + eventId + '/duplicate', event); + return response.data; + }, + updateEventStatus: async (eventId: IdParam, status: string) => { const response = await api.put>('events/' + eventId + '/status', { status diff --git a/frontend/src/components/common/EventCard/index.tsx b/frontend/src/components/common/EventCard/index.tsx index c59c98f6..21722f77 100644 --- a/frontend/src/components/common/EventCard/index.tsx +++ b/frontend/src/components/common/EventCard/index.tsx @@ -1,10 +1,11 @@ import {Button, Group, Menu, Text,} from '@mantine/core'; -import {Event} from "../../../types.ts"; +import {Event, IdParam} from "../../../types.ts"; import classes from "./EventCard.module.scss"; import {Card} from "../Card"; import {NavLink, useNavigate} from "react-router-dom"; import { IconCalendarEvent, + IconCopy, IconDotsVertical, IconEye, IconMap, @@ -16,6 +17,9 @@ import {relativeDate} from "../../../utilites/dates.ts"; import {t} from "@lingui/macro" import {eventHomepagePath} from "../../../utilites/urlHelper.ts"; import {EventStatusBadge} from "../EventStatusBadge"; +import {useDisclosure} from "@mantine/hooks"; +import {DuplicateEventModal} from "../../modals/DuplicateEventModal"; +import {useState} from "react"; interface EventCardProps { event: Event; @@ -23,6 +27,13 @@ interface EventCardProps { export function EventCard({event}: EventCardProps) { const navigate = useNavigate(); + const [isDuplicateModalOpen, duplicateModal] = useDisclosure(false); + const [eventId, setEventId] = useState(); + + const handleDuplicate = (event: Event) => { + setEventId(() => event.id); + duplicateModal.open(); + } return ( <> @@ -94,10 +105,15 @@ export function EventCard({event}: EventCardProps) { leftSection={} >{t`Check-in`} )} + + handleDuplicate(event)} + leftSection={} + >{t`Duplicate event`} + {isDuplicateModalOpen && } ); } \ No newline at end of file diff --git a/frontend/src/components/modals/DuplicateEventModal/index.tsx b/frontend/src/components/modals/DuplicateEventModal/index.tsx new file mode 100644 index 00000000..718d6643 --- /dev/null +++ b/frontend/src/components/modals/DuplicateEventModal/index.tsx @@ -0,0 +1,114 @@ +import {t} from "@lingui/macro"; +import {EventDuplicatePayload, GenericModalProps, IdParam} from "../../../types.ts"; +import {Button, Switch, TextInput} from "@mantine/core"; +import {Modal} from "../../common/Modal"; +import {useForm} from "@mantine/form"; +import {useDuplicateEvent} from "../../../mutations/useDuplicateEvent.ts"; +import {Editor} from "../../common/Editor"; +import {InputGroup} from "../../common/InputGroup"; +import {useGetEvent} from "../../../queries/useGetEvent.ts"; +import {useNavigate} from "react-router-dom"; +import {useEffect} from "react"; +import {utcToTz} from "../../../utilites/dates.ts"; + +interface DuplicateEventModalProps extends GenericModalProps { + eventId: IdParam; +} + +export const DuplicateEventModal = ({onClose, eventId}: DuplicateEventModalProps) => { + const form = useForm({ + initialValues: { + title: '', + start_date: '', + end_date: '', + description: '', + duplicate_tickets: true, + duplicate_questions: true, + duplicate_settings: true, + duplicate_promo_codes: true, + } + }); + const mutation = useDuplicateEvent(); + const eventQuery = useGetEvent(eventId); + const nav = useNavigate(); + + + useEffect(() => { + if (eventQuery?.data) { + form.setValues({ + title: eventQuery.data.title, + description: eventQuery.data.description, + start_date: utcToTz(eventQuery.data.start_date, eventQuery.data.timezone), + end_date: utcToTz(eventQuery.data.end_date, eventQuery.data.timezone), + }); + } + }, [eventQuery.isFetched]); + + const handleDuplicate = (eventId: IdParam, duplicateData: EventDuplicatePayload) => { + mutation.mutate({eventId, duplicateData}, { + onSuccess: ({data}) => { + nav(`/manage/event/${data.id}`); + } + }); + } + + return ( + +
handleDuplicate(eventId, values))}> +
+ + + form.setFieldValue('description', value)} + error={form.errors?.description as string} + /> + + + + + + + + + + +
+ +
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/mutations/useDuplicateEvent.ts b/frontend/src/mutations/useDuplicateEvent.ts new file mode 100644 index 00000000..c96b5205 --- /dev/null +++ b/frontend/src/mutations/useDuplicateEvent.ts @@ -0,0 +1,12 @@ +import {useMutation} from "@tanstack/react-query"; +import {EventDuplicatePayload, IdParam} from "../types.ts"; +import {eventsClient} from "../api/event.client.ts"; + +export const useDuplicateEvent = () => { + return useMutation( + ({eventId, duplicateData}: { + eventId: IdParam; + duplicateData: EventDuplicatePayload; + }) => eventsClient.duplicate(eventId, duplicateData) + ) +} \ No newline at end of file diff --git a/frontend/src/types.ts b/frontend/src/types.ts index ccac576b..0a0d525c 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -130,14 +130,23 @@ export interface VenueAddress { country?: string; } -export interface Event { - id?: IdParam; +export interface EventBase { title: string; - slug: string; - status?: 'DRAFT' | 'LIVE' | 'PAUSED'; + description?: string; start_date: string; end_date?: string; - description?: string; +} + +export interface EventDuplicatePayload extends EventBase { + duplicate_tickets: boolean; + duplicate_questions: boolean; + duplicate_settings: boolean; +} + +export interface Event extends EventBase { + id?: IdParam; + slug: string; + status?: 'DRAFT' | 'LIVE' | 'PAUSED'; description_preview?: string; lifecycle_status?: 'ONGOING' | 'UPCOMING' | 'ENDED'; From e1206d88dcec3912f2e2cb882e34ee95c1bd2540 Mon Sep 17 00:00:00 2001 From: Dave Earley Date: Sun, 30 Jun 2024 22:43:57 -0700 Subject: [PATCH 2/9] remove unused classes --- backend/app/DataTransferObjects/BaseDTO.php | 52 -------- .../Domain/Event/DTO/CreateEventDTO.php | 13 -- .../Domain/Event/DuplicateEventService.php | 112 +++++++++--------- backend/app/Validators/EventRules.php | 8 +- 4 files changed, 59 insertions(+), 126 deletions(-) delete mode 100644 backend/app/Services/Domain/Event/DTO/CreateEventDTO.php diff --git a/backend/app/DataTransferObjects/BaseDTO.php b/backend/app/DataTransferObjects/BaseDTO.php index 687a3dbb..caed0835 100644 --- a/backend/app/DataTransferObjects/BaseDTO.php +++ b/backend/app/DataTransferObjects/BaseDTO.php @@ -56,58 +56,6 @@ public static function collectionFromArray(array $items): Collection return collect(array_map([static::class, 'fromArray'], $items)); } - /** - * Convert the DTO to a flattened array. Converts all enums to string and DTOs to arrays. - * - * It ain't pretty, but it works. - * - * @param array $without - * @return array - */ - public function toFlattenedArray(array $without = []): array - { - $data = []; - $properties = get_object_vars($this); - - foreach ($properties as $key => $value) { - if (in_array($key, $without, true)) { - continue; - } - - if ($value instanceof self) { - $data[$key] = $value->toFlattenedArray(); - } elseif ($value instanceof UnitEnum) { - $data[$key] = $value instanceof BackedEnum ? $value->value : $value->name; - } elseif ($value instanceof Collection) { - $data[$key] = $value->map(function ($item) { - if ($item instanceof BaseDTO) { - return $item->toFlattenedArray(); - } - - if ($item instanceof UnitEnum) { - return $item instanceof BackedEnum ? $item->value : $item->name; - } - return $item; - })->toArray(); - } elseif (is_array($value)) { - $data[$key] = array_map(static function ($item) { - if ($item instanceof BaseDTO) { - return $item->toFlattenedArray(); - } - - if ($item instanceof UnitEnum) { - return $item instanceof BackedEnum ? $item->value : $item->name; - } - return $item; - }, $value); - } else { - $data[$key] = $value; - } - } - - return $data; - } - /** * Hydrate objects from properties based on property to object map. * diff --git a/backend/app/Services/Domain/Event/DTO/CreateEventDTO.php b/backend/app/Services/Domain/Event/DTO/CreateEventDTO.php deleted file mode 100644 index 1fc88d3d..00000000 --- a/backend/app/Services/Domain/Event/DTO/CreateEventDTO.php +++ /dev/null @@ -1,13 +0,0 @@ -setTimezone($event->getTimezone()) ->setCurrency($event->getCurrency()) ->setStatus($event->getStatus()), - eventSettings: $cloneEventSettings - ? $event->getEventSettings() - : null, + eventSettings: $cloneEventSettings ? $event->getEventSettings() : null, ); } @@ -108,58 +105,63 @@ private function cloneExistingTickets( { $oldTicketToNewTicketMap = []; - $tickets = $event->getTickets(); - foreach ($tickets as $ticket) { + foreach ($event->getTickets() as $ticket) { $ticket->setEventId($newEventId); - $newTicket = $this->createTicketService->createTicket( - $ticket, - $event->getAccountId(), - ); - + $newTicket = $this->createTicketService->createTicket($ticket, $event->getAccountId()); $oldTicketToNewTicketMap[$ticket->getId()] = $newTicket->getId(); } if ($duplicateQuestions) { - /** @var Collection $questions */ - $questions = $event->getQuestions(); - - foreach ($questions as $question) { - $this->createQuestionService->createQuestion( - (new QuestionDomainObject()) - ->setTitle($question->getTitle()) - ->setEventId($newEventId) - ->setBelongsTo($question->getBelongsTo()) - ->setType($question->getType()) - ->setRequired($question->getRequired()) - ->setOptions($question->getOptions()) - ->setIsHidden($question->getIsHidden()), - array_map( - static fn(TicketDomainObject $ticket) => $oldTicketToNewTicketMap[$ticket->getId()], - $question->getTickets()?->all(), - ), - ); - } + $this->cloneQuestions($event, $newEventId, $oldTicketToNewTicketMap); } if ($duplicatePromoCodes) { - /** @var Collection $promoCodes */ - $promoCodes = $event->getPromoCodes(); - - foreach ($promoCodes as $promoCode) { - $this->createPromoCodeService->createPromoCode( - (new PromoCodeDomainObject()) - ->setCode($promoCode->getCode()) - ->setEventId($newEventId) - ->setApplicableTicketIds(array_map( - static fn($ticketId) => $oldTicketToNewTicketMap[$ticketId], - $promoCode->getApplicableTicketIds() ?? [], - )) - ->setDiscountType($promoCode->getDiscountType()) - ->setDiscount($promoCode->getDiscount()) - ->setExpiryDate($promoCode->getExpiryDate()) - ->setMaxAllowedUsages($promoCode->getMaxAllowedUsages()), - ); - } + $this->clonePromoCodes($event, $newEventId, $oldTicketToNewTicketMap); + } + } + + /** + * @throws Throwable + */ + private function cloneQuestions(EventDomainObject $event, int $newEventId, array $oldTicketToNewTicketMap): void + { + foreach ($event->getQuestions() as $question) { + $this->createQuestionService->createQuestion( + (new QuestionDomainObject()) + ->setTitle($question->getTitle()) + ->setEventId($newEventId) + ->setBelongsTo($question->getBelongsTo()) + ->setType($question->getType()) + ->setRequired($question->getRequired()) + ->setOptions($question->getOptions()) + ->setIsHidden($question->getIsHidden()), + array_map( + static fn(TicketDomainObject $ticket) => $oldTicketToNewTicketMap[$ticket->getId()], + $question->getTickets()?->all(), + ), + ); + } + } + + /** + * @throws Throwable + */ + private function clonePromoCodes(EventDomainObject $event, int $newEventId, array $oldTicketToNewTicketMap): void + { + foreach ($event->getPromoCodes() as $promoCode) { + $this->createPromoCodeService->createPromoCode( + (new PromoCodeDomainObject()) + ->setCode($promoCode->getCode()) + ->setEventId($newEventId) + ->setApplicableTicketIds(array_map( + static fn($ticketId) => $oldTicketToNewTicketMap[$ticketId], + $promoCode->getApplicableTicketIds() ?? [], + )) + ->setDiscountType($promoCode->getDiscountType()) + ->setDiscount($promoCode->getDiscount()) + ->setExpiryDate($promoCode->getExpiryDate()) + ->setMaxAllowedUsages($promoCode->getMaxAllowedUsages()), + ); } } @@ -171,18 +173,12 @@ private function getEventWithRelations(string $eventId, string $accountId): Even ->loadRelation(PromoCodeDomainObject::class) ->loadRelation(new Relationship( domainObject: QuestionDomainObject::class, - nested: [ - new Relationship( - domainObject: TicketDomainObject::class, - ), - ])) + nested: [new Relationship(domainObject: TicketDomainObject::class)] + )) ->loadRelation(new Relationship( domainObject: TicketDomainObject::class, - nested: [ - new Relationship( - domainObject: TicketPriceDomainObject::class, - ), - ])) + nested: [new Relationship(domainObject: TicketPriceDomainObject::class)] + )) ->findFirstWhere([ 'id' => $eventId, 'account_id' => $accountId, diff --git a/backend/app/Validators/EventRules.php b/backend/app/Validators/EventRules.php index 8528f1c9..a2081237 100644 --- a/backend/app/Validators/EventRules.php +++ b/backend/app/Validators/EventRules.php @@ -34,9 +34,11 @@ public function minimalRules(): array return [ 'title' => ['string', 'required', 'max:150', 'min:1'], 'description' => ['string', 'min:1', 'max:50000', 'nullable'], - 'start_date' => ['date', 'required', - Rule::when($this->input('end_date') !== null, - ['before_or_equal:end_date'])], + 'start_date' => [ + 'date', + 'required', + Rule::when($this->input('end_date') !== null, ['before_or_equal:end_date']) + ], 'end_date' => ['date', 'nullable'], ]; } From 541c9dbc4d9f1aa6dcdeb0f7918c2c92533bce64 Mon Sep 17 00:00:00 2001 From: Dave Earley Date: Sun, 30 Jun 2024 22:49:30 -0700 Subject: [PATCH 3/9] i18n --- .../modals/DuplicateEventModal/index.tsx | 2 +- frontend/src/locales/de.js | 2 +- frontend/src/locales/de.po | 41 +++++++++++++++--- frontend/src/locales/en.js | 2 +- frontend/src/locales/en.po | 43 +++++++++++++++---- frontend/src/locales/es.js | 2 +- frontend/src/locales/es.po | 41 +++++++++++++++--- frontend/src/locales/fr.js | 2 +- frontend/src/locales/fr.po | 41 +++++++++++++++--- frontend/src/locales/pt-br.js | 2 +- frontend/src/locales/pt-br.po | 41 +++++++++++++++--- frontend/src/locales/pt.js | 2 +- frontend/src/locales/pt.po | 41 +++++++++++++++--- frontend/src/locales/ru.js | 2 +- frontend/src/locales/ru.po | 43 +++++++++++++++---- frontend/src/locales/zh-cn.js | 2 +- frontend/src/locales/zh-cn.po | 41 +++++++++++++++--- 17 files changed, 295 insertions(+), 55 deletions(-) diff --git a/frontend/src/components/modals/DuplicateEventModal/index.tsx b/frontend/src/components/modals/DuplicateEventModal/index.tsx index 718d6643..bbb8c457 100644 --- a/frontend/src/components/modals/DuplicateEventModal/index.tsx +++ b/frontend/src/components/modals/DuplicateEventModal/index.tsx @@ -55,7 +55,7 @@ export const DuplicateEventModal = ({onClose, eventId}: DuplicateEventModalProps return (