diff --git a/.github/workflows/post-release-push-images.yml b/.github/workflows/post-release-push-images.yml index 145ebfdf2..818b5fff9 100644 --- a/.github/workflows/post-release-push-images.yml +++ b/.github/workflows/post-release-push-images.yml @@ -17,16 +17,22 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} persist-credentials: true + - name: Set up QEMU (for multi-arch builds) + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Log in to Docker Hub - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - # All-in-one Image Steps + # All-in-one Image Steps (multi-arch: amd64 + arm64) - name: Extract metadata (tags, labels) for All-in-one Docker id: meta_all_in_one - uses: docker/metadata-action@v3 + uses: docker/metadata-action@v5 with: images: daveearley/hi.events-all-in-one tags: | @@ -34,13 +40,16 @@ jobs: type=raw,value=latest,enable=${{ github.event.release.prerelease == false }} - name: Build and push All-in-one Docker image - uses: docker/build-push-action@v3 + uses: docker/build-push-action@v5 with: context: ./ file: ./Dockerfile.all-in-one + platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta_all_in_one.outputs.tags }} labels: ${{ steps.meta_all_in_one.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max # Backend Image Steps - name: Extract metadata (tags, labels) for Backend Docker diff --git a/.gitignore b/.gitignore index 2ce74fd5c..4d8a44035 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ prompts/ /playground/** /playground/ + +/plans/** +/plans diff --git a/Dockerfile.all-in-one b/Dockerfile.all-in-one index c5377aa15..8e5ff472a 100644 --- a/Dockerfile.all-in-one +++ b/Dockerfile.all-in-one @@ -4,19 +4,26 @@ WORKDIR /app/frontend RUN apk add --no-cache yarn +# Increase network timeout for slow ARM emulation builds +RUN yarn config set network-timeout 600000 + COPY ./frontend/package.json ./frontend/yarn.lock ./ COPY ./frontend . -RUN yarn install && yarn build +RUN yarn install --network-timeout 600000 --frozen-lockfile && yarn build -FROM serversideup/php:beta-8.3.2-fpm-alpine +# Use stable multi-arch serversideup/php image +FROM serversideup/php:8.3-fpm-alpine ENV PHP_OPCACHE_ENABLE=1 +# Switch to root for installing extensions and packages +USER root + RUN install-php-extensions intl -RUN apk add --no-cache nodejs yarn nginx supervisor +RUN apk add --no-cache nodejs yarn nginx supervisor dos2unix COPY --from=node-frontend /app/frontend /app/frontend diff --git a/backend/app/Console/Kernel.php b/backend/app/Console/Kernel.php index 53e011dca..540f41147 100644 --- a/backend/app/Console/Kernel.php +++ b/backend/app/Console/Kernel.php @@ -2,10 +2,19 @@ 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; 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 { $this->load(__DIR__ . '/Commands'); diff --git a/backend/app/DomainObjects/AccountConfigurationDomainObject.php b/backend/app/DomainObjects/AccountConfigurationDomainObject.php index c515968a6..8e6a1b485 100644 --- a/backend/app/DomainObjects/AccountConfigurationDomainObject.php +++ b/backend/app/DomainObjects/AccountConfigurationDomainObject.php @@ -13,4 +13,9 @@ public function getPercentageApplicationFee(): float { return $this->getApplicationFees()['percentage'] ?? config('app.default_application_fee_percentage'); } + + public function getApplicationFeeCurrency(): string + { + return $this->getApplicationFees()['currency'] ?? 'USD'; + } } diff --git a/backend/app/DomainObjects/Enums/CapacityChangeDirection.php b/backend/app/DomainObjects/Enums/CapacityChangeDirection.php new file mode 100644 index 000000000..1b5d7935a --- /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/MessageDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/MessageDomainObjectAbstract.php index 813b4f059..30fdcfcff 100644 --- a/backend/app/DomainObjects/Generated/MessageDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/MessageDomainObjectAbstract.php @@ -27,6 +27,7 @@ abstract class MessageDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr final public const UPDATED_AT = 'updated_at'; final public const DELETED_AT = 'deleted_at'; final public const ELIGIBILITY_FAILURES = 'eligibility_failures'; + final public const SCHEDULED_AT = 'scheduled_at'; protected int $id; protected int $event_id; @@ -45,6 +46,7 @@ abstract class MessageDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr protected ?string $updated_at = null; protected ?string $deleted_at = null; protected array|string|null $eligibility_failures = null; + protected ?string $scheduled_at = null; public function toArray(): array { @@ -66,6 +68,7 @@ public function toArray(): array 'updated_at' => $this->updated_at ?? null, 'deleted_at' => $this->deleted_at ?? null, 'eligibility_failures' => $this->eligibility_failures ?? null, + 'scheduled_at' => $this->scheduled_at ?? null, ]; } @@ -255,4 +258,15 @@ public function getEligibilityFailures(): array|string|null { return $this->eligibility_failures; } + + public function setScheduledAt(?string $scheduled_at): self + { + $this->scheduled_at = $scheduled_at; + return $this; + } + + public function getScheduledAt(): ?string + { + return $this->scheduled_at; + } } diff --git a/backend/app/DomainObjects/Generated/ProductDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/ProductDomainObjectAbstract.php index 4d16950f2..482362a6f 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 000000000..7c310634e --- /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/Generated/WebhookDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/WebhookDomainObjectAbstract.php index 5527c2881..660f7a66f 100644 --- a/backend/app/DomainObjects/Generated/WebhookDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/WebhookDomainObjectAbstract.php @@ -13,6 +13,7 @@ abstract class WebhookDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr final public const ID = 'id'; final public const USER_ID = 'user_id'; final public const EVENT_ID = 'event_id'; + final public const ORGANIZER_ID = 'organizer_id'; final public const ACCOUNT_ID = 'account_id'; final public const URL = 'url'; final public const EVENT_TYPES = 'event_types'; @@ -27,7 +28,8 @@ abstract class WebhookDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr protected int $id; protected int $user_id; - protected int $event_id; + protected ?int $event_id = null; + protected ?int $organizer_id = null; protected int $account_id; protected string $url; protected array|string $event_types; @@ -46,6 +48,7 @@ public function toArray(): array 'id' => $this->id ?? null, 'user_id' => $this->user_id ?? null, 'event_id' => $this->event_id ?? null, + 'organizer_id' => $this->organizer_id ?? null, 'account_id' => $this->account_id ?? null, 'url' => $this->url ?? null, 'event_types' => $this->event_types ?? null, @@ -82,17 +85,28 @@ public function getUserId(): int return $this->user_id; } - public function setEventId(int $event_id): self + public function setEventId(?int $event_id): self { $this->event_id = $event_id; return $this; } - public function getEventId(): int + public function getEventId(): ?int { return $this->event_id; } + public function setOrganizerId(?int $organizer_id): self + { + $this->organizer_id = $organizer_id; + return $this; + } + + public function getOrganizerId(): ?int + { + return $this->organizer_id; + } + public function setAccountId(int $account_id): self { $this->account_id = $account_id; diff --git a/backend/app/DomainObjects/MessageDomainObject.php b/backend/app/DomainObjects/MessageDomainObject.php index 00036f3b6..3d8a9af26 100644 --- a/backend/app/DomainObjects/MessageDomainObject.php +++ b/backend/app/DomainObjects/MessageDomainObject.php @@ -2,11 +2,12 @@ namespace HiEvents\DomainObjects; +use HiEvents\DomainObjects\Interfaces\IsFilterable; use HiEvents\DomainObjects\Interfaces\IsSortable; use HiEvents\DomainObjects\SortingAndFiltering\AllowedSorts; use HiEvents\Helper\StringHelper; -class MessageDomainObject extends Generated\MessageDomainObjectAbstract implements IsSortable +class MessageDomainObject extends Generated\MessageDomainObjectAbstract implements IsSortable, IsFilterable { private ?UserDomainObject $sentByUser = null; @@ -37,6 +38,13 @@ public static function getAllowedSorts(): AllowedSorts } + public static function getAllowedFilterFields(): array + { + return [ + self::STATUS, + ]; + } + public function getSentByUser(): ?UserDomainObject { return $this->sentByUser; diff --git a/backend/app/DomainObjects/ProductPriceDomainObject.php b/backend/app/DomainObjects/ProductPriceDomainObject.php index 97ee5ee23..000c05183 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 000000000..5d2df336d --- /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 000000000..f0894827a --- /dev/null +++ b/backend/app/Events/CapacityChangedEvent.php @@ -0,0 +1,18 @@ + 'required|array', 'application_fees.fixed' => 'required|numeric|min:0', 'application_fees.percentage' => 'required|numeric|min:0|max:100', + 'application_fees.currency' => 'sometimes|string|size:3|alpha|uppercase', 'bypass_application_fees' => 'sometimes|boolean', ]); diff --git a/backend/app/Http/Actions/Admin/Configurations/UpdateConfigurationAction.php b/backend/app/Http/Actions/Admin/Configurations/UpdateConfigurationAction.php index 1d5378ddb..947a152c2 100644 --- a/backend/app/Http/Actions/Admin/Configurations/UpdateConfigurationAction.php +++ b/backend/app/Http/Actions/Admin/Configurations/UpdateConfigurationAction.php @@ -27,6 +27,7 @@ public function __invoke(Request $request, int $configurationId): JsonResponse 'application_fees' => 'required|array', 'application_fees.fixed' => 'required|numeric|min:0', 'application_fees.percentage' => 'required|numeric|min:0|max:100', + 'application_fees.currency' => 'sometimes|string|size:3|alpha|uppercase', 'bypass_application_fees' => 'sometimes|boolean', ]); diff --git a/backend/app/Http/Actions/EventSettings/GetPlatformFeePreviewAction.php b/backend/app/Http/Actions/EventSettings/GetPlatformFeePreviewAction.php new file mode 100644 index 000000000..1afd068e6 --- /dev/null +++ b/backend/app/Http/Actions/EventSettings/GetPlatformFeePreviewAction.php @@ -0,0 +1,38 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $this->validate($request, [ + 'price' => 'required|numeric|min:0', + ]); + + $dto = new GetPlatformFeePreviewDTO( + eventId: $eventId, + price: (float)$request->input('price'), + ); + + $result = $this->handler->handle($dto); + + return $this->resourceResponse(PlatformFeePreviewResource::class, $result); + } +} diff --git a/backend/app/Http/Actions/Messages/CancelMessageAction.php b/backend/app/Http/Actions/Messages/CancelMessageAction.php new file mode 100644 index 000000000..ff232f09f --- /dev/null +++ b/backend/app/Http/Actions/Messages/CancelMessageAction.php @@ -0,0 +1,28 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $message = $this->cancelMessageHandler->handle($messageId, $eventId); + + return $this->resourceResponse(MessageResource::class, $message); + } +} diff --git a/backend/app/Http/Actions/Messages/GetMessageRecipientsAction.php b/backend/app/Http/Actions/Messages/GetMessageRecipientsAction.php new file mode 100644 index 000000000..4b944ca37 --- /dev/null +++ b/backend/app/Http/Actions/Messages/GetMessageRecipientsAction.php @@ -0,0 +1,30 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $params = $this->getPaginationQueryParams($request); + + $recipients = $this->handler->handle($eventId, $messageId, $params); + + return $this->resourceResponse(OutgoingMessageResource::class, $recipients); + } +} diff --git a/backend/app/Http/Actions/Messages/SendMessageAction.php b/backend/app/Http/Actions/Messages/SendMessageAction.php index 949a19795..8c72b1104 100644 --- a/backend/app/Http/Actions/Messages/SendMessageAction.php +++ b/backend/app/Http/Actions/Messages/SendMessageAction.php @@ -42,6 +42,7 @@ public function __invoke(SendMessageRequest $request, int $eventId): JsonRespons 'send_copy_to_current_user' => $request->boolean('send_copy_to_current_user'), 'sent_by_user_id' => $user->getId(), 'account_id' => $this->getAuthenticatedAccountId(), + 'scheduled_at' => $request->input('scheduled_at'), ])); } catch (AccountNotVerifiedException $e) { return $this->errorResponse($e->getMessage(), Response::HTTP_UNAUTHORIZED); diff --git a/backend/app/Http/Actions/Organizers/Webhooks/CreateOrganizerWebhookAction.php b/backend/app/Http/Actions/Organizers/Webhooks/CreateOrganizerWebhookAction.php new file mode 100644 index 000000000..603bfde2a --- /dev/null +++ b/backend/app/Http/Actions/Organizers/Webhooks/CreateOrganizerWebhookAction.php @@ -0,0 +1,43 @@ +isActionAuthorized($organizerId, OrganizerDomainObject::class); + + $webhook = $this->createWebhookHandler->handle( + new CreateWebhookDTO( + url: $request->validated('url'), + eventTypes: $request->validated('event_types'), + eventId: null, + organizerId: $organizerId, + userId: $this->getAuthenticatedUser()->getId(), + accountId: $this->getAuthenticatedAccountId(), + status: WebhookStatus::fromName($request->validated('status')), + ) + ); + + return $this->resourceResponse( + resource: WebhookResource::class, + data: $webhook + ); + } +} diff --git a/backend/app/Http/Actions/Organizers/Webhooks/DeleteOrganizerWebhookAction.php b/backend/app/Http/Actions/Organizers/Webhooks/DeleteOrganizerWebhookAction.php new file mode 100644 index 000000000..cd157307f --- /dev/null +++ b/backend/app/Http/Actions/Organizers/Webhooks/DeleteOrganizerWebhookAction.php @@ -0,0 +1,30 @@ +isActionAuthorized($organizerId, OrganizerDomainObject::class); + + $this->deleteWebhookHandler->handle( + webhookId: $webhookId, + eventId: null, + organizerId: $organizerId, + ); + + return $this->deletedResponse(); + } +} diff --git a/backend/app/Http/Actions/Organizers/Webhooks/EditOrganizerWebhookAction.php b/backend/app/Http/Actions/Organizers/Webhooks/EditOrganizerWebhookAction.php new file mode 100644 index 000000000..6bc51334c --- /dev/null +++ b/backend/app/Http/Actions/Organizers/Webhooks/EditOrganizerWebhookAction.php @@ -0,0 +1,44 @@ +isActionAuthorized($organizerId, OrganizerDomainObject::class); + + $webhook = $this->editWebhookHandler->handle( + new EditWebhookDTO( + webhookId: $webhookId, + url: $request->validated('url'), + eventTypes: $request->validated('event_types'), + eventId: null, + organizerId: $organizerId, + userId: $this->getAuthenticatedUser()->getId(), + accountId: $this->getAuthenticatedAccountId(), + status: WebhookStatus::fromName($request->validated('status')), + ) + ); + + return $this->resourceResponse( + resource: WebhookResource::class, + data: $webhook + ); + } +} diff --git a/backend/app/Http/Actions/Organizers/Webhooks/GetOrganizerWebhookAction.php b/backend/app/Http/Actions/Organizers/Webhooks/GetOrganizerWebhookAction.php new file mode 100644 index 000000000..cb7ea4ad2 --- /dev/null +++ b/backend/app/Http/Actions/Organizers/Webhooks/GetOrganizerWebhookAction.php @@ -0,0 +1,34 @@ +isActionAuthorized($organizerId, OrganizerDomainObject::class); + + $webhook = $this->getWebhookHandler->handle( + webhookId: $webhookId, + eventId: null, + organizerId: $organizerId + ); + + return $this->resourceResponse( + resource: WebhookResource::class, + data: $webhook + ); + } +} diff --git a/backend/app/Http/Actions/Organizers/Webhooks/GetOrganizerWebhookLogsAction.php b/backend/app/Http/Actions/Organizers/Webhooks/GetOrganizerWebhookLogsAction.php new file mode 100644 index 000000000..720728f6b --- /dev/null +++ b/backend/app/Http/Actions/Organizers/Webhooks/GetOrganizerWebhookLogsAction.php @@ -0,0 +1,39 @@ +isActionAuthorized($organizerId, OrganizerDomainObject::class); + + $webhookLogs = $this->getWebhookLogsHandler->handle( + webhookId: $webhookId, + eventId: null, + organizerId: $organizerId, + ); + + $webhookLogs = $webhookLogs->sortBy(function (WebhookLogDomainObject $webhookLog) { + return $webhookLog->getId(); + }, SORT_REGULAR, true); + + return $this->resourceResponse( + resource: WebhookLogResource::class, + data: $webhookLogs + ); + } +} diff --git a/backend/app/Http/Actions/Organizers/Webhooks/GetOrganizerWebhooksAction.php b/backend/app/Http/Actions/Organizers/Webhooks/GetOrganizerWebhooksAction.php new file mode 100644 index 000000000..f6e7e51ef --- /dev/null +++ b/backend/app/Http/Actions/Organizers/Webhooks/GetOrganizerWebhooksAction.php @@ -0,0 +1,34 @@ +isActionAuthorized($organizerId, OrganizerDomainObject::class); + + $webhooks = $this->getWebhooksHandler->handler( + accountId: $this->getAuthenticatedAccountId(), + eventId: null, + organizerId: $organizerId + ); + + return $this->resourceResponse( + resource: WebhookResource::class, + data: $webhooks + ); + } +} diff --git a/backend/app/Http/Actions/Waitlist/Organizer/CancelWaitlistEntryAction.php b/backend/app/Http/Actions/Waitlist/Organizer/CancelWaitlistEntryAction.php new file mode 100644 index 000000000..5b75857d3 --- /dev/null +++ b/backend/app/Http/Actions/Waitlist/Organizer/CancelWaitlistEntryAction.php @@ -0,0 +1,41 @@ +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 000000000..ca0f1bf1c --- /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 000000000..ef72fe63e --- /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 000000000..43b22a3bf --- /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 000000000..b08ff230a --- /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 000000000..5dca378c5 --- /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/Actions/Webhooks/CreateWebhookAction.php b/backend/app/Http/Actions/Webhooks/CreateWebhookAction.php index 7d0142001..a61d3acf6 100644 --- a/backend/app/Http/Actions/Webhooks/CreateWebhookAction.php +++ b/backend/app/Http/Actions/Webhooks/CreateWebhookAction.php @@ -27,10 +27,10 @@ public function __invoke(int $eventId, UpsertWebhookRequest $request): JsonRespo new CreateWebhookDTO( url: $request->validated('url'), eventTypes: $request->validated('event_types'), - eventId: $eventId, userId: $this->getAuthenticatedUser()->getId(), accountId: $this->getAuthenticatedAccountId(), status: WebhookStatus::fromName($request->validated('status')), + eventId: $eventId, ) ); diff --git a/backend/app/Http/Actions/Webhooks/DeleteWebhookAction.php b/backend/app/Http/Actions/Webhooks/DeleteWebhookAction.php index 508c0fa3d..b807d4719 100644 --- a/backend/app/Http/Actions/Webhooks/DeleteWebhookAction.php +++ b/backend/app/Http/Actions/Webhooks/DeleteWebhookAction.php @@ -20,8 +20,8 @@ public function __invoke(int $eventId, int $webhookId): Response $this->isActionAuthorized($eventId, EventDomainObject::class); $this->deleteWebhookHandler->handle( - $eventId, - $webhookId, + webhookId: $webhookId, + eventId: $eventId, ); return $this->deletedResponse(); diff --git a/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php index 78b78b7a3..213038a74 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/Message/SendMessageRequest.php b/backend/app/Http/Request/Message/SendMessageRequest.php index c6ebba139..c91475abb 100644 --- a/backend/app/Http/Request/Message/SendMessageRequest.php +++ b/backend/app/Http/Request/Message/SendMessageRequest.php @@ -13,7 +13,7 @@ public function rules(): array { return [ 'subject' => 'required|string|max:100', - 'message' => 'required|string|max:5000', + 'message' => 'required|string|max:8000', 'message_type' => [new In(MessageTypeEnum::valuesArray()), 'required'], 'is_test' => 'boolean', 'attendee_ids' => 'max:50,array|required_if:message_type,' . MessageTypeEnum::INDIVIDUAL_ATTENDEES->name, @@ -25,6 +25,7 @@ public function rules(): array 'required_if:message_type,' . MessageTypeEnum::ORDER_OWNERS_WITH_PRODUCT->name, new In([OrderStatus::COMPLETED->name, OrderStatus::AWAITING_OFFLINE_PAYMENT->name]), ], + 'scheduled_at' => 'nullable|date|after:now', ]; } diff --git a/backend/app/Http/Request/Product/UpsertProductRequest.php b/backend/app/Http/Request/Product/UpsertProductRequest.php index 9f43749a5..adb485cf8 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 000000000..1fe7aea86 --- /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 000000000..f04e6d334 --- /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/Event/UpdateEventStatisticsJob.php b/backend/app/Jobs/Event/UpdateEventStatisticsJob.php index 3101501a3..e71bbf544 100644 --- a/backend/app/Jobs/Event/UpdateEventStatisticsJob.php +++ b/backend/app/Jobs/Event/UpdateEventStatisticsJob.php @@ -6,13 +6,14 @@ use HiEvents\Exceptions\EventStatisticsVersionMismatchException; use HiEvents\Services\Domain\EventStatistics\EventStatisticsIncrementService; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Throwable; -class UpdateEventStatisticsJob implements ShouldQueue +class UpdateEventStatisticsJob implements ShouldQueue, ShouldBeUnique { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; @@ -22,11 +23,18 @@ class UpdateEventStatisticsJob implements ShouldQueue public int $backoff = 10; // seconds + public int $uniqueFor = 60; // seconds + public function __construct(OrderDomainObject $order) { $this->order = $order; } + public function uniqueId(): string + { + return (string) $this->order->getId(); + } + /** * @throws EventStatisticsVersionMismatchException|Throwable */ diff --git a/backend/app/Jobs/Event/Webhook/DispatchEventWebhookJob.php b/backend/app/Jobs/Event/Webhook/DispatchEventWebhookJob.php new file mode 100644 index 000000000..0f73e76e8 --- /dev/null +++ b/backend/app/Jobs/Event/Webhook/DispatchEventWebhookJob.php @@ -0,0 +1,31 @@ +dispatchEventWebhook( + eventType: $this->eventType, + eventId: $this->eventId, + ); + } +} diff --git a/backend/app/Jobs/Message/SendScheduledMessagesJob.php b/backend/app/Jobs/Message/SendScheduledMessagesJob.php new file mode 100644 index 000000000..a7f5a0241 --- /dev/null +++ b/backend/app/Jobs/Message/SendScheduledMessagesJob.php @@ -0,0 +1,44 @@ +findWhere([ + 'status' => MessageStatus::SCHEDULED->name, + ['scheduled_at', '<=', Carbon::now()->toDateTimeString()], + ]); + + foreach ($messages as $message) { + try { + $messageDispatchService->dispatchMessage($message); + } catch (Throwable $e) { + Log::error('Failed to dispatch scheduled message', [ + 'message_id' => $message->getId(), + 'error' => $e->getMessage(), + ]); + } + } + } +} diff --git a/backend/app/Jobs/Waitlist/ProcessExpiredWaitlistOffersJob.php b/backend/app/Jobs/Waitlist/ProcessExpiredWaitlistOffersJob.php new file mode 100644 index 000000000..1c17ce497 --- /dev/null +++ b/backend/app/Jobs/Waitlist/ProcessExpiredWaitlistOffersJob.php @@ -0,0 +1,90 @@ +findWhere([ + 'status' => WaitlistEntryStatus::OFFERED->name, + ['offer_expires_at', '<=', now()->toDateTimeString()], + ['offer_expires_at', '!=', null], + ]); + + foreach ($expiredEntries as $entry) { + try { + $databaseManager->transaction(function () use ($entry, $repository, $orderRepository) { + $lockedEntry = $repository->findByIdLocked($entry->getId()); + + if ($lockedEntry === null || $lockedEntry->getStatus() !== WaitlistEntryStatus::OFFERED->name) { + return; + } + + if ($lockedEntry->getOrderId() !== null) { + $orderRepository->deleteWhere([ + 'id' => $lockedEntry->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()], + ); + }); + + $freshEntry = $repository->findById($entry->getId()); + + if ($freshEntry->getStatus() !== WaitlistEntryStatus::OFFER_EXPIRED->name) { + continue; + } + + 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 000000000..f22fdaa9e --- /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 000000000..31a52696a --- /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 000000000..c47b3f63d --- /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 000000000..6fe49f890 --- /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 000000000..9c582143b --- /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 000000000..1a29619cd --- /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 000000000..aa3c976f5 --- /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 000000000..8cd81e268 --- /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/Organizer.php b/backend/app/Models/Organizer.php index b174f9727..9eb57f2bd 100644 --- a/backend/app/Models/Organizer.php +++ b/backend/app/Models/Organizer.php @@ -21,4 +21,9 @@ public function organizer_settings(): HasOne { return $this->hasOne(OrganizerSetting::class); } + + public function webhooks(): HasMany + { + return $this->hasMany(Webhook::class); + } } diff --git a/backend/app/Models/WaitlistEntry.php b/backend/app/Models/WaitlistEntry.php new file mode 100644 index 000000000..45f07495d --- /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/Models/Webhook.php b/backend/app/Models/Webhook.php index 7d2520b75..1c85c17c4 100644 --- a/backend/app/Models/Webhook.php +++ b/backend/app/Models/Webhook.php @@ -32,6 +32,11 @@ public function event(): BelongsTo return $this->belongsTo(Event::class); } + public function organizer(): BelongsTo + { + return $this->belongsTo(Organizer::class); + } + public function account(): BelongsTo { return $this->belongsTo(Account::class); diff --git a/backend/app/Providers/RepositoryServiceProvider.php b/backend/app/Providers/RepositoryServiceProvider.php index 712839ee3..55f77ef5b 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/AccountAttributionRepository.php b/backend/app/Repository/Eloquent/AccountAttributionRepository.php index 44ced9ebb..c2c9a93f0 100644 --- a/backend/app/Repository/Eloquent/AccountAttributionRepository.php +++ b/backend/app/Repository/Eloquent/AccountAttributionRepository.php @@ -11,6 +11,9 @@ use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\DB; +/** + * @extends BaseRepository + */ class AccountAttributionRepository extends BaseRepository implements AccountAttributionRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/AccountConfigurationRepository.php b/backend/app/Repository/Eloquent/AccountConfigurationRepository.php index 5b10131f8..3a2a5a193 100644 --- a/backend/app/Repository/Eloquent/AccountConfigurationRepository.php +++ b/backend/app/Repository/Eloquent/AccountConfigurationRepository.php @@ -6,6 +6,9 @@ use HiEvents\Models\AccountConfiguration; use HiEvents\Repository\Interfaces\AccountConfigurationRepositoryInterface; +/** + * @extends BaseRepository + */ class AccountConfigurationRepository extends BaseRepository implements AccountConfigurationRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/AccountMessagingTierRepository.php b/backend/app/Repository/Eloquent/AccountMessagingTierRepository.php index 3af0a344b..5717e4b54 100644 --- a/backend/app/Repository/Eloquent/AccountMessagingTierRepository.php +++ b/backend/app/Repository/Eloquent/AccountMessagingTierRepository.php @@ -8,6 +8,9 @@ use HiEvents\Models\AccountMessagingTier; use HiEvents\Repository\Interfaces\AccountMessagingTierRepositoryInterface; +/** + * @extends BaseRepository + */ class AccountMessagingTierRepository extends BaseRepository implements AccountMessagingTierRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/AccountRepository.php b/backend/app/Repository/Eloquent/AccountRepository.php index e611137a6..54f136d31 100644 --- a/backend/app/Repository/Eloquent/AccountRepository.php +++ b/backend/app/Repository/Eloquent/AccountRepository.php @@ -9,6 +9,9 @@ use HiEvents\Repository\Interfaces\AccountRepositoryInterface; use Illuminate\Contracts\Pagination\LengthAwarePaginator; +/** + * @extends BaseRepository + */ class AccountRepository extends BaseRepository implements AccountRepositoryInterface { protected function getModel(): string @@ -30,6 +33,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/AccountStripePlatformRepository.php b/backend/app/Repository/Eloquent/AccountStripePlatformRepository.php index 05a5ffcd0..d8f45cc0a 100644 --- a/backend/app/Repository/Eloquent/AccountStripePlatformRepository.php +++ b/backend/app/Repository/Eloquent/AccountStripePlatformRepository.php @@ -8,6 +8,9 @@ use HiEvents\Models\AccountStripePlatform; use HiEvents\Repository\Interfaces\AccountStripePlatformRepositoryInterface; +/** + * @extends BaseRepository + */ class AccountStripePlatformRepository extends BaseRepository implements AccountStripePlatformRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/AccountUserRepository.php b/backend/app/Repository/Eloquent/AccountUserRepository.php index e3c6efb32..042908509 100644 --- a/backend/app/Repository/Eloquent/AccountUserRepository.php +++ b/backend/app/Repository/Eloquent/AccountUserRepository.php @@ -8,6 +8,9 @@ use HiEvents\Models\AccountUser; use HiEvents\Repository\Interfaces\AccountUserRepositoryInterface; +/** + * @extends BaseRepository + */ class AccountUserRepository extends BaseRepository implements AccountUserRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/AccountVatSettingRepository.php b/backend/app/Repository/Eloquent/AccountVatSettingRepository.php index 4aad33234..6e9a7393f 100644 --- a/backend/app/Repository/Eloquent/AccountVatSettingRepository.php +++ b/backend/app/Repository/Eloquent/AccountVatSettingRepository.php @@ -6,6 +6,9 @@ use HiEvents\Models\AccountVatSetting; use HiEvents\Repository\Interfaces\AccountVatSettingRepositoryInterface; +/** + * @extends BaseRepository + */ class AccountVatSettingRepository extends BaseRepository implements AccountVatSettingRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/AffiliateRepository.php b/backend/app/Repository/Eloquent/AffiliateRepository.php index 9dfe87fdd..1fdacd195 100644 --- a/backend/app/Repository/Eloquent/AffiliateRepository.php +++ b/backend/app/Repository/Eloquent/AffiliateRepository.php @@ -13,6 +13,9 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Pagination\LengthAwarePaginator; +/** + * @extends BaseRepository + */ class AffiliateRepository extends BaseRepository implements AffiliateRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/AttendeeCheckInRepository.php b/backend/app/Repository/Eloquent/AttendeeCheckInRepository.php index d6d31e76a..7aedb6994 100644 --- a/backend/app/Repository/Eloquent/AttendeeCheckInRepository.php +++ b/backend/app/Repository/Eloquent/AttendeeCheckInRepository.php @@ -6,6 +6,9 @@ use HiEvents\Models\AttendeeCheckIn; use HiEvents\Repository\Interfaces\AttendeeCheckInRepositoryInterface; +/** + * @extends BaseRepository + */ class AttendeeCheckInRepository extends BaseRepository implements AttendeeCheckInRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/AttendeeRepository.php b/backend/app/Repository/Eloquent/AttendeeRepository.php index a65aa4c5e..1fae4a7e5 100644 --- a/backend/app/Repository/Eloquent/AttendeeRepository.php +++ b/backend/app/Repository/Eloquent/AttendeeRepository.php @@ -17,6 +17,9 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; +/** + * @extends BaseRepository + */ class AttendeeRepository extends BaseRepository implements AttendeeRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/BaseRepository.php b/backend/app/Repository/Eloquent/BaseRepository.php index 973ac658d..c3d98d1c8 100644 --- a/backend/app/Repository/Eloquent/BaseRepository.php +++ b/backend/app/Repository/Eloquent/BaseRepository.php @@ -24,7 +24,7 @@ use TypeError; /** - * @template T + * @template T of DomainObjectInterface * @implements RepositoryInterface */ abstract class BaseRepository implements RepositoryInterface diff --git a/backend/app/Repository/Eloquent/CapacityAssignmentRepository.php b/backend/app/Repository/Eloquent/CapacityAssignmentRepository.php index 089e05ed5..5ad644952 100644 --- a/backend/app/Repository/Eloquent/CapacityAssignmentRepository.php +++ b/backend/app/Repository/Eloquent/CapacityAssignmentRepository.php @@ -10,6 +10,9 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Pagination\LengthAwarePaginator; +/** + * @extends BaseRepository + */ class CapacityAssignmentRepository extends BaseRepository implements CapacityAssignmentRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/CheckInListRepository.php b/backend/app/Repository/Eloquent/CheckInListRepository.php index 00a60518f..0e6c62e08 100644 --- a/backend/app/Repository/Eloquent/CheckInListRepository.php +++ b/backend/app/Repository/Eloquent/CheckInListRepository.php @@ -13,6 +13,9 @@ use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; +/** + * @extends BaseRepository + */ class CheckInListRepository extends BaseRepository implements CheckInListRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/EmailTemplateRepository.php b/backend/app/Repository/Eloquent/EmailTemplateRepository.php index cd8cab8f9..fca1008bb 100644 --- a/backend/app/Repository/Eloquent/EmailTemplateRepository.php +++ b/backend/app/Repository/Eloquent/EmailTemplateRepository.php @@ -8,6 +8,9 @@ use HiEvents\Repository\Interfaces\EmailTemplateRepositoryInterface; use Illuminate\Support\Collection; +/** + * @extends BaseRepository + */ class EmailTemplateRepository extends BaseRepository implements EmailTemplateRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/EventDailyStatisticRepository.php b/backend/app/Repository/Eloquent/EventDailyStatisticRepository.php index aff29ec43..0a9d63f90 100644 --- a/backend/app/Repository/Eloquent/EventDailyStatisticRepository.php +++ b/backend/app/Repository/Eloquent/EventDailyStatisticRepository.php @@ -6,6 +6,9 @@ use HiEvents\Models\EventDailyStatistic; use HiEvents\Repository\Interfaces\EventDailyStatisticRepositoryInterface; +/** + * @extends BaseRepository + */ class EventDailyStatisticRepository extends BaseRepository implements EventDailyStatisticRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/EventRepository.php b/backend/app/Repository/Eloquent/EventRepository.php index 8dc70543a..824e9d2cc 100644 --- a/backend/app/Repository/Eloquent/EventRepository.php +++ b/backend/app/Repository/Eloquent/EventRepository.php @@ -18,6 +18,9 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Pagination\LengthAwarePaginator; +/** + * @extends BaseRepository + */ class EventRepository extends BaseRepository implements EventRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/EventSettingsRepository.php b/backend/app/Repository/Eloquent/EventSettingsRepository.php index 731bf9305..f7d39dd17 100644 --- a/backend/app/Repository/Eloquent/EventSettingsRepository.php +++ b/backend/app/Repository/Eloquent/EventSettingsRepository.php @@ -6,6 +6,9 @@ use HiEvents\Models\EventSetting; use HiEvents\Repository\Interfaces\EventSettingsRepositoryInterface; +/** + * @extends BaseRepository + */ class EventSettingsRepository extends BaseRepository implements EventSettingsRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/EventStatisticRepository.php b/backend/app/Repository/Eloquent/EventStatisticRepository.php index 2bc554b21..7c5a184df 100644 --- a/backend/app/Repository/Eloquent/EventStatisticRepository.php +++ b/backend/app/Repository/Eloquent/EventStatisticRepository.php @@ -6,6 +6,9 @@ use HiEvents\Models\EventStatistic; use HiEvents\Repository\Interfaces\EventStatisticRepositoryInterface; +/** + * @extends BaseRepository + */ class EventStatisticRepository extends BaseRepository implements EventStatisticRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/ImageRepository.php b/backend/app/Repository/Eloquent/ImageRepository.php index fab4b21ea..d36c52d88 100644 --- a/backend/app/Repository/Eloquent/ImageRepository.php +++ b/backend/app/Repository/Eloquent/ImageRepository.php @@ -6,6 +6,9 @@ use HiEvents\Models\Image; use HiEvents\Repository\Interfaces\ImageRepositoryInterface; +/** + * @extends BaseRepository + */ class ImageRepository extends BaseRepository implements ImageRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/InvoiceRepository.php b/backend/app/Repository/Eloquent/InvoiceRepository.php index b7a85edb4..3103751eb 100644 --- a/backend/app/Repository/Eloquent/InvoiceRepository.php +++ b/backend/app/Repository/Eloquent/InvoiceRepository.php @@ -6,6 +6,9 @@ use HiEvents\Models\Invoice; use HiEvents\Repository\Interfaces\InvoiceRepositoryInterface; +/** + * @extends BaseRepository + */ class InvoiceRepository extends BaseRepository implements InvoiceRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/MessageRepository.php b/backend/app/Repository/Eloquent/MessageRepository.php index 5325e8385..d47bd35ab 100644 --- a/backend/app/Repository/Eloquent/MessageRepository.php +++ b/backend/app/Repository/Eloquent/MessageRepository.php @@ -12,6 +12,9 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Pagination\LengthAwarePaginator; +/** + * @extends BaseRepository + */ class MessageRepository extends BaseRepository implements MessageRepositoryInterface { protected function getModel(): string @@ -38,6 +41,10 @@ public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAware }; } + if ($params->filter_fields && $params->filter_fields->isNotEmpty()) { + $this->applyFilterFields($params, MessageDomainObject::getAllowedFilterFields()); + } + $this->model = $this->model->orderBy( $params->sort_by ?? MessageDomainObject::getDefaultSort(), $params->sort_direction ?? 'desc', diff --git a/backend/app/Repository/Eloquent/OrderApplicationFeeRepository.php b/backend/app/Repository/Eloquent/OrderApplicationFeeRepository.php index a3aab12e1..2120ae070 100644 --- a/backend/app/Repository/Eloquent/OrderApplicationFeeRepository.php +++ b/backend/app/Repository/Eloquent/OrderApplicationFeeRepository.php @@ -6,6 +6,9 @@ use HiEvents\Models\OrderApplicationFee; use HiEvents\Repository\Interfaces\OrderApplicationFeeRepositoryInterface; +/** + * @extends BaseRepository + */ class OrderApplicationFeeRepository extends BaseRepository implements OrderApplicationFeeRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/OrderAuditLogRepository.php b/backend/app/Repository/Eloquent/OrderAuditLogRepository.php index 3b1f14599..392d8c788 100644 --- a/backend/app/Repository/Eloquent/OrderAuditLogRepository.php +++ b/backend/app/Repository/Eloquent/OrderAuditLogRepository.php @@ -8,6 +8,9 @@ use HiEvents\Models\OrderAuditLog; use HiEvents\Repository\Interfaces\OrderAuditLogRepositoryInterface; +/** + * @extends BaseRepository + */ class OrderAuditLogRepository extends BaseRepository implements OrderAuditLogRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/OrderItemRepository.php b/backend/app/Repository/Eloquent/OrderItemRepository.php index 08c5d26f3..72384aa8b 100644 --- a/backend/app/Repository/Eloquent/OrderItemRepository.php +++ b/backend/app/Repository/Eloquent/OrderItemRepository.php @@ -6,6 +6,9 @@ use HiEvents\Models\OrderItem; use HiEvents\Repository\Interfaces\OrderItemRepositoryInterface; +/** + * @extends BaseRepository + */ class OrderItemRepository extends BaseRepository implements OrderItemRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/OrderPaymentPlatformFeeRepository.php b/backend/app/Repository/Eloquent/OrderPaymentPlatformFeeRepository.php index 7cfa12cfa..5523a3152 100644 --- a/backend/app/Repository/Eloquent/OrderPaymentPlatformFeeRepository.php +++ b/backend/app/Repository/Eloquent/OrderPaymentPlatformFeeRepository.php @@ -6,6 +6,9 @@ use HiEvents\Models\OrderPaymentPlatformFee; use HiEvents\Repository\Interfaces\OrderPaymentPlatformFeeRepositoryInterface; +/** + * @extends BaseRepository + */ class OrderPaymentPlatformFeeRepository extends BaseRepository implements OrderPaymentPlatformFeeRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/OrderRefundRepository.php b/backend/app/Repository/Eloquent/OrderRefundRepository.php index 4e81d0963..cfd4ef7ce 100644 --- a/backend/app/Repository/Eloquent/OrderRefundRepository.php +++ b/backend/app/Repository/Eloquent/OrderRefundRepository.php @@ -6,6 +6,9 @@ use HiEvents\Models\OrderRefund; use HiEvents\Repository\Interfaces\OrderRefundRepositoryInterface; +/** + * @extends BaseRepository + */ class OrderRefundRepository extends BaseRepository implements OrderRefundRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/OrderRepository.php b/backend/app/Repository/Eloquent/OrderRepository.php index eb4071cad..5820db882 100644 --- a/backend/app/Repository/Eloquent/OrderRepository.php +++ b/backend/app/Repository/Eloquent/OrderRepository.php @@ -22,6 +22,9 @@ use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\AccountDomainObject; +/** + * @extends BaseRepository + */ class OrderRepository extends BaseRepository implements OrderRepositoryInterface { public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator diff --git a/backend/app/Repository/Eloquent/OrganizerRepository.php b/backend/app/Repository/Eloquent/OrganizerRepository.php index 3257efc95..644923978 100644 --- a/backend/app/Repository/Eloquent/OrganizerRepository.php +++ b/backend/app/Repository/Eloquent/OrganizerRepository.php @@ -13,6 +13,9 @@ use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface; use Illuminate\Pagination\LengthAwarePaginator; +/** + * @extends BaseRepository + */ class OrganizerRepository extends BaseRepository implements OrganizerRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/OrganizerSettingsRepository.php b/backend/app/Repository/Eloquent/OrganizerSettingsRepository.php index f792e5d78..ab1eed74e 100644 --- a/backend/app/Repository/Eloquent/OrganizerSettingsRepository.php +++ b/backend/app/Repository/Eloquent/OrganizerSettingsRepository.php @@ -6,6 +6,9 @@ use HiEvents\Models\OrganizerSetting; use HiEvents\Repository\Interfaces\OrganizerSettingsRepositoryInterface; +/** + * @extends BaseRepository + */ class OrganizerSettingsRepository extends BaseRepository implements OrganizerSettingsRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/OutgoingMessageRepository.php b/backend/app/Repository/Eloquent/OutgoingMessageRepository.php index b977237a6..119d0251d 100644 --- a/backend/app/Repository/Eloquent/OutgoingMessageRepository.php +++ b/backend/app/Repository/Eloquent/OutgoingMessageRepository.php @@ -6,6 +6,9 @@ use HiEvents\Models\OutgoingMessage; use HiEvents\Repository\Interfaces\OutgoingMessageRepositoryInterface; +/** + * @extends BaseRepository + */ class OutgoingMessageRepository extends BaseRepository implements OutgoingMessageRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/PasswordResetRepository.php b/backend/app/Repository/Eloquent/PasswordResetRepository.php index 430817651..f5f1c3457 100644 --- a/backend/app/Repository/Eloquent/PasswordResetRepository.php +++ b/backend/app/Repository/Eloquent/PasswordResetRepository.php @@ -6,6 +6,9 @@ use HiEvents\Models\PasswordReset; use HiEvents\Repository\Interfaces\PasswordResetRepositoryInterface; +/** + * @extends BaseRepository + */ class PasswordResetRepository extends BaseRepository implements PasswordResetRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/PasswordResetTokenRepository.php b/backend/app/Repository/Eloquent/PasswordResetTokenRepository.php index 9bef34db8..8e9eece72 100644 --- a/backend/app/Repository/Eloquent/PasswordResetTokenRepository.php +++ b/backend/app/Repository/Eloquent/PasswordResetTokenRepository.php @@ -6,6 +6,9 @@ use HiEvents\Models\PasswordResetToken; use HiEvents\Repository\Interfaces\PasswordResetTokenRepositoryInterface; +/** + * @extends BaseRepository + */ class PasswordResetTokenRepository extends BaseRepository implements PasswordResetTokenRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/ProductCategoryRepository.php b/backend/app/Repository/Eloquent/ProductCategoryRepository.php index 6a1546c47..ab845cc06 100644 --- a/backend/app/Repository/Eloquent/ProductCategoryRepository.php +++ b/backend/app/Repository/Eloquent/ProductCategoryRepository.php @@ -8,6 +8,9 @@ use HiEvents\Repository\Interfaces\ProductCategoryRepositoryInterface; use Illuminate\Support\Collection; +/** + * @extends BaseRepository + */ class ProductCategoryRepository extends BaseRepository implements ProductCategoryRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/ProductPriceRepository.php b/backend/app/Repository/Eloquent/ProductPriceRepository.php index 7114d6f3b..2f2fd3134 100644 --- a/backend/app/Repository/Eloquent/ProductPriceRepository.php +++ b/backend/app/Repository/Eloquent/ProductPriceRepository.php @@ -6,6 +6,9 @@ use HiEvents\Models\ProductPrice; use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface; +/** + * @extends BaseRepository + */ class ProductPriceRepository extends BaseRepository implements ProductPriceRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/ProductRepository.php b/backend/app/Repository/Eloquent/ProductRepository.php index 7d2a45090..c199b678a 100644 --- a/backend/app/Repository/Eloquent/ProductRepository.php +++ b/backend/app/Repository/Eloquent/ProductRepository.php @@ -22,6 +22,9 @@ use RuntimeException; use Throwable; +/** + * @extends BaseRepository + */ class ProductRepository extends BaseRepository implements ProductRepositoryInterface { public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator diff --git a/backend/app/Repository/Eloquent/PromoCodeRepository.php b/backend/app/Repository/Eloquent/PromoCodeRepository.php index 0871dc3cc..376440c86 100644 --- a/backend/app/Repository/Eloquent/PromoCodeRepository.php +++ b/backend/app/Repository/Eloquent/PromoCodeRepository.php @@ -10,6 +10,9 @@ use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; +/** + * @extends BaseRepository + */ class PromoCodeRepository extends BaseRepository implements PromoCodeRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/QuestionAndAnswerViewRepository.php b/backend/app/Repository/Eloquent/QuestionAndAnswerViewRepository.php index 0e447fa9a..0c76a2a46 100644 --- a/backend/app/Repository/Eloquent/QuestionAndAnswerViewRepository.php +++ b/backend/app/Repository/Eloquent/QuestionAndAnswerViewRepository.php @@ -6,6 +6,9 @@ use HiEvents\Models\QuestionAndAnswerView; use HiEvents\Repository\Interfaces\QuestionAndAnswerViewRepositoryInterface; +/** + * @extends BaseRepository + */ class QuestionAndAnswerViewRepository extends BaseRepository implements QuestionAndAnswerViewRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/QuestionAnswerRepository.php b/backend/app/Repository/Eloquent/QuestionAnswerRepository.php index 070469798..3c6a16853 100644 --- a/backend/app/Repository/Eloquent/QuestionAnswerRepository.php +++ b/backend/app/Repository/Eloquent/QuestionAnswerRepository.php @@ -7,6 +7,9 @@ use HiEvents\Models\QuestionAnswer; use HiEvents\Repository\Interfaces\QuestionAnswerRepositoryInterface; +/** + * @extends BaseRepository + */ class QuestionAnswerRepository extends BaseRepository implements QuestionAnswerRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/QuestionRepository.php b/backend/app/Repository/Eloquent/QuestionRepository.php index 1e0dcdbb6..94f9d547a 100644 --- a/backend/app/Repository/Eloquent/QuestionRepository.php +++ b/backend/app/Repository/Eloquent/QuestionRepository.php @@ -12,6 +12,9 @@ use Illuminate\Foundation\Application; use Illuminate\Support\Collection; +/** + * @extends BaseRepository + */ class QuestionRepository extends BaseRepository implements QuestionRepositoryInterface { private ProductRepositoryInterface $productRepository; diff --git a/backend/app/Repository/Eloquent/StripeCustomerRepository.php b/backend/app/Repository/Eloquent/StripeCustomerRepository.php index 799203c4d..bb5cbae6c 100644 --- a/backend/app/Repository/Eloquent/StripeCustomerRepository.php +++ b/backend/app/Repository/Eloquent/StripeCustomerRepository.php @@ -6,6 +6,9 @@ use HiEvents\Models\StripeCustomer; use HiEvents\Repository\Interfaces\StripeCustomerRepositoryInterface; +/** + * @extends BaseRepository + */ class StripeCustomerRepository extends BaseRepository implements StripeCustomerRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/StripePaymentsRepository.php b/backend/app/Repository/Eloquent/StripePaymentsRepository.php index c2994eda3..42edf2edb 100644 --- a/backend/app/Repository/Eloquent/StripePaymentsRepository.php +++ b/backend/app/Repository/Eloquent/StripePaymentsRepository.php @@ -6,6 +6,9 @@ use HiEvents\Models\StripePayment; use HiEvents\Repository\Interfaces\StripePaymentsRepositoryInterface; +/** + * @extends BaseRepository + */ class StripePaymentsRepository extends BaseRepository implements StripePaymentsRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/StripePayoutsRepository.php b/backend/app/Repository/Eloquent/StripePayoutsRepository.php index 167d5abc8..923b0571d 100644 --- a/backend/app/Repository/Eloquent/StripePayoutsRepository.php +++ b/backend/app/Repository/Eloquent/StripePayoutsRepository.php @@ -6,6 +6,9 @@ use HiEvents\Models\StripePayout; use HiEvents\DomainObjects\StripePayoutDomainObject; +/** + * @extends BaseRepository + */ class StripePayoutsRepository extends BaseRepository implements StripePayoutsRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/TaxAndFeeRepository.php b/backend/app/Repository/Eloquent/TaxAndFeeRepository.php index 0b9d9c992..136e49f28 100644 --- a/backend/app/Repository/Eloquent/TaxAndFeeRepository.php +++ b/backend/app/Repository/Eloquent/TaxAndFeeRepository.php @@ -6,6 +6,9 @@ use HiEvents\Models\TaxAndFee; use HiEvents\Repository\Interfaces\TaxAndFeeRepositoryInterface; +/** + * @extends BaseRepository + */ class TaxAndFeeRepository extends BaseRepository implements TaxAndFeeRepositoryInterface { public function getDomainObject(): string diff --git a/backend/app/Repository/Eloquent/TicketLookupTokenRepository.php b/backend/app/Repository/Eloquent/TicketLookupTokenRepository.php index 7a9288b3c..5b875067a 100644 --- a/backend/app/Repository/Eloquent/TicketLookupTokenRepository.php +++ b/backend/app/Repository/Eloquent/TicketLookupTokenRepository.php @@ -6,6 +6,9 @@ use HiEvents\Models\TicketLookupToken; use HiEvents\Repository\Interfaces\TicketLookupTokenRepositoryInterface; +/** + * @extends BaseRepository + */ class TicketLookupTokenRepository extends BaseRepository implements TicketLookupTokenRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/UserRepository.php b/backend/app/Repository/Eloquent/UserRepository.php index c890d2aac..b1a8c6454 100644 --- a/backend/app/Repository/Eloquent/UserRepository.php +++ b/backend/app/Repository/Eloquent/UserRepository.php @@ -14,6 +14,9 @@ use Illuminate\Support\Collection; use Symfony\Component\Routing\Exception\ResourceNotFoundException; +/** + * @extends BaseRepository + */ class UserRepository extends BaseRepository implements UserRepositoryInterface { public function getModel(): string diff --git a/backend/app/Repository/Eloquent/WaitlistEntryRepository.php b/backend/app/Repository/Eloquent/WaitlistEntryRepository.php new file mode 100644 index 000000000..bb27bf8c5 --- /dev/null +++ b/backend/app/Repository/Eloquent/WaitlistEntryRepository.php @@ -0,0 +1,183 @@ +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 findByIdLocked(int $id): ?WaitlistEntryDomainObject + { + $model = WaitlistEntry::query() + ->where('id', $id) + ->lockForUpdate() + ->first(); + + if ($model === null) { + return null; + } + + return $this->handleSingleResult($model); + } + + 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/Eloquent/WebhookLogRepository.php b/backend/app/Repository/Eloquent/WebhookLogRepository.php index 1c518dce1..b52190b84 100644 --- a/backend/app/Repository/Eloquent/WebhookLogRepository.php +++ b/backend/app/Repository/Eloquent/WebhookLogRepository.php @@ -6,6 +6,9 @@ use HiEvents\Models\WebhookLog; use HiEvents\Repository\Interfaces\WebhookLogRepositoryInterface; +/** + * @extends BaseRepository + */ class WebhookLogRepository extends BaseRepository implements WebhookLogRepositoryInterface { protected function getModel(): string diff --git a/backend/app/Repository/Eloquent/WebhookRepository.php b/backend/app/Repository/Eloquent/WebhookRepository.php index bf422e40a..dffca0b3b 100644 --- a/backend/app/Repository/Eloquent/WebhookRepository.php +++ b/backend/app/Repository/Eloquent/WebhookRepository.php @@ -2,10 +2,15 @@ namespace HiEvents\Repository\Eloquent; +use HiEvents\DomainObjects\Status\WebhookStatus; use HiEvents\DomainObjects\WebhookDomainObject; use HiEvents\Models\Webhook; use HiEvents\Repository\Interfaces\WebhookRepositoryInterface; +use Illuminate\Support\Collection; +/** + * @extends BaseRepository + */ class WebhookRepository extends BaseRepository implements WebhookRepositoryInterface { protected function getModel(): string @@ -17,4 +22,24 @@ public function getDomainObject(): string { return WebhookDomainObject::class; } + + public function findEnabledByEventId(int $eventId): Collection + { + $results = $this->model::query() + ->where('status', WebhookStatus::ENABLED->name) + ->where(function ($query) use ($eventId) { + $query->where('event_id', $eventId) + ->orWhere('organizer_id', function ($subquery) use ($eventId) { + $subquery->select('organizer_id') + ->from('events') + ->where('id', $eventId) + ->limit(1); + }); + }) + ->get(); + + $this->resetModel(); + + return $this->handleResults($results); + } } diff --git a/backend/app/Repository/Interfaces/AccountAttributionRepositoryInterface.php b/backend/app/Repository/Interfaces/AccountAttributionRepositoryInterface.php index e4bf7b811..e6668d27d 100644 --- a/backend/app/Repository/Interfaces/AccountAttributionRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/AccountAttributionRepositoryInterface.php @@ -4,8 +4,12 @@ namespace HiEvents\Repository\Interfaces; +use HiEvents\DomainObjects\AccountAttributionDomainObject; use Illuminate\Pagination\LengthAwarePaginator; +/** + * @extends RepositoryInterface + */ interface AccountAttributionRepositoryInterface extends RepositoryInterface { public function getAttributionStats( diff --git a/backend/app/Repository/Interfaces/AccountConfigurationRepositoryInterface.php b/backend/app/Repository/Interfaces/AccountConfigurationRepositoryInterface.php index 89973ed38..98b2add2c 100644 --- a/backend/app/Repository/Interfaces/AccountConfigurationRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/AccountConfigurationRepositoryInterface.php @@ -3,10 +3,9 @@ namespace HiEvents\Repository\Interfaces; use HiEvents\DomainObjects\AccountConfigurationDomainObject; -use HiEvents\Repository\Eloquent\BaseRepository; /** - * @extends BaseRepository + * @extends RepositoryInterface */ interface AccountConfigurationRepositoryInterface extends RepositoryInterface { diff --git a/backend/app/Repository/Interfaces/AccountRepositoryInterface.php b/backend/app/Repository/Interfaces/AccountRepositoryInterface.php index f3a72f98f..e389f4602 100644 --- a/backend/app/Repository/Interfaces/AccountRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/AccountRepositoryInterface.php @@ -6,11 +6,10 @@ use HiEvents\DomainObjects\AccountDomainObject; use HiEvents\Models\Account; -use HiEvents\Repository\Eloquent\BaseRepository; use Illuminate\Contracts\Pagination\LengthAwarePaginator; /** - * @extends BaseRepository + * @extends RepositoryInterface */ interface AccountRepositoryInterface extends RepositoryInterface { diff --git a/backend/app/Repository/Interfaces/AccountStripePlatformRepositoryInterface.php b/backend/app/Repository/Interfaces/AccountStripePlatformRepositoryInterface.php index 9d7819809..8276d490f 100644 --- a/backend/app/Repository/Interfaces/AccountStripePlatformRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/AccountStripePlatformRepositoryInterface.php @@ -4,6 +4,11 @@ namespace HiEvents\Repository\Interfaces; +use HiEvents\DomainObjects\AccountStripePlatformDomainObject; + +/** + * @extends RepositoryInterface + */ interface AccountStripePlatformRepositoryInterface extends RepositoryInterface { } \ No newline at end of file diff --git a/backend/app/Repository/Interfaces/AccountUserRepositoryInterface.php b/backend/app/Repository/Interfaces/AccountUserRepositoryInterface.php index 010d1ec19..dbcebd133 100644 --- a/backend/app/Repository/Interfaces/AccountUserRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/AccountUserRepositoryInterface.php @@ -5,10 +5,9 @@ namespace HiEvents\Repository\Interfaces; use HiEvents\DomainObjects\AccountUserDomainObject; -use HiEvents\Repository\Eloquent\BaseRepository; /** - * @extends BaseRepository + * @extends RepositoryInterface */ interface AccountUserRepositoryInterface extends RepositoryInterface { diff --git a/backend/app/Repository/Interfaces/AccountVatSettingRepositoryInterface.php b/backend/app/Repository/Interfaces/AccountVatSettingRepositoryInterface.php index 95a1af27b..ea15229e0 100644 --- a/backend/app/Repository/Interfaces/AccountVatSettingRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/AccountVatSettingRepositoryInterface.php @@ -4,6 +4,9 @@ use HiEvents\DomainObjects\AccountVatSettingDomainObject; +/** + * @extends RepositoryInterface + */ interface AccountVatSettingRepositoryInterface extends RepositoryInterface { public function findByAccountId(int $accountId): ?AccountVatSettingDomainObject; diff --git a/backend/app/Repository/Interfaces/AffiliateRepositoryInterface.php b/backend/app/Repository/Interfaces/AffiliateRepositoryInterface.php index 8f1a4fb93..b89362c0e 100644 --- a/backend/app/Repository/Interfaces/AffiliateRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/AffiliateRepositoryInterface.php @@ -4,9 +4,13 @@ namespace HiEvents\Repository\Interfaces; +use HiEvents\DomainObjects\AffiliateDomainObject; use HiEvents\Http\DTO\QueryParamsDTO; use Illuminate\Pagination\LengthAwarePaginator; +/** + * @extends RepositoryInterface + */ interface AffiliateRepositoryInterface extends RepositoryInterface { public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator; diff --git a/backend/app/Repository/Interfaces/AttendeeCheckInRepositoryInterface.php b/backend/app/Repository/Interfaces/AttendeeCheckInRepositoryInterface.php index ac0b5bfc0..758265f2a 100644 --- a/backend/app/Repository/Interfaces/AttendeeCheckInRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/AttendeeCheckInRepositoryInterface.php @@ -3,10 +3,9 @@ namespace HiEvents\Repository\Interfaces; use HiEvents\DomainObjects\AttendeeCheckInDomainObject; -use HiEvents\Repository\Eloquent\BaseRepository; /** - * @extends BaseRepository + * @extends RepositoryInterface */ interface AttendeeCheckInRepositoryInterface extends RepositoryInterface { diff --git a/backend/app/Repository/Interfaces/AttendeeRepositoryInterface.php b/backend/app/Repository/Interfaces/AttendeeRepositoryInterface.php index ea8dfd99c..e176a4ce5 100644 --- a/backend/app/Repository/Interfaces/AttendeeRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/AttendeeRepositoryInterface.php @@ -4,13 +4,12 @@ use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\Http\DTO\QueryParamsDTO; -use HiEvents\Repository\Eloquent\BaseRepository; use Illuminate\Contracts\Pagination\Paginator; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; /** - * @extends BaseRepository + * @extends RepositoryInterface */ interface AttendeeRepositoryInterface extends RepositoryInterface { diff --git a/backend/app/Repository/Interfaces/EventDailyStatisticRepositoryInterface.php b/backend/app/Repository/Interfaces/EventDailyStatisticRepositoryInterface.php index 36bce76da..e2e611980 100644 --- a/backend/app/Repository/Interfaces/EventDailyStatisticRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/EventDailyStatisticRepositoryInterface.php @@ -3,10 +3,9 @@ namespace HiEvents\Repository\Interfaces; use HiEvents\DomainObjects\EventDailyStatisticDomainObject; -use HiEvents\Repository\Eloquent\BaseRepository; /** - * @extends BaseRepository + * @extends RepositoryInterface */ interface EventDailyStatisticRepositoryInterface extends RepositoryInterface { diff --git a/backend/app/Repository/Interfaces/EventRepositoryInterface.php b/backend/app/Repository/Interfaces/EventRepositoryInterface.php index cd7d03fab..7f04c4277 100644 --- a/backend/app/Repository/Interfaces/EventRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/EventRepositoryInterface.php @@ -6,11 +6,10 @@ use HiEvents\DomainObjects\EventDomainObject; use HiEvents\Http\DTO\QueryParamsDTO; -use HiEvents\Repository\Eloquent\BaseRepository; use Illuminate\Pagination\LengthAwarePaginator; /** - * @extends BaseRepository + * @extends RepositoryInterface */ interface EventRepositoryInterface extends RepositoryInterface { diff --git a/backend/app/Repository/Interfaces/EventSettingsRepositoryInterface.php b/backend/app/Repository/Interfaces/EventSettingsRepositoryInterface.php index 011f2b935..c350fb8fc 100644 --- a/backend/app/Repository/Interfaces/EventSettingsRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/EventSettingsRepositoryInterface.php @@ -3,10 +3,9 @@ namespace HiEvents\Repository\Interfaces; use HiEvents\DomainObjects\EventSettingDomainObject; -use HiEvents\Repository\Eloquent\BaseRepository; /** - * @extends BaseRepository + * @extends RepositoryInterface */ interface EventSettingsRepositoryInterface extends RepositoryInterface { diff --git a/backend/app/Repository/Interfaces/EventStatisticRepositoryInterface.php b/backend/app/Repository/Interfaces/EventStatisticRepositoryInterface.php index f6d2e618d..c389929bb 100644 --- a/backend/app/Repository/Interfaces/EventStatisticRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/EventStatisticRepositoryInterface.php @@ -3,10 +3,9 @@ namespace HiEvents\Repository\Interfaces; use HiEvents\DomainObjects\EventStatisticDomainObject; -use HiEvents\Repository\Eloquent\BaseRepository; /** - * @extends BaseRepository + * @extends RepositoryInterface */ interface EventStatisticRepositoryInterface extends RepositoryInterface { diff --git a/backend/app/Repository/Interfaces/ImageRepositoryInterface.php b/backend/app/Repository/Interfaces/ImageRepositoryInterface.php index 6826679b0..04edc73e3 100644 --- a/backend/app/Repository/Interfaces/ImageRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/ImageRepositoryInterface.php @@ -2,6 +2,11 @@ namespace HiEvents\Repository\Interfaces; +use HiEvents\DomainObjects\ImageDomainObject; + +/** + * @extends RepositoryInterface + */ interface ImageRepositoryInterface extends RepositoryInterface { diff --git a/backend/app/Repository/Interfaces/InvoiceRepositoryInterface.php b/backend/app/Repository/Interfaces/InvoiceRepositoryInterface.php index e2091a9fb..dc178fe12 100644 --- a/backend/app/Repository/Interfaces/InvoiceRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/InvoiceRepositoryInterface.php @@ -3,10 +3,9 @@ namespace HiEvents\Repository\Interfaces; use HiEvents\DomainObjects\InvoiceDomainObject; -use HiEvents\Repository\Eloquent\BaseRepository; /** - * @extends BaseRepository + * @extends RepositoryInterface */ interface InvoiceRepositoryInterface extends RepositoryInterface { diff --git a/backend/app/Repository/Interfaces/MessageRepositoryInterface.php b/backend/app/Repository/Interfaces/MessageRepositoryInterface.php index 4c10b5398..e60253f44 100644 --- a/backend/app/Repository/Interfaces/MessageRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/MessageRepositoryInterface.php @@ -4,11 +4,10 @@ use HiEvents\DomainObjects\MessageDomainObject; use HiEvents\Http\DTO\QueryParamsDTO; -use HiEvents\Repository\Eloquent\BaseRepository; use Illuminate\Pagination\LengthAwarePaginator; /** - * @extends BaseRepository + * @extends RepositoryInterface */ interface MessageRepositoryInterface extends RepositoryInterface { diff --git a/backend/app/Repository/Interfaces/OrderApplicationFeeRepositoryInterface.php b/backend/app/Repository/Interfaces/OrderApplicationFeeRepositoryInterface.php index 7f9f517d7..82aa340a9 100644 --- a/backend/app/Repository/Interfaces/OrderApplicationFeeRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/OrderApplicationFeeRepositoryInterface.php @@ -3,10 +3,9 @@ namespace HiEvents\Repository\Interfaces; use HiEvents\DomainObjects\OrderApplicationFeeDomainObject; -use HiEvents\Repository\Eloquent\BaseRepository; /** - * @extends BaseRepository + * @extends RepositoryInterface */ interface OrderApplicationFeeRepositoryInterface extends RepositoryInterface { diff --git a/backend/app/Repository/Interfaces/OrderAuditLogRepositoryInterface.php b/backend/app/Repository/Interfaces/OrderAuditLogRepositoryInterface.php index c9de2c3db..b3a5bddec 100644 --- a/backend/app/Repository/Interfaces/OrderAuditLogRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/OrderAuditLogRepositoryInterface.php @@ -5,10 +5,9 @@ namespace HiEvents\Repository\Interfaces; use HiEvents\DomainObjects\OrderAuditLogDomainObject; -use HiEvents\Repository\Eloquent\BaseRepository; /** - * @extends BaseRepository + * @extends RepositoryInterface */ interface OrderAuditLogRepositoryInterface extends RepositoryInterface { diff --git a/backend/app/Repository/Interfaces/OrderItemRepositoryInterface.php b/backend/app/Repository/Interfaces/OrderItemRepositoryInterface.php index e3490f737..6360d893e 100644 --- a/backend/app/Repository/Interfaces/OrderItemRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/OrderItemRepositoryInterface.php @@ -3,10 +3,9 @@ namespace HiEvents\Repository\Interfaces; use HiEvents\DomainObjects\OrderItemDomainObject; -use HiEvents\Repository\Eloquent\BaseRepository; /** - * @extends BaseRepository + * @extends RepositoryInterface */ interface OrderItemRepositoryInterface extends RepositoryInterface { diff --git a/backend/app/Repository/Interfaces/OrderPaymentPlatformFeeRepositoryInterface.php b/backend/app/Repository/Interfaces/OrderPaymentPlatformFeeRepositoryInterface.php index 86aa5190a..4f9cb1d4f 100644 --- a/backend/app/Repository/Interfaces/OrderPaymentPlatformFeeRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/OrderPaymentPlatformFeeRepositoryInterface.php @@ -3,10 +3,9 @@ namespace HiEvents\Repository\Interfaces; use HiEvents\DomainObjects\OrderPaymentPlatformFeeDomainObject; -use HiEvents\Repository\Eloquent\BaseRepository; /** - * @extends BaseRepository + * @extends RepositoryInterface */ interface OrderPaymentPlatformFeeRepositoryInterface extends RepositoryInterface { diff --git a/backend/app/Repository/Interfaces/OrderRepositoryInterface.php b/backend/app/Repository/Interfaces/OrderRepositoryInterface.php index 524b6500c..58cf2c7a8 100644 --- a/backend/app/Repository/Interfaces/OrderRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/OrderRepositoryInterface.php @@ -7,12 +7,11 @@ use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\Http\DTO\QueryParamsDTO; -use HiEvents\Repository\Eloquent\BaseRepository; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; /** - * @extends BaseRepository + * @extends RepositoryInterface */ interface OrderRepositoryInterface extends RepositoryInterface { diff --git a/backend/app/Repository/Interfaces/OrganizerRepositoryInterface.php b/backend/app/Repository/Interfaces/OrganizerRepositoryInterface.php index 68ce1db05..069993a93 100644 --- a/backend/app/Repository/Interfaces/OrganizerRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/OrganizerRepositoryInterface.php @@ -6,11 +6,10 @@ use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\Repository\DTO\Organizer\OrganizerStatsResponseDTO; -use HiEvents\Repository\Eloquent\BaseRepository; use Illuminate\Pagination\LengthAwarePaginator; /** - * @extends BaseRepository + * @extends RepositoryInterface */ interface OrganizerRepositoryInterface extends RepositoryInterface { diff --git a/backend/app/Repository/Interfaces/OrganizerSettingsRepositoryInterface.php b/backend/app/Repository/Interfaces/OrganizerSettingsRepositoryInterface.php index fb74d88b7..dbb530139 100644 --- a/backend/app/Repository/Interfaces/OrganizerSettingsRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/OrganizerSettingsRepositoryInterface.php @@ -3,10 +3,9 @@ namespace HiEvents\Repository\Interfaces; use HiEvents\DomainObjects\OrganizerSettingDomainObject; -use HiEvents\Repository\Eloquent\BaseRepository; /** - * @extends BaseRepository + * @extends RepositoryInterface */ interface OrganizerSettingsRepositoryInterface extends RepositoryInterface { diff --git a/backend/app/Repository/Interfaces/OutgoingMessageRepositoryInterface.php b/backend/app/Repository/Interfaces/OutgoingMessageRepositoryInterface.php index af335677f..21155af66 100644 --- a/backend/app/Repository/Interfaces/OutgoingMessageRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/OutgoingMessageRepositoryInterface.php @@ -3,10 +3,9 @@ namespace HiEvents\Repository\Interfaces; use HiEvents\DomainObjects\OutgoingMessageDomainObject; -use HiEvents\Repository\Eloquent\BaseRepository; /** - * @extends BaseRepository + * @extends RepositoryInterface */ interface OutgoingMessageRepositoryInterface extends RepositoryInterface { diff --git a/backend/app/Repository/Interfaces/PasswordResetRepositoryInterface.php b/backend/app/Repository/Interfaces/PasswordResetRepositoryInterface.php index e6c14b241..a206ca5db 100644 --- a/backend/app/Repository/Interfaces/PasswordResetRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/PasswordResetRepositoryInterface.php @@ -3,10 +3,9 @@ namespace HiEvents\Repository\Interfaces; use HiEvents\DomainObjects\PasswordResetDomainObject; -use HiEvents\Repository\Eloquent\BaseRepository; /** - * @extends BaseRepository + * @extends RepositoryInterface */ interface PasswordResetRepositoryInterface extends RepositoryInterface { diff --git a/backend/app/Repository/Interfaces/PasswordResetTokenRepositoryInterface.php b/backend/app/Repository/Interfaces/PasswordResetTokenRepositoryInterface.php index c9008c1a4..c8564fee5 100644 --- a/backend/app/Repository/Interfaces/PasswordResetTokenRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/PasswordResetTokenRepositoryInterface.php @@ -3,10 +3,9 @@ namespace HiEvents\Repository\Interfaces; use HiEvents\DomainObjects\PasswordResetTokenDomainObject; -use HiEvents\Repository\Eloquent\BaseRepository; /** - * @extends BaseRepository + * @extends RepositoryInterface */ interface PasswordResetTokenRepositoryInterface extends RepositoryInterface { diff --git a/backend/app/Repository/Interfaces/ProductCategoryRepositoryInterface.php b/backend/app/Repository/Interfaces/ProductCategoryRepositoryInterface.php index 593fb5d7b..b330fc0bb 100644 --- a/backend/app/Repository/Interfaces/ProductCategoryRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/ProductCategoryRepositoryInterface.php @@ -4,11 +4,10 @@ use HiEvents\DomainObjects\ProductCategoryDomainObject; use HiEvents\Http\DTO\QueryParamsDTO; -use HiEvents\Repository\Eloquent\BaseRepository; use Illuminate\Support\Collection; /** - * @extends BaseRepository + * @extends RepositoryInterface */ interface ProductCategoryRepositoryInterface extends RepositoryInterface { diff --git a/backend/app/Repository/Interfaces/ProductPriceRepositoryInterface.php b/backend/app/Repository/Interfaces/ProductPriceRepositoryInterface.php index 23b538f1e..55b74f91c 100644 --- a/backend/app/Repository/Interfaces/ProductPriceRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/ProductPriceRepositoryInterface.php @@ -5,10 +5,9 @@ namespace HiEvents\Repository\Interfaces; use HiEvents\DomainObjects\ProductPriceDomainObject; -use HiEvents\Repository\Eloquent\BaseRepository; /** - * @extends BaseRepository + * @extends RepositoryInterface */ interface ProductPriceRepositoryInterface extends RepositoryInterface { diff --git a/backend/app/Repository/Interfaces/ProductRepositoryInterface.php b/backend/app/Repository/Interfaces/ProductRepositoryInterface.php index d484fb0c8..557ee19b5 100644 --- a/backend/app/Repository/Interfaces/ProductRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/ProductRepositoryInterface.php @@ -6,12 +6,11 @@ use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\Http\DTO\QueryParamsDTO; -use HiEvents\Repository\Eloquent\BaseRepository; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; /** - * @extends BaseRepository + * @extends RepositoryInterface */ interface ProductRepositoryInterface extends RepositoryInterface { diff --git a/backend/app/Repository/Interfaces/PromoCodeRepositoryInterface.php b/backend/app/Repository/Interfaces/PromoCodeRepositoryInterface.php index 65853476a..829af2c46 100644 --- a/backend/app/Repository/Interfaces/PromoCodeRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/PromoCodeRepositoryInterface.php @@ -4,11 +4,10 @@ use HiEvents\DomainObjects\PromoCodeDomainObject; use HiEvents\Http\DTO\QueryParamsDTO; -use HiEvents\Repository\Eloquent\BaseRepository; use Illuminate\Contracts\Pagination\LengthAwarePaginator; /** - * @extends BaseRepository + * @extends RepositoryInterface */ interface PromoCodeRepositoryInterface extends RepositoryInterface { diff --git a/backend/app/Repository/Interfaces/QuestionAndAnswerViewRepositoryInterface.php b/backend/app/Repository/Interfaces/QuestionAndAnswerViewRepositoryInterface.php index 398ed767d..bf8bc7001 100644 --- a/backend/app/Repository/Interfaces/QuestionAndAnswerViewRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/QuestionAndAnswerViewRepositoryInterface.php @@ -3,10 +3,9 @@ namespace HiEvents\Repository\Interfaces; use HiEvents\DomainObjects\QuestionAndAnswerViewDomainObject; -use HiEvents\Repository\Eloquent\BaseRepository; /** - * @extends BaseRepository + * @extends RepositoryInterface */ interface QuestionAndAnswerViewRepositoryInterface extends RepositoryInterface { diff --git a/backend/app/Repository/Interfaces/QuestionAnswerRepositoryInterface.php b/backend/app/Repository/Interfaces/QuestionAnswerRepositoryInterface.php index a797f1d39..704996fc0 100644 --- a/backend/app/Repository/Interfaces/QuestionAnswerRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/QuestionAnswerRepositoryInterface.php @@ -3,10 +3,9 @@ namespace HiEvents\Repository\Interfaces; use HiEvents\DomainObjects\QuestionAnswerDomainObject; -use HiEvents\Repository\Eloquent\BaseRepository; /** - * @extends BaseRepository + * @extends RepositoryInterface */ interface QuestionAnswerRepositoryInterface extends RepositoryInterface { diff --git a/backend/app/Repository/Interfaces/QuestionRepositoryInterface.php b/backend/app/Repository/Interfaces/QuestionRepositoryInterface.php index 61c303e64..3c13f259c 100644 --- a/backend/app/Repository/Interfaces/QuestionRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/QuestionRepositoryInterface.php @@ -3,11 +3,10 @@ namespace HiEvents\Repository\Interfaces; use HiEvents\DomainObjects\QuestionDomainObject; -use HiEvents\Repository\Eloquent\BaseRepository; use Illuminate\Support\Collection; /** - * @extends BaseRepository + * @extends RepositoryInterface */ interface QuestionRepositoryInterface extends RepositoryInterface { diff --git a/backend/app/Repository/Interfaces/RepositoryInterface.php b/backend/app/Repository/Interfaces/RepositoryInterface.php index 073ab60c3..e6157bf73 100644 --- a/backend/app/Repository/Interfaces/RepositoryInterface.php +++ b/backend/app/Repository/Interfaces/RepositoryInterface.php @@ -12,7 +12,7 @@ use Illuminate\Support\Collection; /** - * @template T + * @template T of DomainObjectInterface */ interface RepositoryInterface { diff --git a/backend/app/Repository/Interfaces/StripeCustomerRepositoryInterface.php b/backend/app/Repository/Interfaces/StripeCustomerRepositoryInterface.php index 37484b3f5..2d586933b 100644 --- a/backend/app/Repository/Interfaces/StripeCustomerRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/StripeCustomerRepositoryInterface.php @@ -3,10 +3,9 @@ namespace HiEvents\Repository\Interfaces; use HiEvents\DomainObjects\StripeCustomerDomainObject; -use HiEvents\Repository\Eloquent\BaseRepository; /** - * @extends BaseRepository + * @extends RepositoryInterface */ interface StripeCustomerRepositoryInterface extends RepositoryInterface { diff --git a/backend/app/Repository/Interfaces/StripePaymentsRepositoryInterface.php b/backend/app/Repository/Interfaces/StripePaymentsRepositoryInterface.php index 154e4aab4..8ad2e9a45 100644 --- a/backend/app/Repository/Interfaces/StripePaymentsRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/StripePaymentsRepositoryInterface.php @@ -3,10 +3,9 @@ namespace HiEvents\Repository\Interfaces; use HiEvents\DomainObjects\StripePaymentDomainObject; -use HiEvents\Repository\Eloquent\BaseRepository; /** - * @extends BaseRepository + * @extends RepositoryInterface */ interface StripePaymentsRepositoryInterface extends RepositoryInterface { diff --git a/backend/app/Repository/Interfaces/StripePayoutsRepositoryInterface.php b/backend/app/Repository/Interfaces/StripePayoutsRepositoryInterface.php index 443b79274..88655c271 100644 --- a/backend/app/Repository/Interfaces/StripePayoutsRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/StripePayoutsRepositoryInterface.php @@ -2,10 +2,10 @@ namespace HiEvents\Repository\Interfaces; -use HiEvents\Repository\Eloquent\BaseRepository; +use HiEvents\DomainObjects\StripePayoutDomainObject; /** - * @extends BaseRepository + * @extends RepositoryInterface */ interface StripePayoutsRepositoryInterface extends RepositoryInterface { diff --git a/backend/app/Repository/Interfaces/TaxAndFeeRepositoryInterface.php b/backend/app/Repository/Interfaces/TaxAndFeeRepositoryInterface.php index effe35ba3..409824850 100644 --- a/backend/app/Repository/Interfaces/TaxAndFeeRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/TaxAndFeeRepositoryInterface.php @@ -2,6 +2,11 @@ namespace HiEvents\Repository\Interfaces; +use HiEvents\DomainObjects\TaxAndFeesDomainObject; + +/** + * @extends RepositoryInterface + */ interface TaxAndFeeRepositoryInterface extends RepositoryInterface { } diff --git a/backend/app/Repository/Interfaces/TicketLookupTokenRepositoryInterface.php b/backend/app/Repository/Interfaces/TicketLookupTokenRepositoryInterface.php index 265c3e7c7..67f631b62 100644 --- a/backend/app/Repository/Interfaces/TicketLookupTokenRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/TicketLookupTokenRepositoryInterface.php @@ -2,6 +2,11 @@ namespace HiEvents\Repository\Interfaces; +use HiEvents\DomainObjects\TicketLookupTokenDomainObject; + +/** + * @extends RepositoryInterface + */ interface TicketLookupTokenRepositoryInterface extends RepositoryInterface { } diff --git a/backend/app/Repository/Interfaces/UserRepositoryInterface.php b/backend/app/Repository/Interfaces/UserRepositoryInterface.php index 9ea9b6947..5120a5ff1 100644 --- a/backend/app/Repository/Interfaces/UserRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/UserRepositoryInterface.php @@ -5,12 +5,11 @@ namespace HiEvents\Repository\Interfaces; use HiEvents\DomainObjects\UserDomainObject; -use HiEvents\Repository\Eloquent\BaseRepository; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; /** - * @extends BaseRepository + * @extends RepositoryInterface */ interface UserRepositoryInterface extends RepositoryInterface { diff --git a/backend/app/Repository/Interfaces/WaitlistEntryRepositoryInterface.php b/backend/app/Repository/Interfaces/WaitlistEntryRepositoryInterface.php new file mode 100644 index 000000000..99d6901c7 --- /dev/null +++ b/backend/app/Repository/Interfaces/WaitlistEntryRepositoryInterface.php @@ -0,0 +1,32 @@ + + */ +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; + + public function findByIdLocked(int $id): ?WaitlistEntryDomainObject; +} diff --git a/backend/app/Repository/Interfaces/WebhookLogRepositoryInterface.php b/backend/app/Repository/Interfaces/WebhookLogRepositoryInterface.php index b9b120b17..9daed4b9e 100644 --- a/backend/app/Repository/Interfaces/WebhookLogRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/WebhookLogRepositoryInterface.php @@ -3,10 +3,9 @@ namespace HiEvents\Repository\Interfaces; use HiEvents\DomainObjects\WebhookLogDomainObject; -use HiEvents\Repository\Eloquent\BaseRepository; /** - * @extends BaseRepository + * @extends RepositoryInterface */ interface WebhookLogRepositoryInterface extends RepositoryInterface { diff --git a/backend/app/Repository/Interfaces/WebhookRepositoryInterface.php b/backend/app/Repository/Interfaces/WebhookRepositoryInterface.php index 81479fb8e..a2fd914f1 100644 --- a/backend/app/Repository/Interfaces/WebhookRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/WebhookRepositoryInterface.php @@ -3,11 +3,12 @@ namespace HiEvents\Repository\Interfaces; use HiEvents\DomainObjects\WebhookDomainObject; -use HiEvents\Repository\Eloquent\BaseRepository; +use Illuminate\Support\Collection; /** - * @extends BaseRepository + * @extends RepositoryInterface */ interface WebhookRepositoryInterface extends RepositoryInterface { + public function findEnabledByEventId(int $eventId): Collection; } diff --git a/backend/app/Resources/Account/AccountConfigurationResource.php b/backend/app/Resources/Account/AccountConfigurationResource.php index c1f2ea784..548477f47 100644 --- a/backend/app/Resources/Account/AccountConfigurationResource.php +++ b/backend/app/Resources/Account/AccountConfigurationResource.php @@ -19,6 +19,7 @@ public function toArray($request): array 'application_fees' => [ 'percentage' => $this->getPercentageApplicationFee(), 'fixed' => $this->getFixedApplicationFee(), + 'currency' => $this->getApplicationFeeCurrency(), ], 'bypass_application_fees' => $this->getBypassApplicationFees(), ]; diff --git a/backend/app/Resources/Event/EventSettingsResource.php b/backend/app/Resources/Event/EventSettingsResource.php index 2d933583c..b61c69bf0 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 b6e0e1a97..02ed37b5c 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/Event/PlatformFeePreviewResource.php b/backend/app/Resources/Event/PlatformFeePreviewResource.php new file mode 100644 index 000000000..34b9921d4 --- /dev/null +++ b/backend/app/Resources/Event/PlatformFeePreviewResource.php @@ -0,0 +1,26 @@ + $this->eventCurrency, + 'fee_currency' => $this->feeCurrency, + 'fixed_fee_original' => $this->fixedFeeOriginal, + 'fixed_fee_converted' => $this->fixedFeeConverted, + 'percentage_fee' => $this->percentageFee, + 'sample_price' => $this->samplePrice, + 'platform_fee' => $this->platformFee, + 'total' => $this->total, + ]; + } +} diff --git a/backend/app/Resources/Message/MessageResource.php b/backend/app/Resources/Message/MessageResource.php index b4b5ce81e..c4d28de42 100644 --- a/backend/app/Resources/Message/MessageResource.php +++ b/backend/app/Resources/Message/MessageResource.php @@ -23,8 +23,9 @@ public function toArray(Request $request): array 'attendee_ids' => $this->getAttendeeIds(), 'order_id' => $this->getOrderId(), 'product_ids' => $this->getProductIds(), - 'sent_at' => $this->getCreatedAt(), + 'sent_at' => $this->getSentAt(), 'status' => $this->getStatus(), + 'scheduled_at' => $this->getScheduledAt(), 'message_preview' => $this->getMessagePreview(), $this->mergeWhen(!is_null($this->getSentByUser()), fn() => [ 'sent_by_user' => new UserResource($this->getSentByUser()), diff --git a/backend/app/Resources/Message/OutgoingMessageResource.php b/backend/app/Resources/Message/OutgoingMessageResource.php new file mode 100644 index 000000000..4bf92638d --- /dev/null +++ b/backend/app/Resources/Message/OutgoingMessageResource.php @@ -0,0 +1,25 @@ + $this->getId(), + 'message_id' => $this->getMessageId(), + 'recipient' => $this->getRecipient(), + 'status' => $this->getStatus(), + 'subject' => $this->getSubject(), + 'created_at' => $this->getCreatedAt(), + ]; + } +} diff --git a/backend/app/Resources/Product/ProductResource.php b/backend/app/Resources/Product/ProductResource.php index 2c265f485..d172df663 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 28a775f5b..eec7789f1 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 000000000..a2ea76a20 --- /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/Admin/ApproveMessageHandler.php b/backend/app/Services/Application/Handlers/Admin/ApproveMessageHandler.php index 128c1477c..0e46928dd 100644 --- a/backend/app/Services/Application/Handlers/Admin/ApproveMessageHandler.php +++ b/backend/app/Services/Application/Handlers/Admin/ApproveMessageHandler.php @@ -4,13 +4,12 @@ namespace HiEvents\Services\Application\Handlers\Admin; -use HiEvents\DomainObjects\Enums\MessageTypeEnum; +use Carbon\Carbon; use HiEvents\DomainObjects\MessageDomainObject; use HiEvents\DomainObjects\Status\MessageStatus; use HiEvents\Exceptions\ResourceNotFoundException; -use HiEvents\Jobs\Event\SendMessagesJob; use HiEvents\Repository\Interfaces\MessageRepositoryInterface; -use HiEvents\Services\Application\Handlers\Message\DTO\SendMessageDTO; +use HiEvents\Services\Domain\Message\MessageDispatchService; use Illuminate\Database\DatabaseManager; use Illuminate\Validation\ValidationException; @@ -19,6 +18,7 @@ class ApproveMessageHandler public function __construct( private readonly MessageRepositoryInterface $messageRepository, private readonly DatabaseManager $databaseManager, + private readonly MessageDispatchService $messageDispatchService, ) { } @@ -44,29 +44,17 @@ private function approveMessage(int $messageId): MessageDomainObject ]); } - $updatedMessage = $this->messageRepository->updateFromArray($messageId, [ - 'status' => MessageStatus::PROCESSING->name, - ]); + $scheduledAt = $message->getScheduledAt(); + $isFutureScheduled = $scheduledAt !== null && Carbon::parse($scheduledAt)->isFuture(); - $sendData = $message->getSendData(); - $sendDataArray = is_string($sendData) ? json_decode($sendData, true) : $sendData; + if ($isFutureScheduled) { + return $this->messageRepository->updateFromArray($messageId, [ + 'status' => MessageStatus::SCHEDULED->name, + ]); + } - SendMessagesJob::dispatch(new SendMessageDTO( - account_id: $sendDataArray['account_id'], - event_id: $message->getEventId(), - subject: $message->getSubject(), - message: $message->getMessage(), - type: MessageTypeEnum::fromName($message->getType()), - is_test: false, - send_copy_to_current_user: $sendDataArray['send_copy_to_current_user'] ?? false, - sent_by_user_id: $message->getSentByUserId(), - order_id: $message->getOrderId(), - order_statuses: $sendDataArray['order_statuses'] ?? [], - id: $message->getId(), - attendee_ids: $message->getAttendeeIds() ?? [], - product_ids: $message->getProductIds() ?? [], - )); + $this->messageDispatchService->dispatchMessage($message, MessageStatus::PENDING_REVIEW); - return $updatedMessage; + return $this->messageRepository->findFirst($messageId); } } diff --git a/backend/app/Services/Application/Handlers/Attendee/EditAttendeeHandler.php b/backend/app/Services/Application/Handlers/Attendee/EditAttendeeHandler.php index 8a7051bfd..f7dd85f02 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 393dd0ecc..36c5bc22a 100644 --- a/backend/app/Services/Application/Handlers/Attendee/PartialEditAttendeeHandler.php +++ b/backend/app/Services/Application/Handlers/Attendee/PartialEditAttendeeHandler.php @@ -3,28 +3,34 @@ 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; use HiEvents\Services\Domain\EventStatistics\EventStatisticsCancellationService; +use HiEvents\Services\Domain\EventStatistics\EventStatisticsReactivationService; use HiEvents\Services\Domain\Product\ProductQuantityUpdateService; use HiEvents\Services\Infrastructure\DomainEvents\DomainEventDispatcherService; use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType; use HiEvents\Services\Infrastructure\DomainEvents\Events\AttendeeEvent; use Illuminate\Database\DatabaseManager; +use Psr\Log\LoggerInterface; use Symfony\Component\Routing\Exception\ResourceNotFoundException; use Throwable; class PartialEditAttendeeHandler { public function __construct( - private readonly AttendeeRepositoryInterface $attendeeRepository, - private readonly OrderRepositoryInterface $orderRepository, - private readonly ProductQuantityUpdateService $productQuantityService, - private readonly DatabaseManager $databaseManager, - private readonly DomainEventDispatcherService $domainEventDispatcherService, + private readonly AttendeeRepositoryInterface $attendeeRepository, + private readonly OrderRepositoryInterface $orderRepository, + private readonly ProductQuantityUpdateService $productQuantityService, + private readonly DatabaseManager $databaseManager, + private readonly DomainEventDispatcherService $domainEventDispatcherService, private readonly EventStatisticsCancellationService $eventStatisticsCancellationService, + private readonly EventStatisticsReactivationService $eventStatisticsReactivationService, + private readonly LoggerInterface $logger, ) { } @@ -88,8 +94,22 @@ private function adjustProductQuantity(PartialEditAttendeeDTO $data, AttendeeDom { if ($data->status === AttendeeStatus::ACTIVE->name) { $this->productQuantityService->increaseQuantitySold($attendee->getProductPriceId()); + + event(new CapacityChangedEvent( + eventId: $attendee->getEventId(), + direction: CapacityChangeDirection::DECREASED, + productId: $attendee->getProductId(), + productPriceId: $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(), + )); } } @@ -100,21 +120,30 @@ private function adjustProductQuantity(PartialEditAttendeeDTO $data, AttendeeDom */ private function adjustEventStatistics(PartialEditAttendeeDTO $data, AttendeeDomainObject $attendee): void { - if ($data->status === AttendeeStatus::CANCELLED->name) { - // Get the order to access the creation date for daily statistics - $order = $this->orderRepository->findFirstWhere([ - 'id' => $attendee->getOrderId(), + $order = $this->orderRepository->findFirstWhere([ + 'id' => $attendee->getOrderId(), + 'event_id' => $attendee->getEventId(), + ]); + + if ($order === null) { + $this->logger->error('Order not found when adjusting event statistics for attendee', [ + 'attendee_id' => $attendee->getId(), + 'order_id' => $attendee->getOrderId(), 'event_id' => $attendee->getEventId(), ]); + return; + } - if ($order === null) { - return; - } - + if ($data->status === AttendeeStatus::CANCELLED->name) { $this->eventStatisticsCancellationService->decrementForCancelledAttendee( eventId: $attendee->getEventId(), orderDate: $order->getCreatedAt() ); + } elseif ($data->status === AttendeeStatus::ACTIVE->name) { + $this->eventStatisticsReactivationService->incrementForReactivatedAttendee( + eventId: $attendee->getEventId(), + orderDate: $order->getCreatedAt() + ); } } } diff --git a/backend/app/Services/Application/Handlers/CapacityAssignment/DeleteCapacityAssignmentHandler.php b/backend/app/Services/Application/Handlers/CapacityAssignment/DeleteCapacityAssignmentHandler.php index fa2b7708f..71bc8f996 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 eedf3eef6..b8b0c81af 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/Event/CreateEventHandler.php b/backend/app/Services/Application/Handlers/Event/CreateEventHandler.php index 0ebd327e1..7b86a0001 100644 --- a/backend/app/Services/Application/Handlers/Event/CreateEventHandler.php +++ b/backend/app/Services/Application/Handlers/Event/CreateEventHandler.php @@ -9,8 +9,10 @@ use HiEvents\Exceptions\OrganizerNotFoundException; use HiEvents\Services\Application\Handlers\Event\DTO\CreateEventDTO; use HiEvents\Services\Domain\Event\CreateEventService; -use HiEvents\Services\Domain\Organizer\OrganizerFetchService; use HiEvents\Services\Domain\ProductCategory\CreateProductCategoryService; +use HiEvents\Services\Domain\Organizer\OrganizerFetchService; +use HiEvents\Jobs\Event\Webhook\DispatchEventWebhookJob; +use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType; use Illuminate\Database\DatabaseManager; use Throwable; @@ -65,6 +67,11 @@ private function createEvent(CreateEventDTO $eventData): EventDomainObject $this->createProductCategoryService->createDefaultProductCategory($newEvent); + DispatchEventWebhookJob::dispatch( + $newEvent->getId(), + DomainEventType::EVENT_CREATED, + ); + return $newEvent; } } diff --git a/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php b/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php index 0ff2ded0b..8f284ff63 100644 --- a/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php +++ b/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php @@ -12,6 +12,8 @@ use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Services\Application\Handlers\Event\DTO\UpdateEventDTO; use HiEvents\Services\Infrastructure\HtmlPurifier\HtmlPurifierService; +use HiEvents\Jobs\Event\Webhook\DispatchEventWebhookJob; +use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType; use Illuminate\Database\DatabaseManager; use Symfony\Component\Routing\Exception\ResourceNotFoundException; use Throwable; @@ -97,6 +99,11 @@ private function getUpdateEvent(UpdateEventDTO $eventData): EventDomainObject $this->dispatcher->dispatchEvent(new EventUpdateEvent($event)); + DispatchEventWebhookJob::dispatch( + $event->getId(), + DomainEventType::EVENT_UPDATED, + ); + return $event; } diff --git a/backend/app/Services/Application/Handlers/Event/UpdateEventStatusHandler.php b/backend/app/Services/Application/Handlers/Event/UpdateEventStatusHandler.php index 175e1e304..4d4387940 100644 --- a/backend/app/Services/Application/Handlers/Event/UpdateEventStatusHandler.php +++ b/backend/app/Services/Application/Handlers/Event/UpdateEventStatusHandler.php @@ -7,6 +7,9 @@ use HiEvents\Repository\Interfaces\AccountRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Services\Application\Handlers\Event\DTO\UpdateEventStatusDTO; +use HiEvents\DomainObjects\Status\EventStatus; +use HiEvents\Jobs\Event\Webhook\DispatchEventWebhookJob; +use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType; use Illuminate\Database\DatabaseManager; use Psr\Log\LoggerInterface; use Throwable; @@ -60,9 +63,20 @@ private function updateEventStatus(UpdateEventStatusDTO $updateEventStatusDTO): 'status' => $updateEventStatusDTO->status ]); - return $this->eventRepository->findFirstWhere([ + $event = $this->eventRepository->findFirstWhere([ 'id' => $updateEventStatusDTO->eventId, 'account_id' => $updateEventStatusDTO->accountId, ]); + + $eventType = $updateEventStatusDTO->status === EventStatus::ARCHIVED->name + ? DomainEventType::EVENT_ARCHIVED + : DomainEventType::EVENT_UPDATED; + + DispatchEventWebhookJob::dispatch( + $event->getId(), + $eventType, + ); + + return $event; } } diff --git a/backend/app/Services/Application/Handlers/EventSettings/DTO/GetPlatformFeePreviewDTO.php b/backend/app/Services/Application/Handlers/EventSettings/DTO/GetPlatformFeePreviewDTO.php new file mode 100644 index 000000000..b9006e9ed --- /dev/null +++ b/backend/app/Services/Application/Handlers/EventSettings/DTO/GetPlatformFeePreviewDTO.php @@ -0,0 +1,14 @@ +eventRepository->findById($dto->eventId); + $eventCurrency = $event->getCurrency(); + + $account = $this->accountRepository + ->loadRelation(new Relationship( + domainObject: AccountConfigurationDomainObject::class, + name: 'configuration', + )) + ->findByEventId($dto->eventId); + + $configuration = $account->getConfiguration(); + + if ($configuration === null) { + return new PlatformFeePreviewResponseDTO( + eventCurrency: $eventCurrency, + feeCurrency: null, + fixedFeeOriginal: 0, + fixedFeeConverted: 0, + percentageFee: 0, + samplePrice: $dto->price, + platformFee: 0, + total: $dto->price, + ); + } + + $feeCurrency = $configuration->getApplicationFeeCurrency(); + $fixedFeeOriginal = $configuration->getFixedApplicationFee(); + $percentageFee = $configuration->getPercentageApplicationFee(); + + $fixedFeeConverted = $this->convertFixedFee($fixedFeeOriginal, $feeCurrency, $eventCurrency); + + $platformFee = $this->calculatePlatformFee($fixedFeeConverted, $percentageFee, $dto->price); + + return new PlatformFeePreviewResponseDTO( + eventCurrency: $eventCurrency, + feeCurrency: $feeCurrency, + fixedFeeOriginal: $fixedFeeOriginal, + fixedFeeConverted: round($fixedFeeConverted, 2), + percentageFee: $percentageFee, + samplePrice: $dto->price, + platformFee: $platformFee, + total: round($dto->price + $platformFee, 2), + ); + } + + private function convertFixedFee(float $amount, string $fromCurrency, string $toCurrency): float + { + if ($fromCurrency === $toCurrency) { + return $amount; + } + + return $this->currencyConversionClient->convert( + fromCurrency: Currency::of($fromCurrency), + toCurrency: Currency::of($toCurrency), + amount: $amount + )->toFloat(); + } + + private function calculatePlatformFee(float $fixedFee, float $percentageFee, float $price): float + { + $percentageRate = $percentageFee / 100; + + $platformFee = $percentageRate >= 1 + ? $fixedFee + ($price * $percentageRate) + : ($fixedFee + ($price * $percentageRate)) / (1 - $percentageRate); + + return round($platformFee, 2); + } +} diff --git a/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php b/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php index fe2c7d096..cbad542a8 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 23568784a..771953480 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php +++ b/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php @@ -27,12 +27,9 @@ public function handle(UpdateEventSettingsDTO $settings): EventSettingDomainObje return $this->databaseManager->transaction(function () use ($settings) { $this->eventSettingsRepository->updateWhere( attributes: [ - 'post_checkout_message' => $settings->post_checkout_message - ?? $this->purifier->purify($settings->post_checkout_message), - 'pre_checkout_message' => $settings->pre_checkout_message - ?? $this->purifier->purify($settings->pre_checkout_message), - 'email_footer_message' => $settings->email_footer_message - ?? $this->purifier->purify($settings->email_footer_message), + 'post_checkout_message' => $this->purifier->purify($settings->post_checkout_message), + 'pre_checkout_message' => $this->purifier->purify($settings->pre_checkout_message), + 'email_footer_message' => $this->purifier->purify($settings->email_footer_message), 'support_email' => $settings->support_email, 'require_attendee_details' => $settings->require_attendee_details, 'attendee_details_collection_method' => $settings->attendee_details_collection_method->name, @@ -51,8 +48,7 @@ public function handle(UpdateEventSettingsDTO $settings): EventSettingDomainObje 'maps_url' => trim($settings->maps_url), 'location_details' => $settings->location_details?->toArray(), 'is_online_event' => $settings->is_online_event, - 'online_event_connection_details' => $settings->online_event_connection_details - ?? $this->purifier->purify($settings->online_event_connection_details), + 'online_event_connection_details' => $this->purifier->purify($settings->online_event_connection_details), 'seo_title' => $settings->seo_title, 'seo_description' => $settings->seo_description, @@ -64,8 +60,7 @@ public function handle(UpdateEventSettingsDTO $settings): EventSettingDomainObje // Payment settings 'payment_providers' => $settings->payment_providers, - 'offline_payment_instructions' => $settings->offline_payment_instructions - ?? $this->purifier->purify($settings->offline_payment_instructions), + 'offline_payment_instructions' => $this->purifier->purify($settings->offline_payment_instructions), 'allow_orders_awaiting_offline_payment_to_check_in' => $settings->allow_orders_awaiting_offline_payment_to_check_in, // Invoice settings @@ -93,7 +88,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/Message/CancelMessageHandler.php b/backend/app/Services/Application/Handlers/Message/CancelMessageHandler.php new file mode 100644 index 000000000..29206a250 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Message/CancelMessageHandler.php @@ -0,0 +1,51 @@ +messageRepository->findFirstWhere([ + 'id' => $messageId, + 'event_id' => $eventId, + ]); + + if ($message === null) { + throw new ResourceNotFoundException(__('Message not found')); + } + + if ($message->getStatus() !== MessageStatus::SCHEDULED->name) { + throw ValidationException::withMessages([ + 'status' => [__('Only scheduled messages can be cancelled')], + ]); + } + + $updated = $this->messageRepository->updateWhere( + ['status' => MessageStatus::CANCELLED->name], + ['id' => $messageId, 'status' => MessageStatus::SCHEDULED->name], + ); + + if ($updated === 0) { + throw ValidationException::withMessages([ + 'status' => [__('This message can no longer be cancelled')], + ]); + } + + return $this->messageRepository->findFirst($messageId); + } +} diff --git a/backend/app/Services/Application/Handlers/Message/DTO/SendMessageDTO.php b/backend/app/Services/Application/Handlers/Message/DTO/SendMessageDTO.php index 99b54adb2..ef6eb83ce 100644 --- a/backend/app/Services/Application/Handlers/Message/DTO/SendMessageDTO.php +++ b/backend/app/Services/Application/Handlers/Message/DTO/SendMessageDTO.php @@ -21,6 +21,7 @@ public function __construct( public readonly ?int $id = null, public readonly ?array $attendee_ids = [], public readonly ?array $product_ids = [], + public readonly ?string $scheduled_at = null, ) { } diff --git a/backend/app/Services/Application/Handlers/Message/GetMessageRecipientsHandler.php b/backend/app/Services/Application/Handlers/Message/GetMessageRecipientsHandler.php new file mode 100644 index 000000000..1dd600d5a --- /dev/null +++ b/backend/app/Services/Application/Handlers/Message/GetMessageRecipientsHandler.php @@ -0,0 +1,39 @@ +messageRepository->findFirstWhere([ + 'id' => $messageId, + 'event_id' => $eventId, + ]); + + if ($message === null) { + throw new ResourceNotFoundException(__('Message not found')); + } + + return $this->outgoingMessageRepository->paginateWhere( + where: [ + 'event_id' => $eventId, + 'message_id' => $messageId, + ], + limit: $params->per_page, + ); + } +} diff --git a/backend/app/Services/Application/Handlers/Message/SendMessageHandler.php b/backend/app/Services/Application/Handlers/Message/SendMessageHandler.php index cec0298f7..a6621eda6 100644 --- a/backend/app/Services/Application/Handlers/Message/SendMessageHandler.php +++ b/backend/app/Services/Application/Handlers/Message/SendMessageHandler.php @@ -8,10 +8,12 @@ use HiEvents\DomainObjects\Status\MessageStatus; use HiEvents\Exceptions\AccountNotVerifiedException; use HiEvents\Exceptions\MessagingTierLimitExceededException; +use HiEvents\Helper\DateHelper; use HiEvents\Jobs\Event\SendMessagesJob; use HiEvents\Jobs\Message\MessagePendingReviewJob; use HiEvents\Repository\Interfaces\AccountRepositoryInterface; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; +use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\MessageRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; @@ -29,6 +31,7 @@ public function __construct( private readonly ProductRepositoryInterface $productRepository, private readonly MessageRepositoryInterface $messageRepository, private readonly AccountRepositoryInterface $accountRepository, + private readonly EventRepositoryInterface $eventRepository, private readonly HtmlPurifierService $purifier, private readonly Repository $config, private readonly MessagingEligibilityService $eligibilityService, @@ -73,9 +76,21 @@ public function handle(SendMessageDTO $messageData): MessageDomainObject $messageData->event_id ); - $status = $eligibilityFailure !== null - ? MessageStatus::PENDING_REVIEW - : MessageStatus::PROCESSING; + $isScheduled = $messageData->scheduled_at !== null && !$messageData->is_test; + + $event = $this->eventRepository->findById($messageData->event_id); + + $scheduledAtUtc = $messageData->scheduled_at + ? DateHelper::convertToUTC($messageData->scheduled_at, $event->getTimezone()) + : null; + + if ($eligibilityFailure !== null) { + $status = MessageStatus::PENDING_REVIEW; + } elseif ($isScheduled) { + $status = MessageStatus::SCHEDULED; + } else { + $status = MessageStatus::PROCESSING; + } $message = $this->messageRepository->create([ 'event_id' => $messageData->event_id, @@ -85,9 +100,10 @@ public function handle(SendMessageDTO $messageData): MessageDomainObject 'order_id' => $this->getOrderId($messageData), 'attendee_ids' => $this->getAttendeeIds($messageData)->toArray(), 'product_ids' => $this->getProductIds($messageData)->toArray(), - 'sent_at' => Carbon::now()->toDateTimeString(), + 'sent_at' => $isScheduled ? null : Carbon::now()->toDateTimeString(), 'sent_by_user_id' => $messageData->sent_by_user_id, 'status' => $status->name, + 'scheduled_at' => $scheduledAtUtc, 'eligibility_failures' => $eligibilityFailure?->getFailureValues(), 'send_data' => [ 'is_test' => $messageData->is_test, @@ -101,7 +117,7 @@ public function handle(SendMessageDTO $messageData): MessageDomainObject if ($status === MessageStatus::PENDING_REVIEW) { MessagePendingReviewJob::dispatch($message->getId(), $eligibilityFailure->getFailureValues()); - } else { + } elseif ($status === MessageStatus::PROCESSING) { $updatedData = SendMessageDTO::fromArray([ 'account_id' => $messageData->account_id, 'event_id' => $messageData->event_id, diff --git a/backend/app/Services/Application/Handlers/Order/CompleteOrderHandler.php b/backend/app/Services/Application/Handlers/Order/CompleteOrderHandler.php index 4daf4c2e2..49abae7e4 100644 --- a/backend/app/Services/Application/Handlers/Order/CompleteOrderHandler.php +++ b/backend/app/Services/Application/Handlers/Order/CompleteOrderHandler.php @@ -22,6 +22,7 @@ use HiEvents\DomainObjects\Status\OrderStatus; use HiEvents\Events\OrderStatusChangedEvent; use HiEvents\Exceptions\ResourceConflictException; +use HiEvents\Exceptions\UnauthorizedException; use HiEvents\Helper\IdHelper; use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\AffiliateRepositoryInterface; @@ -38,6 +39,7 @@ use HiEvents\Services\Domain\Payment\Stripe\EventHandlers\PaymentIntentSucceededHandler; use HiEvents\Services\Domain\Product\ProductQuantityUpdateService; use HiEvents\Services\Infrastructure\DomainEvents\DomainEventDispatcherService; +use HiEvents\Services\Infrastructure\Session\CheckoutSessionManagementService; use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType; use HiEvents\Services\Infrastructure\DomainEvents\Events\OrderEvent; use Illuminate\Support\Collection; @@ -59,6 +61,7 @@ public function __construct( private readonly ProductPriceRepositoryInterface $productPriceRepository, private readonly DomainEventDispatcherService $domainEventDispatcherService, private readonly EventSettingsRepositoryInterface $eventSettingsRepository, + private readonly CheckoutSessionManagementService $sessionManagementService, ) { } @@ -289,6 +292,13 @@ private function getOrder(string $orderShortId): OrderDomainObject throw new ResourceNotFoundException(__('Order not found')); } + if ($order->getSessionId() === null + || !$this->sessionManagementService->verifySession($order->getSessionId())) { + throw new UnauthorizedException( + __('Sorry, we could not verify your session. Please restart your order.') + ); + } + $this->validateOrder($order); return $order; diff --git a/backend/app/Services/Application/Handlers/Order/CreateOrderHandler.php b/backend/app/Services/Application/Handlers/Order/CreateOrderHandler.php index 8867a5bf9..5278ef224 100644 --- a/backend/app/Services/Application/Handlers/Order/CreateOrderHandler.php +++ b/backend/app/Services/Application/Handlers/Order/CreateOrderHandler.php @@ -19,19 +19,22 @@ use HiEvents\Services\Application\Handlers\Order\DTO\CreateOrderPublicDTO; use HiEvents\Services\Domain\Order\OrderItemProcessingService; use HiEvents\Services\Domain\Order\OrderManagementService; +use HiEvents\Services\Domain\Product\AvailableProductQuantitiesFetchService; use Illuminate\Database\DatabaseManager; use Illuminate\Validation\UnauthorizedException; +use Illuminate\Validation\ValidationException; use Throwable; class CreateOrderHandler { public function __construct( - private readonly EventRepositoryInterface $eventRepository, - private readonly PromoCodeRepositoryInterface $promoCodeRepository, - private readonly AffiliateRepositoryInterface $affiliateRepository, - private readonly OrderManagementService $orderManagementService, - private readonly OrderItemProcessingService $orderItemProcessingService, - private readonly DatabaseManager $databaseManager, + private readonly EventRepositoryInterface $eventRepository, + private readonly PromoCodeRepositoryInterface $promoCodeRepository, + private readonly AffiliateRepositoryInterface $affiliateRepository, + private readonly OrderManagementService $orderManagementService, + private readonly OrderItemProcessingService $orderItemProcessingService, + private readonly AvailableProductQuantitiesFetchService $availableProductQuantitiesFetchService, + private readonly DatabaseManager $databaseManager, ) { } @@ -46,6 +49,8 @@ public function handle( ): OrderDomainObject { return $this->databaseManager->transaction(function () use ($eventId, $createOrderPublicDTO, $deleteExistingOrdersForSession) { + $this->databaseManager->statement('SELECT pg_advisory_xact_lock(?)', [$eventId]); + $event = $this->eventRepository ->loadRelation(EventSettingDomainObject::class) ->findById($eventId); @@ -59,6 +64,8 @@ public function handle( $this->orderManagementService->deleteExistingOrders($eventId, $createOrderPublicDTO->session_identifier); } + $this->validateProductAvailability($eventId, $createOrderPublicDTO); + $order = $this->orderManagementService->createNewOrder( eventId: $eventId, event: $event, @@ -119,4 +126,32 @@ public function validateEventStatus(EventDomainObject $event, CreateOrderPublicD ); } } + + /** + * @throws ValidationException + */ + private function validateProductAvailability(int $eventId, CreateOrderPublicDTO $createOrderPublicDTO): void + { + $availability = $this->availableProductQuantitiesFetchService + ->getAvailableProductQuantities($eventId, ignoreCache: true); + + foreach ($createOrderPublicDTO->products as $product) { + foreach ($product->quantities as $priceQuantity) { + if ($priceQuantity->quantity <= 0) { + continue; + } + + $available = $availability->productQuantities + ->where('product_id', $product->product_id) + ->where('price_id', $priceQuantity->price_id) + ->first()?->quantity_available ?? 0; + + if ($priceQuantity->quantity > $available) { + throw ValidationException::withMessages([ + 'products' => __('Not enough products available. Please try again.'), + ]); + } + } + } + } } diff --git a/backend/app/Services/Application/Handlers/Order/GetOrderPublicHandler.php b/backend/app/Services/Application/Handlers/Order/GetOrderPublicHandler.php index ddda2fc50..0672a0e99 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/Order/TransitionOrderToOfflinePaymentHandler.php b/backend/app/Services/Application/Handlers/Order/TransitionOrderToOfflinePaymentHandler.php index b7d577734..9a03323d3 100644 --- a/backend/app/Services/Application/Handlers/Order/TransitionOrderToOfflinePaymentHandler.php +++ b/backend/app/Services/Application/Handlers/Order/TransitionOrderToOfflinePaymentHandler.php @@ -19,6 +19,7 @@ use HiEvents\Services\Infrastructure\DomainEvents\DomainEventDispatcherService; use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType; use HiEvents\Services\Infrastructure\DomainEvents\Events\OrderEvent; +use HiEvents\Services\Infrastructure\Session\CheckoutSessionManagementService; use Illuminate\Database\DatabaseManager; class TransitionOrderToOfflinePaymentHandler @@ -29,7 +30,7 @@ public function __construct( private readonly DatabaseManager $databaseManager, private readonly EventSettingsRepositoryInterface $eventSettingsRepository, private readonly DomainEventDispatcherService $domainEventDispatcherService, - + private readonly CheckoutSessionManagementService $sessionManagementService, ) { } @@ -42,6 +43,17 @@ public function handle(TransitionOrderToOfflinePaymentPublicDTO $dto): OrderDoma ->loadRelation(OrderItemDomainObject::class) ->findByShortId($dto->orderShortId); + if ($order === null) { + throw new ResourceConflictException(__('Order not found')); + } + + if ($order->getSessionId() === null + || !$this->sessionManagementService->verifySession($order->getSessionId())) { + throw new UnauthorizedException( + __('Sorry, we could not verify your session. Please restart your order.') + ); + } + /** @var EventSettingDomainObject $eventSettings */ $eventSettings = $this->eventSettingsRepository->findFirstWhere([ 'event_id' => $order->getEventId(), diff --git a/backend/app/Services/Application/Handlers/Product/CreateProductHandler.php b/backend/app/Services/Application/Handlers/Product/CreateProductHandler.php index ff96ae0bf..053cac6d1 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 e8e5ff642..1980214e6 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 b5fce3500..4e51bfc7f 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/CreateProductCategoryHandler.php b/backend/app/Services/Application/Handlers/ProductCategory/CreateProductCategoryHandler.php index 30ffc4008..fd51e0e50 100644 --- a/backend/app/Services/Application/Handlers/ProductCategory/CreateProductCategoryHandler.php +++ b/backend/app/Services/Application/Handlers/ProductCategory/CreateProductCategoryHandler.php @@ -5,11 +5,13 @@ use HiEvents\DomainObjects\ProductCategoryDomainObject; use HiEvents\Services\Application\Handlers\ProductCategory\DTO\UpsertProductCategoryDTO; use HiEvents\Services\Domain\ProductCategory\CreateProductCategoryService; +use HiEvents\Services\Infrastructure\HtmlPurifier\HtmlPurifierService; class CreateProductCategoryHandler { public function __construct( private readonly CreateProductCategoryService $productCategoryService, + private readonly HtmlPurifierService $purifier, ) { } @@ -20,7 +22,7 @@ public function handle(UpsertProductCategoryDTO $dto): ProductCategoryDomainObje $productCategory->setName($dto->name); $productCategory->setIsHidden($dto->is_hidden); $productCategory->setEventId($dto->event_id); - $productCategory->setDescription($dto->description); + $productCategory->setDescription($this->purifier->purify($dto->description)); $productCategory->setNoProductsMessage( $dto->no_products_message ?? __('There are no products available in this category' )); diff --git a/backend/app/Services/Application/Handlers/ProductCategory/EditProductCategoryHandler.php b/backend/app/Services/Application/Handlers/ProductCategory/EditProductCategoryHandler.php index 574183a57..ae013ef50 100644 --- a/backend/app/Services/Application/Handlers/ProductCategory/EditProductCategoryHandler.php +++ b/backend/app/Services/Application/Handlers/ProductCategory/EditProductCategoryHandler.php @@ -5,11 +5,13 @@ use HiEvents\DomainObjects\ProductCategoryDomainObject; use HiEvents\Repository\Interfaces\ProductCategoryRepositoryInterface; use HiEvents\Services\Application\Handlers\ProductCategory\DTO\UpsertProductCategoryDTO; +use HiEvents\Services\Infrastructure\HtmlPurifier\HtmlPurifierService; class EditProductCategoryHandler { public function __construct( private readonly ProductCategoryRepositoryInterface $productCategoryRepository, + private readonly HtmlPurifierService $purifier, ) { } @@ -20,7 +22,7 @@ public function handle(UpsertProductCategoryDTO $dto): ProductCategoryDomainObje attributes: [ 'name' => $dto->name, 'is_hidden' => $dto->is_hidden, - 'description' => $dto->description, + 'description' => $this->purifier->purify($dto->description), 'no_products_message' => $dto->no_products_message ?? __('There are no products available in this category'), ], where: [ diff --git a/backend/app/Services/Application/Handlers/ProductCategory/GetProductCategoriesHandler.php b/backend/app/Services/Application/Handlers/ProductCategory/GetProductCategoriesHandler.php index ccd815bb9..804b005a6 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 000000000..a22d04697 --- /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 000000000..781377141 --- /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 000000000..a8da7ff6e --- /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 000000000..69e56fab9 --- /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 000000000..1a6ad7bf2 --- /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/Application/Handlers/Webhook/CreateWebhookHandler.php b/backend/app/Services/Application/Handlers/Webhook/CreateWebhookHandler.php index 35160fffb..a8f283a16 100644 --- a/backend/app/Services/Application/Handlers/Webhook/CreateWebhookHandler.php +++ b/backend/app/Services/Application/Handlers/Webhook/CreateWebhookHandler.php @@ -31,6 +31,7 @@ private function createWebhook(CreateWebhookDTO $upsertWebhookDTO): WebhookDomai ->setUrl($upsertWebhookDTO->url) ->setEventTypes($upsertWebhookDTO->eventTypes) ->setEventId($upsertWebhookDTO->eventId) + ->setOrganizerId($upsertWebhookDTO->organizerId) ->setUserId($upsertWebhookDTO->userId) ->setAccountId($upsertWebhookDTO->accountId) ->setStatus($upsertWebhookDTO->status->value); diff --git a/backend/app/Services/Application/Handlers/Webhook/DTO/CreateWebhookDTO.php b/backend/app/Services/Application/Handlers/Webhook/DTO/CreateWebhookDTO.php index 2c834e336..1ee6d9e21 100644 --- a/backend/app/Services/Application/Handlers/Webhook/DTO/CreateWebhookDTO.php +++ b/backend/app/Services/Application/Handlers/Webhook/DTO/CreateWebhookDTO.php @@ -10,10 +10,11 @@ class CreateWebhookDTO extends BaseDTO public function __construct( public string $url, public array $eventTypes, - public int $eventId, public int $userId, public int $accountId, public WebhookStatus $status, + public ?int $eventId = null, + public ?int $organizerId = null, ) { } diff --git a/backend/app/Services/Application/Handlers/Webhook/DTO/EditWebhookDTO.php b/backend/app/Services/Application/Handlers/Webhook/DTO/EditWebhookDTO.php index 0c3fdf9e8..5c23e1561 100644 --- a/backend/app/Services/Application/Handlers/Webhook/DTO/EditWebhookDTO.php +++ b/backend/app/Services/Application/Handlers/Webhook/DTO/EditWebhookDTO.php @@ -10,19 +10,21 @@ public function __construct( public int $webhookId, string $url, array $eventTypes, - int $eventId, int $userId, int $accountId, WebhookStatus $status, + ?int $eventId = null, + ?int $organizerId = null, ) { parent::__construct( url: $url, eventTypes: $eventTypes, - eventId: $eventId, userId: $userId, accountId: $accountId, status: $status, + eventId: $eventId, + organizerId: $organizerId, ); } } diff --git a/backend/app/Services/Application/Handlers/Webhook/DeleteWebhookHandler.php b/backend/app/Services/Application/Handlers/Webhook/DeleteWebhookHandler.php index 6fbc127a1..6a8edb909 100644 --- a/backend/app/Services/Application/Handlers/Webhook/DeleteWebhookHandler.php +++ b/backend/app/Services/Application/Handlers/Webhook/DeleteWebhookHandler.php @@ -17,28 +17,29 @@ public function __construct( { } - public function handle(int $eventId, int $webhookId): void + public function handle(int $webhookId, ?int $eventId = null, ?int $organizerId = null): void { - $this->databaseManager->transaction(function () use ($eventId, $webhookId) { - $webhook = $this->webhookRepository->findFirstWhere([ - 'id' => $webhookId, - 'event_id' => $eventId, - ]); + $this->databaseManager->transaction(function () use ($eventId, $webhookId, $organizerId) { + $where = ['id' => $webhookId]; + if ($eventId !== null) { + $where['event_id'] = $eventId; + } + if ($organizerId !== null) { + $where['organizer_id'] = $organizerId; + } + + $webhook = $this->webhookRepository->findFirstWhere($where); if (!$webhook) { throw new ResourceNotFoundException(__( - key: 'Webhook not found for ID: :webhookId and event ID: :eventId', + key: 'Webhook not found for ID: :webhookId', replace: [ 'webhookId' => $webhookId, - 'eventId' => $eventId, ] )); } - $this->webhookRepository->deleteWhere([ - 'id' => $webhookId, - 'event_id' => $eventId, - ]); + $this->webhookRepository->deleteWhere($where); $this->webhookLogRepository ->deleteOldLogs($webhookId, numberToKeep: 0); diff --git a/backend/app/Services/Application/Handlers/Webhook/EditWebhookHandler.php b/backend/app/Services/Application/Handlers/Webhook/EditWebhookHandler.php index 3f3c86efe..9d76c441e 100644 --- a/backend/app/Services/Application/Handlers/Webhook/EditWebhookHandler.php +++ b/backend/app/Services/Application/Handlers/Webhook/EditWebhookHandler.php @@ -20,18 +20,22 @@ public function __construct( public function handle(EditWebhookDTO $dto): WebhookDomainObject { return $this->databaseManager->transaction(function () use ($dto) { + $where = ['id' => $dto->webhookId]; + if ($dto->eventId !== null) { + $where['event_id'] = $dto->eventId; + } + if ($dto->organizerId !== null) { + $where['organizer_id'] = $dto->organizerId; + } + /** @var WebhookDomainObject $webhook */ - $webhook = $this->webhookRepository->findFirstWhere([ - 'id' => $dto->webhookId, - 'event_id' => $dto->eventId, - ]); + $webhook = $this->webhookRepository->findFirstWhere($where); if (!$webhook) { throw new ResourceNotFoundException(__( - key: 'Webhook not found for ID: :webhookId and event ID: :eventId', + key: 'Webhook not found for ID: :webhookId', replace: [ 'webhookId' => $dto->webhookId, - 'eventId' => $dto->eventId, ] )); } diff --git a/backend/app/Services/Application/Handlers/Webhook/GetWebhookHandler.php b/backend/app/Services/Application/Handlers/Webhook/GetWebhookHandler.php index 8d4eafc0d..2d63ff111 100644 --- a/backend/app/Services/Application/Handlers/Webhook/GetWebhookHandler.php +++ b/backend/app/Services/Application/Handlers/Webhook/GetWebhookHandler.php @@ -13,13 +13,18 @@ public function __construct( { } - public function handle(int $eventId, int $webhookId): WebhookDomainObject + public function handle(int $webhookId, ?int $eventId = null, ?int $organizerId = null): WebhookDomainObject { + $where = ['id' => $webhookId]; + if ($eventId !== null) { + $where['event_id'] = $eventId; + } + if ($organizerId !== null) { + $where['organizer_id'] = $organizerId; + } + return $this->webhookRepository->findFirstWhere( - where: [ - 'id' => $webhookId, - 'event_id' => $eventId, - ] + where: $where ); } } diff --git a/backend/app/Services/Application/Handlers/Webhook/GetWebhookLogsHandler.php b/backend/app/Services/Application/Handlers/Webhook/GetWebhookLogsHandler.php index e3e6a3782..80cd5a319 100644 --- a/backend/app/Services/Application/Handlers/Webhook/GetWebhookLogsHandler.php +++ b/backend/app/Services/Application/Handlers/Webhook/GetWebhookLogsHandler.php @@ -16,13 +16,18 @@ public function __construct( { } - public function handle(int $eventId, int $webhookId): LengthAwarePaginator + public function handle(int $webhookId, ?int $eventId = null, ?int $organizerId = null): LengthAwarePaginator { + $where = ['id' => $webhookId]; + if ($eventId !== null) { + $where['event_id'] = $eventId; + } + if ($organizerId !== null) { + $where['organizer_id'] = $organizerId; + } + $webhook = $this->webhookRepository->findFirstWhere( - where: [ - 'id' => $webhookId, - 'event_id' => $eventId, - ] + where: $where ); if (!$webhook) { diff --git a/backend/app/Services/Application/Handlers/Webhook/GetWebhooksHandler.php b/backend/app/Services/Application/Handlers/Webhook/GetWebhooksHandler.php index 1ae708df0..c68b99ffa 100644 --- a/backend/app/Services/Application/Handlers/Webhook/GetWebhooksHandler.php +++ b/backend/app/Services/Application/Handlers/Webhook/GetWebhooksHandler.php @@ -14,13 +14,18 @@ public function __construct( { } - public function handler(int $accountId, int $eventId): Collection + public function handler(int $accountId, ?int $eventId = null, ?int $organizerId = null): Collection { + $where = ['account_id' => $accountId]; + if ($eventId !== null) { + $where['event_id'] = $eventId; + } + if ($organizerId !== null) { + $where['organizer_id'] = $organizerId; + } + return $this->webhookRepository->findWhere( - where: [ - 'account_id' => $accountId, - 'event_id' => $eventId, - ], + where: $where, orderAndDirections: [ new OrderAndDirection('id', OrderAndDirection::DIRECTION_DESC), ] diff --git a/backend/app/Services/Domain/CreateWebhookService.php b/backend/app/Services/Domain/CreateWebhookService.php index 7ea1b663a..8d2701ad0 100644 --- a/backend/app/Services/Domain/CreateWebhookService.php +++ b/backend/app/Services/Domain/CreateWebhookService.php @@ -14,9 +14,7 @@ class CreateWebhookService public function __construct( private readonly WebhookRepositoryInterface $webhookRepository, private readonly LoggerInterface $logger, - ) - { - } + ) {} public function createWebhook(WebhookDomainObject $webhookDomainObject): WebhookDomainObject { @@ -26,6 +24,7 @@ public function createWebhook(WebhookDomainObject $webhookDomainObject): Webhook WebhookDomainObjectAbstract::ACCOUNT_ID => $webhookDomainObject->getAccountId(), WebhookDomainObjectAbstract::STATUS => $webhookDomainObject->getStatus(), WebhookDomainObjectAbstract::EVENT_ID => $webhookDomainObject->getEventId(), + WebhookDomainObjectAbstract::ORGANIZER_ID => $webhookDomainObject->getOrganizerId(), WebhookDomainObjectAbstract::USER_ID => $webhookDomainObject->getUserId(), WebhookDomainObjectAbstract::SECRET => Str::random(32), ]); diff --git a/backend/app/Services/Domain/Event/CreateEventService.php b/backend/app/Services/Domain/Event/CreateEventService.php index 6262fb5b8..7126eda94 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/EventStatistics/EventStatisticsReactivationService.php b/backend/app/Services/Domain/EventStatistics/EventStatisticsReactivationService.php new file mode 100644 index 000000000..61428ec45 --- /dev/null +++ b/backend/app/Services/Domain/EventStatistics/EventStatisticsReactivationService.php @@ -0,0 +1,156 @@ +retrier->retry( + callableAction: function () use ($eventId, $orderDate, $attendeeCount): void { + $this->databaseManager->transaction(function () use ($eventId, $orderDate, $attendeeCount): void { + $this->incrementAggregateAttendeeStatistics($eventId, $attendeeCount); + $this->incrementDailyAttendeeStatistics($eventId, $orderDate, $attendeeCount); + }); + }, + onFailure: function (int $attempt, Throwable $e) use ($eventId, $orderDate, $attendeeCount): void { + $this->logger->error( + 'Failed to increment event statistics for reactivated attendee after multiple attempts', + [ + 'event_id' => $eventId, + 'order_date' => $orderDate, + 'attendee_count' => $attendeeCount, + 'attempts' => $attempt, + 'exception' => $e::class, + 'message' => $e->getMessage(), + ] + ); + }, + retryOn: [EventStatisticsVersionMismatchException::class] + ); + } + + /** + * @throws EventStatisticsVersionMismatchException + */ + private function incrementAggregateAttendeeStatistics(int $eventId, int $attendeeCount): void + { + $eventStatistics = $this->eventStatisticsRepository->findFirstWhere([ + 'event_id' => $eventId, + ]); + + if (!$eventStatistics) { + throw new ResourceNotFoundException('Event statistics not found for event ' . $eventId); + } + + $updates = [ + 'attendees_registered' => $eventStatistics->getAttendeesRegistered() + $attendeeCount, + 'version' => $eventStatistics->getVersion() + 1, + ]; + + $updated = $this->eventStatisticsRepository->updateWhere( + attributes: $updates, + where: [ + 'id' => $eventStatistics->getId(), + 'version' => $eventStatistics->getVersion(), + ] + ); + + if ($updated === 0) { + throw new EventStatisticsVersionMismatchException( + 'Event statistics version mismatch. Expected version ' + . $eventStatistics->getVersion() . ' but it was already updated.' + ); + } + + $this->logger->info( + 'Event aggregate statistics incremented for reactivated attendee', + [ + 'event_id' => $eventId, + 'attendees_incremented' => $attendeeCount, + 'new_version' => $eventStatistics->getVersion() + 1, + ] + ); + } + + /** + * @throws EventStatisticsVersionMismatchException + */ + private function incrementDailyAttendeeStatistics(int $eventId, string $orderDate, int $attendeeCount): void + { + $formattedDate = (new Carbon($orderDate))->format('Y-m-d'); + + $eventDailyStatistic = $this->eventDailyStatisticRepository->findFirstWhere([ + 'event_id' => $eventId, + 'date' => $formattedDate, + ]); + + if (!$eventDailyStatistic) { + $this->logger->warning( + 'Event daily statistics not found for event, skipping daily increment for reactivated attendee', + [ + 'event_id' => $eventId, + 'date' => $formattedDate, + ] + ); + return; + } + + $updates = [ + 'attendees_registered' => $eventDailyStatistic->getAttendeesRegistered() + $attendeeCount, + 'version' => $eventDailyStatistic->getVersion() + 1, + ]; + + $updated = $this->eventDailyStatisticRepository->updateWhere( + attributes: $updates, + where: [ + 'event_id' => $eventId, + 'date' => $formattedDate, + 'version' => $eventDailyStatistic->getVersion(), + ] + ); + + if ($updated === 0) { + throw new EventStatisticsVersionMismatchException( + 'Event daily statistics version mismatch. Expected version ' + . $eventDailyStatistic->getVersion() . ' but it was already updated.' + ); + } + + $this->logger->info( + 'Event daily statistics incremented for reactivated attendee', + [ + 'event_id' => $eventId, + 'date' => $formattedDate, + 'attendees_incremented' => $attendeeCount, + 'new_version' => $eventDailyStatistic->getVersion() + 1, + ] + ); + } +} diff --git a/backend/app/Services/Domain/Mail/SendEventEmailMessagesService.php b/backend/app/Services/Domain/Mail/SendEventEmailMessagesService.php index f1937a52f..833ac9b16 100644 --- a/backend/app/Services/Domain/Mail/SendEventEmailMessagesService.php +++ b/backend/app/Services/Domain/Mail/SendEventEmailMessagesService.php @@ -163,10 +163,16 @@ private function emailAttendees( private function updateMessageStatus(SendMessageDTO $messageData, MessageStatus $status): void { + $attributes = [ + 'status' => $status->name, + ]; + + if ($status === MessageStatus::SENT) { + $attributes['sent_at'] = now()->toDateTimeString(); + } + $this->messageRepository->updateWhere( - attributes: [ - 'status' => $status->name, - ], + attributes: $attributes, where: [ 'id' => $messageData->id, ] diff --git a/backend/app/Services/Domain/Message/MessageDispatchService.php b/backend/app/Services/Domain/Message/MessageDispatchService.php new file mode 100644 index 000000000..006e666ef --- /dev/null +++ b/backend/app/Services/Domain/Message/MessageDispatchService.php @@ -0,0 +1,79 @@ +getSendData(); + $sendDataArray = is_string($sendData) ? json_decode($sendData, true) : $sendData; + + if (!is_array($sendDataArray) || !isset($sendDataArray['account_id'])) { + Log::error('Message has invalid send_data, marking as FAILED', [ + 'message_id' => $message->getId(), + ]); + $this->messageRepository->updateFromArray($message->getId(), [ + 'status' => MessageStatus::FAILED->name, + ]); + return; + } + + $updated = $this->messageRepository->updateWhere( + ['status' => MessageStatus::PROCESSING->name], + ['id' => $message->getId(), 'status' => $expectedStatus->name], + ); + + if ($updated === 0) { + Log::info('Message status changed before dispatch, skipping', [ + 'message_id' => $message->getId(), + ]); + return; + } + + try { + SendMessagesJob::dispatch(new SendMessageDTO( + account_id: $sendDataArray['account_id'], + event_id: $message->getEventId(), + subject: $message->getSubject(), + message: $message->getMessage(), + type: MessageTypeEnum::fromName($message->getType()), + is_test: false, + send_copy_to_current_user: $sendDataArray['send_copy_to_current_user'] ?? false, + sent_by_user_id: $message->getSentByUserId(), + order_id: $message->getOrderId(), + order_statuses: $sendDataArray['order_statuses'] ?? [], + id: $message->getId(), + attendee_ids: $message->getAttendeeIds() ?? [], + product_ids: $message->getProductIds() ?? [], + )); + } catch (Throwable $e) { + Log::error('Failed to dispatch SendMessagesJob, reverting status', [ + 'message_id' => $message->getId(), + 'error' => $e->getMessage(), + ]); + $this->messageRepository->updateWhere( + ['status' => $expectedStatus->name], + ['id' => $message->getId(), 'status' => MessageStatus::PROCESSING->name], + ); + throw $e; + } + } +} diff --git a/backend/app/Services/Domain/Order/OrderApplicationFeeCalculationService.php b/backend/app/Services/Domain/Order/OrderApplicationFeeCalculationService.php index ccfe9cb3e..69c81ff8b 100644 --- a/backend/app/Services/Domain/Order/OrderApplicationFeeCalculationService.php +++ b/backend/app/Services/Domain/Order/OrderApplicationFeeCalculationService.php @@ -14,8 +14,6 @@ class OrderApplicationFeeCalculationService { - private const BASE_CURRENCY = 'USD'; - public function __construct( private readonly Repository $config, private readonly CurrencyConversionClientInterface $currencyConversionClient, @@ -64,12 +62,14 @@ private function getConvertedFixedFee( string $currency ): MoneyValue { - if ($currency === self::BASE_CURRENCY) { + $baseCurrency = $accountConfiguration->getApplicationFeeCurrency(); + + if ($currency === $baseCurrency) { return MoneyValue::fromFloat($accountConfiguration->getFixedApplicationFee(), $currency); } return $this->currencyConversionClient->convert( - fromCurrency: Currency::of(self::BASE_CURRENCY), + fromCurrency: Currency::of($baseCurrency), toCurrency: Currency::of($currency), amount: $accountConfiguration->getFixedApplicationFee() ); diff --git a/backend/app/Services/Domain/Order/OrderCancelService.php b/backend/app/Services/Domain/Order/OrderCancelService.php index d1677ed50..0c0c90443 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/Order/OrderManagementService.php b/backend/app/Services/Domain/Order/OrderManagementService.php index f788f2646..0e627482f 100644 --- a/backend/app/Services/Domain/Order/OrderManagementService.php +++ b/backend/app/Services/Domain/Order/OrderManagementService.php @@ -40,7 +40,7 @@ public function createNewOrder( string $locale, ?PromoCodeDomainObject $promoCode, ?AffiliateDomainObject $affiliate = null, - string $sessionId = null, + ?string $sessionId = null, ): OrderDomainObject { $reservedUntil = Carbon::now()->addMinutes($timeOutMinutes); diff --git a/backend/app/Services/Domain/Order/OrderPlatformFeePassThroughService.php b/backend/app/Services/Domain/Order/OrderPlatformFeePassThroughService.php index 89ccf9506..7f87def40 100644 --- a/backend/app/Services/Domain/Order/OrderPlatformFeePassThroughService.php +++ b/backend/app/Services/Domain/Order/OrderPlatformFeePassThroughService.php @@ -11,8 +11,6 @@ class OrderPlatformFeePassThroughService { - private const BASE_CURRENCY = 'USD'; - public const PLATFORM_FEE_ID = 0; public static function getPlatformFeeName(): string @@ -37,12 +35,14 @@ public function isEnabled(EventSettingDomainObject $eventSettings): bool } /** - * Calculate platform fee that exactly covers Stripe's application fee. + * Calculate the platform fee line item to show on the buyer's invoice. * - * Formula: P = (fixed + total * r) / (1 - r) + * Uses gross-up formula: P = (fixed + total * r) / (1 - r) * Where r = percentage rate, P = platform fee * - * This ensures: Stripe fee on (total + P) = P + * This ensures that when the platform fee (P) is added to the order total, + * the resulting Stripe application fee calculated on the new total equals P. + * In other words: application_fee(total + P) = P */ public function calculatePlatformFee( AccountConfigurationDomainObject $accountConfiguration, @@ -75,13 +75,14 @@ private function getConvertedFixedFee( ): float { $baseFee = $accountConfiguration->getFixedApplicationFee(); + $baseCurrency = $accountConfiguration->getApplicationFeeCurrency(); - if ($currency === self::BASE_CURRENCY) { + if ($currency === $baseCurrency) { return $baseFee; } return $this->currencyConversionClient->convert( - fromCurrency: BrickCurrency::of(self::BASE_CURRENCY), + fromCurrency: BrickCurrency::of($baseCurrency), toCurrency: BrickCurrency::of($currency), amount: $baseFee )->toFloat(); diff --git a/backend/app/Services/Domain/Product/CreateProductService.php b/backend/app/Services/Domain/Product/CreateProductService.php index f87f1a45a..ee7bbb872 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 000000000..fa8aa6d54 --- /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 000000000..b4e8c814e --- /dev/null +++ b/backend/app/Services/Domain/Waitlist/CreateWaitlistEntryService.php @@ -0,0 +1,95 @@ +validateWaitlistEnabled($product); + + /** @var WaitlistEntryDomainObject $entry */ + $entry = $this->databaseManager->transaction(function () use ($dto) { + $this->waitlistEntryRepository->lockForProductPrice($dto->product_price_id); + $this->validateNoDuplicate($dto); + $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 000000000..eedc88ffa --- /dev/null +++ b/backend/app/Services/Domain/Waitlist/ProcessWaitlistService.php @@ -0,0 +1,244 @@ + + */ + public function offerToNext( + int $productPriceId, + int $quantity, + EventDomainObject $event, + EventSettingDomainObject $eventSettings, + ): Collection + { + return $this->databaseManager->transaction(function () use ($productPriceId, $quantity, $event, $eventSettings) { + $this->databaseManager->statement('SELECT pg_advisory_xact_lock(?)', [$event->getId()]); + $this->waitlistEntryRepository->lockForProductPrice($productPriceId); + + $quantities = $this->availableQuantitiesService->getAvailableProductQuantities( + $event->getId(), + ignoreCache: true, + ); + + $availableCount = $this->getAvailableCountForPrice($quantities, $productPriceId); + + if ($availableCount <= 0) { + throw new NoCapacityAvailableException( + __('No capacity available. Available: :available', [ + 'available' => $availableCount, + ]) + ); + } + + $toOffer = min($quantity, $availableCount); + $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) { + $this->databaseManager->statement('SELECT pg_advisory_xact_lock(?)', [$event->getId()]); + + /** @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, + ); + + $availableCount = $this->getAvailableCountForPrice($quantities, $entry->getProductPriceId()); + + if ($availableCount <= 0) { + throw new NoCapacityAvailableException( + __('No capacity available to offer this waitlist entry. You will need to increase the available quantity for the product. Available: :available', [ + 'available' => $availableCount, + ]) + ); + } + + $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/app/Services/Infrastructure/DomainEvents/Enums/DomainEventType.php b/backend/app/Services/Infrastructure/DomainEvents/Enums/DomainEventType.php index dbaf38aec..690dadd39 100644 --- a/backend/app/Services/Infrastructure/DomainEvents/Enums/DomainEventType.php +++ b/backend/app/Services/Infrastructure/DomainEvents/Enums/DomainEventType.php @@ -12,6 +12,10 @@ enum DomainEventType: string case PRODUCT_UPDATED = 'product.updated'; case PRODUCT_DELETED = 'product.deleted'; + case EVENT_CREATED = 'event.created'; + case EVENT_UPDATED = 'event.updated'; + case EVENT_ARCHIVED = 'event.archived'; + case ORDER_CREATED = 'order.created'; case ORDER_UPDATED = 'order.updated'; case ORDER_MARKED_AS_PAID = 'order.marked_as_paid'; diff --git a/backend/app/Services/Infrastructure/HtmlPurifier/HtmlPurifierService.php b/backend/app/Services/Infrastructure/HtmlPurifier/HtmlPurifierService.php index efefb4f44..fc13a850d 100644 --- a/backend/app/Services/Infrastructure/HtmlPurifier/HtmlPurifierService.php +++ b/backend/app/Services/Infrastructure/HtmlPurifier/HtmlPurifierService.php @@ -20,10 +20,10 @@ public function __construct(private readonly HTMLPurifier $htmlPurifier) $this->config->set('Cache.SerializerPath', $cachePath); } - public function purify(?string $html): string + public function purify(?string $html): ?string { if ($html === null) { - return ''; + return null; } return $this->htmlPurifier->purify($html, $this->config); diff --git a/backend/app/Services/Infrastructure/Webhook/WebhookDispatchService.php b/backend/app/Services/Infrastructure/Webhook/WebhookDispatchService.php index 5790c015c..25fe3369e 100644 --- a/backend/app/Services/Infrastructure/Webhook/WebhookDispatchService.php +++ b/backend/app/Services/Infrastructure/Webhook/WebhookDispatchService.php @@ -6,7 +6,6 @@ use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\DomainObjects\ProductPriceDomainObject; use HiEvents\DomainObjects\QuestionAndAnswerViewDomainObject; -use HiEvents\DomainObjects\Status\WebhookStatus; use HiEvents\DomainObjects\TaxAndFeesDomainObject; use HiEvents\DomainObjects\WebhookDomainObject; use HiEvents\Repository\Eloquent\Value\Relationship; @@ -15,13 +14,14 @@ use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; use HiEvents\Repository\Interfaces\WebhookRepositoryInterface; +use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Resources\Attendee\AttendeeResource; +use HiEvents\Resources\Event\EventResource; use HiEvents\Resources\CheckInList\AttendeeCheckInResource; use HiEvents\Resources\Order\OrderResource; use HiEvents\Resources\Product\ProductResource; use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType; use Illuminate\Http\Resources\Json\JsonResource; -use Illuminate\Support\Collection; use Psr\Log\LoggerInterface; use Spatie\WebhookServer\WebhookCall; @@ -34,10 +34,22 @@ public function __construct( private readonly ProductRepositoryInterface $productRepository, private readonly AttendeeRepositoryInterface $attendeeRepository, private readonly AttendeeCheckInRepositoryInterface $attendeeCheckInRepository, + private readonly EventRepositoryInterface $eventRepository, ) { } + public function dispatchEventWebhook(DomainEventType $eventType, int $eventId): void + { + $event = $this->eventRepository->findById($eventId); + + $this->dispatchWebhook( + eventType: $eventType, + payload: new EventResource($event), + eventId: $eventId, + ); + } + public function dispatchAttendeeWebhook(DomainEventType $eventType, int $attendeeId): void { $attendee = $this->attendeeRepository @@ -132,11 +144,7 @@ public function dispatchOrderWebhook(DomainEventType $eventType, int $orderId): private function dispatchWebhook(DomainEventType $eventType, JsonResource $payload, int $eventId): void { - /** @var Collection $webhooks */ - $webhooks = $this->webhookRepository->findWhere([ - 'event_id' => $eventId, - 'status' => WebhookStatus::ENABLED->name, - ]) + $webhooks = $this->webhookRepository->findEnabledByEventId($eventId) ->filter(fn(WebhookDomainObject $webhook) => in_array($eventType->value, $webhook->getEventTypes(), true)); foreach ($webhooks as $webhook) { diff --git a/backend/app/Services/Infrastructure/Webhook/WebhookResponseHandlerService.php b/backend/app/Services/Infrastructure/Webhook/WebhookResponseHandlerService.php index 1f1bd332c..5c2ccfd56 100644 --- a/backend/app/Services/Infrastructure/Webhook/WebhookResponseHandlerService.php +++ b/backend/app/Services/Infrastructure/Webhook/WebhookResponseHandlerService.php @@ -15,9 +15,7 @@ public function __construct( private readonly LoggerInterface $logger, private readonly WebhookLogRepositoryInterface $webhookLogRepository, private readonly DatabaseManager $databaseManager, - ) - { - } + ) {} public function handleResponse( int $eventId, @@ -25,12 +23,10 @@ public function handleResponse( string $eventType, array $payload, ?Response $response - ): void - { + ): void { $this->databaseManager->transaction(function () use ($payload, $eventType, $eventId, $webhookId, $response) { $webhook = $this->webhookRepository->findFirstWhere([ 'id' => $webhookId, - 'event_id' => $eventId, ]); if (!$webhook) { @@ -49,8 +45,8 @@ public function handleResponse( ], where: [ 'id' => $webhookId, - 'event_id' => $eventId, - ]); + ] + ); $this->webhookLogRepository->create([ 'webhook_id' => $webhook->getId(), diff --git a/backend/app/Validators/Rules/NoInternalUrlRule.php b/backend/app/Validators/Rules/NoInternalUrlRule.php index 24096a108..5e3360ef8 100644 --- a/backend/app/Validators/Rules/NoInternalUrlRule.php +++ b/backend/app/Validators/Rules/NoInternalUrlRule.php @@ -100,14 +100,14 @@ private function isCloudMetadataHost(string $host): bool private function isPrivateIpAddress(string $host): bool { - $ip = gethostbyname($host); + $ip = $this->resolveAndNormalize($host); - if ($ip === $host && !filter_var($host, FILTER_VALIDATE_IP)) { - return false; + if ($ip === false) { + return true; } if (!filter_var($ip, FILTER_VALIDATE_IP)) { - return false; + return true; } if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) { @@ -120,4 +120,29 @@ private function isPrivateIpAddress(string $host): bool return false; } + + private function resolveAndNormalize(string $host): string|false + { + if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + $binary = inet_pton($host); + if ($binary !== false && strlen($binary) === 16) { + $prefix = substr($binary, 0, 12); + if ($prefix === "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff") { + return inet_ntop(substr($binary, 12)); + } + } + return $host; + } + + if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return $host; + } + + $ip = gethostbyname($host); + if ($ip === $host) { + return false; + } + + return $ip; + } } diff --git a/backend/config/app.php b/backend/config/app.php index 99344b4d8..f5c6bcbaa 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_01_30_000000_add_currency_to_application_fees.php b/backend/database/migrations/2026_01_30_000000_add_currency_to_application_fees.php new file mode 100644 index 000000000..6e9482ef4 --- /dev/null +++ b/backend/database/migrations/2026_01_30_000000_add_currency_to_application_fees.php @@ -0,0 +1,39 @@ +whereNotNull('application_fees') + ->get() + ->each(function ($row) { + $fees = json_decode($row->application_fees, true); + if ($fees && !isset($fees['currency'])) { + $fees['currency'] = 'USD'; + DB::table('account_configuration') + ->where('id', $row->id) + ->update(['application_fees' => json_encode($fees)]); + } + }); + } + + public function down(): void + { + DB::table('account_configuration') + ->whereNotNull('application_fees') + ->get() + ->each(function ($row) { + $fees = json_decode($row->application_fees, true); + if ($fees && isset($fees['currency'])) { + unset($fees['currency']); + DB::table('account_configuration') + ->where('id', $row->id) + ->update(['application_fees' => json_encode($fees)]); + } + }); + } +}; diff --git a/backend/database/migrations/2026_02_07_000000_add_scheduled_at_to_messages_table.php b/backend/database/migrations/2026_02_07_000000_add_scheduled_at_to_messages_table.php new file mode 100644 index 000000000..79423d7aa --- /dev/null +++ b/backend/database/migrations/2026_02_07_000000_add_scheduled_at_to_messages_table.php @@ -0,0 +1,22 @@ +dateTime('scheduled_at')->nullable()->after('sent_at'); + }); + } + + public function down(): void + { + Schema::table('messages', function (Blueprint $table) { + $table->dropColumn('scheduled_at'); + }); + } +}; 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 000000000..50bc459cb --- /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 000000000..cd41776f8 --- /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 000000000..d729fef53 --- /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 000000000..36b7f870c --- /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/database/migrations/2026_02_22_115000_add_organizer_id_to_webhooks_table.php b/backend/database/migrations/2026_02_22_115000_add_organizer_id_to_webhooks_table.php new file mode 100644 index 000000000..fba97e757 --- /dev/null +++ b/backend/database/migrations/2026_02_22_115000_add_organizer_id_to_webhooks_table.php @@ -0,0 +1,24 @@ +foreignId('organizer_id')->nullable()->constrained()->onDelete('cascade'); + $table->foreignId('event_id')->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('webhooks', static function (Blueprint $table) { + $table->dropForeign(['organizer_id']); + $table->dropColumn('organizer_id'); + $table->foreignId('event_id')->nullable(false)->change(); + }); + } +}; diff --git a/backend/lang/it.json b/backend/lang/it.json index 69a5ba13c..dea6eda9c 100644 --- a/backend/lang/it.json +++ b/backend/lang/it.json @@ -70,14 +70,14 @@ "The sale end date must be a valid date.": "La data di fine della vendita deve essere una data valida.", "The sale start date must be after the ticket sale start date.": "La data di inizio della vendita deve essere successiva alla data di inizio della vendita dei biglietti.", "Welcome to :app_name! Please confirm your email address": "Benvenuti su :app_name! Conferma il tuo indirizzo email.", - "🎟️ Your Ticket for :event": "🎟️ Il tuo biglietto per :evento", + "🎟️ Your Ticket for :event": "🎟️ Il tuo biglietto per :event", "Your order has been cancelled": "Il tuo ordine è stato annullato", "Your order wasn\\'t successful": "Il tuo ordine non è andato a buon fine", "You\\'ve received a refund": "Hai ricevuto un rimborso", "Your Order is Confirmed!": "Il tuo ordine è confermato!", "We were unable to process your order": "Non siamo riusciti a elaborare il tuo ordine", - "New order for :amount for :event 🎉": "Nuovo ordine per :importo per :evento 🎉", - "New order for :event 🎉": "Nuovo ordine per :evento 🎉", + "New order for :amount for :event 🎉": "Nuovo ordine per :importo per :event 🎉", + "New order for :event 🎉": "Nuovo ordine per :event 🎉", "Confirm email change": "Conferma la modifica dell'email", "Password reset": "Reimpostazione della password", "Your password has been reset": "La tua password è stata reimpostata", 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 000000000..588180029 --- /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 000000000..2e530b286 --- /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 000000000..2174f77f3 --- /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 c84c87fe9..09ca424e4 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -61,6 +61,7 @@ use HiEvents\Http\Actions\Events\UpdateEventStatusAction; use HiEvents\Http\Actions\EventSettings\EditEventSettingsAction; use HiEvents\Http\Actions\EventSettings\GetEventSettingsAction; +use HiEvents\Http\Actions\EventSettings\GetPlatformFeePreviewAction; use HiEvents\Http\Actions\EmailTemplates\CreateOrganizerEmailTemplateAction; use HiEvents\Http\Actions\EmailTemplates\CreateEventEmailTemplateAction; use HiEvents\Http\Actions\EmailTemplates\UpdateOrganizerEmailTemplateAction; @@ -76,6 +77,8 @@ use HiEvents\Http\Actions\EventSettings\PartialEditEventSettingsAction; use HiEvents\Http\Actions\Images\CreateImageAction; use HiEvents\Http\Actions\Images\DeleteImageAction; +use HiEvents\Http\Actions\Messages\CancelMessageAction; +use HiEvents\Http\Actions\Messages\GetMessageRecipientsAction; use HiEvents\Http\Actions\Messages\GetMessagesAction; use HiEvents\Http\Actions\Messages\SendMessageAction; use HiEvents\Http\Actions\Orders\CancelOrderAction; @@ -112,6 +115,12 @@ use HiEvents\Http\Actions\Organizers\Settings\PartialUpdateOrganizerSettingsAction; use HiEvents\Http\Actions\Organizers\Stats\GetOrganizerStatsAction; use HiEvents\Http\Actions\Organizers\UpdateOrganizerStatusAction; +use HiEvents\Http\Actions\Organizers\Webhooks\CreateOrganizerWebhookAction; +use HiEvents\Http\Actions\Organizers\Webhooks\DeleteOrganizerWebhookAction; +use HiEvents\Http\Actions\Organizers\Webhooks\EditOrganizerWebhookAction; +use HiEvents\Http\Actions\Organizers\Webhooks\GetOrganizerWebhookAction; +use HiEvents\Http\Actions\Organizers\Webhooks\GetOrganizerWebhookLogsAction; +use HiEvents\Http\Actions\Organizers\Webhooks\GetOrganizerWebhooksAction; use HiEvents\Http\Actions\ProductCategories\CreateProductCategoryAction; use HiEvents\Http\Actions\ProductCategories\DeleteProductCategoryAction; use HiEvents\Http\Actions\ProductCategories\EditProductCategoryAction; @@ -189,6 +198,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; @@ -266,6 +281,12 @@ function (Router $router): void { $router->patch('/organizers/{organizer_id}/settings', PartialUpdateOrganizerSettingsAction::class); $router->get('/organizers/{organizer_id}/reports/{report_type}', GetOrganizerReportAction::class); $router->get('/organizers/{organizer_id}/reports/{report_type}/export', ExportOrganizerReportAction::class); + $router->post('/organizers/{organizer_id}/webhooks', CreateOrganizerWebhookAction::class); + $router->get('/organizers/{organizer_id}/webhooks', GetOrganizerWebhooksAction::class); + $router->put('/organizers/{organizer_id}/webhooks/{webhook_id}', EditOrganizerWebhookAction::class); + $router->get('/organizers/{organizer_id}/webhooks/{webhook_id}', GetOrganizerWebhookAction::class); + $router->delete('/organizers/{organizer_id}/webhooks/{webhook_id}', DeleteOrganizerWebhookAction::class); + $router->get('/organizers/{organizer_id}/webhooks/{webhook_id}/logs', GetOrganizerWebhookLogsAction::class); // Email Templates - Organizer level $router->get('/organizers/{organizerId}/email-templates', GetOrganizerEmailTemplatesAction::class); @@ -371,11 +392,14 @@ function (Router $router): void { // Messages $router->post('/events/{event_id}/messages', SendMessageAction::class); $router->get('/events/{event_id}/messages', GetMessagesAction::class); + $router->post('/events/{event_id}/messages/{message_id}/cancel', CancelMessageAction::class); + $router->get('/events/{event_id}/messages/{message_id}/recipients', GetMessageRecipientsAction::class); // Event Settings $router->get('/events/{event_id}/settings', GetEventSettingsAction::class); $router->put('/events/{event_id}/settings', EditEventSettingsAction::class); $router->patch('/events/{event_id}/settings', PartialEditEventSettingsAction::class); + $router->get('/events/{event_id}/settings/platform-fee-preview', GetPlatformFeePreviewAction::class); // Capacity Assignments $router->post('/events/{event_id}/capacity-assignments', CreateCapacityAssignmentAction::class); @@ -402,6 +426,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); @@ -472,6 +502,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/Message/SendScheduledMessagesJobTest.php b/backend/tests/Unit/Jobs/Message/SendScheduledMessagesJobTest.php new file mode 100644 index 000000000..9f29f0bdd --- /dev/null +++ b/backend/tests/Unit/Jobs/Message/SendScheduledMessagesJobTest.php @@ -0,0 +1,100 @@ +messageRepository = m::mock(MessageRepositoryInterface::class); + $this->messageDispatchService = m::mock(MessageDispatchService::class); + } + + public function testPicksUpScheduledMessagesWithPastScheduledAt(): void + { + $message = m::mock(MessageDomainObject::class); + + $this->messageRepository->shouldReceive('findWhere') + ->once() + ->withArgs(function ($where) { + return $where['status'] === MessageStatus::SCHEDULED->name + && $where[0][0] === 'scheduled_at' + && $where[0][1] === '<='; + }) + ->andReturn(new Collection([$message])); + + $this->messageDispatchService->shouldReceive('dispatchMessage') + ->once() + ->with($message); + + $job = new SendScheduledMessagesJob(); + $job->handle($this->messageRepository, $this->messageDispatchService); + } + + public function testDoesNotPickUpFutureScheduledMessages(): void + { + $this->messageRepository->shouldReceive('findWhere') + ->once() + ->andReturn(new Collection([])); + + $this->messageDispatchService->shouldNotReceive('dispatchMessage'); + + $job = new SendScheduledMessagesJob(); + $job->handle($this->messageRepository, $this->messageDispatchService); + } + + public function testDoesNotPickUpCancelledMessages(): void + { + $this->messageRepository->shouldReceive('findWhere') + ->once() + ->withArgs(function ($where) { + return $where['status'] === MessageStatus::SCHEDULED->name; + }) + ->andReturn(new Collection([])); + + $this->messageDispatchService->shouldNotReceive('dispatchMessage'); + + $job = new SendScheduledMessagesJob(); + $job->handle($this->messageRepository, $this->messageDispatchService); + } + + public function testContinuesProcessingWhenOneMessageFails(): void + { + $message1 = m::mock(MessageDomainObject::class); + $message1->shouldReceive('getId')->andReturn(1); + $message2 = m::mock(MessageDomainObject::class); + $message2->shouldReceive('getId')->andReturn(2); + + $this->messageRepository->shouldReceive('findWhere') + ->once() + ->andReturn(new Collection([$message1, $message2])); + + $this->messageDispatchService->shouldReceive('dispatchMessage') + ->once() + ->with($message1) + ->andThrow(new RuntimeException('Queue down')); + + $this->messageDispatchService->shouldReceive('dispatchMessage') + ->once() + ->with($message2); + + $job = new SendScheduledMessagesJob(); + $job->handle($this->messageRepository, $this->messageDispatchService); + } +} diff --git a/backend/tests/Unit/Jobs/Waitlist/ProcessExpiredWaitlistOffersJobTest.php b/backend/tests/Unit/Jobs/Waitlist/ProcessExpiredWaitlistOffersJobTest.php new file mode 100644 index 000000000..1bc129d44 --- /dev/null +++ b/backend/tests/Unit/Jobs/Waitlist/ProcessExpiredWaitlistOffersJobTest.php @@ -0,0 +1,272 @@ +repository = m::mock(WaitlistEntryRepositoryInterface::class); + $this->orderRepository = m::mock(OrderRepositoryInterface::class); + $this->productPriceRepository = m::mock(ProductPriceRepositoryInterface::class); + $this->databaseManager = m::mock(DatabaseManager::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); + } + + 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->repository + ->shouldReceive('findByIdLocked') + ->once() + ->with(1) + ->andReturn($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], + ); + + $expiredEntry = new WaitlistEntryDomainObject(); + $expiredEntry->setId(1); + $expiredEntry->setEventId(10); + $expiredEntry->setProductPriceId(20); + $expiredEntry->setStatus(WaitlistEntryStatus::OFFER_EXPIRED->name); + + $this->repository + ->shouldReceive('findById') + ->once() + ->with(1) + ->andReturn($expiredEntry); + + $job = new ProcessExpiredWaitlistOffersJob(); + $job->handle($this->repository, $this->orderRepository, $this->productPriceRepository, $this->databaseManager); + + 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->repository + ->shouldReceive('findByIdLocked') + ->once() + ->with(2) + ->andReturn($entry); + + $this->orderRepository->shouldNotReceive('deleteWhere'); + + $this->repository + ->shouldReceive('updateWhere') + ->once(); + + $expiredEntry = new WaitlistEntryDomainObject(); + $expiredEntry->setId(2); + $expiredEntry->setEventId(10); + $expiredEntry->setProductPriceId(20); + $expiredEntry->setStatus(WaitlistEntryStatus::OFFER_EXPIRED->name); + + $this->repository + ->shouldReceive('findById') + ->once() + ->with(2) + ->andReturn($expiredEntry); + + $job = new ProcessExpiredWaitlistOffersJob(); + $job->handle($this->repository, $this->orderRepository, $this->productPriceRepository, $this->databaseManager); + + 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, $this->databaseManager); + + 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->repository + ->shouldReceive('findByIdLocked') + ->once() + ->with(1) + ->andThrow(new \RuntimeException('DB connection lost')); + + $job = new ProcessExpiredWaitlistOffersJob(); + $job->handle($this->repository, $this->orderRepository, $this->productPriceRepository, $this->databaseManager); + + $this->assertTrue($logged, 'Error was logged for failed expired offer processing'); + Bus::assertNotDispatched(SendWaitlistOfferExpiredEmailJob::class); + Event::assertNotDispatched(CapacityChangedEvent::class); + } + + public function testSkipsEntryWhenStatusChangedBeforeLock(): 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])); + + $cancelledEntry = new WaitlistEntryDomainObject(); + $cancelledEntry->setId(1); + $cancelledEntry->setStatus(WaitlistEntryStatus::CANCELLED->name); + + $this->repository + ->shouldReceive('findByIdLocked') + ->once() + ->with(1) + ->andReturn($cancelledEntry); + + $this->orderRepository->shouldNotReceive('deleteWhere'); + $this->repository->shouldNotReceive('updateWhere'); + + $this->repository + ->shouldReceive('findById') + ->once() + ->with(1) + ->andReturn($cancelledEntry); + + $job = new ProcessExpiredWaitlistOffersJob(); + $job->handle($this->repository, $this->orderRepository, $this->productPriceRepository, $this->databaseManager); + + 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/EventSettings/GetPlatformFeePreviewHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventSettings/GetPlatformFeePreviewHandlerTest.php new file mode 100644 index 000000000..5dfa38f97 --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/EventSettings/GetPlatformFeePreviewHandlerTest.php @@ -0,0 +1,195 @@ +accountRepository = Mockery::mock(AccountRepositoryInterface::class); + $this->eventRepository = Mockery::mock(EventRepositoryInterface::class); + $this->currencyConversionClient = Mockery::mock(CurrencyConversionClientInterface::class); + + $this->handler = new GetPlatformFeePreviewHandler( + $this->accountRepository, + $this->eventRepository, + $this->currencyConversionClient + ); + } + + public function testPreviewWithSameCurrency(): void + { + $eventId = 1; + $price = 100.0; + + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getCurrency')->andReturn('USD'); + + $configuration = Mockery::mock(AccountConfigurationDomainObject::class); + $configuration->shouldReceive('getApplicationFeeCurrency')->andReturn('USD'); + $configuration->shouldReceive('getFixedApplicationFee')->andReturn(1.0); + $configuration->shouldReceive('getPercentageApplicationFee')->andReturn(10.0); + + $account = Mockery::mock(AccountDomainObject::class); + $account->shouldReceive('getConfiguration')->andReturn($configuration); + + $this->eventRepository->shouldReceive('findById') + ->with($eventId) + ->andReturn($event); + + $this->accountRepository->shouldReceive('loadRelation') + ->andReturnSelf(); + $this->accountRepository->shouldReceive('findByEventId') + ->with($eventId) + ->andReturn($account); + + $dto = new GetPlatformFeePreviewDTO(eventId: $eventId, price: $price); + $result = $this->handler->handle($dto); + + $this->assertEquals('USD', $result->eventCurrency); + $this->assertEquals('USD', $result->feeCurrency); + $this->assertEquals(1.0, $result->fixedFeeOriginal); + $this->assertEquals(1.0, $result->fixedFeeConverted); + $this->assertEquals(10.0, $result->percentageFee); + $this->assertEquals(100.0, $result->samplePrice); + // Gross-up: (1 + 100*0.1) / (1 - 0.1) = 11 / 0.9 = 12.22 + $this->assertEquals(12.22, $result->platformFee); + $this->assertEquals(112.22, $result->total); + } + + public function testPreviewWithCurrencyConversion(): void + { + $eventId = 1; + $price = 100.0; + + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getCurrency')->andReturn('EUR'); + + $configuration = Mockery::mock(AccountConfigurationDomainObject::class); + $configuration->shouldReceive('getApplicationFeeCurrency')->andReturn('GBP'); + $configuration->shouldReceive('getFixedApplicationFee')->andReturn(1.0); + $configuration->shouldReceive('getPercentageApplicationFee')->andReturn(10.0); + + $account = Mockery::mock(AccountDomainObject::class); + $account->shouldReceive('getConfiguration')->andReturn($configuration); + + $this->eventRepository->shouldReceive('findById') + ->with($eventId) + ->andReturn($event); + + $this->accountRepository->shouldReceive('loadRelation') + ->andReturnSelf(); + $this->accountRepository->shouldReceive('findByEventId') + ->with($eventId) + ->andReturn($account); + + // Mock GBP to EUR conversion: £1 = €1.15 + $this->currencyConversionClient->shouldReceive('convert') + ->with( + Mockery::on(fn($c) => $c->getCurrencyCode() === 'GBP'), + Mockery::on(fn($c) => $c->getCurrencyCode() === 'EUR'), + 1.0 + ) + ->andReturn(MoneyValue::fromFloat(1.15, 'EUR')); + + $dto = new GetPlatformFeePreviewDTO(eventId: $eventId, price: $price); + $result = $this->handler->handle($dto); + + $this->assertEquals('EUR', $result->eventCurrency); + $this->assertEquals('GBP', $result->feeCurrency); + $this->assertEquals(1.0, $result->fixedFeeOriginal); + $this->assertEquals(1.15, $result->fixedFeeConverted); + $this->assertEquals(10.0, $result->percentageFee); + // Gross-up: (1.15 + 100*0.1) / (1 - 0.1) = 11.15 / 0.9 = 12.39 + $this->assertEquals(12.39, $result->platformFee); + $this->assertEquals(112.39, $result->total); + } + + public function testPreviewWithNoConfiguration(): void + { + $eventId = 1; + $price = 100.0; + + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getCurrency')->andReturn('USD'); + + $account = Mockery::mock(AccountDomainObject::class); + $account->shouldReceive('getConfiguration')->andReturn(null); + + $this->eventRepository->shouldReceive('findById') + ->with($eventId) + ->andReturn($event); + + $this->accountRepository->shouldReceive('loadRelation') + ->andReturnSelf(); + $this->accountRepository->shouldReceive('findByEventId') + ->with($eventId) + ->andReturn($account); + + $dto = new GetPlatformFeePreviewDTO(eventId: $eventId, price: $price); + $result = $this->handler->handle($dto); + + $this->assertEquals('USD', $result->eventCurrency); + $this->assertNull($result->feeCurrency); + $this->assertEquals(0, $result->fixedFeeOriginal); + $this->assertEquals(0, $result->platformFee); + $this->assertEquals(100.0, $result->total); + } + + public function testPreviewWithZeroPercentageFee(): void + { + $eventId = 1; + $price = 100.0; + + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getCurrency')->andReturn('USD'); + + $configuration = Mockery::mock(AccountConfigurationDomainObject::class); + $configuration->shouldReceive('getApplicationFeeCurrency')->andReturn('USD'); + $configuration->shouldReceive('getFixedApplicationFee')->andReturn(0.50); + $configuration->shouldReceive('getPercentageApplicationFee')->andReturn(0.0); + + $account = Mockery::mock(AccountDomainObject::class); + $account->shouldReceive('getConfiguration')->andReturn($configuration); + + $this->eventRepository->shouldReceive('findById') + ->with($eventId) + ->andReturn($event); + + $this->accountRepository->shouldReceive('loadRelation') + ->andReturnSelf(); + $this->accountRepository->shouldReceive('findByEventId') + ->with($eventId) + ->andReturn($account); + + $dto = new GetPlatformFeePreviewDTO(eventId: $eventId, price: $price); + $result = $this->handler->handle($dto); + + // With 0% percentage, just the fixed fee + $this->assertEquals(0.50, $result->platformFee); + $this->assertEquals(100.50, $result->total); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/Message/GetMessageRecipientsHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Message/GetMessageRecipientsHandlerTest.php new file mode 100644 index 000000000..fdb3c532e --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Message/GetMessageRecipientsHandlerTest.php @@ -0,0 +1,114 @@ +outgoingMessageRepository = m::mock(OutgoingMessageRepositoryInterface::class); + $this->messageRepository = m::mock(MessageRepositoryInterface::class); + $this->handler = new GetMessageRecipientsHandler( + $this->outgoingMessageRepository, + $this->messageRepository, + ); + } + + public function testHandleReturnsPaginatedRecipients(): void + { + $eventId = 10; + $messageId = 20; + $params = QueryParamsDTO::fromArray(['per_page' => 100, 'page' => 1]); + + $message = m::mock(MessageDomainObject::class); + $this->messageRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(['id' => $messageId, 'event_id' => $eventId]) + ->andReturn($message); + + $paginator = new LengthAwarePaginator([], 0, 100); + $this->outgoingMessageRepository + ->shouldReceive('paginateWhere') + ->once() + ->with(['event_id' => $eventId, 'message_id' => $messageId], 100) + ->andReturn($paginator); + + $result = $this->handler->handle($eventId, $messageId, $params); + + $this->assertSame($paginator, $result); + } + + public function testHandleUsesDefaultPerPageFromDto(): void + { + $eventId = 5; + $messageId = 15; + $params = QueryParamsDTO::fromArray(['page' => 1]); + + $message = m::mock(MessageDomainObject::class); + $this->messageRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(['id' => $messageId, 'event_id' => $eventId]) + ->andReturn($message); + + $paginator = new LengthAwarePaginator([], 0, 25); + $this->outgoingMessageRepository + ->shouldReceive('paginateWhere') + ->once() + ->with(['event_id' => $eventId, 'message_id' => $messageId], 25) + ->andReturn($paginator); + + $result = $this->handler->handle($eventId, $messageId, $params); + + $this->assertSame($paginator, $result); + } + + public function testHandleThrowsNotFoundWhenMessageDoesNotExist(): void + { + $this->expectException(ResourceNotFoundException::class); + + $this->messageRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(['id' => 999, 'event_id' => 1]) + ->andReturn(null); + + $this->outgoingMessageRepository->shouldNotReceive('paginateWhere'); + + $params = QueryParamsDTO::fromArray(['page' => 1]); + $this->handler->handle(1, 999, $params); + } + + public function testHandleThrowsNotFoundWhenMessageBelongsToDifferentEvent(): void + { + $this->expectException(ResourceNotFoundException::class); + + $this->messageRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(['id' => 20, 'event_id' => 99]) + ->andReturn(null); + + $this->outgoingMessageRepository->shouldNotReceive('paginateWhere'); + + $params = QueryParamsDTO::fromArray(['page' => 1]); + $this->handler->handle(99, 20, $params); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/Message/SendMessageHandlerScheduledTest.php b/backend/tests/Unit/Services/Application/Handlers/Message/SendMessageHandlerScheduledTest.php new file mode 100644 index 000000000..0b4c2010d --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Message/SendMessageHandlerScheduledTest.php @@ -0,0 +1,236 @@ +orderRepository = m::mock(OrderRepositoryInterface::class); + $this->attendeeRepository = m::mock(AttendeeRepositoryInterface::class); + $this->productRepository = m::mock(ProductRepositoryInterface::class); + $this->messageRepository = m::mock(MessageRepositoryInterface::class); + $this->accountRepository = m::mock(AccountRepositoryInterface::class); + $this->eventRepository = m::mock(EventRepositoryInterface::class); + $this->purifier = m::mock(HtmlPurifierService::class); + $this->config = m::mock(Repository::class); + $this->eligibilityService = m::mock(MessagingEligibilityService::class); + + $this->handler = new SendMessageHandler( + $this->orderRepository, + $this->attendeeRepository, + $this->productRepository, + $this->messageRepository, + $this->accountRepository, + $this->eventRepository, + $this->purifier, + $this->config, + $this->eligibilityService + ); + } + + private function setupAccountMocks(): void + { + $account = m::mock(AccountDomainObject::class); + $account->shouldReceive('getAccountVerifiedAt')->andReturn(Carbon::now()); + $account->shouldReceive('getIsManuallyVerified')->andReturn(true); + + $event = m::mock(EventDomainObject::class); + $event->shouldReceive('getTimezone')->andReturn('America/New_York'); + + $this->accountRepository->shouldReceive('findById')->with(1)->andReturn($account); + $this->eventRepository->shouldReceive('findById')->with(101)->andReturn($event); + $this->config->shouldReceive('get')->with('app.saas_mode_enabled')->andReturn(false); + + $this->eligibilityService->shouldReceive('checkTierLimits')->andReturn(null); + $this->eligibilityService->shouldReceive('checkEligibility')->andReturn(null); + + $this->purifier->shouldReceive('purify')->andReturn('

Test

'); + } + + private function setupRepositoryMocks(): void + { + $attendee = new AttendeeDomainObject(); + $attendee->setId(10); + + $product = new ProductDomainObject(); + $product->setId(20); + + $order = new OrderDomainObject(); + $order->setId(5); + + $this->attendeeRepository->shouldReceive('findWhereIn')->andReturn(collect([$attendee])); + $this->productRepository->shouldReceive('findWhereIn')->andReturn(collect([$product])); + $this->orderRepository->shouldReceive('findFirstWhere')->andReturn($order); + } + + public function testFutureScheduledAtSetsScheduledStatusAndDoesNotDispatchJob(): void + { + Bus::fake(); + + $this->setupAccountMocks(); + $this->setupRepositoryMocks(); + + $message = m::mock(MessageDomainObject::class); + $message->shouldReceive('getId')->andReturn(1); + $message->shouldReceive('getOrderId')->andReturn(5); + $message->shouldReceive('getAttendeeIds')->andReturn([10]); + $message->shouldReceive('getProductIds')->andReturn([20]); + $message->shouldReceive('getStatus')->andReturn(MessageStatus::SCHEDULED->name); + + $this->messageRepository->shouldReceive('create') + ->once() + ->withArgs(function ($data) { + return $data['status'] === MessageStatus::SCHEDULED->name + && $data['scheduled_at'] !== null + && $data['sent_at'] === null; + }) + ->andReturn($message); + + $dto = new SendMessageDTO( + account_id: 1, + event_id: 101, + subject: 'Hello', + message: '

Test

', + type: MessageTypeEnum::INDIVIDUAL_ATTENDEES, + is_test: false, + send_copy_to_current_user: false, + sent_by_user_id: 99, + order_id: 5, + order_statuses: [], + attendee_ids: [10], + product_ids: [20], + scheduled_at: Carbon::now('America/New_York')->addHour()->format('Y-m-d\TH:i'), + ); + + $result = $this->handler->handle($dto); + + $this->assertSame($message, $result); + Bus::assertNotDispatched(SendMessagesJob::class); + } + + public function testNoScheduledAtDispatchesJobImmediately(): void + { + Bus::fake(); + + $this->setupAccountMocks(); + $this->setupRepositoryMocks(); + + $message = m::mock(MessageDomainObject::class); + $message->shouldReceive('getId')->andReturn(1); + $message->shouldReceive('getOrderId')->andReturn(5); + $message->shouldReceive('getAttendeeIds')->andReturn([10]); + $message->shouldReceive('getProductIds')->andReturn([20]); + $message->shouldReceive('getStatus')->andReturn(MessageStatus::PROCESSING->name); + + $this->messageRepository->shouldReceive('create') + ->once() + ->withArgs(function ($data) { + return $data['status'] === MessageStatus::PROCESSING->name + && $data['sent_at'] !== null; + }) + ->andReturn($message); + + $dto = new SendMessageDTO( + account_id: 1, + event_id: 101, + subject: 'Hello', + message: '

Test

', + type: MessageTypeEnum::INDIVIDUAL_ATTENDEES, + is_test: false, + send_copy_to_current_user: false, + sent_by_user_id: 99, + order_id: 5, + order_statuses: [], + attendee_ids: [10], + product_ids: [20], + ); + + $result = $this->handler->handle($dto); + + $this->assertSame($message, $result); + Bus::assertDispatched(SendMessagesJob::class); + } + + public function testIsTestWithScheduledAtSendsImmediately(): void + { + Bus::fake(); + + $this->setupAccountMocks(); + $this->setupRepositoryMocks(); + + $message = m::mock(MessageDomainObject::class); + $message->shouldReceive('getId')->andReturn(1); + $message->shouldReceive('getOrderId')->andReturn(5); + $message->shouldReceive('getAttendeeIds')->andReturn([10]); + $message->shouldReceive('getProductIds')->andReturn([20]); + $message->shouldReceive('getStatus')->andReturn(MessageStatus::PROCESSING->name); + + $this->messageRepository->shouldReceive('create') + ->once() + ->withArgs(function ($data) { + return $data['status'] === MessageStatus::PROCESSING->name + && $data['sent_at'] !== null; + }) + ->andReturn($message); + + $dto = new SendMessageDTO( + account_id: 1, + event_id: 101, + subject: 'Hello', + message: '

Test

', + type: MessageTypeEnum::INDIVIDUAL_ATTENDEES, + is_test: true, + send_copy_to_current_user: false, + sent_by_user_id: 99, + order_id: 5, + order_statuses: [], + attendee_ids: [10], + product_ids: [20], + scheduled_at: Carbon::now('America/New_York')->addHour()->format('Y-m-d\TH:i'), + ); + + $result = $this->handler->handle($dto); + + $this->assertSame($message, $result); + Bus::assertDispatched(SendMessagesJob::class); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/Message/SendMessageHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Message/SendMessageHandlerTest.php index 8376eb838..4f9cccded 100644 --- a/backend/tests/Unit/Services/Application/Handlers/Message/SendMessageHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/Message/SendMessageHandlerTest.php @@ -6,6 +6,7 @@ use HiEvents\DomainObjects\AccountDomainObject; use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\Enums\MessageTypeEnum; +use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\MessageDomainObject; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\ProductDomainObject; @@ -13,6 +14,7 @@ use HiEvents\Jobs\Event\SendMessagesJob; use HiEvents\Repository\Interfaces\AccountRepositoryInterface; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; +use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\MessageRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; @@ -35,6 +37,7 @@ class SendMessageHandlerTest extends TestCase private HtmlPurifierService $purifier; private Repository $config; private MessagingEligibilityService $eligibilityService; + private EventRepositoryInterface $eventRepository; private SendMessageHandler $handler; @@ -50,16 +53,18 @@ protected function setUp(): void $this->purifier = m::mock(HtmlPurifierService::class); $this->config = m::mock(Repository::class); $this->eligibilityService = m::mock(MessagingEligibilityService::class); + $this->eventRepository = m::mock(EventRepositoryInterface::class); $this->handler = new SendMessageHandler( - $this->orderRepository, - $this->attendeeRepository, - $this->productRepository, - $this->messageRepository, - $this->accountRepository, - $this->purifier, - $this->config, - $this->eligibilityService + orderRepository: $this->orderRepository, + attendeeRepository: $this->attendeeRepository, + productRepository: $this->productRepository, + messageRepository: $this->messageRepository, + accountRepository: $this->accountRepository, + eventRepository: $this->eventRepository, + purifier: $this->purifier, + config: $this->config, + eligibilityService: $this->eligibilityService ); } @@ -74,8 +79,8 @@ public function testThrowsIfAccountNotVerified(): void is_test: false, send_copy_to_current_user: false, sent_by_user_id: 1, - order_statuses: [], order_id: null, + order_statuses: [], attendee_ids: [], product_ids: [] ); @@ -101,8 +106,8 @@ public function testThrowsIfSaasModeEnabledAndNotManuallyVerified(): void is_test: false, send_copy_to_current_user: false, sent_by_user_id: 1, - order_statuses: [], order_id: null, + order_statuses: [], attendee_ids: [], product_ids: [] ); @@ -131,12 +136,16 @@ public function testHandleCreatesMessageAndDispatchesJob(): void is_test: false, send_copy_to_current_user: false, sent_by_user_id: 99, - order_statuses: [], order_id: 5, + order_statuses: [], attendee_ids: [10], product_ids: [20], ); + $event = m::mock(EventDomainObject::class); + $event->shouldReceive('getTimezone')->andReturn('UTC'); + $this->eventRepository->shouldReceive('findById')->with(101)->andReturn($event); + $account = m::mock(AccountDomainObject::class); $account->shouldReceive('getAccountVerifiedAt')->andReturn(Carbon::now()); $account->shouldReceive('getIsManuallyVerified')->andReturn(true); diff --git a/backend/tests/Unit/Services/Application/Handlers/Order/CompleteOrderHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Order/CompleteOrderHandlerTest.php index 709ed69e5..4d820ff70 100644 --- a/backend/tests/Unit/Services/Application/Handlers/Order/CompleteOrderHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/Order/CompleteOrderHandlerTest.php @@ -25,10 +25,12 @@ use HiEvents\Services\Infrastructure\DomainEvents\DomainEventDispatcherService; use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType; use HiEvents\Services\Infrastructure\DomainEvents\Events\OrderEvent; +use HiEvents\Services\Infrastructure\Session\CheckoutSessionManagementService; use Illuminate\Database\Connection; 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; @@ -47,6 +49,7 @@ class CompleteOrderHandlerTest extends TestCase private DomainEventDispatcherService $domainEventDispatcherService; private AffiliateRepositoryInterface|MockInterface $affiliateRepository; private EventSettingsRepositoryInterface $eventSettingsRepository; + private CheckoutSessionManagementService|MockInterface $sessionManagementService; protected function setUp(): void { @@ -64,6 +67,8 @@ protected function setUp(): void $this->domainEventDispatcherService = Mockery::mock(DomainEventDispatcherService::class); $this->affiliateRepository = Mockery::mock(AffiliateRepositoryInterface::class); $this->eventSettingsRepository = Mockery::mock(EventSettingsRepositoryInterface::class); + $this->sessionManagementService = Mockery::mock(CheckoutSessionManagementService::class); + $this->sessionManagementService->shouldReceive('verifySession')->andReturn(true)->byDefault(); $this->completeOrderHandler = new CompleteOrderHandler( $this->orderRepository, @@ -74,6 +79,7 @@ protected function setUp(): void $this->productPriceRepository, $this->domainEventDispatcherService, $this->eventSettingsRepository, + $this->sessionManagementService, ); } @@ -162,6 +168,8 @@ public function testHandleThrowsResourceConflictExceptionWhenOrderExpired(): voi public function testHandleUpdatesProductQuantitiesForFreeOrder(): void { + Event::fake(); + $orderShortId = 'ABC123'; $orderData = $this->createMockCompleteOrderDTO(); $order = $this->createMockOrder(); @@ -285,7 +293,7 @@ private function createMockCompleteOrderDTO(): CompleteOrderDTO return new CompleteOrderDTO( order: $orderDTO, products: new Collection([$attendeeDTO]) - ,event_id: 1 + , event_id: 1 ); } @@ -293,6 +301,7 @@ private function createMockOrder(OrderStatus $status = OrderStatus::RESERVED): O { return (new OrderDomainObject()) ->setEmail(null) + ->setSessionId('test-session-id') ->setReservedUntil(Carbon::now()->addHour()->toDateTimeString()) ->setStatus($status->name) ->setId(1) diff --git a/backend/tests/Unit/Services/Application/Handlers/Order/CreateOrderHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Order/CreateOrderHandlerTest.php new file mode 100644 index 000000000..21a06aeb0 --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Order/CreateOrderHandlerTest.php @@ -0,0 +1,214 @@ +eventRepository = Mockery::mock(EventRepositoryInterface::class); + $this->promoCodeRepository = Mockery::mock(PromoCodeRepositoryInterface::class); + $this->affiliateRepository = Mockery::mock(AffiliateRepositoryInterface::class); + $this->orderManagementService = Mockery::mock(OrderManagementService::class); + $this->orderItemProcessingService = Mockery::mock(OrderItemProcessingService::class); + $this->availabilityService = Mockery::mock(AvailableProductQuantitiesFetchService::class); + $this->databaseManager = Mockery::mock(DatabaseManager::class); + + $this->databaseManager->shouldReceive('transaction') + ->andReturnUsing(fn($callback) => $callback()); + + $this->handler = new CreateOrderHandler( + $this->eventRepository, + $this->promoCodeRepository, + $this->affiliateRepository, + $this->orderManagementService, + $this->orderItemProcessingService, + $this->availabilityService, + $this->databaseManager, + ); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + public function testAcquiresAdvisoryLockBeforeCreatingOrder(): void + { + $eventId = 42; + + $this->databaseManager->shouldReceive('statement') + ->once() + ->with('SELECT pg_advisory_xact_lock(?)', [$eventId]) + ->andReturn(true); + + $this->setupSuccessfulOrderCreation($eventId); + + $result = $this->handler->handle($eventId, $this->createOrderDTO()); + $this->assertInstanceOf(OrderDomainObject::class, $result); + } + + public function testThrowsWhenProductQuantityExceedsAvailability(): void + { + $eventId = 1; + + $this->databaseManager->shouldReceive('statement')->andReturn(true); + + $this->setupEventMock($eventId); + $this->orderManagementService->shouldReceive('deleteExistingOrders'); + + $this->availabilityService->shouldReceive('getAvailableProductQuantities') + ->with($eventId, true) + ->andReturn(new AvailableProductQuantitiesResponseDTO( + productQuantities: collect([ + AvailableProductQuantitiesDTO::fromArray([ + 'product_id' => 10, + 'price_id' => 100, + 'product_title' => 'Test', + 'price_label' => null, + 'quantity_available' => 2, + 'quantity_reserved' => 0, + 'initial_quantity_available' => 10, + ]), + ]), + )); + + $dto = $this->createOrderDTO(quantity: 5); + + $this->expectException(ValidationException::class); + $this->handler->handle($eventId, $dto); + } + + public function testPassesWhenQuantityIsWithinAvailability(): void + { + $eventId = 1; + + $this->databaseManager->shouldReceive('statement')->andReturn(true); + $this->setupSuccessfulOrderCreation($eventId, productId: 10, priceId: 100, available: 5); + + $dto = $this->createOrderDTO(quantity: 2); + + $result = $this->handler->handle($eventId, $dto); + $this->assertInstanceOf(OrderDomainObject::class, $result); + } + + public function testSkipsZeroQuantityProducts(): void + { + $eventId = 1; + + $this->databaseManager->shouldReceive('statement')->andReturn(true); + $this->setupSuccessfulOrderCreation($eventId, available: 0); + + $dto = $this->createOrderDTO(quantity: 0); + + $result = $this->handler->handle($eventId, $dto); + $this->assertInstanceOf(OrderDomainObject::class, $result); + } + + private function createOrderDTO(int $productId = 10, int $priceId = 100, int $quantity = 1): CreateOrderPublicDTO + { + return CreateOrderPublicDTO::fromArray([ + 'is_user_authenticated' => false, + 'session_identifier' => 'test-session', + 'order_locale' => 'en', + 'products' => collect([ + ProductOrderDetailsDTO::fromArray([ + 'product_id' => $productId, + 'quantities' => collect([ + OrderProductPriceDTO::fromArray([ + 'price_id' => $priceId, + 'quantity' => $quantity, + ]), + ]), + ]), + ]), + ]); + } + + private function setupEventMock(int $eventId): void + { + $eventSettings = Mockery::mock(EventSettingDomainObject::class); + $eventSettings->shouldReceive('getOrderTimeoutInMinutes')->andReturn(15); + + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getId')->andReturn($eventId); + $event->shouldReceive('getStatus')->andReturn(EventStatus::LIVE->name); + $event->shouldReceive('getEventSettings')->andReturn($eventSettings); + + $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->eventRepository->shouldReceive('findById')->with($eventId)->andReturn($event); + } + + private function setupSuccessfulOrderCreation( + int $eventId, + int $productId = 10, + int $priceId = 100, + int $available = 10, + ): void + { + $this->setupEventMock($eventId); + + $this->orderManagementService->shouldReceive('deleteExistingOrders'); + + $this->availabilityService->shouldReceive('getAvailableProductQuantities') + ->with($eventId, true) + ->andReturn(new AvailableProductQuantitiesResponseDTO( + productQuantities: collect([ + AvailableProductQuantitiesDTO::fromArray([ + 'product_id' => $productId, + 'price_id' => $priceId, + 'product_title' => 'Test Product', + 'price_label' => null, + 'quantity_available' => $available, + 'quantity_reserved' => 0, + 'initial_quantity_available' => 100, + ]), + ]), + )); + + $order = Mockery::mock(OrderDomainObject::class); + $order->shouldReceive('getId')->andReturn(1); + + $this->orderManagementService->shouldReceive('createNewOrder')->andReturn($order); + + $orderItems = collect([Mockery::mock(OrderItemDomainObject::class)]); + $this->orderItemProcessingService->shouldReceive('process')->andReturn($orderItems); + + $this->orderManagementService->shouldReceive('updateOrderTotals')->andReturn($order); + } +} diff --git a/backend/tests/Unit/Services/Domain/Order/OrderApplicationFeeCalculationServiceTest.php b/backend/tests/Unit/Services/Domain/Order/OrderApplicationFeeCalculationServiceTest.php index 19e6ce5c7..2566caebb 100644 --- a/backend/tests/Unit/Services/Domain/Order/OrderApplicationFeeCalculationServiceTest.php +++ b/backend/tests/Unit/Services/Domain/Order/OrderApplicationFeeCalculationServiceTest.php @@ -52,15 +52,16 @@ private function createItem(float $price, int $quantity): OrderItemDomainObject return $item; } - private function createAccountConfig(float $fixedFee = 0, float $percentageFee = 0): AccountConfigurationDomainObject + private function createAccountConfig(float $fixedFee = 0, float $percentageFee = 0, string $currency = 'USD'): AccountConfigurationDomainObject { $config = $this->getMockBuilder(AccountConfigurationDomainObject::class) ->disableOriginalConstructor() - ->onlyMethods(['getFixedApplicationFee', 'getPercentageApplicationFee']) + ->onlyMethods(['getFixedApplicationFee', 'getPercentageApplicationFee', 'getApplicationFeeCurrency']) ->getMock(); $config->method('getFixedApplicationFee')->willReturn($fixedFee); $config->method('getPercentageApplicationFee')->willReturn($percentageFee); + $config->method('getApplicationFeeCurrency')->willReturn($currency); return $config; } @@ -130,4 +131,47 @@ public function testCurrencyConversionForFixedFee(): void $this->assertEquals(5.00, $fee->grossApplicationFee->toFloat()); } + + public function testNoConversionWhenOrderCurrencyMatchesFeeCurrency(): void + { + $this->config->method('get')->willReturn(true); + + $order = $this->createOrderWithItems([ + $this->createItem(100, 1), + ], 'EUR'); + + // Fee is defined in EUR, order is in EUR - no conversion needed + $account = $this->createAccountConfig(2.00, 10, 'EUR'); + + $this->currencyConversionClient->expects($this->never())->method('convert'); + + // 1 chargeable × €2 fixed = €2 + // €100 × 10% = €10 + // Total = €12 + $fee = $this->service->calculateApplicationFee($account, $order); + + $this->assertEquals(12.00, $fee->grossApplicationFee->toFloat()); + } + + public function testConversionFromEurToUsd(): void + { + $this->config->method('get')->willReturn(true); + + $order = $this->createOrderWithItems([ + $this->createItem(100, 2), + ], 'USD'); + + // Fee is defined in EUR, order is in USD - conversion needed + $account = $this->createAccountConfig(1.00, 5, 'EUR'); + + $this->currencyConversionClient->method('convert') + ->willReturn(MoneyValue::fromFloat(1.10, 'USD')); // €1 = $1.10 + + // 2 chargeable × $1.10 fixed = $2.20 + // $200 × 5% = $10 + // Total = $12.20 + $fee = $this->service->calculateApplicationFee($account, $order); + + $this->assertEquals(12.20, $fee->grossApplicationFee->toFloat()); + } } diff --git a/backend/tests/Unit/Services/Domain/Order/OrderCancelServiceTest.php b/backend/tests/Unit/Services/Domain/Order/OrderCancelServiceTest.php index ece4664ba..61a44df09 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/Order/OrderPlatformFeePassThroughServiceTest.php b/backend/tests/Unit/Services/Domain/Order/OrderPlatformFeePassThroughServiceTest.php index a11e0d7b3..202b6a7b5 100644 --- a/backend/tests/Unit/Services/Domain/Order/OrderPlatformFeePassThroughServiceTest.php +++ b/backend/tests/Unit/Services/Domain/Order/OrderPlatformFeePassThroughServiceTest.php @@ -32,11 +32,12 @@ protected function setUp(): void ); } - private function createAccountConfig(float $fixedFee = 0.30, float $percentageFee = 2.9): AccountConfigurationDomainObject + private function createAccountConfig(float $fixedFee = 0.30, float $percentageFee = 2.9, string $currency = 'USD'): AccountConfigurationDomainObject { $mock = $this->createMock(AccountConfigurationDomainObject::class); $mock->method('getFixedApplicationFee')->willReturn($fixedFee); $mock->method('getPercentageApplicationFee')->willReturn($percentageFee); + $mock->method('getApplicationFeeCurrency')->willReturn($currency); return $mock; } @@ -251,6 +252,56 @@ public function testNoCurrencyConversionForUsd(): void $this->assertGreaterThan(0, $result); } + public function testNoConversionWhenOrderCurrencyMatchesFeeCurrency(): void + { + $this->config->method('get')->willReturn(true); + + $currencyConversionClient = $this->createMock(CurrencyConversionClientInterface::class); + $currencyConversionClient->expects($this->never())->method('convert'); + + $service = new OrderPlatformFeePassThroughService( + $this->config, + $currencyConversionClient + ); + + // Fee is defined in EUR, order is in EUR - no conversion needed + $account = $this->createAccountConfig(0.30, 2.9, 'EUR'); + $eventSettings = $this->createEventSettings(true); + + $result = $service->calculatePlatformFee($account, $eventSettings, 100.00, 1, 'EUR'); + + $this->assertGreaterThan(0, $result); + } + + public function testCurrencyConversionFromEurToUsd(): void + { + $this->config->method('get')->willReturn(true); + + $currencyConversionClient = $this->createMock(CurrencyConversionClientInterface::class); + + $currencyConversionClient->expects($this->once()) + ->method('convert') + ->with( + $this->callback(fn(Currency $c) => $c->getCurrencyCode() === 'EUR'), + $this->callback(fn(Currency $c) => $c->getCurrencyCode() === 'USD'), + 0.30 + ) + ->willReturn(MoneyValue::fromFloat(0.33, 'USD')); + + $service = new OrderPlatformFeePassThroughService( + $this->config, + $currencyConversionClient + ); + + // Fee is defined in EUR, order is in USD - conversion needed + $account = $this->createAccountConfig(0.30, 2.9, 'EUR'); + $eventSettings = $this->createEventSettings(true); + + $result = $service->calculatePlatformFee($account, $eventSettings, 100.00, 1, 'USD'); + + $this->assertGreaterThan(0, $result); + } + public function testZeroFixedFeeOnlyPercentage(): void { $this->config->method('get')->willReturn(true); 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 000000000..208249eb4 --- /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 000000000..95310f406 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Waitlist/CreateWaitlistEntryServiceTest.php @@ -0,0 +1,233 @@ +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('lockForProductPrice') + ->once() + ->with(10); + + $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 000000000..e307ebba3 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Waitlist/ProcessWaitlistServiceTest.php @@ -0,0 +1,794 @@ +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->databaseManager + ->shouldReceive('statement') + ->withArgs(function ($sql, $params) { + return $sql === 'SELECT pg_advisory_xact_lock(?)' && is_array($params); + }) + ->zeroOrMoreTimes() + ->andReturn(true); + + $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->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->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->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('getNextWaitingEntries') + ->once() + ->with($productPriceId, Mockery::any()) + ->andReturn(new Collection()); + + $this->expectException(NoCapacityAvailableException::class); + + $this->service->offerToNext($productPriceId, $quantity, $event, $eventSettings); + } + + public function testCapsOffersAtAvailableCapacity(): void + { + Bus::fake(); + + $productPriceId = 10; + $quantity = 3; + $event = $this->createMockEvent(); + $eventSettings = $this->createMockEventSettings(); + + $this->databaseManager + ->shouldReceive('transaction') + ->once() + ->andReturnUsing(function ($callback) { + return $callback(); + }); + + $this->mockAvailableQuantities($event->getId(), $productPriceId, 1); + + $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 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->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->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); + + $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->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->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, 0); + + $this->expectException(NoCapacityAvailableException::class); + + $this->service->offerSpecificEntry($entryId, $eventId, $event, $eventSettings); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Validators/Rules/NoInternalUrlRuleTest.php b/backend/tests/Unit/Validators/Rules/NoInternalUrlRuleTest.php index 64484c0b2..ebe90737a 100644 --- a/backend/tests/Unit/Validators/Rules/NoInternalUrlRuleTest.php +++ b/backend/tests/Unit/Validators/Rules/NoInternalUrlRuleTest.php @@ -92,6 +92,15 @@ public function testRejectsIpv6Localhost(): void $this->assertFalse($this->validate('http://[::1]/webhook')); } + public function testRejectsIpv4MappedIpv6Addresses(): void + { + $this->assertFalse($this->validate('http://[::ffff:127.0.0.1]/webhook')); + $this->assertFalse($this->validate('http://[::ffff:169.254.169.254]/latest/meta-data/')); + $this->assertFalse($this->validate('http://[::ffff:10.0.0.1]/internal')); + $this->assertFalse($this->validate('http://[::ffff:192.168.1.1]/admin')); + $this->assertFalse($this->validate('http://[::ffff:172.16.0.1]/api')); + } + public function testRejectsNonHttpSchemes(): void { $this->assertFalse($this->validate('file:///etc/passwd')); diff --git a/backend/vapor.yml b/backend/vapor.yml index 99da2d69f..b390064ee 100644 --- a/backend/vapor.yml +++ b/backend/vapor.yml @@ -9,6 +9,7 @@ environments: storage: hievents-assets-prod runtime: docker warm: 3 + scheduler: true cache: hievents-redis database: hievents-postgres queues: @@ -29,6 +30,7 @@ environments: cli-memory: 512 runtime: docker warm: 3 + scheduler: true cache: hievents-redis database: hievents-postgres queue: diff --git a/frontend/public/blank-slate/waitlist.svg b/frontend/public/blank-slate/waitlist.svg new file mode 100644 index 000000000..6283ffd08 --- /dev/null +++ b/frontend/public/blank-slate/waitlist.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/widget.js b/frontend/public/widget.js index decc511d2..cb545aec3 100644 --- a/frontend/public/widget.js +++ b/frontend/public/widget.js @@ -1,5 +1,5 @@ /* eslint-disable lingui/no-unlocalized-strings */ -(function () { +(function (scriptElement) { const isScriptLoaded = () => !!window.hiEventWidgetLoaded; const loadWidget = () => { @@ -7,7 +7,7 @@ let scriptOrigin; try { - const scriptURL = document.currentScript.src; + const scriptURL = scriptElement.src; scriptOrigin = new URL(scriptURL).origin; } catch (e) { console.error('HiEvent widget error: Invalid script URL'); @@ -86,4 +86,4 @@ loadWidget(); } } -})(); +})(document.currentScript); diff --git a/frontend/src/api/admin.client.ts b/frontend/src/api/admin.client.ts index 6dfaafaad..4fde19eac 100644 --- a/frontend/src/api/admin.client.ts +++ b/frontend/src/api/admin.client.ts @@ -51,6 +51,7 @@ export interface AccountConfiguration { application_fees: { fixed: number; percentage: number; + currency: string; }; bypass_application_fees: boolean; } @@ -60,6 +61,7 @@ export interface CreateConfigurationData { application_fees: { fixed: number; percentage: number; + currency: string; }; bypass_application_fees?: boolean; } @@ -69,6 +71,7 @@ export interface UpdateConfigurationData { application_fees: { fixed: number; percentage: number; + currency: string; }; bypass_application_fees?: boolean; } diff --git a/frontend/src/api/event-settings.client.ts b/frontend/src/api/event-settings.client.ts index eb7812c49..abcc67166 100644 --- a/frontend/src/api/event-settings.client.ts +++ b/frontend/src/api/event-settings.client.ts @@ -1,6 +1,17 @@ import {api} from "./client"; import {Event, EventSettings, GenericDataResponse, IdParam,} from "../types"; +export interface PlatformFeePreview { + event_currency: string; + fee_currency: string | null; + fixed_fee_original: number; + fixed_fee_converted: number; + percentage_fee: number; + sample_price: number; + platform_fee: number; + total: number; +} + export const eventsSettingsClient = { partialUpdate: async (eventId: IdParam, event: Partial) => { const response = await api.patch>('events/' + eventId + '/settings', event); @@ -11,4 +22,11 @@ export const eventsSettingsClient = { const response = await api.get>('events/' + eventId + '/settings'); return response.data; }, + + getPlatformFeePreview: async (eventId: IdParam, price: number) => { + const response = await api.get>('events/' + eventId + '/settings/platform-fee-preview', { + params: { price } + }); + return response.data.data; + }, } diff --git a/frontend/src/api/messages.client.ts b/frontend/src/api/messages.client.ts index 0e1d9f4de..19b77b89d 100644 --- a/frontend/src/api/messages.client.ts +++ b/frontend/src/api/messages.client.ts @@ -1,5 +1,5 @@ import {api} from "./client"; -import {GenericPaginatedResponse, IdParam, Message, QueryFilters,} from "../types"; +import {GenericPaginatedResponse, IdParam, Message, OutgoingMessage, QueryFilters,} from "../types"; import {queryParamsHelper} from "../utilites/queryParamsHelper.ts"; import {AxiosResponse} from "axios"; @@ -13,4 +13,13 @@ export const messagesClient = { ); return response.data; }, + cancel: async (eventId: IdParam, messageId: IdParam) => { + return await api.post(`events/${eventId}/messages/${messageId}/cancel`); + }, + recipients: async (eventId: IdParam, messageId: IdParam, pagination: QueryFilters) => { + const response: AxiosResponse> = await api.get>( + `events/${eventId}/messages/${messageId}/recipients` + queryParamsHelper.buildQueryString(pagination), + ); + return response.data; + }, } diff --git a/frontend/src/api/organizer-webhook.client.ts b/frontend/src/api/organizer-webhook.client.ts new file mode 100644 index 000000000..574f2e511 --- /dev/null +++ b/frontend/src/api/organizer-webhook.client.ts @@ -0,0 +1,34 @@ +import {GenericDataResponse, IdParam, Webhook, WebhookLog} from "../types"; +import {api} from "./client"; + +export interface OrganizerWebhookRequest { + url: string; + event_types: string[]; + status: 'ENABLED' | 'PAUSED'; +} + +export const organizerWebhookClient = { + get: async (organizerId: IdParam, webhookId: IdParam) => { + return await api.get>(`organizers/${organizerId}/webhooks/${webhookId}`); + }, + + create: async (organizerId: IdParam, webhook: OrganizerWebhookRequest) => { + return await api.post>(`organizers/${organizerId}/webhooks`, webhook); + }, + + all: async (organizerId: IdParam) => { + return await api.get>(`organizers/${organizerId}/webhooks`); + }, + + logs: async (organizerId: IdParam, webhookId: IdParam) => { + return await api.get>(`organizers/${organizerId}/webhooks/${webhookId}/logs`); + }, + + delete: async (organizerId: IdParam, webhookId: IdParam) => { + return await api.delete(`organizers/${organizerId}/webhooks/${webhookId}`); + }, + + update: async (organizerId: IdParam, webhookId: IdParam, webhook: OrganizerWebhookRequest) => { + return await api.put>(`organizers/${organizerId}/webhooks/${webhookId}`, webhook); + }, +} diff --git a/frontend/src/api/waitlist.client.ts b/frontend/src/api/waitlist.client.ts new file mode 100644 index 000000000..96e6c0512 --- /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 000000000..30368db70 --- /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/MessageList/MessageList.module.scss b/frontend/src/components/common/MessageList/MessageList.module.scss index b206ae3da..0eab17474 100644 --- a/frontend/src/components/common/MessageList/MessageList.module.scss +++ b/frontend/src/components/common/MessageList/MessageList.module.scss @@ -1,67 +1,102 @@ +@use "../../../styles/mixins"; -.message { +.listContainer { + display: flex; + flex-direction: column; + height: 100%; +} + +.messageItem { display: flex; flex-direction: row; - margin-bottom: 20px; - padding: 20px; - border-radius: 5px; - position: relative; + align-items: flex-start; + gap: 12px; + padding: 14px 16px; + cursor: pointer; + border-bottom: 1px solid var(--mantine-color-gray-2); + transition: background-color 0.15s ease; + outline: none; - .avatar { - justify-content: center; - align-items: center; - margin-right: 15px; + &:hover { + background-color: var(--mantine-color-gray-0); } - .details { - .status { - position: absolute; - top: 20px; - right: 20px; - font-size: .8rem; - font-weight: 400; - color: #9ca3af; - } + &:focus-visible { + background-color: var(--mantine-color-gray-0); + box-shadow: inset 0 0 0 2px var(--mantine-color-primary-4); + } - .date { - font-size: .8rem; - font-weight: 400; - color: #9ca3af; - display: flex; - flex: 1; - gap: 10px; - margin-bottom: 5px; - margin-top: 4px; + &.selected { + background-color: var(--mantine-color-primary-0); + border-left: 3px solid var(--mantine-color-primary-6); + padding-left: 13px; + } - .date { - margin-right: 5px; - } - } - .subject { - font-size: 1.1rem; - font-weight: 600; - margin-bottom: 10px; - } + &.cancelled { + opacity: 0.55; + } +} - .type { - font-size: .9rem; - font-weight: 400; - margin-bottom: 10px; - } +.itemContent { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; +} - .content { - font-size: 1rem; - font-weight: 200; - margin-bottom: 1rem; +.itemTopRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} - .preview { - font-size: .9rem; - font-weight: 400; - color: #9ca3af; - } - } - } +.sender { + font-size: 0.8rem; + font-weight: 500; + color: var(--mantine-color-dark-6); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.date { + font-size: 0.72rem; + color: var(--mantine-color-gray-6); + white-space: nowrap; + flex-shrink: 0; +} +.subject { + font-size: 0.85rem; + font-weight: 600; + color: var(--mantine-color-dark-8); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.preview { + font-size: 0.78rem; + color: var(--mantine-color-gray-6); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.4; +} + +.itemBottomRow { + display: flex; + align-items: center; + gap: 6px; + margin-top: 2px; +} -} \ No newline at end of file +.typeLabel { + font-size: 0.7rem; + color: var(--mantine-color-gray-5); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/frontend/src/components/common/MessageList/index.tsx b/frontend/src/components/common/MessageList/index.tsx index 88c10a41f..73361fd17 100644 --- a/frontend/src/components/common/MessageList/index.tsx +++ b/frontend/src/components/common/MessageList/index.tsx @@ -1,89 +1,99 @@ -import {Message, MessageType} from "../../../types.ts"; +import {IdParam, Message, MessageType} from "../../../types.ts"; import classes from './MessageList.module.scss'; import {relativeDate} from "../../../utilites/dates.ts"; -import {Card} from "../Card"; -import {Anchor, Avatar, Badge} from "@mantine/core"; +import {Avatar, Badge} from "@mantine/core"; import {getInitials} from "../../../utilites/helpers.ts"; -import {NoResultsSplash} from "../NoResultsSplash"; import {t} from "@lingui/macro"; -import {useState} from "react"; interface MessageListProps { messages: Message[]; + selectedId?: IdParam; + onSelect: (message: Message) => void; } -const SingleMessage = ({message}: { message: Message }) => { - const [showFullMessage, setShowFullMessage] = useState(false); +export const statusBadgeColor = (status?: string) => { + switch (status) { + case 'SENT': return 'green'; + case 'PROCESSING': return 'orange'; + case 'SCHEDULED': return 'blue'; + case 'CANCELLED': return 'gray'; + case 'FAILED': return 'red'; + default: return 'orange'; + } +}; - const typeToDescription = { +export const typeLabel = (type: MessageType) => { + const map: Record = { [MessageType.OrderOwnersWithProduct]: t`Order owners with products`, [MessageType.IndividualAttendees]: t`Individual attendees`, [MessageType.AllAttendees]: t`All attendees`, [MessageType.TicketHolders]: t`Ticket holders`, [MessageType.OrderOwner]: t`Order owner`, - } + }; + return map[type] || type; +}; - return ( - -
- {getInitials(message.sent_by_user?.first_name + " " + message.sent_by_user?.last_name)} -
-
+const MessageItem = ({message, isSelected, onSelect}: { + message: Message; + isSelected: boolean; + onSelect: () => void; +}) => { + const isCancelled = message.status === 'CANCELLED'; + const senderName = message.sent_by_user + ? `${message.sent_by_user.first_name} ${message.sent_by_user.last_name}` + : t`Unknown`; -
- - {message.status} - -
-
-
- {relativeDate(message.sent_at as string)} -
+ const displayTimestamp = (message.status === 'SCHEDULED' || message.status === 'CANCELLED') + ? (message.scheduled_at ?? message.sent_at ?? message.created_at) + : (message.sent_at ?? message.created_at ?? message.scheduled_at); + const displayDate = displayTimestamp ? relativeDate(displayTimestamp) : t`Unknown`; + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSelect(); + } + }} + > + + {getInitials(senderName)} + +
+
+ {senderName} + {displayDate}
{message.subject}
-
- {typeToDescription[message.type]} -
-
- {showFullMessage - ?
- :
{message.message_preview}
} +
{message.message_preview}
+
+ + {message.status} + + {typeLabel(message.type)}
- setShowFullMessage(!showFullMessage)} - > - {showFullMessage ? t`Read less` : t`View full message`} -
- +
); }; -export const MessageList = ({messages}: MessageListProps) => { - if (messages.length === 0) { - return -

- {t`You haven't sent any messages yet. You can send messages to all attendees, or to specific product holders.`} -

- - )} - /> - } +export const MessageList = ({messages, selectedId, onSelect}: MessageListProps) => { return ( -
- {messages.map((message) => { - return ( - - ) - })} +
+ {messages.map((message) => ( + onSelect(message)} + /> + ))}
- ) -} + ); +}; diff --git a/frontend/src/components/common/OrganizerWebhookTable/index.tsx b/frontend/src/components/common/OrganizerWebhookTable/index.tsx new file mode 100644 index 000000000..a34f1ca8a --- /dev/null +++ b/frontend/src/components/common/OrganizerWebhookTable/index.tsx @@ -0,0 +1,291 @@ +import { + Anchor, + Badge, + Button, + Group, + Menu, + Paper, + Popover, + Stack, + Table as MantineTable, + Text, + Tooltip +} from '@mantine/core'; +import { + IconBolt, + IconClipboardList, + IconClockHour4, + IconDotsVertical, + IconPencil, + IconPlus, + IconTrash +} from '@tabler/icons-react'; +import { Table, TableHead } from '../Table'; +import classes from '../WebhookTable/WebhookTable.module.scss'; +import { IdParam, Webhook } from '../../../types'; +import { confirmationDialog } from '../../../utilites/confirmationDialog'; +import Truncate from '../Truncate'; +import { relativeDate } from "../../../utilites/dates.ts"; +import { useDisclosure } from "@mantine/hooks"; +import { useState } from "react"; +import { t, Trans } from "@lingui/macro"; +import { EditOrganizerWebhookModal } from "../../modals/EditOrganizerWebhookModal"; +import { useDeleteOrganizerWebhook } from "../../../mutations/useDeleteOrganizerWebhook.ts"; +import { useParams } from "react-router"; +import { showError, showSuccess } from "../../../utilites/notifications.tsx"; +import { NoResultsSplash } from "../NoResultsSplash"; +import { OrganizerWebhookLogsModal } from "../../modals/OrganizerWebhookLogsModal"; + +interface OrganizerWebhookTableProps { + webhooks: Webhook[]; + openCreateModal: () => void; +} + +export const OrganizerWebhookTable = ({ webhooks, openCreateModal }: OrganizerWebhookTableProps) => { + const { organizerId } = useParams(); + const [editModalOpen, { open: openEditModal, close: closeEditModal }] = useDisclosure(false); + const [logsModalOpen, { open: openLogsModal, close: closeLogsModal }] = useDisclosure(false); + const [selectedWebhookId, setSelectedWebhookId] = useState(); + const deleteMutation = useDeleteOrganizerWebhook(); + + const handleDelete = (webhookId: IdParam) => { + deleteMutation.mutate({ organizerId: organizerId as IdParam, webhookId }, { + onSuccess: () => showSuccess(t`Webhook deleted successfully`), + onError: (error) => showError(error.message) + }); + } + + const EventTypeDisplay = ({ webhook }: { webhook: Webhook }) => { + const eventTypes = webhook.event_types; + + if (!eventTypes || eventTypes.length === 0) { + return <>-; + } + + const eventCount = eventTypes.length; + + return ( +
+ + {eventTypes.map((type) => ( +
{type}
+ ))} +
+ } + > + + {eventCount > 1 ? {eventCount} events : eventTypes[0]} + + +
+ ); + }; + + const ActionMenu = ({ webhook }: { webhook: Webhook }) => ( + + + + + + + + {t`Manage`} + } + onClick={() => { + setSelectedWebhookId(webhook.id as IdParam); + openEditModal(); + }} + > + {t`Edit webhook`} + + } + onClick={() => { + setSelectedWebhookId(webhook.id as IdParam); + openLogsModal(); + }} + > + {t`View logs`} + + + {t`Danger zone`} + } + onClick={() => { + confirmationDialog( + t`Are you sure you want to delete this webhook?`, + () => handleDelete(webhook.id as IdParam) + ); + }} + > + {t`Delete webhook`} + + + + + ); + + + const ResponseDisplay = ({ webhook }: { webhook: Webhook }) => { + if (webhook.last_response_code === null || webhook.last_response_code === undefined) { + return ( + + + + {t`No responses yet`} + + + ); + } + + const isSuccess = (webhook.last_response_code >= 200 && webhook.last_response_code < 300) && webhook.last_response_code !== 0; + const statusColor = isSuccess ? 'green' : 'red'; + const statusText = isSuccess ? t`Success` : t`Error`; + + return ( + + + + } + > + {statusText} {webhook.last_response_code > 0 ? `- ${webhook.last_response_code}` : ''} + + + + + + + + {t`Response Details`} + + {webhook.last_response_code > 0 ? webhook.last_response_code : t`No response`} + + + + {webhook.last_response_body && ( + + + {webhook.last_response_body} + + + )} + + + + ); + }; + + if (webhooks.length === 0) { + return ( + + +

+ Webhooks instantly notify external services when events happen, like adding a new attendee + to your CRM or mailing list upon registration, ensuring seamless automation. +

+

+ Use third-party services like Zapier,{' '} + IFTTT or Make to + create custom workflows and automate tasks. +

+
+ + + )} + /> + ); + } + + return ( + <> + + + + {t`URL`} + {t`Event Types`} + {t`Status`} + {t`Last Response`} + {t`Last Triggered`} + + + + + {webhooks.map((webhook) => ( + + + + + + + + + + {webhook.status} + + + + + + + + {webhook.last_triggered_at ? relativeDate(webhook.last_triggered_at as string) : t`Never`} + + + + + + + ))} + +
+ {logsModalOpen && selectedWebhookId && ( + + )} + + {(editModalOpen && selectedWebhookId) && ( + + )} + + ); +}; diff --git a/frontend/src/components/common/PlatformFeesSettings/index.tsx b/frontend/src/components/common/PlatformFeesSettings/index.tsx index 738a2e6f6..4e7c570ed 100644 --- a/frontend/src/components/common/PlatformFeesSettings/index.tsx +++ b/frontend/src/components/common/PlatformFeesSettings/index.tsx @@ -7,6 +7,7 @@ import {formatCurrency} from "../../../utilites/currency.ts"; import {IconArrowRight} from "@tabler/icons-react"; import classes from "./PlatformFeesSettings.module.scss"; import {AccountConfiguration} from "../../../types.ts"; +import {PlatformFeePreview} from "../../../api/event-settings.client.ts"; const formatPercentage = (value: number) => { return new Intl.NumberFormat('en-US', { @@ -26,11 +27,20 @@ interface FeeBreakdownProps { const FeeBreakdown = ({ticketPrice, feePercentage, fixedFee, currency, passToBuyer}: FeeBreakdownProps) => { const percentageRate = feePercentage / 100; - // Formula: P = (fixed + total * r) / (1 - r) - // This ensures the platform fee exactly covers what Stripe will charge - const platformFee = percentageRate >= 1 - ? fixedFee + (ticketPrice * percentageRate) - : (fixedFee + (ticketPrice * percentageRate)) / (1 - percentageRate); + + let platformFee: number; + if (passToBuyer) { + // Gross-up formula: P = (fixed + total * r) / (1 - r) + // When passing to buyer, we need to add enough so that after Stripe takes + // its percentage from the new total, the platform still receives the full fee + platformFee = percentageRate >= 1 + ? fixedFee + (ticketPrice * percentageRate) + : (fixedFee + (ticketPrice * percentageRate)) / (1 - percentageRate); + } else { + // Simple formula: fee = fixed + (total * percentage) + // When absorbing, the fee is calculated directly on the ticket price + platformFee = fixedFee + (ticketPrice * percentageRate); + } const roundedPlatformFee = Math.round(platformFee * 100) / 100; const buyerPays = passToBuyer ? ticketPrice + roundedPlatformFee : ticketPrice; @@ -78,6 +88,8 @@ export interface PlatformFeesSettingsProps { description: string; feeHandlingLabel: string; feeHandlingDescription: string; + feePreview?: PlatformFeePreview; + onPriceChange?: (price: number) => void; } export const PlatformFeesSettings = ({ @@ -90,16 +102,27 @@ export const PlatformFeesSettings = ({ description, feeHandlingLabel, feeHandlingDescription, + feePreview, + onPriceChange, }: PlatformFeesSettingsProps) => { const [samplePrice, setSamplePrice] = useState(50); const [selectedOption, setSelectedOption] = useState<'pass' | 'absorb'>(currentValue ? 'pass' : 'absorb'); - const feePercentage = configuration?.application_fees?.percentage || 0; - const fixedFee = configuration?.application_fees?.fixed || 0; - const feeCurrency = 'USD'; // Platform fees are always in USD + const feePercentage = feePreview?.percentage_fee ?? configuration?.application_fees?.percentage ?? 0; + const fixedFee = feePreview?.fixed_fee_converted ?? configuration?.application_fees?.fixed ?? 0; + const feeCurrency = feePreview?.event_currency ?? configuration?.application_fees?.currency ?? 'USD'; + const configCurrency = configuration?.application_fees?.currency ?? 'USD'; const numericPrice = typeof samplePrice === 'number' ? samplePrice : parseFloat(samplePrice) || 0; + const handlePriceChange = (value: number | string) => { + setSamplePrice(value); + const price = typeof value === 'number' ? value : parseFloat(value) || 0; + if (onPriceChange && price > 0) { + onPriceChange(price); + } + }; + const handleSave = () => { onSave(selectedOption === 'pass'); }; @@ -130,8 +153,13 @@ export const PlatformFeesSettings = ({ {t`Platform fee`} {formatPercentage(feePercentage)} - {fixedFee > 0 && ` + ${formatCurrency(fixedFee, feeCurrency)}`} + {configuration.application_fees?.fixed > 0 && ` + ${formatCurrency(configuration.application_fees.fixed, configCurrency)}`} + {feePreview && configCurrency !== feeCurrency && configuration.application_fees?.fixed > 0 && ( + + {t`≈ ${formatCurrency(fixedFee, feeCurrency)} at current rate`} + + )}
@@ -146,7 +174,7 @@ export const PlatformFeesSettings = ({ { +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 dc83e83f4..5ccbba1b9 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 000000000..cfb9cb7f4 --- /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 000000000..24769eede --- /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 000000000..a86de06cc --- /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 711784511..76063f94f 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/forms/WebhookForm/index.tsx b/frontend/src/components/forms/WebhookForm/index.tsx index cb9162961..878240a52 100644 --- a/frontend/src/components/forms/WebhookForm/index.tsx +++ b/frontend/src/components/forms/WebhookForm/index.tsx @@ -1,8 +1,8 @@ -import {TextInput} from "@mantine/core"; -import {t} from "@lingui/macro"; -import {UseFormReturnType} from "@mantine/form"; -import {CustomSelect, ItemProps} from "../../common/CustomSelect"; -import {IconBolt, IconWebhook, IconWebhookOff} from "@tabler/icons-react"; +import { TextInput } from "@mantine/core"; +import { t } from "@lingui/macro"; +import { UseFormReturnType } from "@mantine/form"; +import { CustomSelect, ItemProps } from "../../common/CustomSelect"; +import { IconBolt, IconWebhook, IconWebhookOff } from "@tabler/icons-react"; interface WebhookFormProps { form: UseFormReturnType<{ @@ -12,16 +12,16 @@ interface WebhookFormProps { }>; } -export const WebhookForm = ({form}: WebhookFormProps) => { +export const WebhookForm = ({ form }: WebhookFormProps) => { const statusOptions: ItemProps[] = [ { - icon: , + icon: , label: t`Enabled`, value: 'ENABLED', description: t`Webhook will send notifications`, }, { - icon: , + icon: , label: t`Paused`, value: 'PAUSED', description: t`Webhook will not send notifications`, @@ -30,79 +30,97 @@ export const WebhookForm = ({form}: WebhookFormProps) => { const eventTypeOptions: ItemProps[] = [ { - icon: , + icon: , label: t`Product Created`, value: 'product.created', description: t`When a new product is created`, }, { - icon: , + icon: , + label: t`Event Created`, + value: 'event.created', + description: t`When a new event is created`, + }, + { + icon: , + label: t`Event Updated`, + value: 'event.updated', + description: t`When an event is updated`, + }, + { + icon: , + label: t`Event Archived`, + value: 'event.archived', + description: t`When an event is archived`, + }, + { + icon: , label: t`Product Updated`, value: 'product.updated', description: t`When a product is updated`, }, { - icon: , + icon: , label: t`Product Deleted`, value: 'product.deleted', description: t`When a product is deleted`, }, { - icon: , + icon: , label: t`Order Created`, value: 'order.created', description: t`When a new order is created`, }, { - icon: , + icon: , label: t`Order Updated`, value: 'order.updated', description: t`When an order is updated`, }, { - icon: , + icon: , label: t`Order Marked as Paid`, value: 'order.marked_as_paid', description: t`When an order is marked as paid`, }, { - icon: , + icon: , label: t`Order Refunded`, value: 'order.refunded', description: t`When an order is refunded`, }, { - icon: , + icon: , label: t`Order Cancelled`, value: 'order.cancelled', description: t`When an order is cancelled`, }, { - icon: , + icon: , label: t`Attendee Created`, value: 'attendee.created', description: t`When a new attendee is created`, }, { - icon: , + icon: , label: t`Attendee Updated`, value: 'attendee.updated', description: t`When an attendee is updated`, }, { - icon: , + icon: , label: t`Attendee Cancelled`, value: 'attendee.cancelled', description: t`When an attendee is cancelled`, }, { - icon: , + icon: , label: t`Check-in Created`, value: 'checkin.created', description: t`When an attendee is checked in`, }, { - icon: , + icon: , label: t`Check-in Deleted`, value: 'checkin.deleted', description: t`When a check-in is deleted`, diff --git a/frontend/src/components/layouts/Checkout/CheckoutThemeProvider.tsx b/frontend/src/components/layouts/Checkout/CheckoutThemeProvider.tsx index 0a5d3d856..6279a5dd0 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 877818dd7..95fef67ec 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"; @@ -38,6 +39,7 @@ import {confirmationDialog} from "../../../utilites/confirmationDialog.tsx"; import {useUpdateEventStatus} from "../../../mutations/useUpdateEventStatus.ts"; import {showError, showSuccess} from "../../../utilites/notifications.tsx"; import {ShareModal} from "../../modals/ShareModal"; +import {EventLiveCelebrationModal} from "../../modals/EventLiveCelebrationModal"; import {useDisclosure} from "@mantine/hooks"; import {TopBarButton} from "../../common/TopBarButton"; import {useWindowWidth} from "../../../hooks/useWindowWidth.ts"; @@ -52,6 +54,7 @@ const EventLayout = () => { const {eventId} = useParams(); const [opened, {open, close}] = useDisclosure(false); + const [celebrationOpened, {open: openCelebration, close: closeCelebration}] = useDisclosure(false); const statusToggleMutation = useUpdateEventStatus(); @@ -115,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 @@ -145,6 +149,7 @@ const EventLayout = () => { ]; const handleStatusToggle = () => { + const isGoingLive = event?.status !== 'LIVE'; const message = event?.status === 'LIVE' ? t`Are you sure you want to make this event draft? This will make the event invisible to the public` : t`Are you sure you want to make this event public? This will make the event visible to the public`; @@ -155,7 +160,11 @@ const EventLayout = () => { status: event?.status === 'LIVE' ? 'DRAFT' : 'LIVE' }, { onSuccess: () => { - showSuccess(t`Event status updated`); + if (isGoingLive) { + openCelebration(); + } else { + showSuccess(t`Event status updated`); + } }, onError: (error: any) => { showError(error?.response?.data?.message || t`Event status update failed. Please try again later`); @@ -200,13 +209,21 @@ const EventLayout = () => { {t`Share Event`} - {event && } + /> + + )}
diff --git a/frontend/src/components/layouts/OrganizerLayout/index.tsx b/frontend/src/components/layouts/OrganizerLayout/index.tsx index 3e66cb94e..92dba3a63 100644 --- a/frontend/src/components/layouts/OrganizerLayout/index.tsx +++ b/frontend/src/components/layouts/OrganizerLayout/index.tsx @@ -13,51 +13,52 @@ import { IconPaint, IconSettings, IconShare, - IconUsersGroup + IconUsersGroup, + IconWebhook } from "@tabler/icons-react"; -import {t} from "@lingui/macro"; -import {BreadcrumbItem, NavItem} from "../AppLayout/types.ts"; +import { t } from "@lingui/macro"; +import { BreadcrumbItem, NavItem } from "../AppLayout/types.ts"; import AppLayout from "../AppLayout"; -import {NavLink, useLocation, useParams} from "react-router"; -import {Button, Modal, Stack, Text} from "@mantine/core"; -import {useGetOrganizer} from "../../../queries/useGetOrganizer.ts"; -import {useState} from "react"; -import {CreateEventModal} from "../../modals/CreateEventModal"; -import {TopBarButton} from "../../common/TopBarButton"; +import { NavLink, useLocation, useParams } from "react-router"; +import { Button, Modal, Stack, Text } from "@mantine/core"; +import { useGetOrganizer } from "../../../queries/useGetOrganizer.ts"; +import { useState } from "react"; +import { CreateEventModal } from "../../modals/CreateEventModal"; +import { TopBarButton } from "../../common/TopBarButton"; import classes from "./OrganizerLayout.module.scss"; -import {CalloutConfig, SidebarCalloutQueue} from "../../common/SidebarCallout/SidebarCalloutQueue"; -import {InviteUserModal} from "../../modals/InviteUserModal"; -import {useDisclosure, useMediaQuery} from "@mantine/hooks"; -import {SwitchOrganizerModal} from "../../modals/SwitchOrganizerModal"; -import {useGetOrganizers} from "../../../queries/useGetOrganizers.ts"; -import {useGetAccount} from "../../../queries/useGetAccount.ts"; -import {StripeConnectButton} from "../../common/StripeConnectButton"; -import {ShareModal} from "../../modals/ShareModal"; -import {organizerHomepageUrl} from "../../../utilites/urlHelper"; -import {useUpdateOrganizerStatus} from "../../../mutations/useUpdateOrganizerStatus.ts"; -import {confirmationDialog} from "../../../utilites/confirmationDialog.tsx"; -import {showError, showSuccess} from "../../../utilites/notifications.tsx"; -import {useResendEmailConfirmation} from "../../../mutations/useResendEmailConfirmation.ts"; -import {useGetMe} from "../../../queries/useGetMe.ts"; +import { CalloutConfig, SidebarCalloutQueue } from "../../common/SidebarCallout/SidebarCalloutQueue"; +import { InviteUserModal } from "../../modals/InviteUserModal"; +import { useDisclosure, useMediaQuery } from "@mantine/hooks"; +import { SwitchOrganizerModal } from "../../modals/SwitchOrganizerModal"; +import { useGetOrganizers } from "../../../queries/useGetOrganizers.ts"; +import { useGetAccount } from "../../../queries/useGetAccount.ts"; +import { StripeConnectButton } from "../../common/StripeConnectButton"; +import { ShareModal } from "../../modals/ShareModal"; +import { organizerHomepageUrl } from "../../../utilites/urlHelper"; +import { useUpdateOrganizerStatus } from "../../../mutations/useUpdateOrganizerStatus.ts"; +import { confirmationDialog } from "../../../utilites/confirmationDialog.tsx"; +import { showError, showSuccess } from "../../../utilites/notifications.tsx"; +import { useResendEmailConfirmation } from "../../../mutations/useResendEmailConfirmation.ts"; +import { useGetMe } from "../../../queries/useGetMe.ts"; const OrganizerLayout = () => { - const {organizerId} = useParams(); + const { organizerId } = useParams(); const location = useLocation(); - const {data: organizer} = useGetOrganizer(organizerId); + const { data: organizer } = useGetOrganizer(organizerId); const [showCreateEventModal, setShowCreateEventModal] = useState(false); - const [createModalOpen, {open: openCreateModal, close: closeCreateModal}] = useDisclosure(false); - const [switchOrganizerModalOpen, {open: openSwitchModal, close: closeSwitchModal}] = useDisclosure(false); - const [shareModalOpen, {open: openShareModal, close: closeShareModal}] = useDisclosure(false); + const [createModalOpen, { open: openCreateModal, close: closeCreateModal }] = useDisclosure(false); + const [switchOrganizerModalOpen, { open: openSwitchModal, close: closeSwitchModal }] = useDisclosure(false); + const [shareModalOpen, { open: openShareModal, close: closeShareModal }] = useDisclosure(false); const [emailVerificationModalOpen, { open: openEmailVerificationModal, close: closeEmailVerificationModal }] = useDisclosure(false); - const {data: organizerResposne} = useGetOrganizers(); + const { data: organizerResposne } = useGetOrganizers(); const organizers = organizerResposne?.data; - const {data: account} = useGetAccount(); + const { data: account } = useGetAccount(); const resendEmailConfirmationMutation = useResendEmailConfirmation(); const [emailConfirmationResent, setEmailConfirmationResent] = useState(false); - const {data: me} = useGetMe(); + const { data: me } = useGetMe(); const isUserEmailVerfied = me?.is_email_verified; const isMobile = useMediaQuery('(max-width: 768px)'); @@ -71,8 +72,8 @@ const OrganizerLayout = () => { isActive: () => false, showWhen: () => organizers && organizers.length > 1, }, - {label: 'Overview'}, - {link: 'dashboard', label: t`Organizer Dashboard`, icon: IconDashboard}, + { label: 'Overview' }, + { link: 'dashboard', label: t`Organizer Dashboard`, icon: IconDashboard }, { link: 'reports', label: t`Reports`, @@ -80,12 +81,15 @@ const OrganizerLayout = () => { isActive: (isActive) => isActive || location.pathname.includes('/report/') }, - {label: t`Manage`}, - {link: 'events', label: t`Events`, icon: IconCalendar}, - {link: 'settings', label: t`Settings`, icon: IconSettings}, + { label: t`Manage` }, + { link: 'events', label: t`Events`, icon: IconCalendar }, + { link: 'settings', label: t`Settings`, icon: IconSettings }, - {label: t`Tools`}, - {link: 'organizer-homepage-designer', label: t`Homepage Designer`, icon: IconPaint}, + { label: t`Tools` }, + { link: 'organizer-homepage-designer', label: t`Homepage Designer`, icon: IconPaint }, + + { label: t`Integrations` }, + { link: 'webhooks', label: t`Webhooks`, icon: IconWebhook }, ]; const handleEmailConfirmationResend = () => { @@ -149,7 +153,7 @@ const OrganizerLayout = () => { className={classes.createEventBreadcrumb} onClick={() => setShowCreateEventModal(true)} > - {t`Create Event`} + {t`Create Event`} ), } @@ -157,10 +161,10 @@ const OrganizerLayout = () => { const callouts: CalloutConfig[] = [ { - icon: , + icon: , heading: t`Invite Your Team`, description: t`Collaborate with your team to create amazing events together.`, - buttonIcon: , + buttonIcon: , buttonText: t`Invite Team Members`, onClick: () => { openCreateModal(); @@ -171,7 +175,7 @@ const OrganizerLayout = () => { if (account && !account?.stripe_connect_setup_complete) { callouts.unshift({ - icon: , + icon: , heading: t`Connect Stripe`, description: t`Connect your Stripe account to accept payments for tickets and products.`, storageKey: `stripe-callout-dismissed`, @@ -179,7 +183,7 @@ const OrganizerLayout = () => { } + buttonIcon={} buttonText={t`Connect Stripe`} className={classes.calloutButton} /> @@ -198,9 +202,9 @@ const OrganizerLayout = () => { : - } - rightSection={} + leftSection={organizer?.status === 'DRAFT' ? : + } + rightSection={} > {organizer?.status === 'DRAFT' ? {t`Draft`} { )} - sidebarFooter={} + sidebarFooter={} /> - {createModalOpen && } + {createModalOpen && } {switchOrganizerModalOpen && - } + } {organizer && shareModalOpen && ( { + const { organizerId } = useParams(); + const errorHandler = useFormErrorResponseHandler(); + + const form = useForm({ + initialValues: { + url: '', + event_types: [], + status: 'ENABLED' + }, + validate: { + url: (value) => { + if (!value) return t`URL is required`; + try { + new URL(value); + return null; + } catch { + return t`Please enter a valid URL`; + } + }, + event_types: (value) => value.length === 0 ? t`At least one event type must be selected` : null, + } + }); + + const createMutation = useCreateOrganizerWebhook(); + + const handleSubmit = (requestData: OrganizerWebhookRequest) => { + createMutation.mutate({ + organizerId: organizerId as IdParam, + webhook: requestData + }, { + onSuccess: () => { + showSuccess(t`Webhook created successfully`); + onClose(); + }, + onError: (error) => errorHandler(form, error), + }); + } + + return ( + +
+ + + +
+ ); +} diff --git a/frontend/src/components/modals/CreateProductModal/index.tsx b/frontend/src/components/modals/CreateProductModal/index.tsx index 03107123c..beee9d69f 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/EditOrganizerWebhookModal/index.tsx b/frontend/src/components/modals/EditOrganizerWebhookModal/index.tsx new file mode 100644 index 000000000..778af26c5 --- /dev/null +++ b/frontend/src/components/modals/EditOrganizerWebhookModal/index.tsx @@ -0,0 +1,92 @@ +import { GenericModalProps, IdParam } from "../../../types.ts"; +import { Modal } from "../../common/Modal"; +import { t } from "@lingui/macro"; +import { WebhookForm } from "../../forms/WebhookForm"; +import { useForm } from "@mantine/form"; +import { Alert, Button, Center, Loader } from "@mantine/core"; +import { showSuccess } from "../../../utilites/notifications.tsx"; +import { useParams } from "react-router"; +import { useFormErrorResponseHandler } from "../../../hooks/useFormErrorResponseHandler.tsx"; +import { useGetOrganizerWebhook } from "../../../queries/useGetOrganizerWebhook.ts"; +import { useEditOrganizerWebhook } from "../../../mutations/useEditOrganizerWebhook.ts"; +import { useEffect } from "react"; +import { OrganizerWebhookRequest } from "../../../api/organizer-webhook.client.ts"; + +interface EditWebhookModalProps { + webhookId: IdParam; +} + +export const EditOrganizerWebhookModal = ({ + onClose, + webhookId +}: GenericModalProps & EditWebhookModalProps) => { + const { organizerId } = useParams(); + const errorHandler = useFormErrorResponseHandler(); + const { data: webhook, error: webhookError, isLoading: webhookLoading } = useGetOrganizerWebhook(organizerId as IdParam, webhookId); + const form = useForm({ + initialValues: { + url: '', + event_types: [], + status: 'ENABLED', + } + }); + const editMutation = useEditOrganizerWebhook(); + + const handleSubmit = (requestData: OrganizerWebhookRequest) => { + editMutation.mutate( + { + organizerId: organizerId as IdParam, + webhook: requestData, + webhookId: webhookId, + }, + { + onSuccess: () => { + showSuccess(t`Successfully updated Webhook`); + onClose(); + }, + onError: (error) => { + errorHandler(form, error); + }, + } + ); + }; + + useEffect(() => { + if (webhook && webhook.data && webhook.data.data) { + form.setValues({ + url: webhook.data.data.url, + event_types: webhook.data.data.event_types, + status: webhook.data.data.status, + }); + } + }, [webhook]); + + return ( + + {webhookLoading && ( +
+ +
+ )} + + {!!webhookError && ( + + {t`Failed to load Webhook`} + + )} + + {webhook && ( +
+ + + + )} +
+ ); +}; diff --git a/frontend/src/components/modals/EditProductModal/index.tsx b/frontend/src/components/modals/EditProductModal/index.tsx index a4bc2d001..4d790193f 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/EventLiveCelebrationModal/EventLiveCelebrationModal.module.scss b/frontend/src/components/modals/EventLiveCelebrationModal/EventLiveCelebrationModal.module.scss new file mode 100644 index 000000000..5af1f3691 --- /dev/null +++ b/frontend/src/components/modals/EventLiveCelebrationModal/EventLiveCelebrationModal.module.scss @@ -0,0 +1,77 @@ +.modal { + :global(.mantine-Modal-body) { + padding: 0; + } +} + +.content { + display: flex; + flex-direction: column; + align-items: center; + padding: 2rem; + text-align: center; +} + +.celebrationEmoji { + font-size: 4rem; + line-height: 1; + margin-bottom: 1rem; + animation: celebrate 0.6s ease-out; +} + +@keyframes celebrate { + 0% { + transform: scale(0); + opacity: 0; + } + 50% { + transform: scale(1.2); + } + 70% { + transform: scale(0.9); + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +.title { + font-size: 1.5rem; + font-weight: 700; + color: var(--mantine-color-dark-7); + margin-bottom: 0.5rem; +} + +.subtitle { + color: var(--mantine-color-dimmed); + font-size: 0.95rem; + margin-bottom: 1.5rem; +} + +.urlSection { + width: 100%; + margin-bottom: 1.5rem; +} + +.urlInput { + padding-right: 110px; + font-size: 0.875rem; + background: var(--mantine-color-gray-0); +} + +.actions { + display: flex; + flex-direction: column; + gap: 0.5rem; + width: 100%; + margin-bottom: 1rem; +} + +.actionButton { + width: 100%; +} + +.doneButton { + width: 100%; +} diff --git a/frontend/src/components/modals/EventLiveCelebrationModal/index.tsx b/frontend/src/components/modals/EventLiveCelebrationModal/index.tsx new file mode 100644 index 000000000..302abbde6 --- /dev/null +++ b/frontend/src/components/modals/EventLiveCelebrationModal/index.tsx @@ -0,0 +1,137 @@ +import {Button, CopyButton, Modal, Text, TextInput, Tooltip} from '@mantine/core'; +import {useDisclosure} from '@mantine/hooks'; +import { + IconCheck, + IconCode, + IconCopy, + IconExternalLink, + IconShare +} from '@tabler/icons-react'; +import {t} from "@lingui/macro"; +import {NavLink} from "react-router"; +import {ShareModal} from "../ShareModal"; +import classes from './EventLiveCelebrationModal.module.scss'; + +interface EventLiveCelebrationModalProps { + opened: boolean; + onClose: () => void; + url: string; + eventTitle: string; + eventId: string; +} + +export const EventLiveCelebrationModal = ({ + opened, + onClose, + url, + eventTitle, + eventId +}: EventLiveCelebrationModalProps) => { + const [shareModalOpened, {open: openShareModal, close: closeShareModal}] = useDisclosure(false); + + return ( + <> + +
+
+ 🎉 +
+ + + {t`Your event is live!`} + + + + {t`Congratulations! Your event is now visible to the public.`} + + +
+ + {({copied, copy}) => ( + + + + )} + + } + /> +
+ +
+ + + + + +
+ + +
+
+ + + + ); +}; diff --git a/frontend/src/components/modals/JoinWaitlistModal/index.tsx b/frontend/src/components/modals/JoinWaitlistModal/index.tsx new file mode 100644 index 000000000..4e840f4b7 --- /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/MessageRecipientsModal/MessageRecipientsModal.module.scss b/frontend/src/components/modals/MessageRecipientsModal/MessageRecipientsModal.module.scss new file mode 100644 index 000000000..e99ccef65 --- /dev/null +++ b/frontend/src/components/modals/MessageRecipientsModal/MessageRecipientsModal.module.scss @@ -0,0 +1,55 @@ +.recipientRow { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 0; + border-bottom: 1px solid var(--mantine-color-gray-2); + gap: 12px; + + &:last-child { + border-bottom: none; + } +} + +.recipientEmail { + font-size: 0.88rem; + color: var(--mantine-color-dark-7); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + flex: 1; +} + +.recipientRight { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} + +.recipientDate { + font-size: 0.75rem; + color: var(--mantine-color-gray-5); + white-space: nowrap; +} + +.emptyState { + text-align: center; + padding: 32px 16px; + color: var(--mantine-color-gray-6); + font-size: 0.88rem; +} + +.loadingState { + display: flex; + justify-content: center; + align-items: center; + padding: 40px; +} + +.headerCount { + font-size: 0.78rem; + color: var(--mantine-color-gray-6); + margin-bottom: 12px; +} diff --git a/frontend/src/components/modals/MessageRecipientsModal/index.tsx b/frontend/src/components/modals/MessageRecipientsModal/index.tsx new file mode 100644 index 000000000..2111aac56 --- /dev/null +++ b/frontend/src/components/modals/MessageRecipientsModal/index.tsx @@ -0,0 +1,93 @@ +import {Modal} from "../../common/Modal"; +import {t} from "@lingui/macro"; +import {Alert, Badge, Loader, Text} from "@mantine/core"; +import {GenericModalProps, IdParam} from "../../../types.ts"; +import {useGetMessageRecipients} from "../../../queries/useGetMessageRecipients.ts"; +import {relativeDate} from "../../../utilites/dates.ts"; +import {Pagination} from "../../common/Pagination"; +import {useState} from "react"; +import classes from "./MessageRecipientsModal.module.scss"; + +interface MessageRecipientsModalProps extends GenericModalProps { + eventId: IdParam; + messageId: IdParam; +} + +const statusColor = (status: string) => { + switch (status?.toUpperCase()) { + case 'SENT': + return 'green'; + case 'FAILED': + return 'red'; + default: + return 'gray'; + } +}; + +export const MessageRecipientsModal = ({onClose, eventId, messageId}: MessageRecipientsModalProps) => { + const [page, setPage] = useState(1); + const recipientsQuery = useGetMessageRecipients(eventId, messageId, {pageNumber: page, perPage: 100}); + const recipients = recipientsQuery.data?.data; + const pagination = recipientsQuery.data?.meta; + const total = pagination?.total; + + return ( + + {recipientsQuery.isLoading && ( +
+ +
+ )} + + {!!recipientsQuery.error && ( + + {t`Failed to load recipients`} + + )} + + {!recipientsQuery.isLoading && !recipientsQuery.error && recipients && recipients.length === 0 && ( +
+ {t`No recipients found`} +
+ )} + + {!recipientsQuery.isLoading && !recipientsQuery.error && recipients && recipients.length > 0 && ( + <> + {total !== undefined && ( +
+ {total} {total === 1 ? t`recipient` : t`recipients`} +
+ )} + {recipients.map((recipient) => ( +
+ {recipient.recipient} +
+ + {recipient.status} + + {recipient.created_at && ( + + {relativeDate(recipient.created_at)} + + )} +
+
+ ))} + + {pagination && Number(pagination.last_page) > 1 && ( + + )} + + )} +
+ ); +}; diff --git a/frontend/src/components/modals/OfferWaitlistModal/index.tsx b/frontend/src/components/modals/OfferWaitlistModal/index.tsx new file mode 100644 index 000000000..66e02fac5 --- /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/modals/OrganizerWebhookLogsModal/OrganizerWebhookLogsModal.module.scss b/frontend/src/components/modals/OrganizerWebhookLogsModal/OrganizerWebhookLogsModal.module.scss new file mode 100644 index 000000000..0643d6c79 --- /dev/null +++ b/frontend/src/components/modals/OrganizerWebhookLogsModal/OrganizerWebhookLogsModal.module.scss @@ -0,0 +1,37 @@ +.logEntry { + cursor: pointer; + transition: all 0.2s ease; + border-radius: 8px; +} + +.logEntryExpanded { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.chevronIcon { + transition: transform 0.2s ease; +} + +.chevronIconExpanded { + transform: rotate(90deg); +} + +.statusIcon { + border-radius: 50%; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; +} + +.codeBlock { + border-radius: 6px; + max-height: 300px; + overflow: auto; + background-color: var(--mantine-color-gray-0); +} + +.noLogsAlert { + text-align: center; +} diff --git a/frontend/src/components/modals/OrganizerWebhookLogsModal/index.tsx b/frontend/src/components/modals/OrganizerWebhookLogsModal/index.tsx new file mode 100644 index 000000000..ad21f551f --- /dev/null +++ b/frontend/src/components/modals/OrganizerWebhookLogsModal/index.tsx @@ -0,0 +1,180 @@ +import { useParams } from "react-router"; +import { useGetOrganizerWebhookLogs } from "../../../queries/useGetOrganizerWebhookLogs"; +import { Modal } from "../../common/Modal"; +import { t } from "@lingui/macro"; +import { Center } from "../../common/Center"; +import { Alert, Badge, Code, Collapse, Group, Loader, Paper, Stack, Text } from "@mantine/core"; +import { IconCheck, IconChevronRight, IconX } from '@tabler/icons-react'; +import { GenericModalProps, IdParam } from "../../../types.ts"; +import { useState } from "react"; +import { relativeDate } from "../../../utilites/dates.ts"; +import classes from "./OrganizerWebhookLogsModal.module.scss"; + +interface WebhookLog { + id: IdParam; + webhook_id: IdParam; + payload?: string; + response_code?: number; + response_body?: string; + event_type: string; + created_at: string; +} + +interface WebhookLogsModalProps extends GenericModalProps { + webhookId: IdParam; +} + +const LogEntry = ({ log }: { log: WebhookLog }) => { + const [detailsOpen, setDetailsOpen] = useState(false); + + const getStatusColor = (code?: number) => { + if (!code) return 'gray'; + if (code >= 200 && code < 300) return 'green'; + if (code >= 300 && code < 400) return 'blue'; + return 'red'; + }; + + const formatContent = (content?: string) => { + if (!content) return ''; + + try { + return JSON.stringify(JSON.parse(content), null, 2); + } catch (e) { + return content; + } + }; + + const statusColor = getStatusColor(log.response_code); + + return ( + setDetailsOpen(!detailsOpen)} + className={`${classes.logEntry} ${detailsOpen ? classes.logEntryExpanded : ''}`} + style={{ + borderLeft: `4px solid var(--mantine-color-${statusColor}-6)` + }} + > + + +
+ +
+
+ + + {log.event_type} + + + {log.response_code || t`No Response`} + + + + {relativeDate(log.created_at)} + +
+
+ {log.response_code && ( +
+ {log.response_code >= 200 && log.response_code < 300 ? + : + + } +
+ )} +
+ + + + {log.payload && ( +
+ {t`Payload`}: + + {formatContent(log.payload)} + +
+ )} + + {log.response_body && ( +
+ {t`Response`}: + + {formatContent(log.response_body)} + +
+ )} +
+
+
+ ); +}; + +export const OrganizerWebhookLogsModal = ({ onClose, webhookId }: WebhookLogsModalProps) => { + const { organizerId } = useParams(); + const logsQuery = useGetOrganizerWebhookLogs(organizerId as IdParam, webhookId); + const logs = logsQuery.data?.data?.data; + + return ( + + {logsQuery.isLoading && ( +
+ + + {t`Loading webhook logs...`} + +
+ )} + + {!!logsQuery.error && ( + } + radius="md" + > + {logsQuery.error.message} + + )} + + {logs && logs.length === 0 && !logsQuery.isLoading && ( + +

+ {t`No logs found`} +

+

+ {t`No webhook events have been recorded for this endpoint yet. Events will appear here once they are triggered.`} +

+
+ )} + + {logs && logs.length > 0 && ( + <> + {logs.map((log: WebhookLog) => ( + + ))} + + )} +
+ ); +}; diff --git a/frontend/src/components/modals/SendMessageModal/SendMessageModal.module.scss b/frontend/src/components/modals/SendMessageModal/SendMessageModal.module.scss index a27748fb0..bbd152e55 100644 --- a/frontend/src/components/modals/SendMessageModal/SendMessageModal.module.scss +++ b/frontend/src/components/modals/SendMessageModal/SendMessageModal.module.scss @@ -6,10 +6,15 @@ :global(.mantine-InputWrapper-root) { margin-bottom: 0; } + + // Remove bottom margin from the Editor's wrapper + > div:last-child { + margin-bottom: 0; + } } .footerSection { - margin-top: 0.25rem; + margin-top: 0.875rem; display: flex; flex-direction: column; gap: 0.875rem; @@ -48,3 +53,132 @@ .stripeConnectButton { margin-top: 0.75rem; } + +.scheduleSection { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.sendToggle { + display: flex; + gap: 0; + border: 1px solid var(--mantine-color-gray-3); + border-radius: var(--mantine-radius-md); + overflow: hidden; + background: var(--mantine-color-gray-0); +} + +.toggleOption { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 9px 12px; + font-size: 0.82rem; + font-weight: 500; + border: none; + background: transparent; + color: var(--mantine-color-gray-6); + cursor: pointer; + transition: all 0.15s ease; + + &:hover:not(.toggleActive) { + color: var(--mantine-color-gray-8); + background: var(--mantine-color-gray-1); + } +} + +.toggleActive { + background: #fff; + color: var(--mantine-color-dark-8); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + border-radius: calc(var(--mantine-radius-md) - 1px); + position: relative; + z-index: 1; +} + +.scheduleBody { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.presetChips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.presetChip { + display: inline-flex; + align-items: center; + padding: 5px 12px; + font-size: 0.78rem; + font-weight: 500; + border: 1px solid var(--mantine-color-gray-3); + border-radius: 999px; + background: #fff; + color: var(--mantine-color-gray-7); + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; + + &:hover:not(.presetChipActive) { + border-color: var(--mantine-color-primary-3); + color: var(--mantine-color-primary-6); + background: var(--mantine-color-primary-0); + } +} + +.presetChipActive { + border-color: var(--mantine-color-primary-5); + background: var(--mantine-color-primary-0); + color: var(--mantine-color-primary-7); + font-weight: 600; +} + +.scheduledConfirmation { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + background: var(--mantine-color-primary-0); + border: 1px solid var(--mantine-color-primary-2); + border-radius: var(--mantine-radius-md); +} + +.scheduledConfirmationIcon { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--mantine-color-primary-1); + color: var(--mantine-color-primary-6); + flex-shrink: 0; +} + +.scheduledConfirmationText { + display: flex; + flex-direction: column; + gap: 1px; +} + +.scheduledConfirmationDate { + font-size: 0.85rem; + font-weight: 600; + color: var(--mantine-color-dark-7); +} + +.scheduledConfirmationTime { + font-size: 0.78rem; + color: var(--mantine-color-gray-7); +} + +.scheduledConfirmationTz { + font-size: 0.7rem; + color: var(--mantine-color-gray-5); +} diff --git a/frontend/src/components/modals/SendMessageModal/index.tsx b/frontend/src/components/modals/SendMessageModal/index.tsx index e7bd77e58..f33cc8f5f 100644 --- a/frontend/src/components/modals/SendMessageModal/index.tsx +++ b/frontend/src/components/modals/SendMessageModal/index.tsx @@ -1,10 +1,30 @@ -import {GenericModalProps, IdParam, MessageType, ProductType} from "../../../types.ts"; +import {Event, GenericModalProps, IdParam, MessageType, ProductType} from "../../../types.ts"; import {useParams} from "react-router"; import {useGetEvent} from "../../../queries/useGetEvent.ts"; import {useGetOrder} from "../../../queries/useGetOrder.ts"; import {Modal} from "../../common/Modal"; -import {Alert, Button, Checkbox, ComboboxItemGroup, Group, LoadingOverlay, Menu, MultiSelect, Select, TextInput} from "@mantine/core"; -import {IconAlertCircle, IconCheck, IconChevronDown, IconCopy, IconInfoCircle, IconSend, IconTestPipe} from "@tabler/icons-react"; +import { + Alert, + Button, + Checkbox, + ComboboxItemGroup, + Group, + LoadingOverlay, + Menu, + MultiSelect, + Select, + TextInput +} from "@mantine/core"; +import { + IconAlertCircle, + IconCheck, + IconChevronDown, + IconClock, + IconCopy, + IconInfoCircle, + IconSend, + IconTestPipe +} from "@tabler/icons-react"; import {useGetMe} from "../../../queries/useGetMe.ts"; import {useForm, UseFormReturnType} from "@mantine/form"; import {useFormErrorResponseHandler} from "../../../hooks/useFormErrorResponseHandler.tsx"; @@ -13,10 +33,12 @@ import {t} from "@lingui/macro"; import {Editor} from "../../common/Editor"; import {useSendEventMessage} from "../../../mutations/useSendEventMessage.ts"; import {ProductSelector} from "../../common/ProductSelector"; -import {useEffect, useState} from "react"; +import {useEffect, useMemo, useState} from "react"; import {useGetAccount} from "../../../queries/useGetAccount.ts"; import {StripeConnectButton} from "../../common/StripeConnectButton"; import {getConfig} from "../../../utilites/config"; +import {utcToTz} from "../../../utilites/dates.ts"; +import dayjs from "dayjs"; import classes from "./SendMessageModal.module.scss"; interface EventMessageModalProps extends GenericModalProps { @@ -77,6 +99,31 @@ const AttendeeField = ({orderId, eventId, attendeeId, form}: { ) } +const CUSTOM_PRESET = 'custom'; + +const getSchedulePresets = (event: Event) => { + const now = dayjs.utc(); + const startDate = dayjs.utc(event.start_date); + const endDate = event.end_date ? dayjs.utc(event.end_date) : null; + + const presets: { value: string; label: string; utcDate: dayjs.Dayjs }[] = [ + {value: '1_week_before', label: t`1 week before event`, utcDate: startDate.subtract(1, 'week')}, + {value: '1_day_before', label: t`1 day before event`, utcDate: startDate.subtract(1, 'day')}, + {value: '1_hour_before', label: t`1 hour before event`, utcDate: startDate.subtract(1, 'hour')}, + {value: '1_day_after_start', label: t`1 day after start date`, utcDate: startDate.add(1, 'day')}, + ]; + + if (endDate) { + presets.push({ + value: '1_day_after_end', + label: t`1 day after end date`, + utcDate: endDate.add(1, 'day'), + }); + } + + return presets.filter(p => p.utcDate.isAfter(now)); +}; + export const SendMessageModal = (props: EventMessageModalProps) => { const {onClose, orderId, productId, messageType, attendeeId} = props; const {eventId} = useParams(); @@ -90,6 +137,15 @@ export const SendMessageModal = (props: EventMessageModalProps) => { const formIsDisabled = !isAccountVerified || accountRequiresManualVerification; const supportEmail = getConfig('VITE_PLATFORM_SUPPORT_EMAIL'); const [tierLimitError, setTierLimitError] = useState(null); + const [isScheduled, setIsScheduled] = useState(false); + const [selectedPreset, setSelectedPreset] = useState(null); + + const presets = useMemo(() => event ? getSchedulePresets(event) : [], [event]); + + const resolvedPreset = useMemo(() => { + if (!selectedPreset || selectedPreset === CUSTOM_PRESET) return null; + return presets.find(p => p.value === selectedPreset) ?? null; + }, [selectedPreset, presets]); const sendMessageMutation = useSendEventMessage(); @@ -106,20 +162,36 @@ export const SendMessageModal = (props: EventMessageModalProps) => { type: 'EVENT', acknowledgement: false, order_statuses: ['COMPLETED'], + scheduled_at: '', }, validate: { acknowledgement: (value) => value === true ? null : t`You must acknowledge that this email is not promotional`, + scheduled_at: (value) => { + if (!isScheduled) return null; + if (selectedPreset && selectedPreset !== CUSTOM_PRESET) return null; + if (!value) return t`The scheduled time is required`; + if (event && dayjs.tz(value, event.timezone).isBefore(dayjs())) return t`The scheduled time must be in the future`; + return null; + }, } }); const handleSend = (values: any) => { setTierLimitError(null); + const submitData = {...values}; + if (isScheduled) { + if (selectedPreset && selectedPreset !== CUSTOM_PRESET && resolvedPreset && event) { + submitData.scheduled_at = resolvedPreset.utcDate.tz(event.timezone).format('YYYY-MM-DDTHH:mm'); + } + } else { + delete submitData.scheduled_at; + } sendMessageMutation.mutate({ eventId: eventId, - messageData: values, + messageData: submitData, }, { onSuccess: () => { - showSuccess(t`Message Sent`); + showSuccess(isScheduled ? t`Message Scheduled` : t`Message Sent`); form.reset(); onClose(); }, @@ -157,7 +229,8 @@ export const SendMessageModal = (props: EventMessageModalProps) => {
{(!isAccountVerified && isAccountFetched) && ( - }> + }> {t`You need to verify your account email before you can send messages.`} )} @@ -177,7 +250,7 @@ export const SendMessageModal = (props: EventMessageModalProps) => { } + icon={} mb="md" > {tierLimitError} @@ -194,7 +267,7 @@ export const SendMessageModal = (props: EventMessageModalProps) => { } + icon={} mb="md" > {t`Your account has messaging limits. To increase your limits, contact us at`}{' '} @@ -285,6 +358,82 @@ export const SendMessageModal = (props: EventMessageModalProps) => {
+
+
+ + +
+ {isScheduled && ( +
+
+ {presets.map(p => ( + + ))} + +
+ {selectedPreset === CUSTOM_PRESET && ( + + )} + {resolvedPreset && event && ( +
+
+ +
+
+ + {resolvedPreset.utcDate.tz(event.timezone).format('dddd, MMMM D, YYYY')} + + + {resolvedPreset.utcDate.tz(event.timezone).format('h:mm A')} + {' '}{event.timezone} + +
+
+ )} +
+ )} +
+ { className={classes.sendButton} loading={sendMessageMutation.isPending} type={'submit'} - leftSection={} + leftSection={isScheduled ? : } disabled={!form.values.acknowledgement || !isAccountVerified || accountRequiresManualVerification} > - {form.values.is_test ? t`Send Test` : t`Send Message`} + {isScheduled ? t`Schedule Message` : (form.values.is_test ? t`Send Test` : t`Send Message`)} + + + + ); +}; diff --git a/frontend/src/components/routes/event/Settings/index.tsx b/frontend/src/components/routes/event/Settings/index.tsx index 9b658bdd6..60c8eaa13 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 000000000..fb4f5a4d9 --- /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={( +