diff --git a/backend/app/Console/Kernel.php b/backend/app/Console/Kernel.php index b44dab4d65..540f411478 100644 --- a/backend/app/Console/Kernel.php +++ b/backend/app/Console/Kernel.php @@ -3,6 +3,7 @@ namespace HiEvents\Console; use HiEvents\Jobs\Message\SendScheduledMessagesJob; +use HiEvents\Jobs\Waitlist\ProcessExpiredWaitlistOffersJob; use Illuminate\Console\Scheduling\Schedule; use Illuminate\Foundation\Console\Kernel as ConsoleKernel; @@ -11,6 +12,7 @@ class Kernel extends ConsoleKernel protected function schedule(Schedule $schedule): void { $schedule->job(new SendScheduledMessagesJob)->everyMinute()->withoutOverlapping(); + $schedule->job(new ProcessExpiredWaitlistOffersJob)->everyMinute()->withoutOverlapping(); } protected function commands(): void diff --git a/backend/app/DomainObjects/Enums/CapacityChangeDirection.php b/backend/app/DomainObjects/Enums/CapacityChangeDirection.php new file mode 100644 index 0000000000..1b5d7935ab --- /dev/null +++ b/backend/app/DomainObjects/Enums/CapacityChangeDirection.php @@ -0,0 +1,11 @@ + $this->homepage_theme_settings ?? null, 'pass_platform_fee_to_buyer' => $this->pass_platform_fee_to_buyer ?? null, 'allow_attendee_self_edit' => $this->allow_attendee_self_edit ?? null, + 'waitlist_enabled' => $this->waitlist_enabled ?? null, + 'waitlist_auto_process' => $this->waitlist_auto_process ?? null, + 'waitlist_offer_timeout_minutes' => $this->waitlist_offer_timeout_minutes ?? null, ]; } @@ -774,4 +783,37 @@ public function getAllowAttendeeSelfEdit(): bool { return $this->allow_attendee_self_edit; } + + public function setWaitlistEnabled(bool $waitlist_enabled): self + { + $this->waitlist_enabled = $waitlist_enabled; + return $this; + } + + public function getWaitlistEnabled(): bool + { + return $this->waitlist_enabled; + } + + public function setWaitlistAutoProcess(bool $waitlist_auto_process): self + { + $this->waitlist_auto_process = $waitlist_auto_process; + return $this; + } + + public function getWaitlistAutoProcess(): bool + { + return $this->waitlist_auto_process; + } + + public function setWaitlistOfferTimeoutMinutes(?int $waitlist_offer_timeout_minutes): self + { + $this->waitlist_offer_timeout_minutes = $waitlist_offer_timeout_minutes; + return $this; + } + + public function getWaitlistOfferTimeoutMinutes(): ?int + { + return $this->waitlist_offer_timeout_minutes; + } } diff --git a/backend/app/DomainObjects/Generated/ProductDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/ProductDomainObjectAbstract.php index 4d16950f2e..482362a6f6 100644 --- a/backend/app/DomainObjects/Generated/ProductDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/ProductDomainObjectAbstract.php @@ -36,6 +36,7 @@ abstract class ProductDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr final public const START_COLLAPSED = 'start_collapsed'; final public const IS_HIGHLIGHTED = 'is_highlighted'; final public const HIGHLIGHT_MESSAGE = 'highlight_message'; + final public const WAITLIST_ENABLED = 'waitlist_enabled'; protected int $id; protected int $event_id; @@ -63,6 +64,7 @@ abstract class ProductDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr protected bool $start_collapsed = false; protected bool $is_highlighted = false; protected ?string $highlight_message = null; + protected ?bool $waitlist_enabled = null; public function toArray(): array { @@ -93,6 +95,7 @@ public function toArray(): array 'start_collapsed' => $this->start_collapsed ?? null, 'is_highlighted' => $this->is_highlighted ?? null, 'highlight_message' => $this->highlight_message ?? null, + 'waitlist_enabled' => $this->waitlist_enabled ?? null, ]; } @@ -381,4 +384,15 @@ public function getHighlightMessage(): ?string { return $this->highlight_message; } + + public function setWaitlistEnabled(?bool $waitlist_enabled): self + { + $this->waitlist_enabled = $waitlist_enabled; + return $this; + } + + public function getWaitlistEnabled(): ?bool + { + return $this->waitlist_enabled; + } } diff --git a/backend/app/DomainObjects/Generated/WaitlistEntryDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/WaitlistEntryDomainObjectAbstract.php new file mode 100644 index 0000000000..7c310634e1 --- /dev/null +++ b/backend/app/DomainObjects/Generated/WaitlistEntryDomainObjectAbstract.php @@ -0,0 +1,286 @@ + $this->id ?? null, + 'event_id' => $this->event_id ?? null, + 'product_price_id' => $this->product_price_id ?? null, + 'order_id' => $this->order_id ?? null, + 'email' => $this->email ?? null, + 'first_name' => $this->first_name ?? null, + 'last_name' => $this->last_name ?? null, + 'status' => $this->status ?? null, + 'offer_token' => $this->offer_token ?? null, + 'cancel_token' => $this->cancel_token ?? null, + 'offered_at' => $this->offered_at ?? null, + 'offer_expires_at' => $this->offer_expires_at ?? null, + 'purchased_at' => $this->purchased_at ?? null, + 'cancelled_at' => $this->cancelled_at ?? null, + 'position' => $this->position ?? null, + 'locale' => $this->locale ?? 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 setProductPriceId(int $product_price_id): self + { + $this->product_price_id = $product_price_id; + return $this; + } + + public function getProductPriceId(): int + { + return $this->product_price_id; + } + + public function setOrderId(?int $order_id): self + { + $this->order_id = $order_id; + return $this; + } + + public function getOrderId(): ?int + { + return $this->order_id; + } + + public function setEmail(string $email): self + { + $this->email = $email; + return $this; + } + + public function getEmail(): string + { + return $this->email; + } + + public function setFirstName(string $first_name): self + { + $this->first_name = $first_name; + return $this; + } + + public function getFirstName(): string + { + return $this->first_name; + } + + public function setLastName(?string $last_name): self + { + $this->last_name = $last_name; + return $this; + } + + public function getLastName(): ?string + { + return $this->last_name; + } + + public function setStatus(string $status): self + { + $this->status = $status; + return $this; + } + + public function getStatus(): string + { + return $this->status; + } + + public function setOfferToken(?string $offer_token): self + { + $this->offer_token = $offer_token; + return $this; + } + + public function getOfferToken(): ?string + { + return $this->offer_token; + } + + public function setCancelToken(?string $cancel_token): self + { + $this->cancel_token = $cancel_token; + return $this; + } + + public function getCancelToken(): ?string + { + return $this->cancel_token; + } + + public function setOfferedAt(?string $offered_at): self + { + $this->offered_at = $offered_at; + return $this; + } + + public function getOfferedAt(): ?string + { + return $this->offered_at; + } + + public function setOfferExpiresAt(?string $offer_expires_at): self + { + $this->offer_expires_at = $offer_expires_at; + return $this; + } + + public function getOfferExpiresAt(): ?string + { + return $this->offer_expires_at; + } + + public function setPurchasedAt(?string $purchased_at): self + { + $this->purchased_at = $purchased_at; + return $this; + } + + public function getPurchasedAt(): ?string + { + return $this->purchased_at; + } + + public function setCancelledAt(?string $cancelled_at): self + { + $this->cancelled_at = $cancelled_at; + return $this; + } + + public function getCancelledAt(): ?string + { + return $this->cancelled_at; + } + + public function setPosition(int $position): self + { + $this->position = $position; + return $this; + } + + public function getPosition(): int + { + return $this->position; + } + + public function setLocale(string $locale): self + { + $this->locale = $locale; + return $this; + } + + public function getLocale(): string + { + return $this->locale; + } + + 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/ProductPriceDomainObject.php b/backend/app/DomainObjects/ProductPriceDomainObject.php index 97ee5ee23b..000c051832 100644 --- a/backend/app/DomainObjects/ProductPriceDomainObject.php +++ b/backend/app/DomainObjects/ProductPriceDomainObject.php @@ -8,6 +8,8 @@ class ProductPriceDomainObject extends Generated\ProductPriceDomainObjectAbstract { + public ?ProductDomainObject $product = null; + private ?float $priceBeforeDiscount = null; private ?float $taxTotal = null; @@ -118,4 +120,15 @@ public function isFree(): bool { return $this->getPrice() === 0.00; } + + public function setProduct(?ProductDomainObject $product): self + { + $this->product = $product; + return $this; + } + + public function getProduct(): ?ProductDomainObject + { + return $this->product; + } } diff --git a/backend/app/DomainObjects/RazorpayOrderDomainObject.php b/backend/app/DomainObjects/RazorpayOrderDomainObject.php new file mode 100644 index 0000000000..5d2df336d7 --- /dev/null +++ b/backend/app/DomainObjects/RazorpayOrderDomainObject.php @@ -0,0 +1,7 @@ + [ + 'asc' => __('Position ascending'), + 'desc' => __('Position descending'), + ], + self::CREATED_AT => [ + 'asc' => __('Oldest first'), + 'desc' => __('Newest first'), + ], + self::STATUS => [ + 'asc' => __('Status A-Z'), + 'desc' => __('Status Z-A'), + ], + ] + ); + } + + public function setOrder(?OrderDomainObject $order): self + { + $this->order = $order; + return $this; + } + + public function getOrder(): ?OrderDomainObject + { + return $this->order; + } + + public function setProductPrice(?ProductPriceDomainObject $productPrice): self + { + $this->productPrice = $productPrice; + return $this; + } + + public function getProductPrice(): ?ProductPriceDomainObject + { + return $this->productPrice; + } + +} diff --git a/backend/app/Events/CapacityChangedEvent.php b/backend/app/Events/CapacityChangedEvent.php new file mode 100644 index 0000000000..f0894827a0 --- /dev/null +++ b/backend/app/Events/CapacityChangedEvent.php @@ -0,0 +1,18 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + try { + $this->cancelWaitlistEntryHandler->handleCancelById($entryId, $eventId); + } catch (ResourceNotFoundException $e) { + return $this->errorResponse( + message: $e->getMessage(), + statusCode: SymfonyResponse::HTTP_NOT_FOUND, + ); + } catch (ResourceConflictException $e) { + return $this->errorResponse( + message: $e->getMessage(), + statusCode: SymfonyResponse::HTTP_CONFLICT, + ); + } + + return $this->noContentResponse(); + } +} diff --git a/backend/app/Http/Actions/Waitlist/Organizer/GetWaitlistEntriesAction.php b/backend/app/Http/Actions/Waitlist/Organizer/GetWaitlistEntriesAction.php new file mode 100644 index 0000000000..ca0f1bf1ca --- /dev/null +++ b/backend/app/Http/Actions/Waitlist/Organizer/GetWaitlistEntriesAction.php @@ -0,0 +1,36 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $entries = $this->handler->handle( + $eventId, + $this->getPaginationQueryParams($request), + ); + + return $this->filterableResourceResponse( + resource: WaitlistEntryResource::class, + data: $entries, + domainObject: WaitlistEntryDomainObject::class, + ); + } +} diff --git a/backend/app/Http/Actions/Waitlist/Organizer/GetWaitlistStatsAction.php b/backend/app/Http/Actions/Waitlist/Organizer/GetWaitlistStatsAction.php new file mode 100644 index 0000000000..ef72fe63ef --- /dev/null +++ b/backend/app/Http/Actions/Waitlist/Organizer/GetWaitlistStatsAction.php @@ -0,0 +1,40 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $stats = $this->handler->handle($eventId); + + return $this->jsonResponse([ + 'total' => $stats->total, + 'waiting' => $stats->waiting, + 'offered' => $stats->offered, + 'purchased' => $stats->purchased, + 'cancelled' => $stats->cancelled, + 'expired' => $stats->expired, + 'products' => array_map(fn($p) => [ + 'product_price_id' => $p->product_price_id, + 'product_title' => $p->product_title, + 'waiting' => $p->waiting, + 'offered' => $p->offered, + 'available' => $p->available, + ], $stats->products), + ]); + } +} diff --git a/backend/app/Http/Actions/Waitlist/Organizer/OfferWaitlistEntryAction.php b/backend/app/Http/Actions/Waitlist/Organizer/OfferWaitlistEntryAction.php new file mode 100644 index 0000000000..43b22a3bf7 --- /dev/null +++ b/backend/app/Http/Actions/Waitlist/Organizer/OfferWaitlistEntryAction.php @@ -0,0 +1,52 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + try { + $entries = $this->handler->handle(new OfferWaitlistEntryDTO( + event_id: $eventId, + product_price_id: $request->validated('product_price_id'), + entry_id: $request->validated('entry_id'), + quantity: $request->validated('quantity') ?? 1, + )); + } catch (NoCapacityAvailableException $exception) { + throw ValidationException::withMessages([ + 'quantity' => $exception->getMessage(), + ]); + } catch (ResourceNotFoundException $exception) { + return $this->errorResponse($exception->getMessage(), Response::HTTP_NOT_FOUND); + } catch (ResourceConflictException $exception) { + return $this->errorResponse($exception->getMessage(), Response::HTTP_CONFLICT); + } + + return $this->resourceResponse( + resource: WaitlistEntryResource::class, + data: $entries, + ); + } +} diff --git a/backend/app/Http/Actions/Waitlist/Public/CancelWaitlistEntryActionPublic.php b/backend/app/Http/Actions/Waitlist/Public/CancelWaitlistEntryActionPublic.php new file mode 100644 index 0000000000..b08ff230ab --- /dev/null +++ b/backend/app/Http/Actions/Waitlist/Public/CancelWaitlistEntryActionPublic.php @@ -0,0 +1,39 @@ +cancelWaitlistEntryService->cancelByToken($token, $eventId); + } catch (ResourceNotFoundException $e) { + return $this->errorResponse( + message: $e->getMessage(), + statusCode: SymfonyResponse::HTTP_NOT_FOUND, + ); + } catch (ResourceConflictException $e) { + return $this->errorResponse( + message: $e->getMessage(), + statusCode: SymfonyResponse::HTTP_CONFLICT, + ); + } + + return $this->noContentResponse(); + } +} diff --git a/backend/app/Http/Actions/Waitlist/Public/CreateWaitlistEntryActionPublic.php b/backend/app/Http/Actions/Waitlist/Public/CreateWaitlistEntryActionPublic.php new file mode 100644 index 0000000000..5dca378c5b --- /dev/null +++ b/backend/app/Http/Actions/Waitlist/Public/CreateWaitlistEntryActionPublic.php @@ -0,0 +1,47 @@ +handler->handle(new CreateWaitlistEntryDTO( + event_id: $eventId, + product_price_id: $request->validated('product_price_id'), + email: $request->validated('email'), + first_name: $request->validated('first_name'), + last_name: $request->validated('last_name'), + locale: $request->input('locale', 'en'), + )); + } catch (ResourceConflictException $e) { + return $this->errorResponse( + message: $e->getMessage(), + statusCode: Response::HTTP_CONFLICT, + ); + } + + return $this->resourceResponse( + resource: WaitlistEntryResource::class, + data: $entry, + statusCode: ResponseCodes::HTTP_CREATED, + ); + } +} diff --git a/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php index 78b78b7a3e..213038a74a 100644 --- a/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php +++ b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php @@ -101,6 +101,10 @@ public function rules(): array // Self-service settings 'allow_attendee_self_edit' => ['boolean'], + + // Waitlist settings + 'waitlist_auto_process' => ['boolean'], + 'waitlist_offer_timeout_minutes' => ['nullable', 'integer', 'min:1', 'max:10080'], ]; } diff --git a/backend/app/Http/Request/Product/UpsertProductRequest.php b/backend/app/Http/Request/Product/UpsertProductRequest.php index 9f43749a57..adb485cf88 100644 --- a/backend/app/Http/Request/Product/UpsertProductRequest.php +++ b/backend/app/Http/Request/Product/UpsertProductRequest.php @@ -43,6 +43,7 @@ public function rules(): array 'product_category_id' => ['required', 'integer'], 'is_highlighted' => 'boolean', 'highlight_message' => 'string|nullable|max:255', + 'waitlist_enabled' => 'boolean|nullable', ]; } diff --git a/backend/app/Http/Request/Waitlist/CreateWaitlistEntryRequest.php b/backend/app/Http/Request/Waitlist/CreateWaitlistEntryRequest.php new file mode 100644 index 0000000000..1fe7aea86a --- /dev/null +++ b/backend/app/Http/Request/Waitlist/CreateWaitlistEntryRequest.php @@ -0,0 +1,18 @@ + ['required', 'integer', 'exists:product_prices,id'], + 'email' => ['required', 'email', 'max:255'], + 'first_name' => ['required', 'string', 'max:255'], + 'last_name' => ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/backend/app/Http/Request/Waitlist/OfferWaitlistEntryRequest.php b/backend/app/Http/Request/Waitlist/OfferWaitlistEntryRequest.php new file mode 100644 index 0000000000..f04e6d334e --- /dev/null +++ b/backend/app/Http/Request/Waitlist/OfferWaitlistEntryRequest.php @@ -0,0 +1,17 @@ + ['required_without:entry_id', 'integer', 'exists:product_prices,id'], + 'entry_id' => ['required_without:product_price_id', 'integer', 'exists:waitlist_entries,id'], + 'quantity' => ['sometimes', 'integer', 'min:1', 'max:50'], + ]; + } +} diff --git a/backend/app/Jobs/Waitlist/ProcessExpiredWaitlistOffersJob.php b/backend/app/Jobs/Waitlist/ProcessExpiredWaitlistOffersJob.php new file mode 100644 index 0000000000..14102c3bdd --- /dev/null +++ b/backend/app/Jobs/Waitlist/ProcessExpiredWaitlistOffersJob.php @@ -0,0 +1,74 @@ +findWhere([ + 'status' => WaitlistEntryStatus::OFFERED->name, + ['offer_expires_at', '<=', now()->toDateTimeString()], + ['offer_expires_at', '!=', null], + ]); + + foreach ($expiredEntries as $entry) { + try { + if ($entry->getOrderId() !== null) { + $orderRepository->deleteWhere([ + 'id' => $entry->getOrderId(), + 'status' => OrderStatus::RESERVED->name, + ]); + } + + $repository->updateWhere( + attributes: [ + 'status' => WaitlistEntryStatus::OFFER_EXPIRED->name, + 'offer_token' => null, + 'offered_at' => null, + 'offer_expires_at' => null, + 'order_id' => null, + ], + where: ['id' => $entry->getId()], + ); + + SendWaitlistOfferExpiredEmailJob::dispatch($entry); + + $productPrice = $productPriceRepository->findById($entry->getProductPriceId()); + + event(new CapacityChangedEvent( + eventId: $entry->getEventId(), + direction: CapacityChangeDirection::INCREASED, + productId: $productPrice->getProductId(), + productPriceId: $entry->getProductPriceId(), + )); + } catch (Throwable $e) { + Log::error('Failed to process expired waitlist offer', [ + 'entry_id' => $entry->getId(), + 'error' => $e->getMessage(), + ]); + } + } + } +} diff --git a/backend/app/Jobs/Waitlist/SendWaitlistConfirmationEmailJob.php b/backend/app/Jobs/Waitlist/SendWaitlistConfirmationEmailJob.php new file mode 100644 index 0000000000..f22fdaa9e4 --- /dev/null +++ b/backend/app/Jobs/Waitlist/SendWaitlistConfirmationEmailJob.php @@ -0,0 +1,63 @@ +loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer')) + ->loadRelation(new Relationship(EventSettingDomainObject::class)) + ->findById($this->entry->getEventId()); + + $product = null; + $productPrice = null; + if ($this->entry->getProductPriceId()) { + $productPrice = $productPriceRepository->findById($this->entry->getProductPriceId()); + $product = $productRepository->findById($productPrice->getProductId()); + } + + $mailer + ->to($this->entry->getEmail()) + ->locale($this->entry->getLocale()) + ->send(new WaitlistConfirmationMail( + entry: $this->entry, + event: $event, + product: $product, + productPrice: $productPrice, + organizer: $event->getOrganizer(), + eventSettings: $event->getEventSettings(), + )); + } +} diff --git a/backend/app/Jobs/Waitlist/SendWaitlistOfferEmailJob.php b/backend/app/Jobs/Waitlist/SendWaitlistOfferEmailJob.php new file mode 100644 index 0000000000..31a52696aa --- /dev/null +++ b/backend/app/Jobs/Waitlist/SendWaitlistOfferEmailJob.php @@ -0,0 +1,68 @@ +afterCommit = true; + } + + public function handle( + EventRepositoryInterface $eventRepository, + ProductPriceRepositoryInterface $productPriceRepository, + ProductRepositoryInterface $productRepository, + Mailer $mailer, + ): void + { + $event = $eventRepository + ->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer')) + ->loadRelation(new Relationship(EventSettingDomainObject::class)) + ->findById($this->entry->getEventId()); + + $product = null; + $productPrice = null; + if ($this->entry->getProductPriceId()) { + $productPrice = $productPriceRepository->findById($this->entry->getProductPriceId()); + $product = $productRepository->findById($productPrice->getProductId()); + } + + $mailer + ->to($this->entry->getEmail()) + ->locale($this->entry->getLocale()) + ->send(new WaitlistOfferMail( + entry: $this->entry, + event: $event, + product: $product, + productPrice: $productPrice, + organizer: $event->getOrganizer(), + eventSettings: $event->getEventSettings(), + orderShortId: $this->orderShortId, + sessionIdentifier: $this->sessionIdentifier, + )); + } +} diff --git a/backend/app/Jobs/Waitlist/SendWaitlistOfferExpiredEmailJob.php b/backend/app/Jobs/Waitlist/SendWaitlistOfferExpiredEmailJob.php new file mode 100644 index 0000000000..c47b3f63de --- /dev/null +++ b/backend/app/Jobs/Waitlist/SendWaitlistOfferExpiredEmailJob.php @@ -0,0 +1,63 @@ +loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer')) + ->loadRelation(new Relationship(EventSettingDomainObject::class)) + ->findById($this->entry->getEventId()); + + $product = null; + $productPrice = null; + if ($this->entry->getProductPriceId()) { + $productPrice = $productPriceRepository->findById($this->entry->getProductPriceId()); + $product = $productRepository->findById($productPrice->getProductId()); + } + + $mailer + ->to($this->entry->getEmail()) + ->locale($this->entry->getLocale()) + ->send(new WaitlistOfferExpiredMail( + entry: $this->entry, + event: $event, + product: $product, + productPrice: $productPrice, + organizer: $event->getOrganizer(), + eventSettings: $event->getEventSettings(), + )); + } +} diff --git a/backend/app/Listeners/Waitlist/ProcessWaitlistOnCapacityAvailableListener.php b/backend/app/Listeners/Waitlist/ProcessWaitlistOnCapacityAvailableListener.php new file mode 100644 index 0000000000..6fe49f8901 --- /dev/null +++ b/backend/app/Listeners/Waitlist/ProcessWaitlistOnCapacityAvailableListener.php @@ -0,0 +1,72 @@ +direction !== CapacityChangeDirection::INCREASED) { + return; + } + + if ($event->productId === null) { + return; + } + + $eventDomainObject = $this->eventRepository + ->loadRelation(new Relationship(EventSettingDomainObject::class)) + ->findById($event->eventId); + + $eventSettings = $eventDomainObject->getEventSettings(); + + if (!$eventSettings?->getWaitlistAutoProcess()) { + return; + } + + $quantities = $this->availableQuantitiesService->getAvailableProductQuantities( + $event->eventId, + ignoreCache: true, + ); + + foreach ($quantities->productQuantities as $productQuantity) { + if ($productQuantity->product_id !== $event->productId) { + continue; + } + + $availableCount = max(0, $productQuantity->quantity_available); + + if ($availableCount <= 0) { + continue; + } + + try { + $this->processWaitlistService->offerToNext( + productPriceId: $productQuantity->price_id, + quantity: $availableCount, + event: $eventDomainObject, + eventSettings: $eventSettings, + ); + } catch (NoCapacityAvailableException) { + // Expected: no waiting entries or capacity consumed by pending offers + } + } + } +} diff --git a/backend/app/Listeners/Waitlist/ResolveWaitlistEntryOnOrderCompletedListener.php b/backend/app/Listeners/Waitlist/ResolveWaitlistEntryOnOrderCompletedListener.php new file mode 100644 index 0000000000..9c582143bc --- /dev/null +++ b/backend/app/Listeners/Waitlist/ResolveWaitlistEntryOnOrderCompletedListener.php @@ -0,0 +1,94 @@ +order; + + if ($order->getStatus() === OrderStatus::COMPLETED->name) { + $this->resolveByOrderId($order->getId()); + return; + } + + if ($order->getStatus() === OrderStatus::CANCELLED->name) { + $this->revertOfferedEntriesByOrderId($order->getId()); + } + } + + private function resolveByOrderId(int $orderId): void + { + $entries = $this->waitlistEntryRepository->findWhere([ + 'order_id' => $orderId, + ['status', 'in', [WaitlistEntryStatus::OFFERED->name]], + ]); + + foreach ($entries as $entry) { + $this->markAsPurchased($entry); + } + } + + private function revertOfferedEntriesByOrderId(int $orderId): void + { + $entries = $this->waitlistEntryRepository->findWhere([ + 'order_id' => $orderId, + ['status', 'in', [WaitlistEntryStatus::OFFERED->name]], + ]); + + foreach ($entries as $entry) { + $this->revertToWaiting($entry); + + $productPrice = $this->productPriceRepository->findById($entry->getProductPriceId()); + event(new CapacityChangedEvent( + eventId: $entry->getEventId(), + direction: CapacityChangeDirection::INCREASED, + productId: $productPrice->getProductId(), + productPriceId: $entry->getProductPriceId(), + )); + } + } + + private function markAsPurchased(WaitlistEntryDomainObject $entry): void + { + $this->waitlistEntryRepository->updateWhere( + attributes: [ + 'status' => WaitlistEntryStatus::PURCHASED->name, + 'purchased_at' => Carbon::now()->toDateTimeString(), + ], + where: ['id' => $entry->getId()], + ); + } + + private function revertToWaiting(WaitlistEntryDomainObject $entry): void + { + $this->waitlistEntryRepository->updateWhere( + attributes: [ + 'status' => WaitlistEntryStatus::WAITING->name, + 'order_id' => null, + 'offered_at' => null, + 'offer_expires_at' => null, + 'offer_token' => null, + ], + where: ['id' => $entry->getId()], + ); + } +} diff --git a/backend/app/Mail/Waitlist/WaitlistConfirmationMail.php b/backend/app/Mail/Waitlist/WaitlistConfirmationMail.php new file mode 100644 index 0000000000..1a29619cd4 --- /dev/null +++ b/backend/app/Mail/Waitlist/WaitlistConfirmationMail.php @@ -0,0 +1,71 @@ +eventSettings->getSupportEmail(), + subject: __("You're on the waitlist!"), + ); + } + + public function content(): Content + { + return new Content( + markdown: 'emails.waitlist.confirmation', + with: [ + 'entry' => $this->entry, + 'event' => $this->event, + 'productName' => $this->buildProductName(), + 'organizer' => $this->organizer, + 'eventSettings' => $this->eventSettings, + 'eventUrl' => sprintf( + Url::getFrontEndUrlFromConfig(Url::EVENT_HOMEPAGE), + $this->event->getId(), + $this->event->getSlug(), + ), + ] + ); + } + + private function buildProductName(): ?string + { + if (!$this->product) { + return null; + } + + $name = $this->product->getTitle(); + + if ($this->productPrice?->getLabel()) { + $name .= ' - ' . $this->productPrice->getLabel(); + } + + return $name; + } +} diff --git a/backend/app/Mail/Waitlist/WaitlistOfferExpiredMail.php b/backend/app/Mail/Waitlist/WaitlistOfferExpiredMail.php new file mode 100644 index 0000000000..aa3c976f55 --- /dev/null +++ b/backend/app/Mail/Waitlist/WaitlistOfferExpiredMail.php @@ -0,0 +1,71 @@ +eventSettings->getSupportEmail(), + subject: __('Your waitlist offer has expired'), + ); + } + + public function content(): Content + { + return new Content( + markdown: 'emails.waitlist.offer-expired', + with: [ + 'entry' => $this->entry, + 'event' => $this->event, + 'productName' => $this->buildProductName(), + 'organizer' => $this->organizer, + 'eventSettings' => $this->eventSettings, + 'eventUrl' => sprintf( + Url::getFrontEndUrlFromConfig(Url::EVENT_HOMEPAGE), + $this->event->getId(), + $this->event->getSlug(), + ), + ] + ); + } + + private function buildProductName(): ?string + { + if (!$this->product) { + return null; + } + + $name = $this->product->getTitle(); + + if ($this->productPrice?->getLabel()) { + $name .= ' - ' . $this->productPrice->getLabel(); + } + + return $name; + } +} diff --git a/backend/app/Mail/Waitlist/WaitlistOfferMail.php b/backend/app/Mail/Waitlist/WaitlistOfferMail.php new file mode 100644 index 0000000000..8cd81e2682 --- /dev/null +++ b/backend/app/Mail/Waitlist/WaitlistOfferMail.php @@ -0,0 +1,89 @@ +eventSettings->getSupportEmail(), + subject: __('A spot has opened up for :event!', ['event' => $this->event->getTitle()]), + ); + } + + public function content(): Content + { + return new Content( + markdown: 'emails.waitlist.offer', + with: [ + 'entry' => $this->entry, + 'event' => $this->event, + 'productName' => $this->buildProductName(), + 'organizer' => $this->organizer, + 'eventSettings' => $this->eventSettings, + 'offerExpiresAtFormatted' => $this->formatOfferExpiry(), + 'checkoutUrl' => sprintf( + Url::getFrontEndUrlFromConfig(Url::ORDER_DETAILS, [ + 'session_identifier' => $this->sessionIdentifier, + 'waitlist' => 'true', + ]), + $this->event->getId(), + $this->orderShortId, + ), + ] + ); + } + + private function formatOfferExpiry(): ?string + { + $expiresAt = $this->entry->getOfferExpiresAt(); + + if ($expiresAt === null) { + return null; + } + + return Carbon::parse($expiresAt)->isoFormat('MMMM D, YYYY [at] h:mm A (z)'); + } + + private function buildProductName(): ?string + { + if (!$this->product) { + return null; + } + + $name = $this->product->getTitle(); + + if ($this->productPrice?->getLabel()) { + $name .= ' - ' . $this->productPrice->getLabel(); + } + + return $name; + } +} diff --git a/backend/app/Models/WaitlistEntry.php b/backend/app/Models/WaitlistEntry.php new file mode 100644 index 0000000000..45f07495d9 --- /dev/null +++ b/backend/app/Models/WaitlistEntry.php @@ -0,0 +1,28 @@ +belongsTo(Event::class); + } + + public function product_price(): BelongsTo + { + return $this->belongsTo(ProductPrice::class); + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } +} diff --git a/backend/app/Providers/RepositoryServiceProvider.php b/backend/app/Providers/RepositoryServiceProvider.php index 712839ee35..55f77ef5b6 100644 --- a/backend/app/Providers/RepositoryServiceProvider.php +++ b/backend/app/Providers/RepositoryServiceProvider.php @@ -48,6 +48,7 @@ use HiEvents\Repository\Eloquent\TaxAndFeeRepository; use HiEvents\Repository\Eloquent\TicketLookupTokenRepository; use HiEvents\Repository\Eloquent\UserRepository; +use HiEvents\Repository\Eloquent\WaitlistEntryRepository; use HiEvents\Repository\Eloquent\WebhookLogRepository; use HiEvents\Repository\Eloquent\WebhookRepository; use HiEvents\Repository\Interfaces\AccountAttributionRepositoryInterface; @@ -94,6 +95,7 @@ use HiEvents\Repository\Interfaces\TaxAndFeeRepositoryInterface; use HiEvents\Repository\Interfaces\TicketLookupTokenRepositoryInterface; use HiEvents\Repository\Interfaces\UserRepositoryInterface; +use HiEvents\Repository\Interfaces\WaitlistEntryRepositoryInterface; use HiEvents\Repository\Interfaces\WebhookLogRepositoryInterface; use HiEvents\Repository\Interfaces\WebhookRepositoryInterface; use Illuminate\Support\ServiceProvider; @@ -150,6 +152,7 @@ class RepositoryServiceProvider extends ServiceProvider AccountVatSettingRepositoryInterface::class => AccountVatSettingRepository::class, TicketLookupTokenRepositoryInterface::class => TicketLookupTokenRepository::class, AccountMessagingTierRepositoryInterface::class => AccountMessagingTierRepository::class, + WaitlistEntryRepositoryInterface::class => WaitlistEntryRepository::class, ]; public function register(): void diff --git a/backend/app/Repository/Eloquent/AccountRepository.php b/backend/app/Repository/Eloquent/AccountRepository.php index e611137a69..14b55a7ac4 100644 --- a/backend/app/Repository/Eloquent/AccountRepository.php +++ b/backend/app/Repository/Eloquent/AccountRepository.php @@ -30,6 +30,8 @@ public function findByEventId(int $eventId): AccountDomainObject ->where('events.id', $eventId) ->first(); + $this->resetModel(); + return $this->handleSingleResult($account, AccountDomainObject::class); } diff --git a/backend/app/Repository/Eloquent/WaitlistEntryRepository.php b/backend/app/Repository/Eloquent/WaitlistEntryRepository.php new file mode 100644 index 0000000000..9960582399 --- /dev/null +++ b/backend/app/Repository/Eloquent/WaitlistEntryRepository.php @@ -0,0 +1,169 @@ +selectRaw(" + COUNT(*) as total, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as waiting, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as offered, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as purchased, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as cancelled, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as expired + ", [ + WaitlistEntryStatus::WAITING->name, + WaitlistEntryStatus::OFFERED->name, + WaitlistEntryStatus::PURCHASED->name, + WaitlistEntryStatus::CANCELLED->name, + WaitlistEntryStatus::OFFER_EXPIRED->name, + ]) + ->where('event_id', $eventId) + ->whereNull('deleted_at') + ->first(); + + return new WaitlistStatsDTO( + total: (int) ($stats->total ?? 0), + waiting: (int) ($stats->waiting ?? 0), + offered: (int) ($stats->offered ?? 0), + purchased: (int) ($stats->purchased ?? 0), + cancelled: (int) ($stats->cancelled ?? 0), + expired: (int) ($stats->expired ?? 0), + ); + } + + public function getProductStatsByEventId(int $eventId): \Illuminate\Support\Collection + { + return DB::table('waitlist_entries') + ->join('product_prices', 'waitlist_entries.product_price_id', '=', 'product_prices.id') + ->join('products', 'product_prices.product_id', '=', 'products.id') + ->selectRaw(" + waitlist_entries.product_price_id, + CASE + WHEN product_prices.label IS NOT NULL AND product_prices.label != '' + THEN products.title || ' - ' || product_prices.label + ELSE products.title + END as product_title, + SUM(CASE WHEN waitlist_entries.status = ? THEN 1 ELSE 0 END) as waiting, + SUM(CASE WHEN waitlist_entries.status = ? THEN 1 ELSE 0 END) as offered + ", [ + WaitlistEntryStatus::WAITING->name, + WaitlistEntryStatus::OFFERED->name, + ]) + ->where('waitlist_entries.event_id', $eventId) + ->whereNull('waitlist_entries.deleted_at') + ->whereNull('product_prices.deleted_at') + ->whereNull('products.deleted_at') + ->groupBy('waitlist_entries.product_price_id', 'products.title', 'product_prices.label') + ->get(); + } + + public function getMaxPosition(int $productPriceId): int + { + return (int) DB::table('waitlist_entries') + ->where('product_price_id', $productPriceId) + ->whereNull('deleted_at') + ->max('position') ?? 0; + } + + /** + * @return \Illuminate\Support\Collection + */ + public function getNextWaitingEntries(int $productPriceId, int $limit): \Illuminate\Support\Collection + { + $models = WaitlistEntry::query() + ->where('product_price_id', $productPriceId) + ->where('status', WaitlistEntryStatus::WAITING->name) + ->orderBy('position') + ->limit($limit) + ->get(); + + return $this->handleResults($models); + } + + public function lockForProductPrice(int $productPriceId): void + { + DB::table('waitlist_entries') + ->where('product_price_id', $productPriceId) + ->whereIn('status', [WaitlistEntryStatus::WAITING->name, WaitlistEntryStatus::OFFERED->name]) + ->lockForUpdate() + ->select('id') + ->get(); + } + + public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator + { + $where = [ + [WaitlistEntryDomainObjectAbstract::EVENT_ID, '=', $eventId], + ]; + + if ($params->query) { + $where[] = static function (Builder $builder) use ($params) { + $builder + ->where(WaitlistEntryDomainObjectAbstract::FIRST_NAME, 'ilike', '%' . $params->query . '%') + ->orWhere(WaitlistEntryDomainObjectAbstract::LAST_NAME, 'ilike', '%' . $params->query . '%') + ->orWhere(WaitlistEntryDomainObjectAbstract::EMAIL, 'ilike', '%' . $params->query . '%'); + }; + } + + if (!empty($params->filter_fields)) { + $this->applyFilterFields($params, WaitlistEntryDomainObject::getAllowedFilterFields()); + } + + $this->model = $this->model->orderBy( + column: $params->sort_by ?? WaitlistEntryDomainObject::getDefaultSort(), + direction: $params->sort_direction ?? WaitlistEntryDomainObject::getDefaultSortDirection(), + ); + + return $this->loadRelation(new Relationship( + domainObject: OrderDomainObject::class, + name: OrderDomainObjectAbstract::SINGULAR_NAME, + )) + ->loadRelation(new Relationship( + domainObject: ProductPriceDomainObject::class, + nested: [ + new Relationship( + domainObject: ProductDomainObject::class, + name: ProductDomainObjectAbstract::SINGULAR_NAME, + ), + ], + name: ProductPriceDomainObjectAbstract::SINGULAR_NAME + )) + ->paginateWhere( + where: $where, + limit: $params->per_page, + page: $params->page, + ); + } +} diff --git a/backend/app/Repository/Interfaces/WaitlistEntryRepositoryInterface.php b/backend/app/Repository/Interfaces/WaitlistEntryRepositoryInterface.php new file mode 100644 index 0000000000..12f790b061 --- /dev/null +++ b/backend/app/Repository/Interfaces/WaitlistEntryRepositoryInterface.php @@ -0,0 +1,30 @@ + + */ +interface WaitlistEntryRepositoryInterface extends RepositoryInterface +{ + public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator; + + public function getStatsByEventId(int $eventId): WaitlistStatsDTO; + + public function getProductStatsByEventId(int $eventId): Collection; + + public function getMaxPosition(int $productPriceId): int; + + /** + * @return Collection + */ + public function getNextWaitingEntries(int $productPriceId, int $limit): Collection; + + public function lockForProductPrice(int $productPriceId): void; +} diff --git a/backend/app/Resources/Event/EventSettingsResource.php b/backend/app/Resources/Event/EventSettingsResource.php index 2d933583c7..b61c69bf09 100644 --- a/backend/app/Resources/Event/EventSettingsResource.php +++ b/backend/app/Resources/Event/EventSettingsResource.php @@ -79,6 +79,10 @@ public function toArray($request): array // Self-service settings 'allow_attendee_self_edit' => $this->getAllowAttendeeSelfEdit(), + + // Waitlist settings + 'waitlist_auto_process' => $this->getWaitlistAutoProcess(), + 'waitlist_offer_timeout_minutes' => $this->getWaitlistOfferTimeoutMinutes(), ]; } } diff --git a/backend/app/Resources/Event/EventSettingsResourcePublic.php b/backend/app/Resources/Event/EventSettingsResourcePublic.php index b6e0e1a975..02ed37b5c2 100644 --- a/backend/app/Resources/Event/EventSettingsResourcePublic.php +++ b/backend/app/Resources/Event/EventSettingsResourcePublic.php @@ -85,6 +85,10 @@ public function toArray($request): array // Self-service settings 'allow_attendee_self_edit' => $this->getAllowAttendeeSelfEdit(), + + // Waitlist settings + 'waitlist_auto_process' => $this->getWaitlistAutoProcess(), + 'waitlist_offer_timeout_minutes' => $this->getWaitlistOfferTimeoutMinutes(), ]; } } diff --git a/backend/app/Resources/Product/ProductResource.php b/backend/app/Resources/Product/ProductResource.php index 2c265f4851..d172df6637 100644 --- a/backend/app/Resources/Product/ProductResource.php +++ b/backend/app/Resources/Product/ProductResource.php @@ -63,6 +63,7 @@ public function toArray(Request $request): array 'product_category_id' => $this->getProductCategoryId(), 'is_highlighted' => $this->getIsHighlighted(), 'highlight_message' => $this->getHighlightMessage(), + 'waitlist_enabled' => $this->getWaitlistEnabled(), ]; } } diff --git a/backend/app/Resources/Product/ProductResourcePublic.php b/backend/app/Resources/Product/ProductResourcePublic.php index 28a775f5b8..eec7789f1b 100644 --- a/backend/app/Resources/Product/ProductResourcePublic.php +++ b/backend/app/Resources/Product/ProductResourcePublic.php @@ -53,6 +53,7 @@ public function toArray(Request $request): array 'product_category_id' => $this->getProductCategoryId(), 'is_highlighted' => $this->getIsHighlighted(), 'highlight_message' => $this->getHighlightMessage(), + 'waitlist_enabled' => $this->getWaitlistEnabled(), ]; } } diff --git a/backend/app/Resources/Waitlist/WaitlistEntryResource.php b/backend/app/Resources/Waitlist/WaitlistEntryResource.php new file mode 100644 index 0000000000..a2ea76a20b --- /dev/null +++ b/backend/app/Resources/Waitlist/WaitlistEntryResource.php @@ -0,0 +1,43 @@ + $this->getId(), + 'event_id' => $this->getEventId(), + 'product_price_id' => $this->getProductPriceId(), + 'email' => $this->getEmail(), + 'first_name' => $this->getFirstName(), + 'last_name' => $this->getLastName(), + 'status' => $this->getStatus(), + 'position' => $this->getPosition(), + 'offered_at' => $this->getOfferedAt(), + 'offer_expires_at' => $this->getOfferExpiresAt(), + 'purchased_at' => $this->getPurchasedAt(), + 'cancelled_at' => $this->getCancelledAt(), + 'order_id' => $this->getOrderId(), + 'locale' => $this->getLocale(), + 'product' => $this->getProductPrice()?->getProduct() + ? new ProductResource($this->getProductPrice()?->getProduct()) + : null, + 'product_price' => $this->getProductPrice() + ? new ProductPriceResource($this->getProductPrice()) + : null, + 'created_at' => $this->getCreatedAt(), + 'updated_at' => $this->getUpdatedAt(), + ]; + } +} diff --git a/backend/app/Services/Application/Handlers/Attendee/EditAttendeeHandler.php b/backend/app/Services/Application/Handlers/Attendee/EditAttendeeHandler.php index 8a7051bfd9..f7dd85f02a 100644 --- a/backend/app/Services/Application/Handlers/Attendee/EditAttendeeHandler.php +++ b/backend/app/Services/Application/Handlers/Attendee/EditAttendeeHandler.php @@ -3,11 +3,13 @@ namespace HiEvents\Services\Application\Handlers\Attendee; use HiEvents\DomainObjects\AttendeeDomainObject; +use HiEvents\DomainObjects\Enums\CapacityChangeDirection; use HiEvents\DomainObjects\Enums\ProductPriceType; use HiEvents\DomainObjects\Generated\AttendeeDomainObjectAbstract; use HiEvents\DomainObjects\Generated\ProductDomainObjectAbstract; use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\DomainObjects\ProductPriceDomainObject; +use HiEvents\Events\CapacityChangedEvent; use HiEvents\Exceptions\NoTicketsAvailableException; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; @@ -63,6 +65,13 @@ private function adjustProductQuantities(AttendeeDomainObject $attendee, EditAtt if ($attendee->getProductPriceId() !== $editAttendeeDTO->product_price_id) { $this->productQuantityService->decreaseQuantitySold($attendee->getProductPriceId()); $this->productQuantityService->increaseQuantitySold($editAttendeeDTO->product_price_id); + + event(new CapacityChangedEvent( + eventId: $editAttendeeDTO->event_id, + direction: CapacityChangeDirection::INCREASED, + productId: $attendee->getProductId(), + productPriceId: $attendee->getProductPriceId(), + )); } } diff --git a/backend/app/Services/Application/Handlers/Attendee/PartialEditAttendeeHandler.php b/backend/app/Services/Application/Handlers/Attendee/PartialEditAttendeeHandler.php index 393dd0ecce..bfd59608dc 100644 --- a/backend/app/Services/Application/Handlers/Attendee/PartialEditAttendeeHandler.php +++ b/backend/app/Services/Application/Handlers/Attendee/PartialEditAttendeeHandler.php @@ -3,7 +3,9 @@ namespace HiEvents\Services\Application\Handlers\Attendee; use HiEvents\DomainObjects\AttendeeDomainObject; +use HiEvents\DomainObjects\Enums\CapacityChangeDirection; use HiEvents\DomainObjects\Status\AttendeeStatus; +use HiEvents\Events\CapacityChangedEvent; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Services\Application\Handlers\Attendee\DTO\PartialEditAttendeeDTO; @@ -90,6 +92,13 @@ private function adjustProductQuantity(PartialEditAttendeeDTO $data, AttendeeDom $this->productQuantityService->increaseQuantitySold($attendee->getProductPriceId()); } elseif ($data->status === AttendeeStatus::CANCELLED->name) { $this->productQuantityService->decreaseQuantitySold($attendee->getProductPriceId()); + + event(new CapacityChangedEvent( + eventId: $attendee->getEventId(), + direction: CapacityChangeDirection::INCREASED, + productId: $attendee->getProductId(), + productPriceId: $attendee->getProductPriceId(), + )); } } diff --git a/backend/app/Services/Application/Handlers/CapacityAssignment/DeleteCapacityAssignmentHandler.php b/backend/app/Services/Application/Handlers/CapacityAssignment/DeleteCapacityAssignmentHandler.php index fa2b7708f0..71bc8f9966 100644 --- a/backend/app/Services/Application/Handlers/CapacityAssignment/DeleteCapacityAssignmentHandler.php +++ b/backend/app/Services/Application/Handlers/CapacityAssignment/DeleteCapacityAssignmentHandler.php @@ -2,6 +2,9 @@ namespace HiEvents\Services\Application\Handlers\CapacityAssignment; +use HiEvents\DomainObjects\Enums\CapacityChangeDirection; +use HiEvents\Events\CapacityChangedEvent; +use HiEvents\Models\CapacityAssignment; use HiEvents\Repository\Interfaces\CapacityAssignmentRepositoryInterface; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; use Illuminate\Database\DatabaseManager; @@ -18,6 +21,10 @@ public function __construct( public function handle(int $id, int $eventId): void { + $capacityAssignment = $this->capacityAssignmentRepository->findById($id); + + $productIds = CapacityAssignment::find($id)?->products()->pluck('products.id')->toArray() ?? []; + $this->databaseManager->transaction(function () use ($id, $eventId) { $this->productRepository->removeCapacityAssignmentFromProducts( capacityAssignmentId: $id, @@ -28,5 +35,15 @@ public function handle(int $id, int $eventId): void 'event_id' => $eventId, ]); }); + + if ($capacityAssignment->getCapacity() !== null) { + foreach ($productIds as $productId) { + event(new CapacityChangedEvent( + eventId: $eventId, + direction: CapacityChangeDirection::INCREASED, + productId: $productId, + )); + } + } } } diff --git a/backend/app/Services/Application/Handlers/CapacityAssignment/UpdateCapacityAssignmentHandler.php b/backend/app/Services/Application/Handlers/CapacityAssignment/UpdateCapacityAssignmentHandler.php index eedf3eef6d..b8b0c81aff 100644 --- a/backend/app/Services/Application/Handlers/CapacityAssignment/UpdateCapacityAssignmentHandler.php +++ b/backend/app/Services/Application/Handlers/CapacityAssignment/UpdateCapacityAssignmentHandler.php @@ -4,6 +4,9 @@ use HiEvents\DomainObjects\CapacityAssignmentDomainObject; use HiEvents\DomainObjects\Enums\CapacityAssignmentAppliesTo; +use HiEvents\DomainObjects\Enums\CapacityChangeDirection; +use HiEvents\Events\CapacityChangedEvent; +use HiEvents\Repository\Interfaces\CapacityAssignmentRepositoryInterface; use HiEvents\Services\Application\Handlers\CapacityAssignment\DTO\UpsertCapacityAssignmentDTO; use HiEvents\Services\Domain\CapacityAssignment\UpdateCapacityAssignmentService; use HiEvents\Services\Domain\Product\Exception\UnrecognizedProductIdException; @@ -11,7 +14,8 @@ class UpdateCapacityAssignmentHandler { public function __construct( - private readonly UpdateCapacityAssignmentService $updateCapacityAssignmentService, + private readonly UpdateCapacityAssignmentService $updateCapacityAssignmentService, + private readonly CapacityAssignmentRepositoryInterface $capacityAssignmentRepository, ) { } @@ -21,6 +25,8 @@ public function __construct( */ public function handle(UpsertCapacityAssignmentDTO $data): CapacityAssignmentDomainObject { + $existingAssignment = $this->capacityAssignmentRepository->findById($data->id); + $capacityAssignment = (new CapacityAssignmentDomainObject) ->setId($data->id) ->setName($data->name) @@ -29,9 +35,52 @@ public function handle(UpsertCapacityAssignmentDTO $data): CapacityAssignmentDom ->setAppliesTo(CapacityAssignmentAppliesTo::PRODUCTS->name) ->setStatus($data->status->name); - return $this->updateCapacityAssignmentService->updateCapacityAssignment( + $result = $this->updateCapacityAssignmentService->updateCapacityAssignment( $capacityAssignment, $data->product_ids, ); + + $this->dispatchCapacityChangedEvents( + $existingAssignment, + $data, + ); + + return $result; + } + + private function dispatchCapacityChangedEvents( + CapacityAssignmentDomainObject $existingAssignment, + UpsertCapacityAssignmentDTO $data, + ): void + { + if (empty($data->product_ids)) { + return; + } + + $oldCapacity = $existingAssignment->getCapacity(); + $newCapacity = $data->capacity; + + $direction = match (true) { + ($newCapacity === null && $oldCapacity !== null), + ($newCapacity !== null && $oldCapacity !== null && $newCapacity > $oldCapacity) + => CapacityChangeDirection::INCREASED, + ($newCapacity !== null && $oldCapacity === null), + ($newCapacity !== null && $oldCapacity !== null && $newCapacity < $oldCapacity) + => CapacityChangeDirection::DECREASED, + default => null, + }; + + if ($direction === null) { + return; + } + + foreach ($data->product_ids as $productId) { + event(new CapacityChangedEvent( + eventId: $data->event_id, + direction: $direction, + productId: $productId, + newCapacity: $data->capacity, + )); + } } } diff --git a/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php b/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php index 3871b386c0..6d3c3864f1 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php +++ b/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php @@ -83,6 +83,10 @@ public function __construct( // Self-service settings 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, ) { } diff --git a/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php b/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php index fe2c7d0964..cbad542a81 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php +++ b/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php @@ -137,6 +137,10 @@ public function handle(PartialUpdateEventSettingsDTO $eventSettingsDTO): EventSe // Self-service settings 'allow_attendee_self_edit' => $eventSettingsDTO->settings['allow_attendee_self_edit'] ?? $existingSettings->getAllowAttendeeSelfEdit(), + + // Waitlist settings + 'waitlist_auto_process' => $eventSettingsDTO->settings['waitlist_auto_process'] ?? $existingSettings->getWaitlistAutoProcess(), + 'waitlist_offer_timeout_minutes' => $eventSettingsDTO->settings['waitlist_offer_timeout_minutes'] ?? $existingSettings->getWaitlistOfferTimeoutMinutes(), ]), ); } diff --git a/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php b/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php index 23568784a2..b668a7d22b 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php +++ b/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php @@ -93,7 +93,11 @@ public function handle(UpdateEventSettingsDTO $settings): EventSettingDomainObje 'homepage_theme_settings' => $settings->homepage_theme_settings, // Self-service settings - 'allow_attendee_self_edit' => $settings->allow_attendee_self_edit + 'allow_attendee_self_edit' => $settings->allow_attendee_self_edit, + + // Waitlist settings + 'waitlist_auto_process' => $settings->waitlist_auto_process, + 'waitlist_offer_timeout_minutes' => $settings->waitlist_offer_timeout_minutes, ], where: [ 'event_id' => $settings->event_id, diff --git a/backend/app/Services/Application/Handlers/Order/GetOrderPublicHandler.php b/backend/app/Services/Application/Handlers/Order/GetOrderPublicHandler.php index ddda2fc502..0672a0e995 100644 --- a/backend/app/Services/Application/Handlers/Order/GetOrderPublicHandler.php +++ b/backend/app/Services/Application/Handlers/Order/GetOrderPublicHandler.php @@ -41,6 +41,11 @@ public function handle(GetOrderPublicDTO $getOrderData): OrderDomainObject } if ($order->getStatus() === OrderStatus::RESERVED->name) { + if ($order->getSessionId() === null) { + throw new UnauthorizedException( + __('Sorry, we could not verify your session. Please restart your order.') + ); + } $this->verifySessionId($order->getSessionId()); } diff --git a/backend/app/Services/Application/Handlers/Product/CreateProductHandler.php b/backend/app/Services/Application/Handlers/Product/CreateProductHandler.php index ff96ae0bf3..053cac6d10 100644 --- a/backend/app/Services/Application/Handlers/Product/CreateProductHandler.php +++ b/backend/app/Services/Application/Handlers/Product/CreateProductHandler.php @@ -61,6 +61,7 @@ public function handle(UpsertProductDTO $productsData): ProductDomainObject ->setIsHiddenWithoutPromoCode($productsData->is_hidden_without_promo_code) ->setIsHighlighted($productsData->is_highlighted ?? false) ->setHighlightMessage($productsData->highlight_message) + ->setWaitlistEnabled($productsData->waitlist_enabled) ->setProductPrices($productPrices) ->setEventId($productsData->event_id) ->setProductType($productsData->product_type->name) diff --git a/backend/app/Services/Application/Handlers/Product/DTO/UpsertProductDTO.php b/backend/app/Services/Application/Handlers/Product/DTO/UpsertProductDTO.php index e8e5ff6422..1980214e6e 100644 --- a/backend/app/Services/Application/Handlers/Product/DTO/UpsertProductDTO.php +++ b/backend/app/Services/Application/Handlers/Product/DTO/UpsertProductDTO.php @@ -40,6 +40,7 @@ public function __construct( public readonly ?int $product_id = null, public readonly ?bool $is_highlighted = false, public readonly ?string $highlight_message = null, + public readonly ?bool $waitlist_enabled = null, ) { } diff --git a/backend/app/Services/Application/Handlers/Product/EditProductHandler.php b/backend/app/Services/Application/Handlers/Product/EditProductHandler.php index b5fce35000..4e51bfc7f0 100644 --- a/backend/app/Services/Application/Handlers/Product/EditProductHandler.php +++ b/backend/app/Services/Application/Handlers/Product/EditProductHandler.php @@ -8,6 +8,8 @@ use HiEvents\DomainObjects\Interfaces\DomainObjectInterface; use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\DomainObjects\ProductPriceDomainObject; +use HiEvents\DomainObjects\Enums\CapacityChangeDirection; +use HiEvents\Events\CapacityChangedEvent; use HiEvents\Exceptions\CannotChangeProductTypeException; use HiEvents\Helper\DateHelper; use HiEvents\Repository\Interfaces\EventRepositoryInterface; @@ -22,6 +24,7 @@ use HiEvents\Services\Infrastructure\DomainEvents\Events\ProductEvent; use HiEvents\Services\Infrastructure\HtmlPurifier\HtmlPurifierService; use Illuminate\Database\DatabaseManager; +use Illuminate\Support\Collection; use Throwable; /** @@ -53,6 +56,8 @@ public function handle(UpsertProductDTO $productsData): DomainObjectInterface 'id' => $productsData->product_id, ]; + $oldPriceQuantities = $this->getExistingPriceQuantities($productsData->product_id); + $product = $this->updateProduct($productsData, $where); $this->addTaxes($product, $productsData); @@ -71,6 +76,11 @@ public function handle(UpsertProductDTO $productsData): DomainObjectInterface ) ); + $this->dispatchCapacityChangedEventIfQuantityChanged( + $productsData, + $oldPriceQuantities, + ); + return $this->productRepository ->loadRelation(ProductPriceDomainObject::class) ->findById($product->getId()); @@ -115,6 +125,7 @@ private function updateProduct(UpsertProductDTO $productsData, array $where): Pr 'product_category_id' => $productCategory->getId(), 'is_highlighted' => $productsData->is_highlighted ?? false, 'highlight_message' => $productsData->highlight_message, + 'waitlist_enabled' => $productsData->waitlist_enabled, ], where: $where ); @@ -138,6 +149,59 @@ private function addTaxes(ProductDomainObject $product, UpsertProductDTO $produc ); } + private function getExistingPriceQuantities(int $productId): Collection + { + $product = $this->productRepository + ->loadRelation(ProductPriceDomainObject::class) + ->findById($productId); + + return $product->getProductPrices() + ->mapWithKeys(fn(ProductPriceDomainObject $price) => [ + $price->getId() => $price->getInitialQuantityAvailable(), + ]); + } + + private function dispatchCapacityChangedEventIfQuantityChanged( + UpsertProductDTO $productsData, + Collection $oldPriceQuantities, + ): void + { + if ($productsData->prices === null) { + return; + } + + foreach ($productsData->prices as $price) { + if ($price->id === null) { + continue; + } + + $oldQuantity = $oldPriceQuantities->get($price->id); + $newQuantity = $price->initial_quantity_available; + + $direction = match (true) { + ($newQuantity === null && $oldQuantity !== null), + ($newQuantity !== null && $oldQuantity !== null && $newQuantity > $oldQuantity) + => CapacityChangeDirection::INCREASED, + ($newQuantity !== null && $oldQuantity === null), + ($newQuantity !== null && $oldQuantity !== null && $newQuantity < $oldQuantity) + => CapacityChangeDirection::DECREASED, + default => null, + }; + + if ($direction === null) { + continue; + } + + event(new CapacityChangedEvent( + eventId: $productsData->event_id, + direction: $direction, + productId: $productsData->product_id, + productPriceId: $price->id, + newCapacity: $price->initial_quantity_available, + )); + } + } + /** * @throws CannotChangeProductTypeException * @todo - We should probably check reserved products here as well diff --git a/backend/app/Services/Application/Handlers/ProductCategory/GetProductCategoriesHandler.php b/backend/app/Services/Application/Handlers/ProductCategory/GetProductCategoriesHandler.php index ccd815bb94..804b005a69 100644 --- a/backend/app/Services/Application/Handlers/ProductCategory/GetProductCategoriesHandler.php +++ b/backend/app/Services/Application/Handlers/ProductCategory/GetProductCategoriesHandler.php @@ -10,19 +10,21 @@ use HiEvents\Repository\Eloquent\Value\OrderAndDirection; use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\ProductCategoryRepositoryInterface; +use HiEvents\Services\Domain\Product\ProductFilterService; use Illuminate\Support\Collection; class GetProductCategoriesHandler { public function __construct( private readonly ProductCategoryRepositoryInterface $productCategoryRepository, + private readonly ProductFilterService $productFilterService, ) { } public function handle(int $eventId): Collection { - return $this->productCategoryRepository + $categories = $this->productCategoryRepository ->loadRelation(new Relationship( domainObject: ProductDomainObject::class, nested: [ @@ -45,5 +47,10 @@ public function handle(int $eventId): Collection ), ], ); + + return $this->productFilterService->filter( + productsCategories: $categories, + hideSoldOutProducts: false, + ); } } diff --git a/backend/app/Services/Application/Handlers/Waitlist/CancelWaitlistEntryHandler.php b/backend/app/Services/Application/Handlers/Waitlist/CancelWaitlistEntryHandler.php new file mode 100644 index 0000000000..a22d046975 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Waitlist/CancelWaitlistEntryHandler.php @@ -0,0 +1,35 @@ +cancelWaitlistEntryService->cancelByToken($cancelToken); + } + + /** + * @throws ResourceConflictException + * @throws ResourceNotFoundException + */ + public function handleCancelById(int $entryId, int $eventId): WaitlistEntryDomainObject + { + return $this->cancelWaitlistEntryService->cancelById($entryId, $eventId); + } +} diff --git a/backend/app/Services/Application/Handlers/Waitlist/CreateWaitlistEntryHandler.php b/backend/app/Services/Application/Handlers/Waitlist/CreateWaitlistEntryHandler.php new file mode 100644 index 0000000000..7813771413 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Waitlist/CreateWaitlistEntryHandler.php @@ -0,0 +1,48 @@ +eventSettingsRepository->findFirstWhere([ + 'event_id' => $dto->event_id, + ]); + + $productPrice = $this->productPriceRepository->findById($dto->product_price_id); + + $product = $this->productRepository->findFirstWhere([ + 'id' => $productPrice->getProductId(), + 'event_id' => $dto->event_id, + ]); + + if ($product === null) { + throw new ResourceNotFoundException(__('Product not found for this event')); + } + + return $this->createWaitlistEntryService->createEntry($dto, $eventSettings, $product); + } +} diff --git a/backend/app/Services/Application/Handlers/Waitlist/DTO/CreateWaitlistEntryDTO.php b/backend/app/Services/Application/Handlers/Waitlist/DTO/CreateWaitlistEntryDTO.php new file mode 100644 index 0000000000..a8da7ff6e1 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Waitlist/DTO/CreateWaitlistEntryDTO.php @@ -0,0 +1,19 @@ +waitlistEntryRepository->findByEventId($eventId, $queryParams); + } +} diff --git a/backend/app/Services/Application/Handlers/Waitlist/GetWaitlistStatsHandler.php b/backend/app/Services/Application/Handlers/Waitlist/GetWaitlistStatsHandler.php new file mode 100644 index 0000000000..69e56fab92 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Waitlist/GetWaitlistStatsHandler.php @@ -0,0 +1,65 @@ +waitlistEntryRepository->getStatsByEventId($eventId); + $productRows = $this->waitlistEntryRepository->getProductStatsByEventId($eventId); + + $quantities = $this->availableQuantitiesService->getAvailableProductQuantities($eventId, ignoreCache: true); + + $products = $productRows->map(function ($row) use ($quantities) { + $actualAvailable = $this->getAvailableCountForPrice($quantities, (int) $row->product_price_id); + $offeredCount = (int) $row->offered; + + if ($actualAvailable === Constants::INFINITE) { + $available = null; + } else { + $available = max(0, $actualAvailable - $offeredCount); + } + + return new WaitlistProductStatsDTO( + product_price_id: (int) $row->product_price_id, + product_title: $row->product_title, + waiting: (int) $row->waiting, + offered: $offeredCount, + available: $available, + ); + })->all(); + + $stats->products = $products; + + return $stats; + } + + private function getAvailableCountForPrice(object $quantities, int $priceId): int + { + foreach ($quantities->productQuantities as $productQuantity) { + if ($productQuantity->price_id === $priceId) { + $available = max(0, $productQuantity->quantity_available); + if ($available === Constants::INFINITE) { + return Constants::INFINITE; + } + return $available; + } + } + + return 0; + } +} diff --git a/backend/app/Services/Application/Handlers/Waitlist/OfferWaitlistEntryHandler.php b/backend/app/Services/Application/Handlers/Waitlist/OfferWaitlistEntryHandler.php new file mode 100644 index 0000000000..1a6ad7bf2b --- /dev/null +++ b/backend/app/Services/Application/Handlers/Waitlist/OfferWaitlistEntryHandler.php @@ -0,0 +1,49 @@ +eventSettingsRepository->findFirstWhere([ + 'event_id' => $dto->event_id, + ]); + + $event = $this->eventRepository + ->loadRelation(new Relationship(EventSettingDomainObject::class)) + ->findById($dto->event_id); + + if ($dto->entry_id !== null) { + return $this->processWaitlistService->offerSpecificEntry( + entryId: $dto->entry_id, + eventId: $dto->event_id, + event: $event, + eventSettings: $eventSettings, + ); + } + + return $this->processWaitlistService->offerToNext( + productPriceId: $dto->product_price_id, + quantity: $dto->quantity, + event: $event, + eventSettings: $eventSettings, + ); + } +} diff --git a/backend/app/Services/Domain/Event/CreateEventService.php b/backend/app/Services/Domain/Event/CreateEventService.php index 6262fb5b87..7126eda943 100644 --- a/backend/app/Services/Domain/Event/CreateEventService.php +++ b/backend/app/Services/Domain/Event/CreateEventService.php @@ -227,6 +227,9 @@ private function createEventSettings( 'ticket_design_settings' => [ 'accent_color' => $homepageThemeSettings['accent'] ?? '#333', ], + + 'waitlist_auto_process' => true, + 'waitlist_offer_timeout_minutes' => 120, ]); } } diff --git a/backend/app/Services/Domain/Order/OrderCancelService.php b/backend/app/Services/Domain/Order/OrderCancelService.php index d1677ed502..0c0c904431 100644 --- a/backend/app/Services/Domain/Order/OrderCancelService.php +++ b/backend/app/Services/Domain/Order/OrderCancelService.php @@ -8,6 +8,8 @@ use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\DomainObjects\Status\AttendeeStatus; use HiEvents\DomainObjects\Status\OrderStatus; +use HiEvents\DomainObjects\Enums\CapacityChangeDirection; +use HiEvents\Events\CapacityChangedEvent; use HiEvents\Mail\Order\OrderCancelled; use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; @@ -71,6 +73,8 @@ public function cancelOrder(OrderDomainObject $order): void orderId: $order->getId(), ), ); + + $this->dispatchCapacityChangedEvents($order); }); } @@ -118,4 +122,23 @@ private function updateOrderStatus(OrderDomainObject $order): void ] ); } + + private function dispatchCapacityChangedEvents(OrderDomainObject $order): void + { + $attendees = $this->attendeeRepository->findWhere([ + 'order_id' => $order->getId(), + ]); + + $productIds = $attendees + ->map(fn(AttendeeDomainObject $attendee) => $attendee->getProductId()) + ->unique(); + + foreach ($productIds as $productId) { + event(new CapacityChangedEvent( + eventId: $order->getEventId(), + direction: CapacityChangeDirection::INCREASED, + productId: $productId, + )); + } + } } diff --git a/backend/app/Services/Domain/Product/CreateProductService.php b/backend/app/Services/Domain/Product/CreateProductService.php index f87f1a45ac..ee7bbb8727 100644 --- a/backend/app/Services/Domain/Product/CreateProductService.php +++ b/backend/app/Services/Domain/Product/CreateProductService.php @@ -93,6 +93,7 @@ private function persistProduct(ProductDomainObject $productsData): ProductDomai 'product_category_id' => $productsData->getProductCategoryId(), 'is_highlighted' => $productsData->getIsHighlighted(), 'highlight_message' => $productsData->getHighlightMessage(), + 'waitlist_enabled' => $productsData->getWaitlistEnabled(), ]); } diff --git a/backend/app/Services/Domain/Waitlist/CancelWaitlistEntryService.php b/backend/app/Services/Domain/Waitlist/CancelWaitlistEntryService.php new file mode 100644 index 0000000000..fa8aa6d541 --- /dev/null +++ b/backend/app/Services/Domain/Waitlist/CancelWaitlistEntryService.php @@ -0,0 +1,112 @@ + $cancelToken]; + + if ($eventId !== null) { + $conditions['event_id'] = $eventId; + } + + $entry = $this->waitlistEntryRepository->findFirstWhere($conditions); + + if ($entry === null) { + throw new ResourceNotFoundException(__('Waitlist entry not found')); + } + + return $this->cancelEntry($entry); + } + + /** + * @throws ResourceConflictException + * @throws ResourceNotFoundException + */ + public function cancelById(int $entryId, int $eventId): WaitlistEntryDomainObject + { + $entry = $this->waitlistEntryRepository->findFirstWhere([ + 'id' => $entryId, + 'event_id' => $eventId, + ]); + + if ($entry === null) { + throw new ResourceNotFoundException(__('Waitlist entry not found')); + } + + return $this->cancelEntry($entry); + } + + /** + * @throws ResourceConflictException + */ + private function cancelEntry(WaitlistEntryDomainObject $entry): WaitlistEntryDomainObject + { + if (!in_array($entry->getStatus(), [ + WaitlistEntryStatus::WAITING->name, + WaitlistEntryStatus::OFFERED->name, + ], true)) { + throw new ResourceConflictException(__('This waitlist entry cannot be cancelled')); + } + + return $this->databaseManager->transaction(function () use ($entry) { + $wasOffered = $entry->getStatus() === WaitlistEntryStatus::OFFERED->name; + + if ($entry->getOrderId() !== null) { + $this->orderRepository->deleteWhere([ + 'id' => $entry->getOrderId(), + 'status' => OrderStatus::RESERVED->name, + ]); + } + + $this->waitlistEntryRepository->updateWhere( + attributes: [ + 'status' => WaitlistEntryStatus::CANCELLED->name, + 'cancelled_at' => now(), + 'order_id' => null, + ], + where: ['id' => $entry->getId()], + ); + + if ($wasOffered) { + $productPrice = $this->productPriceRepository->findById($entry->getProductPriceId()); + + event(new CapacityChangedEvent( + eventId: $entry->getEventId(), + direction: CapacityChangeDirection::INCREASED, + productId: $productPrice->getProductId(), + productPriceId: $entry->getProductPriceId(), + )); + } + + return $this->waitlistEntryRepository->findById($entry->getId()); + }); + } +} diff --git a/backend/app/Services/Domain/Waitlist/CreateWaitlistEntryService.php b/backend/app/Services/Domain/Waitlist/CreateWaitlistEntryService.php new file mode 100644 index 0000000000..62971e7149 --- /dev/null +++ b/backend/app/Services/Domain/Waitlist/CreateWaitlistEntryService.php @@ -0,0 +1,95 @@ +validateWaitlistEnabled($product); + $this->validateNoDuplicate($dto); + + /** @var WaitlistEntryDomainObject $entry */ + $entry = $this->databaseManager->transaction(function () use ($dto) { + $this->waitlistEntryRepository->lockForProductPrice($dto->product_price_id); + $position = $this->calculatePosition($dto); + + return $this->waitlistEntryRepository->create([ + 'event_id' => $dto->event_id, + 'product_price_id' => $dto->product_price_id, + 'email' => strtolower(trim($dto->email)), + 'first_name' => trim($dto->first_name), + 'last_name' => $dto->last_name ? trim($dto->last_name) : null, + 'status' => WaitlistEntryStatus::WAITING->name, + 'cancel_token' => Str::random(64), + 'position' => $position, + 'locale' => $dto->locale, + ]); + }); + + SendWaitlistConfirmationEmailJob::dispatch($entry); + + return $entry; + } + + /** + * @throws ResourceConflictException + */ + private function validateWaitlistEnabled(ProductDomainObject $product): void + { + if ($product->getWaitlistEnabled() === false) { + throw new ResourceConflictException(__('Waitlist is not enabled for this product')); + } + } + + /** + * @throws ResourceConflictException + */ + private function validateNoDuplicate(CreateWaitlistEntryDTO $dto): void + { + $conditions = [ + 'email' => strtolower(trim($dto->email)), + 'event_id' => $dto->event_id, + ['status', 'in', [WaitlistEntryStatus::WAITING->name, WaitlistEntryStatus::OFFERED->name]], + 'product_price_id' => $dto->product_price_id, + ]; + + $existing = $this->waitlistEntryRepository->findFirstWhere($conditions); + + if ($existing !== null) { + throw new ResourceConflictException( + __('You are already on the waitlist for this product') + ); + } + } + + private function calculatePosition(CreateWaitlistEntryDTO $dto): int + { + return $this->waitlistEntryRepository->getMaxPosition($dto->product_price_id) + 1; + } +} diff --git a/backend/app/Services/Domain/Waitlist/ProcessWaitlistService.php b/backend/app/Services/Domain/Waitlist/ProcessWaitlistService.php new file mode 100644 index 0000000000..beaaf73a1a --- /dev/null +++ b/backend/app/Services/Domain/Waitlist/ProcessWaitlistService.php @@ -0,0 +1,255 @@ + + */ + public function offerToNext( + int $productPriceId, + int $quantity, + EventDomainObject $event, + EventSettingDomainObject $eventSettings, + ): Collection + { + return $this->databaseManager->transaction(function () use ($productPriceId, $quantity, $event, $eventSettings) { + $this->waitlistEntryRepository->lockForProductPrice($productPriceId); + + $quantities = $this->availableQuantitiesService->getAvailableProductQuantities( + $event->getId(), + ignoreCache: true, + ); + + $actualAvailable = $this->getAvailableCountForPrice($quantities, $productPriceId); + + $currentlyOfferedCount = $this->waitlistEntryRepository->countWhere([ + 'product_price_id' => $productPriceId, + 'status' => WaitlistEntryStatus::OFFERED->name, + ]); + + $effectiveAvailable = $actualAvailable - $currentlyOfferedCount; + + if ($effectiveAvailable <= 0) { + throw new NoCapacityAvailableException( + __('No capacity available. Available: :available, reserved by pending offers: :offered', [ + 'available' => $actualAvailable, + 'offered' => $currentlyOfferedCount, + ]) + ); + } + + $toOffer = min($quantity, $effectiveAvailable); + $entries = $this->waitlistEntryRepository->getNextWaitingEntries($productPriceId, $toOffer); + + if ($entries->isEmpty()) { + throw new NoCapacityAvailableException( + __('There are no waiting entries for this product') + ); + } + + $offeredEntries = collect(); + + foreach ($entries as $entry) { + $updatedEntry = $this->offerEntry($entry, $event, $eventSettings); + $offeredEntries->push($updatedEntry); + } + + return $offeredEntries; + }); + } + + /** + * @return Collection + */ + public function offerSpecificEntry( + int $entryId, + int $eventId, + EventDomainObject $event, + EventSettingDomainObject $eventSettings, + ): Collection + { + return $this->databaseManager->transaction(function () use ($entryId, $eventId, $event, $eventSettings) { + /** @var WaitlistEntryDomainObject|null $entry */ + $entry = $this->waitlistEntryRepository->findFirstWhere([ + 'id' => $entryId, + 'event_id' => $eventId, + ]); + + if ($entry === null) { + throw new ResourceNotFoundException(__('Waitlist entry not found')); + } + + $validStatuses = [WaitlistEntryStatus::WAITING->name, WaitlistEntryStatus::OFFER_EXPIRED->name]; + if (!in_array($entry->getStatus(), $validStatuses, true)) { + throw new ResourceConflictException( + __('This waitlist entry cannot be offered in its current status') + ); + } + + $this->waitlistEntryRepository->lockForProductPrice($entry->getProductPriceId()); + + $quantities = $this->availableQuantitiesService->getAvailableProductQuantities( + $event->getId(), + ignoreCache: true, + ); + + $actualAvailable = $this->getAvailableCountForPrice($quantities, $entry->getProductPriceId()); + + $currentlyOfferedCount = $this->waitlistEntryRepository->countWhere([ + 'product_price_id' => $entry->getProductPriceId(), + 'status' => WaitlistEntryStatus::OFFERED->name, + ]); + + if ($actualAvailable <= $currentlyOfferedCount) { + throw new NoCapacityAvailableException( + __('No capacity available to offer this waitlist entry. You will need to increase the available quantity for the product. Available: :available, already offered: :offered', [ + 'available' => $actualAvailable, + 'offered' => $currentlyOfferedCount, + ]) + ); + } + + $updatedEntry = $this->offerEntry($entry, $event, $eventSettings); + + return collect([$updatedEntry]); + }); + } + + private function offerEntry( + WaitlistEntryDomainObject $entry, + EventDomainObject $event, + EventSettingDomainObject $eventSettings, + ): WaitlistEntryDomainObject + { + $offerExpiresAt = $this->calculateOfferExpiry($eventSettings); + $sessionIdentifier = sha1(Str::uuid() . Str::random(40)); + $order = $this->createReservedOrder($entry, $event, $eventSettings, $sessionIdentifier); + + $this->waitlistEntryRepository->updateWhere( + attributes: [ + 'status' => WaitlistEntryStatus::OFFERED->name, + 'offer_token' => Str::random(64), + 'offered_at' => now(), + 'offer_expires_at' => $offerExpiresAt, + 'order_id' => $order->getId(), + ], + where: ['id' => $entry->getId()], + ); + + /** @var WaitlistEntryDomainObject $updatedEntry */ + $updatedEntry = $this->waitlistEntryRepository->findById($entry->getId()); + + SendWaitlistOfferEmailJob::dispatch($updatedEntry, $order->getShortId(), $sessionIdentifier); + + return $updatedEntry; + } + + private function createReservedOrder( + WaitlistEntryDomainObject $entry, + EventDomainObject $event, + EventSettingDomainObject $eventSettings, + string $sessionIdentifier, + ): OrderDomainObject + { + $timeoutMinutes = $eventSettings->getWaitlistOfferTimeoutMinutes() ?? self::DEFAULT_OFFER_TIMEOUT_MINUTES; + + $order = $this->orderManagementService->createNewOrder( + eventId: $event->getId(), + event: $event, + timeOutMinutes: $timeoutMinutes, + locale: $entry->getLocale(), + promoCode: null, + sessionId: $sessionIdentifier, + ); + + $productPrice = $this->productPriceRepository->findById($entry->getProductPriceId()); + + $product = $this->productRepository + ->loadRelation(TaxAndFeesDomainObject::class) + ->loadRelation(ProductPriceDomainObject::class) + ->findById($productPrice->getProductId()); + + $orderDetails = collect([ + new ProductOrderDetailsDTO( + product_id: $product->getId(), + quantities: collect([ + new OrderProductPriceDTO( + quantity: 1, + price_id: $productPrice->getId(), + ), + ]), + ), + ]); + + $orderItems = $this->orderItemProcessingService->process( + order: $order, + productsOrderDetails: $orderDetails, + event: $event, + promoCode: null, + ); + + return $this->orderManagementService->updateOrderTotals($order, $orderItems); + } + + private function getAvailableCountForPrice(object $quantities, int $priceId): int + { + foreach ($quantities->productQuantities as $productQuantity) { + if ($productQuantity->price_id === $priceId) { + $available = max(0, $productQuantity->quantity_available); + if ($available === Constants::INFINITE) { + return Constants::INFINITE; + } + return $available; + } + } + + return 0; + } + + private function calculateOfferExpiry(EventSettingDomainObject $eventSettings): string + { + $timeoutMinutes = $eventSettings->getWaitlistOfferTimeoutMinutes() ?? self::DEFAULT_OFFER_TIMEOUT_MINUTES; + + return now()->addMinutes($timeoutMinutes)->toDateTimeString(); + } +} diff --git a/backend/config/app.php b/backend/config/app.php index 99344b4d8b..f5c6bcbaa1 100644 --- a/backend/config/app.php +++ b/backend/config/app.php @@ -51,6 +51,7 @@ 'event_homepage' => '/event/%d/%s', 'attendee_product' => '/product/%d/%s', 'order_summary' => '/checkout/%d/%s/summary', + 'order_details' => '/checkout/%d/%s/details', 'organizer_order_summary' => '/manage/event/%d/orders#order-%d', 'ticket_lookup' => '/my-tickets/%s', ], diff --git a/backend/database/migrations/2026_02_15_000001_create_waitlist_entries_table.php b/backend/database/migrations/2026_02_15_000001_create_waitlist_entries_table.php new file mode 100644 index 0000000000..50bc459cb6 --- /dev/null +++ b/backend/database/migrations/2026_02_15_000001_create_waitlist_entries_table.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('event_id')->constrained('events')->onDelete('cascade'); + $table->foreignId('product_id')->constrained('products')->onDelete('cascade'); + $table->string('email', 255); + $table->string('first_name', 255); + $table->string('last_name', 255)->nullable(); + $table->string('status', 50); + $table->string('offer_token', 100)->unique()->nullable(); + $table->string('cancel_token', 100)->unique()->nullable(); + $table->timestamp('offered_at')->nullable(); + $table->timestamp('offer_expires_at')->nullable(); + $table->timestamp('purchased_at')->nullable(); + $table->timestamp('cancelled_at')->nullable(); + $table->foreignId('order_id')->nullable()->constrained('orders')->onDelete('set null'); + $table->integer('position')->default(0); + $table->string('locale', 10)->default('en'); + $table->timestamps(); + $table->softDeletes(); + + $table->index('event_id'); + $table->index('product_id'); + $table->index('status'); + $table->index(['event_id', 'status']); + $table->index(['product_id', 'status']); + $table->index(['email', 'product_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('waitlist_entries'); + } +}; diff --git a/backend/database/migrations/2026_02_15_000002_add_waitlist_settings_to_event_settings_and_products.php b/backend/database/migrations/2026_02_15_000002_add_waitlist_settings_to_event_settings_and_products.php new file mode 100644 index 0000000000..cd41776f8f --- /dev/null +++ b/backend/database/migrations/2026_02_15_000002_add_waitlist_settings_to_event_settings_and_products.php @@ -0,0 +1,35 @@ +boolean('waitlist_enabled')->default(false); + $table->boolean('waitlist_auto_process')->default(false); + $table->integer('waitlist_offer_timeout_minutes')->nullable()->default(null); + }); + + Schema::table('products', function (Blueprint $table) { + $table->boolean('waitlist_enabled')->nullable()->default(null); + }); + } + + public function down(): void + { + Schema::table('event_settings', function (Blueprint $table) { + $table->dropColumn([ + 'waitlist_enabled', + 'waitlist_auto_process', + 'waitlist_offer_timeout_minutes', + ]); + }); + + Schema::table('products', function (Blueprint $table) { + $table->dropColumn('waitlist_enabled'); + }); + } +}; diff --git a/backend/database/migrations/2026_02_16_000001_add_waitlist_performance_indexes.php b/backend/database/migrations/2026_02_16_000001_add_waitlist_performance_indexes.php new file mode 100644 index 0000000000..d729fef536 --- /dev/null +++ b/backend/database/migrations/2026_02_16_000001_add_waitlist_performance_indexes.php @@ -0,0 +1,33 @@ +index('offer_expires_at', 'idx_offer_expires_at'); + $table->index(['product_id', 'status', 'position'], 'idx_product_status_position'); + }); + + DB::statement(" + CREATE UNIQUE INDEX idx_unique_email_product_status + ON waitlist_entries (email, product_id, status) + WHERE status IN ('WAITING', 'OFFERED') + "); + } + + public function down(): void + { + Schema::table('waitlist_entries', function (Blueprint $table) { + $table->dropIndex('idx_offer_expires_at'); + $table->dropIndex('idx_product_status_position'); + }); + + DB::statement('DROP INDEX IF EXISTS idx_unique_email_product_status'); + } +}; diff --git a/backend/database/migrations/2026_02_18_000001_change_waitlist_product_id_to_product_price_id.php b/backend/database/migrations/2026_02_18_000001_change_waitlist_product_id_to_product_price_id.php new file mode 100644 index 0000000000..36b7f870c7 --- /dev/null +++ b/backend/database/migrations/2026_02_18_000001_change_waitlist_product_id_to_product_price_id.php @@ -0,0 +1,76 @@ +dropIndex('waitlist_entries_email_product_id_index'); + $table->dropIndex('waitlist_entries_product_id_status_index'); + $table->dropIndex('waitlist_entries_product_id_index'); + + $table->dropForeign(['product_id']); + + $table->renameColumn('product_id', 'product_price_id'); + }); + + Schema::table('waitlist_entries', function (Blueprint $table) { + $table->foreign('product_price_id') + ->references('id') + ->on('product_prices') + ->onDelete('cascade'); + + $table->index('product_price_id'); + $table->index(['product_price_id', 'status']); + $table->index(['email', 'product_price_id']); + $table->index(['product_price_id', 'status', 'position'], 'idx_product_price_status_position'); + }); + + DB::statement(" + CREATE UNIQUE INDEX idx_unique_email_product_price_status + ON waitlist_entries (email, product_price_id, status) + WHERE status IN ('WAITING', 'OFFERED') + "); + } + + public function down(): void + { + DB::statement('DROP INDEX IF EXISTS idx_unique_email_product_price_status'); + + Schema::table('waitlist_entries', function (Blueprint $table) { + $table->dropIndex('idx_product_price_status_position'); + $table->dropIndex(['email', 'product_price_id']); + $table->dropIndex(['product_price_id', 'status']); + $table->dropIndex(['product_price_id']); + + $table->dropForeign(['product_price_id']); + + $table->renameColumn('product_price_id', 'product_id'); + }); + + Schema::table('waitlist_entries', function (Blueprint $table) { + $table->foreign('product_id') + ->references('id') + ->on('products') + ->onDelete('cascade'); + + $table->index('product_id'); + $table->index(['product_id', 'status']); + $table->index(['email', 'product_id']); + $table->index(['product_id', 'status', 'position'], 'idx_product_status_position'); + }); + + DB::statement(" + CREATE UNIQUE INDEX idx_unique_email_product_status + ON waitlist_entries (email, product_id, status) + WHERE status IN ('WAITING', 'OFFERED') + "); + } +}; diff --git a/backend/resources/views/emails/waitlist/confirmation.blade.php b/backend/resources/views/emails/waitlist/confirmation.blade.php new file mode 100644 index 0000000000..5881800296 --- /dev/null +++ b/backend/resources/views/emails/waitlist/confirmation.blade.php @@ -0,0 +1,33 @@ +@php /** @var \HiEvents\DomainObjects\WaitlistEntryDomainObject $entry */ @endphp +@php /** @var \HiEvents\DomainObjects\EventDomainObject $event */ @endphp +@php /** @var ?string $productName */ @endphp +@php /** @var \HiEvents\DomainObjects\OrganizerDomainObject $organizer */ @endphp +@php /** @var \HiEvents\DomainObjects\EventSettingDomainObject $eventSettings */ @endphp +@php /** @var string $eventUrl */ @endphp + +@php /** @see \HiEvents\Mail\Waitlist\WaitlistConfirmationMail */ @endphp + + +# {{ __("You're on the waitlist!") }} + +{{ __('Hello') }}, + +@if($productName) +{{ __("You have been added to the waitlist for **:product** for the event **:event**.", ['product' => $productName, 'event' => $event->getTitle()]) }} +@else +{{ __("You have been added to the waitlist for the event **:event**.", ['event' => $event->getTitle()]) }} +@endif + +{{ __("We'll notify you as soon as a spot becomes available.") }} + + +{{ __('View Event') }} + + +{{ __('If you have any questions or need assistance, please respond to this email.') }} + +{{ __('Thank you') }},
+{{ $organizer->getName() ?: config('app.name') }} + +{!! $eventSettings->getGetEmailFooterHtml() !!} +
diff --git a/backend/resources/views/emails/waitlist/offer-expired.blade.php b/backend/resources/views/emails/waitlist/offer-expired.blade.php new file mode 100644 index 0000000000..2e530b286c --- /dev/null +++ b/backend/resources/views/emails/waitlist/offer-expired.blade.php @@ -0,0 +1,33 @@ +@php /** @var \HiEvents\DomainObjects\WaitlistEntryDomainObject $entry */ @endphp +@php /** @var \HiEvents\DomainObjects\EventDomainObject $event */ @endphp +@php /** @var ?string $productName */ @endphp +@php /** @var \HiEvents\DomainObjects\OrganizerDomainObject $organizer */ @endphp +@php /** @var \HiEvents\DomainObjects\EventSettingDomainObject $eventSettings */ @endphp +@php /** @var string $eventUrl */ @endphp + +@php /** @see \HiEvents\Mail\Waitlist\WaitlistOfferExpiredMail */ @endphp + + +# {{ __('Your waitlist offer has expired') }} + +{{ __('Hello') }}, + +@if($productName) +{{ __('Unfortunately, your waitlist offer for **:product** for the event **:event** has expired.', ['product' => $productName, 'event' => $event->getTitle()]) }} +@else +{{ __('Unfortunately, your waitlist offer for the event **:event** has expired.', ['event' => $event->getTitle()]) }} +@endif + +{{ __('If you are still interested, you may rejoin the waitlist from the event page.') }} + + +{{ __('View Event') }} + + +{{ __('If you have any questions or need assistance, please respond to this email.') }} + +{{ __('Thank you') }},
+{{ $organizer->getName() ?: config('app.name') }} + +{!! $eventSettings->getGetEmailFooterHtml() !!} +
diff --git a/backend/resources/views/emails/waitlist/offer.blade.php b/backend/resources/views/emails/waitlist/offer.blade.php new file mode 100644 index 0000000000..2174f77f3a --- /dev/null +++ b/backend/resources/views/emails/waitlist/offer.blade.php @@ -0,0 +1,37 @@ +@php /** @var \HiEvents\DomainObjects\WaitlistEntryDomainObject $entry */ @endphp +@php /** @var \HiEvents\DomainObjects\EventDomainObject $event */ @endphp +@php /** @var ?string $productName */ @endphp +@php /** @var \HiEvents\DomainObjects\OrganizerDomainObject $organizer */ @endphp +@php /** @var \HiEvents\DomainObjects\EventSettingDomainObject $eventSettings */ @endphp +@php /** @var string $checkoutUrl */ @endphp + +@php /** @see \HiEvents\Mail\Waitlist\WaitlistOfferMail */ @endphp + + +# {{ __('A spot has opened up!') }} + +{{ __('Hello') }}, + +@if($productName) +{{ __('Great news! A spot has become available for **:product** for the event **:event**.', ['product' => $productName, 'event' => $event->getTitle()]) }} +@else +{{ __('Great news! A spot has become available for the event **:event**.', ['event' => $event->getTitle()]) }} +@endif + +{{ __('An order has been reserved for you. Click the button below to complete your purchase.') }} + +@if($offerExpiresAtFormatted) +{{ __('This offer expires on :date. Please complete your order before it expires.', ['date' => $offerExpiresAtFormatted]) }} +@endif + + +{{ __('Complete Your Order') }} + + +{{ __('If you have any questions or need assistance, please respond to this email.') }} + +{{ __('Thank you') }},
+{{ $organizer->getName() ?: config('app.name') }} + +{!! $eventSettings->getGetEmailFooterHtml() !!} +
diff --git a/backend/routes/api.php b/backend/routes/api.php index 1753929505..61c631ef32 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -192,6 +192,12 @@ use HiEvents\Http\Actions\Admin\Users\StopImpersonationAction; use HiEvents\Http\Actions\TicketLookup\GetOrdersByLookupTokenAction; use HiEvents\Http\Actions\TicketLookup\SendTicketLookupEmailAction; +use HiEvents\Http\Actions\Waitlist\Organizer\CancelWaitlistEntryAction; +use HiEvents\Http\Actions\Waitlist\Organizer\GetWaitlistEntriesAction; +use HiEvents\Http\Actions\Waitlist\Organizer\GetWaitlistStatsAction; +use HiEvents\Http\Actions\Waitlist\Organizer\OfferWaitlistEntryAction; +use HiEvents\Http\Actions\Waitlist\Public\CancelWaitlistEntryActionPublic; +use HiEvents\Http\Actions\Waitlist\Public\CreateWaitlistEntryActionPublic; use HiEvents\Http\Actions\Webhooks\CreateWebhookAction; use HiEvents\Http\Actions\Webhooks\DeleteWebhookAction; use HiEvents\Http\Actions\Webhooks\EditWebhookAction; @@ -408,6 +414,12 @@ function (Router $router): void { // Reports $router->get('/events/{event_id}/reports/{report_type}', GetReportAction::class); + // Waitlist + $router->get('/events/{event_id}/waitlist', GetWaitlistEntriesAction::class); + $router->get('/events/{event_id}/waitlist/stats', GetWaitlistStatsAction::class); + $router->post('/events/{event_id}/waitlist/offer-next', OfferWaitlistEntryAction::class); + $router->delete('/events/{event_id}/waitlist/{entry_id}', CancelWaitlistEntryAction::class); + // Images $router->post('/images', CreateImageAction::class); $router->delete('/images/{image_id}', DeleteImageAction::class); @@ -478,6 +490,12 @@ function (Router $router): void { // Attendees $router->get('/events/{event_id}/attendees/{attendee_short_id}', GetAttendeeActionPublic::class); + // Waitlist + $router->post('/events/{event_id}/waitlist', CreateWaitlistEntryActionPublic::class) + ->middleware('throttle:10,1'); + $router->delete('/events/{event_id}/waitlist/{token}', CancelWaitlistEntryActionPublic::class) + ->middleware('throttle:10,1'); + // Promo codes $router->get('/events/{event_id}/promo-codes/{promo_code}', GetPromoCodePublic::class); diff --git a/backend/tests/Unit/Jobs/Waitlist/ProcessExpiredWaitlistOffersJobTest.php b/backend/tests/Unit/Jobs/Waitlist/ProcessExpiredWaitlistOffersJobTest.php new file mode 100644 index 0000000000..f26670779a --- /dev/null +++ b/backend/tests/Unit/Jobs/Waitlist/ProcessExpiredWaitlistOffersJobTest.php @@ -0,0 +1,183 @@ +repository = m::mock(WaitlistEntryRepositoryInterface::class); + $this->orderRepository = m::mock(OrderRepositoryInterface::class); + $this->productPriceRepository = m::mock(ProductPriceRepositoryInterface::class); + + $productPrice = new ProductPriceDomainObject(); + $productPrice->setId(20); + $productPrice->setProductId(99); + + $this->productPriceRepository + ->shouldReceive('findById') + ->with(20) + ->andReturn($productPrice); + } + + public function testProcessesExpiredOffersAndDispatchesEmailAndEvent(): void + { + Bus::fake(); + Event::fake(); + + $entry = new WaitlistEntryDomainObject(); + $entry->setId(1); + $entry->setEventId(10); + $entry->setProductPriceId(20); + $entry->setOrderId(100); + $entry->setStatus(WaitlistEntryStatus::OFFERED->name); + + $this->repository + ->shouldReceive('findWhere') + ->once() + ->andReturn(new Collection([$entry])); + + $this->orderRepository + ->shouldReceive('deleteWhere') + ->once() + ->with([ + 'id' => 100, + 'status' => OrderStatus::RESERVED->name, + ]); + + $this->repository + ->shouldReceive('updateWhere') + ->once() + ->with( + m::on(function ($attributes) { + return $attributes['status'] === WaitlistEntryStatus::OFFER_EXPIRED->name + && $attributes['offer_token'] === null + && $attributes['offered_at'] === null + && $attributes['offer_expires_at'] === null + && $attributes['order_id'] === null; + }), + ['id' => 1], + ); + + $job = new ProcessExpiredWaitlistOffersJob(); + $job->handle($this->repository, $this->orderRepository, $this->productPriceRepository); + + Bus::assertDispatched(SendWaitlistOfferExpiredEmailJob::class); + Event::assertDispatched(CapacityChangedEvent::class, function ($event) { + return $event->eventId === 10 && $event->productId === 99; + }); + } + + public function testSkipsOrderDeletionWhenNoOrderId(): void + { + Bus::fake(); + Event::fake(); + + $entry = new WaitlistEntryDomainObject(); + $entry->setId(2); + $entry->setEventId(10); + $entry->setProductPriceId(20); + $entry->setOrderId(null); + $entry->setStatus(WaitlistEntryStatus::OFFERED->name); + + $this->repository + ->shouldReceive('findWhere') + ->once() + ->andReturn(new Collection([$entry])); + + $this->orderRepository->shouldNotReceive('deleteWhere'); + + $this->repository + ->shouldReceive('updateWhere') + ->once(); + + $job = new ProcessExpiredWaitlistOffersJob(); + $job->handle($this->repository, $this->orderRepository, $this->productPriceRepository); + + Bus::assertDispatched(SendWaitlistOfferExpiredEmailJob::class); + Event::assertDispatched(CapacityChangedEvent::class); + } + + public function testDoesNothingWhenNoExpiredEntries(): void + { + Bus::fake(); + Event::fake(); + + $this->repository + ->shouldReceive('findWhere') + ->once() + ->andReturn(new Collection()); + + $job = new ProcessExpiredWaitlistOffersJob(); + $job->handle($this->repository, $this->orderRepository, $this->productPriceRepository); + + Bus::assertNotDispatched(SendWaitlistOfferExpiredEmailJob::class); + Event::assertNotDispatched(CapacityChangedEvent::class); + } + + public function testCatchesExceptionAndLogsError(): void + { + Event::fake(); + Bus::fake(); + + $logged = false; + Log::shouldReceive('error') + ->once() + ->with('Failed to process expired waitlist offer', m::on(function ($context) use (&$logged) { + $logged = true; + return $context['entry_id'] === 1 && isset($context['error']); + })); + + $entry = new WaitlistEntryDomainObject(); + $entry->setId(1); + $entry->setEventId(10); + $entry->setProductPriceId(20); + $entry->setOrderId(100); + $entry->setStatus(WaitlistEntryStatus::OFFERED->name); + + $this->repository + ->shouldReceive('findWhere') + ->once() + ->andReturn(new Collection([$entry])); + + $this->orderRepository + ->shouldReceive('deleteWhere') + ->once() + ->andThrow(new \RuntimeException('DB connection lost')); + + $job = new ProcessExpiredWaitlistOffersJob(); + $job->handle($this->repository, $this->orderRepository, $this->productPriceRepository); + + $this->assertTrue($logged, 'Error was logged for failed expired offer processing'); + Bus::assertNotDispatched(SendWaitlistOfferExpiredEmailJob::class); + Event::assertNotDispatched(CapacityChangedEvent::class); + } + + protected function tearDown(): void + { + m::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/Order/CompleteOrderHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Order/CompleteOrderHandlerTest.php index 709ed69e54..5fefab7699 100644 --- a/backend/tests/Unit/Services/Application/Handlers/Order/CompleteOrderHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/Order/CompleteOrderHandlerTest.php @@ -29,6 +29,7 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Queue; use Mockery; @@ -162,6 +163,8 @@ public function testHandleThrowsResourceConflictExceptionWhenOrderExpired(): voi public function testHandleUpdatesProductQuantitiesForFreeOrder(): void { + Event::fake(); + $orderShortId = 'ABC123'; $orderData = $this->createMockCompleteOrderDTO(); $order = $this->createMockOrder(); @@ -285,7 +288,7 @@ private function createMockCompleteOrderDTO(): CompleteOrderDTO return new CompleteOrderDTO( order: $orderDTO, products: new Collection([$attendeeDTO]) - ,event_id: 1 + , event_id: 1 ); } diff --git a/backend/tests/Unit/Services/Domain/Order/OrderCancelServiceTest.php b/backend/tests/Unit/Services/Domain/Order/OrderCancelServiceTest.php index ece4664ba2..61a44df095 100644 --- a/backend/tests/Unit/Services/Domain/Order/OrderCancelServiceTest.php +++ b/backend/tests/Unit/Services/Domain/Order/OrderCancelServiceTest.php @@ -8,6 +8,7 @@ use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\DomainObjects\Status\AttendeeStatus; +use HiEvents\Events\CapacityChangedEvent; use HiEvents\Mail\Order\OrderCancelled; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; @@ -21,6 +22,7 @@ use Illuminate\Contracts\Mail\Mailer; use Illuminate\Database\DatabaseManager; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Event; use Mockery as m; use Tests\TestCase; use Throwable; @@ -64,6 +66,8 @@ protected function setUp(): void public function testCancelOrder(): void { + Event::fake(); + $order = m::mock(OrderDomainObject::class); $order->shouldReceive('getEventId')->andReturn(1); $order->shouldReceive('getId')->andReturn(1); @@ -72,14 +76,19 @@ public function testCancelOrder(): void $order->shouldReceive('getLocale')->andReturn('en'); - $attendees = new Collection([ - m::mock(AttendeeDomainObject::class)->shouldReceive('getproductPriceId')->andReturn(1)->mock(), - m::mock(AttendeeDomainObject::class)->shouldReceive('getproductPriceId')->andReturn(2)->mock(), - ]); + $attendee1 = m::mock(AttendeeDomainObject::class); + $attendee1->shouldReceive('getproductPriceId')->andReturn(1); + $attendee1->shouldReceive('getProductId')->andReturn(10); + + $attendee2 = m::mock(AttendeeDomainObject::class); + $attendee2->shouldReceive('getproductPriceId')->andReturn(2); + $attendee2->shouldReceive('getProductId')->andReturn(20); + + $attendees = new Collection([$attendee1, $attendee2]); $this->attendeeRepository ->shouldReceive('findWhere') - ->once() + ->twice() ->with([ 'order_id' => $order->getId(), ]) @@ -138,11 +147,19 @@ public function testCancelOrder(): void $this->fail("Failed to cancel order: " . $e->getMessage()); } - $this->assertTrue(true, "Order cancellation proceeded without throwing an exception."); + Event::assertDispatched(CapacityChangedEvent::class, 2); + Event::assertDispatched(CapacityChangedEvent::class, function ($e) { + return $e->eventId === 1 && $e->productId === 10; + }); + Event::assertDispatched(CapacityChangedEvent::class, function ($e) { + return $e->eventId === 1 && $e->productId === 20; + }); } public function testCancelOrderAwaitingOfflinePayment(): void { + Event::fake(); + $order = m::mock(OrderDomainObject::class); $order->shouldReceive('getEventId')->andReturn(1); $order->shouldReceive('getId')->andReturn(1); @@ -150,14 +167,19 @@ public function testCancelOrderAwaitingOfflinePayment(): void $order->shouldReceive('isOrderAwaitingOfflinePayment')->andReturn(true); $order->shouldReceive('getLocale')->andReturn('en'); - $attendees = new Collection([ - m::mock(AttendeeDomainObject::class)->shouldReceive('getproductPriceId')->andReturn(1)->mock(), - m::mock(AttendeeDomainObject::class)->shouldReceive('getproductPriceId')->andReturn(2)->mock(), - ]); + $attendee1 = m::mock(AttendeeDomainObject::class); + $attendee1->shouldReceive('getproductPriceId')->andReturn(1); + $attendee1->shouldReceive('getProductId')->andReturn(10); + + $attendee2 = m::mock(AttendeeDomainObject::class); + $attendee2->shouldReceive('getproductPriceId')->andReturn(2); + $attendee2->shouldReceive('getProductId')->andReturn(20); + + $attendees = new Collection([$attendee1, $attendee2]); $this->attendeeRepository ->shouldReceive('findWhere') - ->once() + ->twice() ->with([ 'order_id' => $order->getId(), ]) diff --git a/backend/tests/Unit/Services/Domain/Waitlist/CancelWaitlistEntryServiceTest.php b/backend/tests/Unit/Services/Domain/Waitlist/CancelWaitlistEntryServiceTest.php new file mode 100644 index 0000000000..208249eb4c --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Waitlist/CancelWaitlistEntryServiceTest.php @@ -0,0 +1,317 @@ +waitlistEntryRepository = Mockery::mock(WaitlistEntryRepositoryInterface::class); + $this->orderRepository = Mockery::mock(OrderRepositoryInterface::class); + $this->databaseManager = Mockery::mock(DatabaseManager::class); + $this->productPriceRepository = Mockery::mock(ProductPriceRepositoryInterface::class); + + $this->databaseManager + ->shouldReceive('transaction') + ->andReturnUsing(function ($callback) { + return $callback(); + }); + + $productPrice = new ProductPriceDomainObject(); + $productPrice->setId(20); + $productPrice->setProductId(99); + + $this->productPriceRepository + ->shouldReceive('findById') + ->with(20) + ->andReturn($productPrice); + + $this->service = new CancelWaitlistEntryService( + waitlistEntryRepository: $this->waitlistEntryRepository, + orderRepository: $this->orderRepository, + databaseManager: $this->databaseManager, + productPriceRepository: $this->productPriceRepository, + ); + } + + public function testSuccessfullyCancelsByToken(): void + { + Event::fake(); + + $cancelToken = 'valid-cancel-token-123'; + + $entry = Mockery::mock(WaitlistEntryDomainObject::class); + $entry->shouldReceive('getId')->andReturn(1); + $entry->shouldReceive('getStatus')->andReturn(WaitlistEntryStatus::WAITING->name); + $entry->shouldReceive('getOrderId')->andReturn(null); + + $this->waitlistEntryRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(['cancel_token' => $cancelToken]) + ->andReturn($entry); + + $this->waitlistEntryRepository + ->shouldReceive('updateWhere') + ->once() + ->with( + Mockery::on(function ($attributes) { + return $attributes['status'] === WaitlistEntryStatus::CANCELLED->name + && isset($attributes['cancelled_at']) + && $attributes['order_id'] === null; + }), + ['id' => 1], + ); + + $cancelledEntry = new WaitlistEntryDomainObject(); + $cancelledEntry->setId(1); + $cancelledEntry->setStatus(WaitlistEntryStatus::CANCELLED->name); + + $this->waitlistEntryRepository + ->shouldReceive('findById') + ->once() + ->with(1) + ->andReturn($cancelledEntry); + + $result = $this->service->cancelByToken($cancelToken); + + $this->assertInstanceOf(WaitlistEntryDomainObject::class, $result); + $this->assertEquals(WaitlistEntryStatus::CANCELLED->name, $result->getStatus()); + + Event::assertNotDispatched(CapacityChangedEvent::class); + } + + public function testSuccessfullyCancelsByTokenWhenStatusIsOfferedDeletesOrder(): void + { + Event::fake(); + + $cancelToken = 'valid-cancel-token-456'; + $orderId = 100; + + $entry = Mockery::mock(WaitlistEntryDomainObject::class); + $entry->shouldReceive('getId')->andReturn(2); + $entry->shouldReceive('getStatus')->andReturn(WaitlistEntryStatus::OFFERED->name); + $entry->shouldReceive('getOrderId')->andReturn($orderId); + $entry->shouldReceive('getEventId')->andReturn(10); + $entry->shouldReceive('getProductPriceId')->andReturn(20); + + $this->waitlistEntryRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(['cancel_token' => $cancelToken]) + ->andReturn($entry); + + $this->orderRepository + ->shouldReceive('deleteWhere') + ->once() + ->with([ + 'id' => $orderId, + 'status' => OrderStatus::RESERVED->name, + ]); + + $this->waitlistEntryRepository + ->shouldReceive('updateWhere') + ->once() + ->with( + Mockery::on(function ($attributes) { + return $attributes['status'] === WaitlistEntryStatus::CANCELLED->name + && isset($attributes['cancelled_at']) + && $attributes['order_id'] === null; + }), + ['id' => 2], + ); + + $cancelledEntry = new WaitlistEntryDomainObject(); + $cancelledEntry->setId(2); + $cancelledEntry->setStatus(WaitlistEntryStatus::CANCELLED->name); + + $this->waitlistEntryRepository + ->shouldReceive('findById') + ->once() + ->with(2) + ->andReturn($cancelledEntry); + + $result = $this->service->cancelByToken($cancelToken); + + $this->assertEquals(WaitlistEntryStatus::CANCELLED->name, $result->getStatus()); + + Event::assertDispatched(CapacityChangedEvent::class, function ($event) { + return $event->eventId === 10 && $event->productId === 99; + }); + } + + public function testSuccessfullyCancelsById(): void + { + Event::fake(); + + $entryId = 5; + $eventId = 1; + + $entry = Mockery::mock(WaitlistEntryDomainObject::class); + $entry->shouldReceive('getId')->andReturn($entryId); + $entry->shouldReceive('getStatus')->andReturn(WaitlistEntryStatus::WAITING->name); + $entry->shouldReceive('getOrderId')->andReturn(null); + + $this->waitlistEntryRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with([ + 'id' => $entryId, + 'event_id' => $eventId, + ]) + ->andReturn($entry); + + $this->waitlistEntryRepository + ->shouldReceive('updateWhere') + ->once() + ->with( + Mockery::on(function ($attributes) { + return $attributes['status'] === WaitlistEntryStatus::CANCELLED->name + && isset($attributes['cancelled_at']) + && $attributes['order_id'] === null; + }), + ['id' => $entryId], + ); + + $cancelledEntry = new WaitlistEntryDomainObject(); + $cancelledEntry->setId($entryId); + $cancelledEntry->setStatus(WaitlistEntryStatus::CANCELLED->name); + + $this->waitlistEntryRepository + ->shouldReceive('findById') + ->once() + ->with($entryId) + ->andReturn($cancelledEntry); + + $result = $this->service->cancelById($entryId, $eventId); + + $this->assertInstanceOf(WaitlistEntryDomainObject::class, $result); + $this->assertEquals(WaitlistEntryStatus::CANCELLED->name, $result->getStatus()); + + Event::assertNotDispatched(CapacityChangedEvent::class); + } + + public function testThrowsExceptionForInvalidToken(): void + { + $invalidToken = 'invalid-token-does-not-exist'; + + $this->waitlistEntryRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(['cancel_token' => $invalidToken]) + ->andReturnNull(); + + $this->expectException(ResourceNotFoundException::class); + $this->expectExceptionMessage('Waitlist entry not found'); + + $this->service->cancelByToken($invalidToken); + } + + public function testThrowsExceptionForInvalidEntryId(): void + { + $entryId = 999; + $eventId = 1; + + $this->waitlistEntryRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with([ + 'id' => $entryId, + 'event_id' => $eventId, + ]) + ->andReturnNull(); + + $this->expectException(ResourceNotFoundException::class); + $this->expectExceptionMessage('Waitlist entry not found'); + + $this->service->cancelById($entryId, $eventId); + } + + public function testThrowsExceptionForAlreadyCancelledEntry(): void + { + $cancelToken = 'already-cancelled-token'; + + $entry = Mockery::mock(WaitlistEntryDomainObject::class); + $entry->shouldReceive('getStatus')->andReturn(WaitlistEntryStatus::CANCELLED->name); + + $this->waitlistEntryRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(['cancel_token' => $cancelToken]) + ->andReturn($entry); + + $this->expectException(ResourceConflictException::class); + $this->expectExceptionMessage('This waitlist entry cannot be cancelled'); + + $this->service->cancelByToken($cancelToken); + } + + public function testThrowsExceptionForPurchasedEntry(): void + { + $cancelToken = 'purchased-token'; + + $entry = Mockery::mock(WaitlistEntryDomainObject::class); + $entry->shouldReceive('getStatus')->andReturn(WaitlistEntryStatus::PURCHASED->name); + + $this->waitlistEntryRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(['cancel_token' => $cancelToken]) + ->andReturn($entry); + + $this->expectException(ResourceConflictException::class); + $this->expectExceptionMessage('This waitlist entry cannot be cancelled'); + + $this->service->cancelByToken($cancelToken); + } + + public function testThrowsExceptionForExpiredOfferEntry(): void + { + $cancelToken = 'expired-offer-token'; + + $entry = Mockery::mock(WaitlistEntryDomainObject::class); + $entry->shouldReceive('getStatus')->andReturn(WaitlistEntryStatus::OFFER_EXPIRED->name); + + $this->waitlistEntryRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(['cancel_token' => $cancelToken]) + ->andReturn($entry); + + $this->expectException(ResourceConflictException::class); + $this->expectExceptionMessage('This waitlist entry cannot be cancelled'); + + $this->service->cancelByToken($cancelToken); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Services/Domain/Waitlist/CreateWaitlistEntryServiceTest.php b/backend/tests/Unit/Services/Domain/Waitlist/CreateWaitlistEntryServiceTest.php new file mode 100644 index 0000000000..573f26019f --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Waitlist/CreateWaitlistEntryServiceTest.php @@ -0,0 +1,228 @@ +waitlistEntryRepository = Mockery::mock(WaitlistEntryRepositoryInterface::class); + $this->databaseManager = Mockery::mock(DatabaseManager::class); + + $this->databaseManager + ->shouldReceive('transaction') + ->andReturnUsing(function ($callback) { + return $callback(); + }); + + $this->service = new CreateWaitlistEntryService( + waitlistEntryRepository: $this->waitlistEntryRepository, + databaseManager: $this->databaseManager, + ); + } + + public function testSuccessfullyCreatesWaitlistEntryWithCorrectPosition(): void + { + Bus::fake(); + + $dto = new CreateWaitlistEntryDTO( + event_id: 1, + product_price_id: 10, + email: 'test@example.com', + first_name: 'John', + last_name: 'Doe', + locale: 'en', + ); + + $eventSettings = new EventSettingDomainObject(); + $eventSettings->setWaitlistEnabled(true); + + $product = Mockery::mock(ProductDomainObject::class); + $product->shouldReceive('getWaitlistEnabled')->andReturn(true); + + $this->waitlistEntryRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with([ + 'email' => 'test@example.com', + 'event_id' => 1, + ['status', 'in', [WaitlistEntryStatus::WAITING->name, WaitlistEntryStatus::OFFERED->name]], + 'product_price_id' => 10, + ]) + ->andReturnNull(); + + $this->waitlistEntryRepository + ->shouldReceive('lockForProductPrice') + ->once() + ->with(10); + + $this->waitlistEntryRepository + ->shouldReceive('getMaxPosition') + ->once() + ->with(10) + ->andReturn(3); + + $createdEntry = new WaitlistEntryDomainObject(); + $createdEntry->setId(1); + $createdEntry->setEventId(1); + $createdEntry->setProductPriceId(10); + $createdEntry->setEmail('test@example.com'); + $createdEntry->setFirstName('John'); + $createdEntry->setLastName('Doe'); + $createdEntry->setStatus(WaitlistEntryStatus::WAITING->name); + $createdEntry->setPosition(4); + + $this->waitlistEntryRepository + ->shouldReceive('create') + ->once() + ->with(Mockery::on(function ($attributes) { + return $attributes['event_id'] === 1 + && $attributes['product_price_id'] === 10 + && $attributes['email'] === 'test@example.com' + && $attributes['first_name'] === 'John' + && $attributes['last_name'] === 'Doe' + && $attributes['status'] === WaitlistEntryStatus::WAITING->name + && $attributes['position'] === 4 + && !empty($attributes['cancel_token']) + && $attributes['locale'] === 'en'; + })) + ->andReturn($createdEntry); + + $result = $this->service->createEntry($dto, $eventSettings, $product); + + $this->assertInstanceOf(WaitlistEntryDomainObject::class, $result); + $this->assertEquals(4, $result->getPosition()); + + Bus::assertDispatched(SendWaitlistConfirmationEmailJob::class); + } + + public function testPreventsDuplicateEntryForSameEmailAndProduct(): void + { + $dto = new CreateWaitlistEntryDTO( + event_id: 1, + product_price_id: 10, + email: 'duplicate@example.com', + first_name: 'Jane', + last_name: 'Doe', + ); + + $eventSettings = new EventSettingDomainObject(); + $eventSettings->setWaitlistEnabled(true); + + $product = Mockery::mock(ProductDomainObject::class); + $product->shouldReceive('getWaitlistEnabled')->andReturn(true); + + $existingEntry = Mockery::mock(WaitlistEntryDomainObject::class); + + $this->waitlistEntryRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with([ + 'email' => 'duplicate@example.com', + 'event_id' => 1, + ['status', 'in', [WaitlistEntryStatus::WAITING->name, WaitlistEntryStatus::OFFERED->name]], + 'product_price_id' => 10, + ]) + ->andReturn($existingEntry); + + $this->expectException(ResourceConflictException::class); + $this->expectExceptionMessage('You are already on the waitlist for this product'); + + $this->service->createEntry($dto, $eventSettings, $product); + } + + public function testDispatchesSendWaitlistConfirmationEmailJob(): void + { + Bus::fake(); + + $dto = new CreateWaitlistEntryDTO( + event_id: 1, + product_price_id: 10, + email: 'confirm@example.com', + first_name: 'Confirm', + last_name: 'Test', + ); + + $eventSettings = new EventSettingDomainObject(); + $eventSettings->setWaitlistEnabled(true); + + $product = Mockery::mock(ProductDomainObject::class); + $product->shouldReceive('getWaitlistEnabled')->andReturn(true); + + $this->waitlistEntryRepository + ->shouldReceive('findFirstWhere') + ->once() + ->andReturnNull(); + + $this->waitlistEntryRepository + ->shouldReceive('lockForProductPrice') + ->once() + ->with(10); + + $this->waitlistEntryRepository + ->shouldReceive('getMaxPosition') + ->once() + ->andReturn(0); + + $createdEntry = new WaitlistEntryDomainObject(); + $createdEntry->setId(1); + + $this->waitlistEntryRepository + ->shouldReceive('create') + ->once() + ->andReturn($createdEntry); + + $this->service->createEntry($dto, $eventSettings, $product); + + Bus::assertDispatched(SendWaitlistConfirmationEmailJob::class); + } + + public function testThrowsExceptionWhenWaitlistNotEnabledOnProduct(): void + { + $dto = new CreateWaitlistEntryDTO( + event_id: 1, + product_price_id: 10, + email: 'test@example.com', + first_name: 'Test', + last_name: 'User', + ); + + $eventSettings = new EventSettingDomainObject(); + $eventSettings->setWaitlistEnabled(true); + + $product = Mockery::mock(ProductDomainObject::class); + $product->shouldReceive('getWaitlistEnabled')->andReturn(false); + + $this->expectException(ResourceConflictException::class); + $this->expectExceptionMessage('Waitlist is not enabled for this product'); + + $this->service->createEntry($dto, $eventSettings, $product); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Services/Domain/Waitlist/ProcessWaitlistServiceTest.php b/backend/tests/Unit/Services/Domain/Waitlist/ProcessWaitlistServiceTest.php new file mode 100644 index 0000000000..016c26ee71 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Waitlist/ProcessWaitlistServiceTest.php @@ -0,0 +1,899 @@ +waitlistEntryRepository = Mockery::mock(WaitlistEntryRepositoryInterface::class); + $this->databaseManager = Mockery::mock(DatabaseManager::class); + $this->orderManagementService = Mockery::mock(OrderManagementService::class); + $this->orderItemProcessingService = Mockery::mock(OrderItemProcessingService::class); + $this->productRepository = Mockery::mock(ProductRepositoryInterface::class); + $this->availableQuantitiesService = Mockery::mock(AvailableProductQuantitiesFetchService::class); + $this->productPriceRepository = Mockery::mock(ProductPriceRepositoryInterface::class); + + $this->waitlistEntryRepository + ->shouldReceive('lockForProductPrice') + ->zeroOrMoreTimes(); + + $this->service = new ProcessWaitlistService( + waitlistEntryRepository: $this->waitlistEntryRepository, + databaseManager: $this->databaseManager, + orderManagementService: $this->orderManagementService, + orderItemProcessingService: $this->orderItemProcessingService, + productRepository: $this->productRepository, + availableQuantitiesService: $this->availableQuantitiesService, + productPriceRepository: $this->productPriceRepository, + ); + } + + private function createMockEvent(int $id = 1, string $currency = 'USD'): EventDomainObject + { + $event = new EventDomainObject(); + $event->setId($id); + $event->setCurrency($currency); + return $event; + } + + private function createMockEventSettings(?int $timeoutMinutes = 30): EventSettingDomainObject + { + $eventSettings = new EventSettingDomainObject(); + $eventSettings->setWaitlistOfferTimeoutMinutes($timeoutMinutes); + return $eventSettings; + } + + private function mockAvailableQuantities(int $eventId, int $priceId, int $quantityAvailable = 10): void + { + $this->availableQuantitiesService + ->shouldReceive('getAvailableProductQuantities') + ->with($eventId, true) + ->andReturn(new AvailableProductQuantitiesResponseDTO( + productQuantities: collect([ + new AvailableProductQuantitiesDTO( + product_id: 99, + price_id: $priceId, + product_title: 'Test Product', + price_label: 'Test Price', + quantity_available: $quantityAvailable, + quantity_reserved: 0, + initial_quantity_available: $quantityAvailable, + ), + ]) + )); + } + + private function mockOrderCreation(): OrderDomainObject + { + $order = new OrderDomainObject(); + $order->setId(100); + $order->setShortId('o_test123'); + + $this->orderManagementService + ->shouldReceive('createNewOrder') + ->once() + ->withArgs(function () { + $args = func_get_args(); + return count($args) >= 7 && is_string($args[6]) && !empty($args[6]); + }) + ->andReturn($order); + + $productPrice = new ProductPriceDomainObject(); + $productPrice->setId(1); + $productPrice->setProductId(10); + + $this->productPriceRepository + ->shouldReceive('findById') + ->andReturn($productPrice); + + $product = new ProductDomainObject(); + $product->setId(10); + $product->setProductPrices(new Collection([$productPrice])); + + $this->productRepository + ->shouldReceive('loadRelation') + ->andReturnSelf(); + $this->productRepository + ->shouldReceive('findById') + ->andReturn($product); + + $orderItem = new OrderItemDomainObject(); + $this->orderItemProcessingService + ->shouldReceive('process') + ->once() + ->andReturn(new Collection([$orderItem])); + + $this->orderManagementService + ->shouldReceive('updateOrderTotals') + ->once() + ->andReturn($order); + + return $order; + } + + public function testSuccessfullyOffersToNextWaitingEntry(): void + { + Bus::fake(); + + $productPriceId = 10; + $quantity = 2; + $event = $this->createMockEvent(); + $eventSettings = $this->createMockEventSettings(); + + $this->waitlistEntryRepository + ->shouldReceive('countWhere') + ->once() + ->with([ + 'product_price_id' => $productPriceId, + 'status' => WaitlistEntryStatus::OFFERED->name, + ]) + ->andReturn(0); + + $this->databaseManager + ->shouldReceive('transaction') + ->once() + ->andReturnUsing(function ($callback) { + return $callback(); + }); + + $this->mockAvailableQuantities($event->getId(), $productPriceId); + + $waitingEntry = Mockery::mock(WaitlistEntryDomainObject::class); + $waitingEntry->shouldReceive('getId')->andReturn(1); + $waitingEntry->shouldReceive('getLocale')->andReturn('en'); + $waitingEntry->shouldReceive('getProductPriceId')->andReturn($productPriceId); + + $this->waitlistEntryRepository + ->shouldReceive('getNextWaitingEntries') + ->once() + ->with($productPriceId, Mockery::any()) + ->andReturn(new Collection([$waitingEntry])); + + $order = $this->mockOrderCreation(); + + $this->waitlistEntryRepository + ->shouldReceive('updateWhere') + ->once() + ->with( + Mockery::on(function ($attributes) use ($order) { + return $attributes['status'] === WaitlistEntryStatus::OFFERED->name + && !empty($attributes['offer_token']) + && $attributes['offered_at'] !== null + && $attributes['offer_expires_at'] !== null + && $attributes['order_id'] === $order->getId(); + }), + ['id' => 1], + ); + + $updatedEntry = new WaitlistEntryDomainObject(); + $updatedEntry->setId(1); + $updatedEntry->setStatus(WaitlistEntryStatus::OFFERED->name); + $updatedEntry->setOfferToken('some-token'); + $updatedEntry->setOrderId($order->getId()); + + $this->waitlistEntryRepository + ->shouldReceive('findById') + ->once() + ->with(1) + ->andReturn($updatedEntry); + + $result = $this->service->offerToNext($productPriceId, $quantity, $event, $eventSettings); + + $this->assertCount(1, $result); + $this->assertEquals(WaitlistEntryStatus::OFFERED->name, $result->first()->getStatus()); + $this->assertEquals($order->getId(), $result->first()->getOrderId()); + + Bus::assertDispatched(SendWaitlistOfferEmailJob::class, function ($job) { + $reflection = new \ReflectionClass($job); + $sessionProp = $reflection->getProperty('sessionIdentifier'); + return !empty($sessionProp->getValue($job)); + }); + } + + public function testSetsCorrectOfferTokenAndOfferExpiresAt(): void + { + Bus::fake(); + + $productPriceId = 10; + $quantity = 1; + $timeoutMinutes = 60; + $event = $this->createMockEvent(); + $eventSettings = $this->createMockEventSettings($timeoutMinutes); + + $this->waitlistEntryRepository + ->shouldReceive('countWhere') + ->once() + ->andReturn(0); + + $this->databaseManager + ->shouldReceive('transaction') + ->once() + ->andReturnUsing(function ($callback) { + return $callback(); + }); + + $this->mockAvailableQuantities($event->getId(), $productPriceId); + + $waitingEntry = Mockery::mock(WaitlistEntryDomainObject::class); + $waitingEntry->shouldReceive('getId')->andReturn(5); + $waitingEntry->shouldReceive('getLocale')->andReturn('en'); + $waitingEntry->shouldReceive('getProductPriceId')->andReturn($productPriceId); + + $this->waitlistEntryRepository + ->shouldReceive('getNextWaitingEntries') + ->once() + ->with($productPriceId, Mockery::any()) + ->andReturn(new Collection([$waitingEntry])); + + $this->mockOrderCreation(); + + $capturedAttributes = null; + $this->waitlistEntryRepository + ->shouldReceive('updateWhere') + ->once() + ->with( + Mockery::on(function ($attributes) use (&$capturedAttributes) { + $capturedAttributes = $attributes; + return true; + }), + ['id' => 5], + ); + + $updatedEntry = new WaitlistEntryDomainObject(); + $updatedEntry->setId(5); + $updatedEntry->setStatus(WaitlistEntryStatus::OFFERED->name); + + $this->waitlistEntryRepository + ->shouldReceive('findById') + ->once() + ->with(5) + ->andReturn($updatedEntry); + + $this->service->offerToNext($productPriceId, $quantity, $event, $eventSettings); + + $this->assertNotNull($capturedAttributes); + $this->assertEquals(WaitlistEntryStatus::OFFERED->name, $capturedAttributes['status']); + $this->assertNotEmpty($capturedAttributes['offer_token']); + $this->assertNotNull($capturedAttributes['offered_at']); + $this->assertNotNull($capturedAttributes['offer_expires_at']); + $this->assertNotNull($capturedAttributes['order_id']); + } + + public function testCreatesReservedOrderWhenOffering(): void + { + Bus::fake(); + + $productPriceId = 10; + $quantity = 1; + $event = $this->createMockEvent(); + $eventSettings = $this->createMockEventSettings(30); + + $this->waitlistEntryRepository + ->shouldReceive('countWhere') + ->once() + ->andReturn(0); + + $this->databaseManager + ->shouldReceive('transaction') + ->once() + ->andReturnUsing(function ($callback) { + return $callback(); + }); + + $this->mockAvailableQuantities($event->getId(), $productPriceId); + + $waitingEntry = Mockery::mock(WaitlistEntryDomainObject::class); + $waitingEntry->shouldReceive('getId')->andReturn(1); + $waitingEntry->shouldReceive('getLocale')->andReturn('en'); + $waitingEntry->shouldReceive('getProductPriceId')->andReturn($productPriceId); + + $this->waitlistEntryRepository + ->shouldReceive('getNextWaitingEntries') + ->once() + ->with($productPriceId, Mockery::any()) + ->andReturn(new Collection([$waitingEntry])); + + $order = new OrderDomainObject(); + $order->setId(100); + $order->setShortId('o_test123'); + + $this->orderManagementService + ->shouldReceive('createNewOrder') + ->once() + ->with( + Mockery::on(fn($v) => $v === $event->getId()), + Mockery::on(fn($v) => $v instanceof EventDomainObject), + Mockery::on(fn($v) => $v === 30), + Mockery::on(fn($v) => $v === 'en'), + Mockery::on(fn($v) => $v === null), + Mockery::on(fn($v) => $v === null), + Mockery::on(fn($v) => is_string($v) && !empty($v)), + ) + ->andReturn($order); + + $productPrice = new ProductPriceDomainObject(); + $productPrice->setId(1); + $productPrice->setProductId(10); + + $this->productPriceRepository + ->shouldReceive('findById') + ->andReturn($productPrice); + + $product = new ProductDomainObject(); + $product->setId(10); + $product->setProductPrices(new Collection([$productPrice])); + + $this->productRepository + ->shouldReceive('loadRelation') + ->andReturnSelf(); + $this->productRepository + ->shouldReceive('findById') + ->with(10) + ->andReturn($product); + + $orderItem = new OrderItemDomainObject(); + $this->orderItemProcessingService + ->shouldReceive('process') + ->once() + ->andReturn(new Collection([$orderItem])); + + $this->orderManagementService + ->shouldReceive('updateOrderTotals') + ->once() + ->andReturn($order); + + $this->waitlistEntryRepository + ->shouldReceive('updateWhere') + ->once() + ->with( + Mockery::on(fn($attrs) => $attrs['order_id'] === 100), + ['id' => 1], + ); + + $updatedEntry = new WaitlistEntryDomainObject(); + $updatedEntry->setId(1); + $updatedEntry->setStatus(WaitlistEntryStatus::OFFERED->name); + $updatedEntry->setOrderId(100); + + $this->waitlistEntryRepository + ->shouldReceive('findById') + ->once() + ->andReturn($updatedEntry); + + $result = $this->service->offerToNext($productPriceId, $quantity, $event, $eventSettings); + + $this->assertCount(1, $result); + $this->assertEquals(100, $result->first()->getOrderId()); + } + + public function testThrowsWhenNoWaitingEntries(): void + { + $productPriceId = 10; + $quantity = 2; + $event = $this->createMockEvent(); + $eventSettings = $this->createMockEventSettings(); + + $this->databaseManager + ->shouldReceive('transaction') + ->once() + ->andReturnUsing(function ($callback) { + return $callback(); + }); + + $this->mockAvailableQuantities($event->getId(), $productPriceId); + + $this->waitlistEntryRepository + ->shouldReceive('countWhere') + ->once() + ->andReturn(0); + + $this->waitlistEntryRepository + ->shouldReceive('getNextWaitingEntries') + ->once() + ->with($productPriceId, Mockery::any()) + ->andReturn(new Collection()); + + $this->expectException(NoCapacityAvailableException::class); + + $this->service->offerToNext($productPriceId, $quantity, $event, $eventSettings); + } + + public function testCapsOffersAtEffectiveAvailableCapacity(): void + { + Bus::fake(); + + $productPriceId = 10; + $quantity = 3; + $event = $this->createMockEvent(); + $eventSettings = $this->createMockEventSettings(); + + $this->waitlistEntryRepository + ->shouldReceive('countWhere') + ->once() + ->with([ + 'product_price_id' => $productPriceId, + 'status' => WaitlistEntryStatus::OFFERED->name, + ]) + ->andReturn(2); + + $this->databaseManager + ->shouldReceive('transaction') + ->once() + ->andReturnUsing(function ($callback) { + return $callback(); + }); + + $this->mockAvailableQuantities($event->getId(), $productPriceId); + + $waitingEntry = Mockery::mock(WaitlistEntryDomainObject::class); + $waitingEntry->shouldReceive('getId')->andReturn(1); + $waitingEntry->shouldReceive('getLocale')->andReturn('en'); + $waitingEntry->shouldReceive('getProductPriceId')->andReturn($productPriceId); + + $this->waitlistEntryRepository + ->shouldReceive('getNextWaitingEntries') + ->once() + ->with($productPriceId, Mockery::any()) + ->andReturn(new Collection([$waitingEntry])); + + $this->mockOrderCreation(); + + $this->waitlistEntryRepository + ->shouldReceive('updateWhere') + ->once(); + + $updatedEntry = new WaitlistEntryDomainObject(); + $updatedEntry->setId(1); + $updatedEntry->setStatus(WaitlistEntryStatus::OFFERED->name); + + $this->waitlistEntryRepository + ->shouldReceive('findById') + ->once() + ->andReturn($updatedEntry); + + $result = $this->service->offerToNext($productPriceId, $quantity, $event, $eventSettings); + + $this->assertCount(1, $result); + } + + public function testThrowsWhenAllCapacityReservedByOffers(): void + { + $productPriceId = 10; + $quantity = 2; + $event = $this->createMockEvent(); + $eventSettings = $this->createMockEventSettings(); + + $this->databaseManager + ->shouldReceive('transaction') + ->once() + ->andReturnUsing(function ($callback) { + return $callback(); + }); + + $this->mockAvailableQuantities($event->getId(), $productPriceId, 2); + + $this->waitlistEntryRepository + ->shouldReceive('countWhere') + ->once() + ->with([ + 'product_price_id' => $productPriceId, + 'status' => WaitlistEntryStatus::OFFERED->name, + ]) + ->andReturn(2); + + $this->expectException(NoCapacityAvailableException::class); + + $this->service->offerToNext($productPriceId, $quantity, $event, $eventSettings); + } + + public function testThrowsWhenNoCapacityAtAll(): void + { + $productPriceId = 10; + $quantity = 2; + $event = $this->createMockEvent(); + $eventSettings = $this->createMockEventSettings(); + + $this->databaseManager + ->shouldReceive('transaction') + ->once() + ->andReturnUsing(function ($callback) { + return $callback(); + }); + + $this->mockAvailableQuantities($event->getId(), $productPriceId, 0); + + $this->waitlistEntryRepository + ->shouldReceive('countWhere') + ->once() + ->with([ + 'product_price_id' => $productPriceId, + 'status' => WaitlistEntryStatus::OFFERED->name, + ]) + ->andReturn(0); + + $this->expectException(NoCapacityAvailableException::class); + + $this->service->offerToNext($productPriceId, $quantity, $event, $eventSettings); + } + + public function testOfferExpiresAtUsesDefaultWhenTimeoutNotSet(): void + { + Bus::fake(); + + $productPriceId = 10; + $quantity = 1; + $event = $this->createMockEvent(); + $eventSettings = $this->createMockEventSettings(null); + + $this->waitlistEntryRepository + ->shouldReceive('countWhere') + ->once() + ->andReturn(0); + + $this->databaseManager + ->shouldReceive('transaction') + ->once() + ->andReturnUsing(function ($callback) { + return $callback(); + }); + + $this->mockAvailableQuantities($event->getId(), $productPriceId); + + $waitingEntry = Mockery::mock(WaitlistEntryDomainObject::class); + $waitingEntry->shouldReceive('getId')->andReturn(1); + $waitingEntry->shouldReceive('getLocale')->andReturn('en'); + $waitingEntry->shouldReceive('getProductPriceId')->andReturn($productPriceId); + + $this->waitlistEntryRepository + ->shouldReceive('getNextWaitingEntries') + ->once() + ->with($productPriceId, Mockery::any()) + ->andReturn(new Collection([$waitingEntry])); + + $this->mockOrderCreation(); + + $capturedAttributes = null; + $this->waitlistEntryRepository + ->shouldReceive('updateWhere') + ->once() + ->with( + Mockery::on(function ($attributes) use (&$capturedAttributes) { + $capturedAttributes = $attributes; + return true; + }), + ['id' => 1], + ); + + $updatedEntry = new WaitlistEntryDomainObject(); + $updatedEntry->setId(1); + $updatedEntry->setStatus(WaitlistEntryStatus::OFFERED->name); + + $this->waitlistEntryRepository + ->shouldReceive('findById') + ->once() + ->andReturn($updatedEntry); + + $this->service->offerToNext($productPriceId, $quantity, $event, $eventSettings); + + $this->assertNotNull($capturedAttributes['offer_expires_at']); + } + + public function testOfferSpecificEntrySuccessfully(): void + { + Bus::fake(); + + $entryId = 7; + $eventId = 1; + $productPriceId = 10; + $event = $this->createMockEvent($eventId); + $eventSettings = $this->createMockEventSettings(); + + $this->databaseManager + ->shouldReceive('transaction') + ->once() + ->andReturnUsing(function ($callback) { + return $callback(); + }); + + $entry = new WaitlistEntryDomainObject(); + $entry->setId($entryId); + $entry->setStatus(WaitlistEntryStatus::WAITING->name); + $entry->setLocale('en'); + $entry->setProductPriceId($productPriceId); + + $this->waitlistEntryRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(['id' => $entryId, 'event_id' => $eventId]) + ->andReturn($entry); + + $this->mockAvailableQuantities($eventId, $productPriceId, 5); + + $this->waitlistEntryRepository + ->shouldReceive('countWhere') + ->once() + ->with([ + 'product_price_id' => $productPriceId, + 'status' => WaitlistEntryStatus::OFFERED->name, + ]) + ->andReturn(0); + + $order = $this->mockOrderCreation(); + + $this->waitlistEntryRepository + ->shouldReceive('updateWhere') + ->once() + ->with( + Mockery::on(function ($attributes) use ($order) { + return $attributes['status'] === WaitlistEntryStatus::OFFERED->name + && !empty($attributes['offer_token']) + && $attributes['offered_at'] !== null + && $attributes['order_id'] === $order->getId(); + }), + ['id' => $entryId], + ); + + $updatedEntry = new WaitlistEntryDomainObject(); + $updatedEntry->setId($entryId); + $updatedEntry->setStatus(WaitlistEntryStatus::OFFERED->name); + $updatedEntry->setOrderId($order->getId()); + + $this->waitlistEntryRepository + ->shouldReceive('findById') + ->once() + ->with($entryId) + ->andReturn($updatedEntry); + + $result = $this->service->offerSpecificEntry($entryId, $eventId, $event, $eventSettings); + + $this->assertCount(1, $result); + $this->assertEquals(WaitlistEntryStatus::OFFERED->name, $result->first()->getStatus()); + + Bus::assertDispatched(SendWaitlistOfferEmailJob::class); + } + + public function testOfferSpecificEntryThrowsWhenEntryNotFound(): void + { + $entryId = 99; + $eventId = 1; + $event = $this->createMockEvent($eventId); + $eventSettings = $this->createMockEventSettings(); + + $this->databaseManager + ->shouldReceive('transaction') + ->once() + ->andReturnUsing(function ($callback) { + return $callback(); + }); + + $this->waitlistEntryRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(['id' => $entryId, 'event_id' => $eventId]) + ->andReturn(null); + + $this->expectException(ResourceNotFoundException::class); + + $this->service->offerSpecificEntry($entryId, $eventId, $event, $eventSettings); + } + + public function testOfferSpecificEntryThrowsWhenStatusNotOfferable(): void + { + $entryId = 7; + $eventId = 1; + $event = $this->createMockEvent($eventId); + $eventSettings = $this->createMockEventSettings(); + + $this->databaseManager + ->shouldReceive('transaction') + ->once() + ->andReturnUsing(function ($callback) { + return $callback(); + }); + + $entry = new WaitlistEntryDomainObject(); + $entry->setId($entryId); + $entry->setStatus(WaitlistEntryStatus::PURCHASED->name); + + $this->waitlistEntryRepository + ->shouldReceive('findFirstWhere') + ->once() + ->andReturn($entry); + + $this->expectException(ResourceConflictException::class); + + $this->service->offerSpecificEntry($entryId, $eventId, $event, $eventSettings); + } + + public function testOfferSpecificEntryAllowsReOfferForExpiredEntries(): void + { + Bus::fake(); + + $entryId = 7; + $eventId = 1; + $productPriceId = 10; + $event = $this->createMockEvent($eventId); + $eventSettings = $this->createMockEventSettings(60); + + $this->databaseManager + ->shouldReceive('transaction') + ->once() + ->andReturnUsing(function ($callback) { + return $callback(); + }); + + $entry = new WaitlistEntryDomainObject(); + $entry->setId($entryId); + $entry->setStatus(WaitlistEntryStatus::OFFER_EXPIRED->name); + $entry->setLocale('en'); + $entry->setProductPriceId($productPriceId); + + $this->waitlistEntryRepository + ->shouldReceive('findFirstWhere') + ->once() + ->andReturn($entry); + + $this->mockAvailableQuantities($eventId, $productPriceId, 3); + + $this->waitlistEntryRepository + ->shouldReceive('countWhere') + ->once() + ->with([ + 'product_price_id' => $productPriceId, + 'status' => WaitlistEntryStatus::OFFERED->name, + ]) + ->andReturn(0); + + $this->mockOrderCreation(); + + $this->waitlistEntryRepository + ->shouldReceive('updateWhere') + ->once(); + + $updatedEntry = new WaitlistEntryDomainObject(); + $updatedEntry->setId($entryId); + $updatedEntry->setStatus(WaitlistEntryStatus::OFFERED->name); + + $this->waitlistEntryRepository + ->shouldReceive('findById') + ->once() + ->andReturn($updatedEntry); + + $result = $this->service->offerSpecificEntry($entryId, $eventId, $event, $eventSettings); + + $this->assertCount(1, $result); + Bus::assertDispatched(SendWaitlistOfferEmailJob::class); + } + + public function testOfferSpecificEntryThrowsWhenNoCapacityAvailable(): void + { + $entryId = 7; + $eventId = 1; + $productPriceId = 10; + $event = $this->createMockEvent($eventId); + $eventSettings = $this->createMockEventSettings(); + + $this->databaseManager + ->shouldReceive('transaction') + ->once() + ->andReturnUsing(function ($callback) { + return $callback(); + }); + + $entry = new WaitlistEntryDomainObject(); + $entry->setId($entryId); + $entry->setStatus(WaitlistEntryStatus::WAITING->name); + $entry->setLocale('en'); + $entry->setProductPriceId($productPriceId); + + $this->waitlistEntryRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(['id' => $entryId, 'event_id' => $eventId]) + ->andReturn($entry); + + $this->mockAvailableQuantities($eventId, $productPriceId, 0); + + $this->waitlistEntryRepository + ->shouldReceive('countWhere') + ->once() + ->with([ + 'product_price_id' => $productPriceId, + 'status' => WaitlistEntryStatus::OFFERED->name, + ]) + ->andReturn(0); + + $this->expectException(NoCapacityAvailableException::class); + + $this->service->offerSpecificEntry($entryId, $eventId, $event, $eventSettings); + } + + public function testOfferSpecificEntryThrowsWhenCapacityFullyOffered(): void + { + $entryId = 7; + $eventId = 1; + $productPriceId = 10; + $event = $this->createMockEvent($eventId); + $eventSettings = $this->createMockEventSettings(); + + $this->databaseManager + ->shouldReceive('transaction') + ->once() + ->andReturnUsing(function ($callback) { + return $callback(); + }); + + $entry = new WaitlistEntryDomainObject(); + $entry->setId($entryId); + $entry->setStatus(WaitlistEntryStatus::WAITING->name); + $entry->setLocale('en'); + $entry->setProductPriceId($productPriceId); + + $this->waitlistEntryRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(['id' => $entryId, 'event_id' => $eventId]) + ->andReturn($entry); + + $this->mockAvailableQuantities($eventId, $productPriceId, 2); + + $this->waitlistEntryRepository + ->shouldReceive('countWhere') + ->once() + ->with([ + 'product_price_id' => $productPriceId, + 'status' => WaitlistEntryStatus::OFFERED->name, + ]) + ->andReturn(2); + + $this->expectException(NoCapacityAvailableException::class); + + $this->service->offerSpecificEntry($entryId, $eventId, $event, $eventSettings); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/frontend/public/blank-slate/waitlist.svg b/frontend/public/blank-slate/waitlist.svg new file mode 100644 index 0000000000..6283ffd08e --- /dev/null +++ b/frontend/public/blank-slate/waitlist.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/api/waitlist.client.ts b/frontend/src/api/waitlist.client.ts new file mode 100644 index 0000000000..96e6c05123 --- /dev/null +++ b/frontend/src/api/waitlist.client.ts @@ -0,0 +1,62 @@ +import {api} from "./client"; +import { + GenericDataResponse, + GenericPaginatedResponse, + IdParam, + JoinWaitlistRequest, + QueryFilters, + WaitlistEntry, + WaitlistStats, +} from "../types"; +import {publicApi} from "./public-client.ts"; +import {queryParamsHelper} from "../utilites/queryParamsHelper.ts"; + +export const waitlistClient = { + all: async (eventId: IdParam, pagination: QueryFilters) => { + const response = await api.get>( + `events/${eventId}/waitlist` + queryParamsHelper.buildQueryString(pagination), + ); + return response.data; + }, + + stats: async (eventId: IdParam) => { + const response = await api.get( + `events/${eventId}/waitlist/stats`, + ); + return response.data; + }, + + offerNext: async (eventId: IdParam, productPriceId: number, quantity: number = 1) => { + const response = await api.post>( + `events/${eventId}/waitlist/offer-next`, + {product_price_id: productPriceId, quantity}, + ); + return response.data; + }, + + offerEntry: async (eventId: IdParam, entryId: IdParam) => { + const response = await api.post>( + `events/${eventId}/waitlist/offer-next`, + {entry_id: entryId}, + ); + return response.data; + }, + + remove: async (eventId: IdParam, entryId: IdParam) => { + return api.delete(`events/${eventId}/waitlist/${entryId}`); + }, +}; + +export const waitlistClientPublic = { + join: async (eventId: IdParam, data: JoinWaitlistRequest) => { + const response = await publicApi.post>( + `events/${eventId}/waitlist`, + data, + ); + return response.data; + }, + + cancel: async (eventId: IdParam, cancelToken: string) => { + return publicApi.delete(`events/${eventId}/waitlist/${cancelToken}`); + }, +}; diff --git a/frontend/src/components/common/JoinWaitlistButton/index.tsx b/frontend/src/components/common/JoinWaitlistButton/index.tsx new file mode 100644 index 0000000000..30368db701 --- /dev/null +++ b/frontend/src/components/common/JoinWaitlistButton/index.tsx @@ -0,0 +1,48 @@ +import {Event, IdParam, Product} from "../../../types.ts"; +import {useDisclosure} from "@mantine/hooks"; +import {JoinWaitlistModal} from "../../modals/JoinWaitlistModal"; +import {t} from "@lingui/macro"; +import {useWaitlistJoined} from "../../../hooks/useWaitlistJoined.ts"; + +interface JoinWaitlistButtonProps { + product: Product; + event: Event; + productPriceId: IdParam; + priceLabel?: string; +} + +export const JoinWaitlistButton = ({product, event, productPriceId, priceLabel}: JoinWaitlistButtonProps) => { + const [modalOpen, {open: openModal, close: closeModal}] = useDisclosure(false); + const {joined: hasJoined, markJoined} = useWaitlistJoined(event.id, productPriceId); + + return ( + <> + + {modalOpen && ( + { + markJoined(); + closeModal(); + }} + /> + )} + + ); +}; diff --git a/frontend/src/components/common/ProductPriceAvailability/index.tsx b/frontend/src/components/common/ProductPriceAvailability/index.tsx index b32f1ece00..552698cfb2 100644 --- a/frontend/src/components/common/ProductPriceAvailability/index.tsx +++ b/frontend/src/components/common/ProductPriceAvailability/index.tsx @@ -3,9 +3,19 @@ import {t} from "@lingui/macro"; import {Tooltip} from "@mantine/core"; import {prettyDate, relativeDate} from "../../../utilites/dates.ts"; import {IconInfoCircle} from "@tabler/icons-react"; +import {JoinWaitlistButton} from "../JoinWaitlistButton"; -const ProductPriceSaleDateMessage = ({price, event}: { price: ProductPrice, event: Event }) => { +interface ProductPriceSaleDateMessageProps { + price: ProductPrice; + event: Event; + product: Product; +} + +const ProductPriceSaleDateMessage = ({price, event, product}: ProductPriceSaleDateMessageProps) => { if (price.is_sold_out) { + if (product.waitlist_enabled) { + return ; + } return t`Sold out`; } @@ -27,8 +37,16 @@ const ProductPriceSaleDateMessage = ({price, event}: { price: ProductPrice, even return t`Not available`; } -export const ProductAvailabilityMessage = ({product, event}: { product: Product, event: Event }) => { +interface ProductAvailabilityMessageProps { + product: Product; + event: Event; +} + +export const ProductAvailabilityMessage = ({product, event}: ProductAvailabilityMessageProps) => { if (product.is_sold_out) { + if (product.waitlist_enabled && product.type !== 'TIERED') { + return ; + } return t`Sold out`; } if (product.is_after_sale_end_date) { @@ -57,7 +75,7 @@ interface ProductAndPriceAvailabilityProps { export const ProductPriceAvailability = ({product, price, event}: ProductAndPriceAvailabilityProps) => { if (product.type === 'TIERED') { - return + return } return diff --git a/frontend/src/components/common/ProductsTable/SortableProduct/index.tsx b/frontend/src/components/common/ProductsTable/SortableProduct/index.tsx index dc83e83f47..5ccbba1b9d 100644 --- a/frontend/src/components/common/ProductsTable/SortableProduct/index.tsx +++ b/frontend/src/components/common/ProductsTable/SortableProduct/index.tsx @@ -268,7 +268,7 @@ export const SortableProduct = ({product, currencyCode, category, categories}: S
{isTicket ? ( } + leftSection={} variant="light" color="violet" size="sm" @@ -277,7 +277,7 @@ export const SortableProduct = ({product, currencyCode, category, categories}: S ) : ( } + leftSection={} variant="light" color="cyan" size="sm" @@ -285,6 +285,16 @@ export const SortableProduct = ({product, currencyCode, category, categories}: S {t`Product`} )} + {product.waitlist_enabled && ( + } + > + {t`Waitlist Enabled`} + + )} {product.type === ProductPriceType.Donation && ( : } + leftSection={product.is_hidden_without_promo_code ? : + } > {product.is_hidden_without_promo_code ? t`Promo Only` : t`Hidden`} @@ -320,7 +331,7 @@ export const SortableProduct = ({product, currencyCode, category, categories}: S variant="light" color="yellow" size="sm" - leftSection={} + leftSection={} > {t`Highlighted`} @@ -357,7 +368,7 @@ export const SortableProduct = ({product, currencyCode, category, categories}: S {hasTaxesOrFees() && (
- + {t`+Tax/Fees`}
@@ -405,17 +416,21 @@ export const SortableProduct = ({product, currencyCode, category, categories}: S {product.sale_start_date || product.sale_end_date ? (
{product.is_before_sale_start_date && product.sale_start_date && ( - +
- + {relativeDate(product.sale_start_date as string)}
)} {!product.is_before_sale_start_date && product.sale_end_date && ( - +
- + {product.is_after_sale_end_date ? t`Ended` : relativeDate(product.sale_end_date as string)}
@@ -444,7 +459,7 @@ export const SortableProduct = ({product, currencyCode, category, categories}: S className={classes.actionButton} > {t`Manage`} - +
@@ -473,7 +488,7 @@ export const SortableProduct = ({product, currencyCode, category, categories}: S {t`Duplicate`} - + {t`Danger zone`} handleDeleteProduct(product.id, product.event_id)} diff --git a/frontend/src/components/common/WaitlistStats/index.tsx b/frontend/src/components/common/WaitlistStats/index.tsx new file mode 100644 index 0000000000..cfb9cb7f4c --- /dev/null +++ b/frontend/src/components/common/WaitlistStats/index.tsx @@ -0,0 +1,31 @@ +import {WaitlistStats as WaitlistStatsType} from "../../../types.ts"; +import {Paper, SimpleGrid, Text} from "@mantine/core"; +import {t} from "@lingui/macro"; + +interface WaitlistStatsProps { + stats: WaitlistStatsType; +} + +export const WaitlistStatsCards = ({stats}: WaitlistStatsProps) => { + const statItems = [ + {label: t`Total Entries`, value: stats.total}, + {label: t`Waiting`, value: stats.waiting}, + {label: t`Offered`, value: stats.offered}, + {label: t`Purchased`, value: stats.purchased}, + ]; + + return ( + + {statItems.map((item) => ( + + + {item.label} + + + {item.value} + + + ))} + + ); +}; diff --git a/frontend/src/components/common/WaitlistTable/WaitlistTable.module.scss b/frontend/src/components/common/WaitlistTable/WaitlistTable.module.scss new file mode 100644 index 0000000000..24769eede6 --- /dev/null +++ b/frontend/src/components/common/WaitlistTable/WaitlistTable.module.scss @@ -0,0 +1,67 @@ +// Contact Section +.contactDetails { + display: flex; + flex-direction: column; + gap: 4px; +} + +.contactName { + font-size: 15px; + font-weight: 600; + line-height: 1.3; + color: var(--mantine-color-text); +} + +.contactEmail { + font-size: 13px; + line-height: 1.3; + color: var(--mantine-color-dimmed); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 280px; +} + +// Status Section +.statusBadge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 6px 12px; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + white-space: nowrap; + + &[data-status="WAITING"] { + background: var(--mantine-color-blue-1); + color: var(--mantine-color-blue-9); + } + + &[data-status="OFFERED"] { + background: var(--mantine-color-yellow-1); + color: var(--mantine-color-yellow-9); + } + + &[data-status="PURCHASED"] { + background: var(--mantine-color-green-1); + color: var(--mantine-color-green-9); + } + + &[data-status="CANCELLED"] { + background: var(--mantine-color-gray-1); + color: var(--mantine-color-gray-9); + } + + &[data-status="OFFER_EXPIRED"] { + background: var(--mantine-color-red-1); + color: var(--mantine-color-red-9); + } +} + +// Actions Section +.actionsMenu { + display: flex; + align-items: center; + justify-content: flex-end; +} diff --git a/frontend/src/components/common/WaitlistTable/index.tsx b/frontend/src/components/common/WaitlistTable/index.tsx new file mode 100644 index 0000000000..a86de06cc3 --- /dev/null +++ b/frontend/src/components/common/WaitlistTable/index.tsx @@ -0,0 +1,235 @@ +import {t} from "@lingui/macro"; +import {Button, Group, Menu, Text} from "@mantine/core"; +import {IconDotsVertical, IconSend, IconTrash} from "@tabler/icons-react"; +import {useMemo} from "react"; +import {CellContext} from "@tanstack/react-table"; +import {IdParam, WaitlistEntry, WaitlistEntryStatus} from "../../../types.ts"; +import {relativeDate} from "../../../utilites/dates.ts"; +import {NoResultsSplash} from "../NoResultsSplash"; +import {confirmationDialog} from "../../../utilites/confirmationDialog.tsx"; +import {useRemoveWaitlistEntry} from "../../../mutations/useRemoveWaitlistEntry.ts"; +import {useOfferSpecificWaitlistEntry} from "../../../mutations/useOfferSpecificWaitlistEntry.ts"; +import {showError, showSuccess} from "../../../utilites/notifications.tsx"; +import {TanStackTable, TanStackTableColumn} from "../TanStackTable"; +import classes from './WaitlistTable.module.scss'; + +interface WaitlistTableProps { + eventId: IdParam; + entries: WaitlistEntry[]; +} + +const statusLabelMap: Record string> = { + [WaitlistEntryStatus.Waiting]: () => t`Waiting`, + [WaitlistEntryStatus.Offered]: () => t`Offered`, + [WaitlistEntryStatus.Purchased]: () => t`Purchased`, + [WaitlistEntryStatus.Cancelled]: () => t`Cancelled`, + [WaitlistEntryStatus.OfferExpired]: () => t`Expired`, +}; + +const ActionMenu = ({entry, onOffer, onRemove}: { + entry: WaitlistEntry; + onOffer: (entryId: IdParam) => void; + onRemove: (entryId: number) => void; +}) => { + const isWaiting = entry.status === WaitlistEntryStatus.Waiting; + const isOffered = entry.status === WaitlistEntryStatus.Offered; + const isExpired = entry.status === WaitlistEntryStatus.OfferExpired; + const canOffer = isWaiting || isExpired; + const canCancel = isWaiting || isOffered; + + const hasActions = canOffer || canCancel; + + return ( + + + +
+ +
+
+ + {t`Actions`} + {canOffer && ( + } + onClick={() => onOffer(entry.id)} + > + {isExpired ? t`Re-offer Spot` : t`Offer Spot`} + + )} + {canCancel && ( + } + onClick={() => onRemove(entry.id as number)} + > + {isOffered ? t`Revoke Offer` : t`Remove`} + + )} + +
+
+ ); +}; + +export const WaitlistTable = ({eventId, entries}: WaitlistTableProps) => { + const removeMutation = useRemoveWaitlistEntry(); + const offerMutation = useOfferSpecificWaitlistEntry(); + + const handleRemove = (entryId: number) => { + confirmationDialog( + t`Are you sure you want to remove this entry from the waitlist?`, + () => { + removeMutation.mutate({eventId, entryId}, { + onSuccess: () => { + showSuccess(t`Successfully removed from waitlist`); + }, + onError: () => { + showError(t`Failed to remove from waitlist`); + }, + }); + }, + {confirm: t`Remove`, cancel: t`Cancel`} + ); + }; + + const handleOffer = (entryId: IdParam) => { + confirmationDialog( + t`Are you sure you want to offer a spot to this person? They will receive an email notification.`, + () => { + offerMutation.mutate({eventId, entryId}, { + onSuccess: () => { + showSuccess(t`Successfully offered a spot`); + }, + onError: (error: any) => { + const errors = error?.response?.data?.errors; + const message = errors + ? Object.values(errors).flat().join(', ') + : t`Failed to offer spot`; + showError(message as string); + }, + }); + }, + {confirm: t`Offer`, cancel: t`Cancel`} + ); + }; + + const columns = useMemo[]>( + () => [ + { + id: 'position', + header: '#', + enableHiding: false, + cell: (info: CellContext) => info.row.original.position, + meta: { + headerStyle: {width: 60}, + }, + }, + { + id: 'contact', + header: t`Contact`, + enableHiding: false, + cell: (info: CellContext) => { + const entry = info.row.original; + return ( +
+ + {entry.first_name} {entry.last_name} + + + {entry.email} + +
+ ); + }, + meta: { + headerStyle: {minWidth: 220}, + }, + }, + { + id: 'product', + header: t`Product`, + enableHiding: true, + cell: (info: CellContext) => { + const entry = info.row.original; + const title = entry.product?.title || ''; + const label = entry.product_price?.label; + return label ? `${title} - ${label}` : title; + } + }, + { + id: 'status', + header: t`Status`, + enableHiding: true, + cell: (info: CellContext) => { + const entry = info.row.original; + return ( +
+ {entry.status ? statusLabelMap[entry.status]() : ''} +
+ ); + }, + meta: { + headerStyle: {minWidth: 130}, + }, + }, + { + id: 'joined', + header: t`Joined`, + enableHiding: true, + cell: (info: CellContext) => { + const entry = info.row.original; + return ( + + {entry.created_at ? relativeDate(String(entry.created_at)) : ''} + + ); + }, + }, + { + id: 'actions', + header: t`Actions`, + enableHiding: false, + cell: (info: CellContext) => { + const entry = info.row.original; + return ( +
+ +
+ ); + }, + meta: { + sticky: 'right', + }, + }, + ], + [eventId] + ); + + if (entries.length === 0) { + return ( + + {t`Entries will appear here when customers join the waitlist for sold out products.`} +

+ )} + /> + ); + } + + return ( + + ); +}; diff --git a/frontend/src/components/forms/ProductForm/index.tsx b/frontend/src/components/forms/ProductForm/index.tsx index 711784511c..76063f94fc 100644 --- a/frontend/src/components/forms/ProductForm/index.tsx +++ b/frontend/src/components/forms/ProductForm/index.tsx @@ -487,6 +487,11 @@ export const ProductForm = ({form, product}: ProductFormProps) => { {...form.getInputProps(`is_hidden`, {type: 'checkbox'})} label={t`Hide this product from customers`} /> +
diff --git a/frontend/src/components/layouts/Checkout/CheckoutThemeProvider.tsx b/frontend/src/components/layouts/Checkout/CheckoutThemeProvider.tsx index 0a5d3d8563..6279a5dd0a 100644 --- a/frontend/src/components/layouts/Checkout/CheckoutThemeProvider.tsx +++ b/frontend/src/components/layouts/Checkout/CheckoutThemeProvider.tsx @@ -160,6 +160,11 @@ function createCSSVariablesResolver(accentColor: string, mode: 'light' | 'dark') '--checkout-text-secondary': palette.textSecondary, '--checkout-text-tertiary': palette.textTertiary, '--checkout-border': palette.border, + + // Override global --hi-text (set to accent in global.scss) and + // Mantine's default text color to use fixed palette instead + '--hi-text': palette.textPrimary, + '--mantine-color-text': palette.textPrimary, }, light: {}, dark: {}, diff --git a/frontend/src/components/layouts/Event/index.tsx b/frontend/src/components/layouts/Event/index.tsx index 4a089f1734..95fef67ec5 100644 --- a/frontend/src/components/layouts/Event/index.tsx +++ b/frontend/src/components/layouts/Event/index.tsx @@ -22,7 +22,8 @@ import { IconUserQuestion, IconUsers, IconUsersGroup, - IconWebhook + IconWebhook, + IconListCheck, } from "@tabler/icons-react"; import {t} from "@lingui/macro"; import {useGetEvent} from "../../../queries/useGetEvent"; @@ -117,6 +118,7 @@ const EventLayout = () => { {link: 'attendees', label: t`Attendees`, icon: IconUsers, badge: eventStats?.total_attendees_registered}, {link: 'check-in', label: t`Check-In Lists`, icon: IconQrcode}, {link: 'messages', label: t`Messages`, icon: IconSend}, + {link: 'sold-out-waitlist', label: t`Waitlist`, icon: IconListCheck}, {link: 'capacity-assignments', label: t`Capacity Management`, icon: IconUsersGroup}, // 5. INTEGRATIONS diff --git a/frontend/src/components/modals/CreateProductModal/index.tsx b/frontend/src/components/modals/CreateProductModal/index.tsx index 03107123c7..beee9d69fc 100644 --- a/frontend/src/components/modals/CreateProductModal/index.tsx +++ b/frontend/src/components/modals/CreateProductModal/index.tsx @@ -34,6 +34,7 @@ export const CreateProductModal = ({onClose, selectedCategoryId = undefined}: Cr is_hidden_without_promo_code: false, is_highlighted: false, highlight_message: undefined, + waitlist_enabled: null, type: ProductPriceType.Paid, product_type: ProductType.Ticket, tax_and_fee_ids: undefined, diff --git a/frontend/src/components/modals/EditProductModal/index.tsx b/frontend/src/components/modals/EditProductModal/index.tsx index a4bc2d0011..4d790193f8 100644 --- a/frontend/src/components/modals/EditProductModal/index.tsx +++ b/frontend/src/components/modals/EditProductModal/index.tsx @@ -35,6 +35,7 @@ export const EditProductModal = ({onClose, productId}: GenericModalProps & { pro is_hidden_without_promo_code: undefined, is_highlighted: false, highlight_message: undefined, + waitlist_enabled: null, type: ProductPriceType.Paid, tax_and_fee_ids: [], prices: [], @@ -69,6 +70,7 @@ export const EditProductModal = ({onClose, productId}: GenericModalProps & { pro is_hidden: product.is_hidden, is_highlighted: product.is_highlighted, highlight_message: product.highlight_message, + waitlist_enabled: product.waitlist_enabled ?? null, product_type: product.product_type, product_category_id: String(product.product_category_id), prices: product.prices?.map(p => ({ diff --git a/frontend/src/components/modals/JoinWaitlistModal/index.tsx b/frontend/src/components/modals/JoinWaitlistModal/index.tsx new file mode 100644 index 0000000000..4e840f4b7a --- /dev/null +++ b/frontend/src/components/modals/JoinWaitlistModal/index.tsx @@ -0,0 +1,226 @@ +import {useMemo, useState} from "react"; +import {Event, GenericModalProps, IdParam, JoinWaitlistRequest, Product} from "../../../types.ts"; +import {hasLength, isEmail, useForm} from "@mantine/form"; +import {useFormErrorResponseHandler} from "../../../hooks/useFormErrorResponseHandler.tsx"; +import {useJoinWaitlist} from "../../../mutations/useJoinWaitlist.ts"; +import {t} from "@lingui/macro"; +import {Button, Checkbox, Modal as MantineModal, Text, TextInput} from "@mantine/core"; +import {InputGroup} from "../../common/InputGroup"; +import {CheckoutThemeProvider} from "../../layouts/Checkout/CheckoutThemeProvider.tsx"; +import {detectMode} from "../../../utilites/themeUtils.ts"; + +const DEFAULT_ACCENT = '#8b5cf6'; + +const KEYFRAMES = ` + @keyframes waitlistBounceIn { + 0% { transform: scale(0); opacity: 0; } + 50% { transform: scale(1.2); } + 70% { transform: scale(0.9); } + 100% { transform: scale(1); opacity: 1; } + } + @keyframes waitlistSubtleBounce { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-6px); } + } +`; + +interface JoinWaitlistModalProps extends GenericModalProps { + product: Product; + event: Event; + productPriceId: IdParam; + priceLabel?: string; + onSuccess: () => void; +} + +export const JoinWaitlistModal = ({onClose, product, event, productPriceId, priceLabel, onSuccess}: JoinWaitlistModalProps) => { + const errorHandler = useFormErrorResponseHandler(); + const mutation = useJoinWaitlist(); + const [status, setStatus] = useState<'form' | 'success' | 'error'>('form'); + const [errorMessage, setErrorMessage] = useState(''); + + const productDisplayName = priceLabel ? `${product?.title} - ${priceLabel}` : product?.title; + + const homepageSettings = event?.settings?.homepage_theme_settings; + const accentColor = homepageSettings?.accent || DEFAULT_ACCENT; + const mode = useMemo( + () => homepageSettings?.mode || detectMode(homepageSettings?.background || '#ffffff'), + [homepageSettings?.mode, homepageSettings?.background] + ); + + const form = useForm & { consent: boolean }>({ + initialValues: { + first_name: '', + last_name: '', + email: '', + consent: false, + }, + validate: { + first_name: hasLength({min: 1}, t`First name is required`), + email: isEmail(t`Please enter a valid email address`), + consent: (value) => (!value ? t`You must agree to receive messages` : null), + }, + validateInputOnBlur: true, + }); + + const handleSubmit = ({consent: _, ...values}: Omit & { consent: boolean }) => { + mutation.mutate({ + eventId: event.id, + data: { + ...values, + product_price_id: Number(productPriceId), + }, + }, { + onSuccess: () => { + setStatus('success'); + form.reset(); + }, + onError: (error: any) => { + const message = error?.response?.data?.message; + if (message) { + setErrorMessage(message); + setStatus('error'); + } else { + errorHandler(form, error); + } + }, + }); + }; + + const handleClose = () => { + if (status === 'success') { + onSuccess(); + } + onClose(); + }; + + if (status === 'success') { + return ( + + +
+
+ {/* eslint-disable-next-line lingui/no-unlocalized-strings */} + 🎉 +
+ + {t`You're on the waitlist!`} + + + {t`We'll notify you by email if a spot becomes available for ${productDisplayName}.`} + + +
+ +
+
+ ); + } + + if (status === 'error') { + return ( + + +
+
+ {/* eslint-disable-next-line lingui/no-unlocalized-strings */} + 😕 +
+ + {t`Unable to join waitlist`} + + + {errorMessage || t`Something went wrong. Please try again later.`} + + +
+ +
+
+ ); + } + + return ( + + +
{ + e.stopPropagation(); + form.onSubmit(handleSubmit)(e); + }} + style={{padding: '0 15px 15px'}} + > + + + + + + + + +
+
+ ); +}; diff --git a/frontend/src/components/modals/OfferWaitlistModal/index.tsx b/frontend/src/components/modals/OfferWaitlistModal/index.tsx new file mode 100644 index 0000000000..66e02fac51 --- /dev/null +++ b/frontend/src/components/modals/OfferWaitlistModal/index.tsx @@ -0,0 +1,175 @@ +import {EventSettings, GenericModalProps, IdParam, WaitlistProductStats, WaitlistStats} from "../../../types.ts"; +import {Modal} from "../../common/Modal"; +import {useOfferWaitlistEntry} from "../../../mutations/useOfferWaitlistEntry.ts"; +import {showError, showSuccess} from "../../../utilites/notifications.tsx"; +import {t} from "@lingui/macro"; +import {Alert, Badge, NumberInput, Paper, Table, Text} from "@mantine/core"; +import {IconBolt, IconInfoCircle, IconSend} from "@tabler/icons-react"; +import {Button} from "../../common/Button"; +import {useState} from "react"; + +interface OfferWaitlistModalProps extends GenericModalProps { + eventId: IdParam; + eventSettings?: EventSettings; + stats?: WaitlistStats; +} + +const getDefaultQuantity = (product: WaitlistProductStats): number => { + if (product.available === 0) return 0; + if (product.available === null) return product.waiting; + return Math.min(product.waiting, product.available); +}; + +const getMaxQuantity = (product: WaitlistProductStats): number => { + if (product.available === null) return product.waiting; + return Math.min(product.waiting, product.available); +}; + +export const OfferWaitlistModal = ({onClose, eventId, eventSettings, stats}: OfferWaitlistModalProps) => { + const mutation = useOfferWaitlistEntry(); + const [loadingProductId, setLoadingProductId] = useState(null); + + const productsWithWaiting = stats?.products?.filter(p => p.waiting > 0) ?? []; + + const [quantities, setQuantities] = useState>(() => { + const initial: Record = {}; + productsWithWaiting.forEach(p => { + initial[p.product_price_id] = getDefaultQuantity(p); + }); + return initial; + }); + + const timeoutHours = eventSettings?.waitlist_offer_timeout_minutes + ? Math.round(eventSettings.waitlist_offer_timeout_minutes / 60 * 10) / 10 + : null; + + const handleOffer = (product: WaitlistProductStats) => { + const qty = quantities[product.product_price_id] ?? 1; + setLoadingProductId(product.product_price_id); + + mutation.mutate({ + eventId, + productPriceId: product.product_price_id, + quantity: qty, + }, { + onSuccess: (response) => { + const count = response?.data?.length ?? qty; + showSuccess( + count === 1 + ? t`Successfully offered tickets to 1 person` + : t`Successfully offered tickets to ${count} people` + ); + setLoadingProductId(null); + }, + onError: (error: any) => { + const message = error?.response?.data?.message || t`Failed to offer tickets`; + showError(message); + setLoadingProductId(null); + }, + }); + }; + + const isBusy = loadingProductId !== null; + + return ( + + } mb="md"> + {t`Each person will receive an email with a reserved spot to complete their purchase.`} + {timeoutHours && ( + + {t`Offers expire after ${timeoutHours} hours.`} + + )} + + + {eventSettings?.waitlist_auto_process && ( + } mb="md"> + {t`Auto-offer is enabled. Tickets are automatically offered when capacity becomes available. Use this to manually offer additional spots.`} + + )} + + {productsWithWaiting.length === 0 ? ( + {t`No products have waiting entries`} + ) : ( + + + + + {t`Product`} + {t`Waiting`} + {t`Available`} + {t`Qty`} + + + + + {productsWithWaiting.map(product => { + const noCapacity = product.available === 0; + const isRowLoading = loadingProductId === product.product_price_id; + const max = getMaxQuantity(product); + + return ( + + + {product.product_title} + + + {product.waiting} + + + {noCapacity ? ( + {t`No capacity`} + ) : product.available === null ? ( + {t`Unlimited`} + ) : ( + product.available + )} + + + {!noCapacity && ( + setQuantities(prev => ({ + ...prev, + [product.product_price_id]: Number(val) || 1, + }))} + disabled={isBusy} + style={{width: 70, marginLeft: 'auto'}} + /> + )} + + + {!noCapacity && ( + + )} + + + ); + })} + +
+
+ )} +
+ ); +}; diff --git a/frontend/src/components/routes/event/Settings/Sections/WaitlistSettings/index.tsx b/frontend/src/components/routes/event/Settings/Sections/WaitlistSettings/index.tsx new file mode 100644 index 0000000000..05c16b48e9 --- /dev/null +++ b/frontend/src/components/routes/event/Settings/Sections/WaitlistSettings/index.tsx @@ -0,0 +1,82 @@ +import {t} from "@lingui/macro"; +import {Button, NumberInput, Switch, Text} from "@mantine/core"; +import {useForm} from "@mantine/form"; +import {useParams} from "react-router"; +import {useEffect} from "react"; +import {Card} from "../../../../../common/Card"; +import {showSuccess} from "../../../../../../utilites/notifications.tsx"; +import {useFormErrorResponseHandler} from "../../../../../../hooks/useFormErrorResponseHandler.tsx"; +import {useUpdateEventSettings} from "../../../../../../mutations/useUpdateEventSettings.ts"; +import {useGetEventSettings} from "../../../../../../queries/useGetEventSettings.ts"; +import {HeadingWithDescription} from "../../../../../common/Card/CardHeading"; + +export const WaitlistSettings = () => { + const {eventId} = useParams(); + const eventSettingsQuery = useGetEventSettings(eventId); + const updateMutation = useUpdateEventSettings(); + const form = useForm({ + initialValues: { + waitlist_auto_process: false, + waitlist_offer_timeout_minutes: null as number | null, + } + }); + const formErrorHandle = useFormErrorResponseHandler(); + + useEffect(() => { + if (eventSettingsQuery?.isFetched && eventSettingsQuery?.data) { + form.setValues({ + waitlist_auto_process: eventSettingsQuery.data.waitlist_auto_process ?? false, + waitlist_offer_timeout_minutes: eventSettingsQuery.data.waitlist_offer_timeout_minutes ?? null, + }); + } + }, [eventSettingsQuery.isFetched]); + + const handleSubmit = (values: typeof form.values) => { + updateMutation.mutate({ + eventSettings: values, + eventId: eventId, + }, { + onSuccess: () => { + showSuccess(t`Successfully Updated Settings`); + }, + onError: (error) => { + formErrorHandle(form, error); + } + }); + }; + + return ( + + +
+
+ + + + + +
+
+
+ ); +}; diff --git a/frontend/src/components/routes/event/Settings/index.tsx b/frontend/src/components/routes/event/Settings/index.tsx index 9b658bdd61..60c8eaa13c 100644 --- a/frontend/src/components/routes/event/Settings/index.tsx +++ b/frontend/src/components/routes/event/Settings/index.tsx @@ -15,6 +15,7 @@ import { IconBuildingStore, IconCreditCard, IconHome, + IconListCheck, IconMapPin, IconPercentage, } from "@tabler/icons-react"; @@ -23,6 +24,7 @@ import {useMemo, useState} from "react"; import {Card} from "../../../common/Card"; import {PaymentAndInvoicingSettings} from "./Sections/PaymentSettings"; import {PlatformFeesSettings} from "./Sections/PlatformFeesSettings"; +import {WaitlistSettings} from "./Sections/WaitlistSettings"; import {useGetAccount} from "../../../../queries/useGetAccount.ts"; export const Settings = () => { @@ -67,6 +69,12 @@ export const Settings = () => { icon: IconAdjustments, component: MiscSettings }, + { + id: 'waitlist-settings', + label: t`Waitlist`, + icon: IconListCheck, + component: WaitlistSettings, + }, { id: 'payment-settings', label: t`Payment & Invoicing`, diff --git a/frontend/src/components/routes/event/SoldOutWaitlist/index.tsx b/frontend/src/components/routes/event/SoldOutWaitlist/index.tsx new file mode 100644 index 0000000000..fb4f5a4d9f --- /dev/null +++ b/frontend/src/components/routes/event/SoldOutWaitlist/index.tsx @@ -0,0 +1,114 @@ +import {useParams} from "react-router"; +import {useGetEvent} from "../../../../queries/useGetEvent.ts"; +import {PageTitle} from "../../../common/PageTitle"; +import {PageBody} from "../../../common/PageBody"; +import {WaitlistTable} from "../../../common/WaitlistTable"; +import {WaitlistStatsCards} from "../../../common/WaitlistStats"; +import {SearchBarWrapper} from "../../../common/SearchBar"; +import {Pagination} from "../../../common/Pagination"; +import {Button, Select} from "@mantine/core"; +import {ToolBar} from "../../../common/ToolBar"; +import {useGetEventWaitlistEntries} from "../../../../queries/useGetEventWaitlistEntries.ts"; +import {useGetWaitlistStats} from "../../../../queries/useGetWaitlistStats.ts"; +import {useGetEventSettings} from "../../../../queries/useGetEventSettings.ts"; +import {useFilterQueryParamSync} from "../../../../hooks/useFilterQueryParamSync.ts"; +import {QueryFilterOperator, QueryFilters, WaitlistEntryStatus} from "../../../../types.ts"; +import {TableSkeleton} from "../../../common/TableSkeleton"; +import {t} from "@lingui/macro"; +import {useDisclosure} from "@mantine/hooks"; +import {OfferWaitlistModal} from "../../../modals/OfferWaitlistModal"; +import {IconSend} from "@tabler/icons-react"; + +export const SoldOutWaitlist = () => { + const {eventId} = useParams(); + const {data: event} = useGetEvent(eventId); + const [searchParams, setSearchParams] = useFilterQueryParamSync(); + const entriesQuery = useGetEventWaitlistEntries(eventId, searchParams as QueryFilters); + const entries = entriesQuery?.data?.data; + const pagination = entriesQuery?.data?.meta; + const {data: stats} = useGetWaitlistStats(eventId); + const {data: eventSettings} = useGetEventSettings(eventId); + const [offerModalOpen, {open: openOfferModal, close: closeOfferModal}] = useDisclosure(false); + + const handleStatusFilter = (value: string | null) => { + setSearchParams({ + pageNumber: 1, + filterFields: { + ...(searchParams.filterFields || {}), + status: value + ? {operator: QueryFilterOperator.Equals, value} + : undefined, + }, + }, true); + }; + + return ( + + {t`Waitlist`} + + {stats && } + + ( + + )} + filterComponent={( +