diff --git a/.do/app.yaml b/.do/app.yaml index fe616701..efa0ecca 100644 --- a/.do/app.yaml +++ b/.do/app.yaml @@ -15,15 +15,15 @@ databases: production: true version: "12" domains: - - domain: dev.app.hi.events + - domain: demo.hi.events type: PRIMARY - - domain: dev.api.hi.events + - domain: app.hi.events type: ALIAS envs: - key: APP_KEY scope: RUN_AND_BUILD_TIME type: SECRET - value: EV[1:tgNynPB6rjrGHux5SLWOaGXa0Dq2wUb9:EkhnCswHYeeErT6Mvx+XPQ2tjyq4C250jc2PCPOkz3c98IeV8s98ncrlucqXN9og5RFoNHD/T0UaZdZo/N5hwf3alA==] + value: EV[1:XXX:XXX] - key: APP_SAAS_MODE_ENABLED scope: RUN_AND_BUILD_TIME value: "true" @@ -42,18 +42,18 @@ envs: - key: JWT_SECRET scope: RUN_AND_BUILD_TIME type: SECRET - value: EV[1:JBreFQlA8IdEHquwAg9P58pMz0JEa+KL:4UMIEVR52tj7N1SzSqykHXJNaO9QrCKB1iSa5AOOEHwBoPoiA8WLeplfaaowgliyk/h51NX26+oA0tw7SHL3JrADnw==] + value: EV[1:XXX:XXX] - key: LOG_CHANNEL scope: RUN_AND_BUILD_TIME value: stderr - key: AWS_ACCESS_KEY_ID scope: RUN_AND_BUILD_TIME type: SECRET - value: EV[1:nzGNZAsAUDy/A4I57t55AtmGPrdMWVtk:SKvcYl/NT0+IKcu2KaftzbiG3t5nQPfaP/VAj8AE+uzcCWlD] + value: EV[1:XXX:XXX] - key: AWS_SECRET_ACCESS_KEY scope: RUN_AND_BUILD_TIME type: SECRET - value: EV[1:vmiq656HFNEtHHJs07EQQeV1HVwjM2ea:h9a00bBytMXPz0sNKHOUOfu5e5EUVeBz1kAcdqRLtYhREmhjr1/oUFQ66fJjMxRuCMCyYpu9kbg=] + value: EV[1:XXX:XXX] - key: AWS_DEFAULT_REGION scope: RUN_AND_BUILD_TIME value: us-west-1 @@ -66,32 +66,20 @@ envs: - key: STRIPE_PUBLIC_KEY scope: RUN_AND_BUILD_TIME type: SECRET - value: EV[1:XWMOWzHz/fCYVb824fdEDC1dzGM8O7cC:HV/yWPv7eI721IxatBR9alNVIgsyzS1+SOpk3sxdo8kBK2QcRl+seuhB/MTx4dENQRvI083S7Ybe66UrWKAgR5jO4T2xoyEulvJlCeuZksbv0dC0L6rLFX6wJnZCCqhUei6ua02tU71XQqRg5WGO6daLvGbN6xQ5hrbO] + value: EV[1:XXX:XXX] - key: STRIPE_SECRET_KEY scope: RUN_AND_BUILD_TIME type: SECRET - value: EV[1:i3ZPSO52CRk6hX2IoC0hox+8yfa8nCNc:NRV6VGMKIGKHlcxw1HTRW25jfNF7tKWuKIF0trHFECPcQ0c7d12BkKlaENi5qi4MxsS3cmbA5wI7lgFwcmEhEz4DOqBBbHjpRgUGF9UXaRvW0PxIixOb9glKW45gPuQGeZn/MJLHNE98p9xi/UyRdgX6wlba96bkauKT] - - key: STRIPE_WEBHOOK_SECRET - scope: RUN_AND_BUILD_TIME - type: SECRET - value: EV[1:R1RgskZKXSKnNsqSCQnPX4VrvJ7GANAW:0yNAzT6WlSBM56ghXUWYtK6Cp0O1WyJZS5RLT17bsqnYrvSCEr2x3mEaR0Go7hSKSODHe1Ql] + value: EV[1:XXX:XXX] - key: MAIL_MAILER scope: RUN_AND_BUILD_TIME value: smtp - key: MAIL_HOST scope: RUN_AND_BUILD_TIME - value: sandbox.smtp.mailtrap.io + value: smtp-relay.brevo.com - key: MAIL_PORT scope: RUN_AND_BUILD_TIME - value: "2525" - - key: MAIL_USERNAME - scope: RUN_AND_BUILD_TIME - type: SECRET - value: EV[1:FVdDTpQf7I1jYF63nPkgQo8dsvRMTKPU:hTCQ3cK6XFZaqp6Fd6jprgRB61oDfwO30p0ii4VL] - - key: MAIL_PASSWORD - scope: RUN_AND_BUILD_TIME - type: SECRET - value: EV[1:kzHCAK6w/8muUxfl9XQ++aCgbbPiq9PK:5Hmdbri+Xz6JVzV9FT0nrrqR+ece7Irq3mm/zdzi] + value: "587" - key: DB_CONNECTION scope: RUN_AND_BUILD_TIME value: pgsql @@ -131,29 +119,49 @@ envs: - key: DATABASE_URL scope: RUN_AND_BUILD_TIME value: ${hi-events-postgres.DATABASE_URL} + - key: STRIPE_WEBHOOK_SECRET + scope: RUN_AND_BUILD_TIME + type: SECRET + value: EV[1:XXX:XXX] + - key: MAIL_USERNAME + scope: RUN_AND_BUILD_TIME + value: 758230001@smtp-brevo.com + - key: MAIL_PASSWORD + scope: RUN_AND_BUILD_TIME + value: XXX + - key: MAIL_FROM_ADDRESS + scope: RUN_AND_BUILD_TIME + value: hello@hi.events + - key: MAIL_FROM_NAME + scope: RUN_AND_BUILD_TIME + value: Hi.Events features: - buildpack-stack=ubuntu-22 ingress: rules: - component: - name: hi-events-frontend + name: hi-events-backend match: path: - prefix: / + prefix: /api - component: - name: hi-events-backend + name: hi-events-frontend match: path: - prefix: /api + prefix: / jobs: - dockerfile_path: /backend/Dockerfile github: branch: main deploy_on_push: true - repo: HiEventsDev/hi.events + repo: HiEventsDev/Hi.Events instance_count: 2 instance_size_slug: professional-xs kind: PRE_DEPLOY + log_destinations: + - logtail: + token: XXX + name: LogTail name: hi-events-migration run_command: php artisan migrate --force source_dir: backend @@ -173,37 +181,53 @@ services: github: branch: main deploy_on_push: true - repo: HiEventsDev/hi.events - http_port: 80 + repo: HiEventsDev/Hi.Events + http_port: 8080 instance_count: 1 instance_size_slug: professional-xs + log_destinations: + - logtail: + token: XXX + name: LogTail name: hi-events-backend source_dir: backend -static_sites: - build_command: yarn build - catchall_document: index.html environment_slug: node-js envs: - - key: VITE_API_URL - scope: BUILD_TIME - value: ${APP_URL}/api - key: VITE_STRIPE_PUBLISHABLE_KEY - scope: BUILD_TIME - value: pk_test_51Ofu1CJKnXOyGeQuDPUHiZcJxZozRuERiv4vQRBtCscwTbxOL574cxUjAoNRL2YLCumgC5160pl6kvTIiAc9mOeM0058KAWQ55 + scope: RUN_AND_BUILD_TIME + value: pk_test_XXX + - key: VITE_API_URL_SERVER + scope: RUN_AND_BUILD_TIME + value: ${APP_URL}/api + - key: VITE_API_URL_CLIENT + scope: RUN_AND_BUILD_TIME + value: ${APP_URL}/api + - key: VITE_FRONTEND_URL + scope: RUN_AND_BUILD_TIME + value: ${APP_URL} github: branch: main deploy_on_push: true - repo: HiEventsDev/hi.events + repo: HiEventsDev/Hi.Events + http_port: 5678 + instance_count: 1 + instance_size_slug: professional-xs name: hi-events-frontend + run_command: yarn start source_dir: frontend workers: - dockerfile_path: /backend/Dockerfile github: branch: main deploy_on_push: true - repo: HiEventsDev/hi.events + repo: HiEventsDev/Hi.Events instance_count: 1 instance_size_slug: professional-xs + log_destinations: + - logtail: + token: XXX + name: LogTail name: hi-events-worker run_command: php artisan queue:work - source_dir: backend \ No newline at end of file + source_dir: backend diff --git a/.do/deploy.template.yml b/.do/deploy.template.yml deleted file mode 100644 index 0996dbbf..00000000 --- a/.do/deploy.template.yml +++ /dev/null @@ -1,16 +0,0 @@ -spec: - name: hi.event - services: - - name: hi.events - dockerfile_path: Dockerfile.all-in-one - git: - repo_clone_url: https://github.com/HiEventsDev/hi.events.git - branch: master - envs: - - key: APP_KEY - scope: RUN_TIME - - key: DATABASE_URL - scope: RUN_TIME - value: ${hievents-db.DATABASE_URL} - databases: - - name: hievents-db \ No newline at end of file diff --git a/backend/app/Constants.php b/backend/app/Constants.php index c81ff7ba..a5e6e312 100644 --- a/backend/app/Constants.php +++ b/backend/app/Constants.php @@ -11,5 +11,5 @@ final class Constants * * @var int */ - public const INFINITE = 123456789101112; + public const INFINITE = PHP_INT_MAX; } diff --git a/backend/app/DomainObjects/AttributeEventDomainObject.php b/backend/app/DomainObjects/AttributeEventDomainObject.php deleted file mode 100644 index 5427d8c2..00000000 --- a/backend/app/DomainObjects/AttributeEventDomainObject.php +++ /dev/null @@ -1,7 +0,0 @@ - [ + 'asc' => __('Name A-Z'), + 'desc' => __('Name Z-A'), + ], + self::CREATED_AT => [ + 'asc' => __('Oldest first'), + 'desc' => __('Newest first'), + ], + self::UPDATED_AT => [ + 'asc' => __('Updated oldest first'), + 'desc' => __('Updated newest first'), + ], + self::USED_CAPACITY => [ + 'desc' => __('Most capacity used'), + 'asc' => __('Least capacity used'), + ], + self::CAPACITY => [ + 'desc' => __('Least capacity'), + 'asc' => __('Most capacity'), + ], + ] + ); + } + + public function getPercentageUsed(): float + { + if (!$this->getCapacity()) { + return 0; + } + + return round(($this->getUsedCapacity() / $this->getCapacity()) * 100, 2); + } + + public function getTickets(): ?Collection + { + return $this->tickets; + } + + public function setTickets(?Collection $tickets): static + { + $this->tickets = $tickets; + + return $this; + } + + public function isCapacityUnlimited(): bool + { + return is_null($this->getCapacity()); + } + + public function getAvailableCapacity(): int + { + if ($this->isCapacityUnlimited()) { + return Constants::INFINITE; + } + + return $this->getCapacity() - $this->getUsedCapacity(); + } +} diff --git a/backend/app/DomainObjects/CustomerDomainObject.php b/backend/app/DomainObjects/CustomerDomainObject.php deleted file mode 100644 index ce7dd124..00000000 --- a/backend/app/DomainObjects/CustomerDomainObject.php +++ /dev/null @@ -1,7 +0,0 @@ - $this->id ?? null, + 'event_id' => $this->event_id ?? null, + 'name' => $this->name ?? null, + 'capacity' => $this->capacity ?? null, + 'used_capacity' => $this->used_capacity ?? null, + 'applies_to' => $this->applies_to ?? null, + 'status' => $this->status ?? 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 setName(string $name): self + { + $this->name = $name; + return $this; + } + + public function getName(): string + { + return $this->name; + } + + public function setCapacity(?int $capacity): self + { + $this->capacity = $capacity; + return $this; + } + + public function getCapacity(): ?int + { + return $this->capacity; + } + + public function setUsedCapacity(int $used_capacity): self + { + $this->used_capacity = $used_capacity; + return $this; + } + + public function getUsedCapacity(): int + { + return $this->used_capacity; + } + + public function setAppliesTo(string $applies_to): self + { + $this->applies_to = $applies_to; + return $this; + } + + public function getAppliesTo(): string + { + return $this->applies_to; + } + + public function setStatus(string $status): self + { + $this->status = $status; + return $this; + } + + public function getStatus(): string + { + return $this->status; + } + + 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/OrderDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/OrderDomainObjectAbstract.php index 939ca2cd..b9d645f1 100644 --- a/backend/app/DomainObjects/Generated/OrderDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/OrderDomainObjectAbstract.php @@ -24,7 +24,6 @@ abstract class OrderDomainObjectAbstract extends \HiEvents\DomainObjects\Abstrac final public const STATUS = 'status'; final public const PAYMENT_STATUS = 'payment_status'; final public const REFUND_STATUS = 'refund_status'; - final public const RESERVED_UNTIL = 'reserved_until'; final public const IS_MANUALLY_CREATED = 'is_manually_created'; final public const SESSION_ID = 'session_id'; final public const PUBLIC_ID = 'public_id'; @@ -39,6 +38,7 @@ abstract class OrderDomainObjectAbstract extends \HiEvents\DomainObjects\Abstrac final public const TOTAL_TAX = 'total_tax'; final public const TOTAL_FEE = 'total_fee'; final public const LOCALE = 'locale'; + final public const RESERVED_UNTIL = 'reserved_until'; protected int $id; protected int $event_id; @@ -54,7 +54,6 @@ abstract class OrderDomainObjectAbstract extends \HiEvents\DomainObjects\Abstrac protected string $status; protected ?string $payment_status = null; protected ?string $refund_status = null; - protected ?string $reserved_until = null; protected bool $is_manually_created = false; protected ?string $session_id = null; protected string $public_id; @@ -69,6 +68,7 @@ abstract class OrderDomainObjectAbstract extends \HiEvents\DomainObjects\Abstrac protected float $total_tax = 0.0; protected float $total_fee = 0.0; protected string $locale = 'en'; + protected ?string $reserved_until = null; public function toArray(): array { @@ -87,7 +87,6 @@ public function toArray(): array 'status' => $this->status ?? null, 'payment_status' => $this->payment_status ?? null, 'refund_status' => $this->refund_status ?? null, - 'reserved_until' => $this->reserved_until ?? null, 'is_manually_created' => $this->is_manually_created ?? null, 'session_id' => $this->session_id ?? null, 'public_id' => $this->public_id ?? null, @@ -102,6 +101,7 @@ public function toArray(): array 'total_tax' => $this->total_tax ?? null, 'total_fee' => $this->total_fee ?? null, 'locale' => $this->locale ?? null, + 'reserved_until' => $this->reserved_until ?? null, ]; } @@ -259,17 +259,6 @@ public function getRefundStatus(): ?string return $this->refund_status; } - public function setReservedUntil(?string $reserved_until): self - { - $this->reserved_until = $reserved_until; - return $this; - } - - public function getReservedUntil(): ?string - { - return $this->reserved_until; - } - public function setIsManuallyCreated(bool $is_manually_created): self { $this->is_manually_created = $is_manually_created; @@ -423,4 +412,15 @@ public function getLocale(): string { return $this->locale; } + + public function setReservedUntil(?string $reserved_until): self + { + $this->reserved_until = $reserved_until; + return $this; + } + + public function getReservedUntil(): ?string + { + return $this->reserved_until; + } } diff --git a/backend/app/DomainObjects/Generated/TicketCapacityAssignmentDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/TicketCapacityAssignmentDomainObjectAbstract.php new file mode 100644 index 00000000..40ed8593 --- /dev/null +++ b/backend/app/DomainObjects/Generated/TicketCapacityAssignmentDomainObjectAbstract.php @@ -0,0 +1,104 @@ + $this->id ?? null, + 'ticket_id' => $this->ticket_id ?? null, + 'capacity_assignment_id' => $this->capacity_assignment_id ?? 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 setTicketId(int $ticket_id): self + { + $this->ticket_id = $ticket_id; + return $this; + } + + public function getTicketId(): int + { + return $this->ticket_id; + } + + public function setCapacityAssignmentId(int $capacity_assignment_id): self + { + $this->capacity_assignment_id = $capacity_assignment_id; + return $this; + } + + public function getCapacityAssignmentId(): int + { + return $this->capacity_assignment_id; + } + + public function setCreatedAt(?string $created_at): self + { + $this->created_at = $created_at; + return $this; + } + + public function getCreatedAt(): ?string + { + return $this->created_at; + } + + public function setUpdatedAt(?string $updated_at): self + { + $this->updated_at = $updated_at; + return $this; + } + + public function getUpdatedAt(): ?string + { + return $this->updated_at; + } + + public function setDeletedAt(?string $deleted_at): self + { + $this->deleted_at = $deleted_at; + return $this; + } + + public function getDeletedAt(): ?string + { + return $this->deleted_at; + } +} diff --git a/backend/app/DomainObjects/Generated/TicketPriceDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/TicketPriceDomainObjectAbstract.php index 1f9f33cf..c6e47272 100644 --- a/backend/app/DomainObjects/Generated/TicketPriceDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/TicketPriceDomainObjectAbstract.php @@ -23,6 +23,7 @@ abstract class TicketPriceDomainObjectAbstract extends \HiEvents\DomainObjects\A final public const QUANTITY_SOLD = 'quantity_sold'; final public const IS_HIDDEN = 'is_hidden'; final public const ORDER = 'order'; + final public const QUANTITY_AVAILABLE = 'quantity_available'; protected int $id; protected int $ticket_id; @@ -37,6 +38,7 @@ abstract class TicketPriceDomainObjectAbstract extends \HiEvents\DomainObjects\A protected int $quantity_sold = 0; protected ?bool $is_hidden = false; protected int $order = 1; + protected ?int $quantity_available = null; public function toArray(): array { @@ -54,6 +56,7 @@ public function toArray(): array 'quantity_sold' => $this->quantity_sold ?? null, 'is_hidden' => $this->is_hidden ?? null, 'order' => $this->order ?? null, + 'quantity_available' => $this->quantity_available ?? null, ]; } @@ -199,4 +202,15 @@ public function getOrder(): int { return $this->order; } + + public function setQuantityAvailable(?int $quantity_available): self + { + $this->quantity_available = $quantity_available; + return $this; + } + + public function getQuantityAvailable(): ?int + { + return $this->quantity_available; + } } diff --git a/backend/app/DomainObjects/Status/CapacityAssignmentStatus.php b/backend/app/DomainObjects/Status/CapacityAssignmentStatus.php new file mode 100644 index 00000000..0ac47e81 --- /dev/null +++ b/backend/app/DomainObjects/Status/CapacityAssignmentStatus.php @@ -0,0 +1,13 @@ +getType() === TicketType::TIERED->name && $this->getTicketPrices()->isEmpty()) { + if ($this->getType() === TicketType::TIERED->name && $this->getTicketPrices()?->isEmpty()) { return false; } @@ -185,4 +187,16 @@ public function getQuantitySold(): int { return $this->getTicketPrices()?->sum(fn(TicketPriceDomainObject $price) => $price->getQuantitySold()) ?? 0; } + + public function setOffSaleReason(?string $offSaleReason): TicketDomainObject + { + $this->offSaleReason = $offSaleReason; + + return $this; + } + + public function getOffSaleReason(): ?string + { + return $this->offSaleReason; + } } diff --git a/backend/app/DomainObjects/TicketPriceDomainObject.php b/backend/app/DomainObjects/TicketPriceDomainObject.php index 41339016..1e249eec 100644 --- a/backend/app/DomainObjects/TicketPriceDomainObject.php +++ b/backend/app/DomainObjects/TicketPriceDomainObject.php @@ -14,10 +14,10 @@ class TicketPriceDomainObject extends Generated\TicketPriceDomainObjectAbstract private ?float $feeTotal = null; - private ?int $quantityAvailable = null; - private ?bool $isAvailable = null; + private ?string $offSaleReason = null; + public function getPriceBeforeDiscount(): ?float { return $this->priceBeforeDiscount; @@ -91,25 +91,26 @@ public function isSoldOut(): bool return $this->getQuantitySold() >= $this->getInitialQuantityAvailable(); } - public function setQuantityAvailable(?int $quantityAvailable): TicketPriceDomainObject + public function isAvailable(): ?bool { - $this->quantityAvailable = $quantityAvailable; - return $this; + return $this->isAvailable; } - public function getQuantityAvailable(): ?int + public function setIsAvailable(?bool $isAvailable): TicketPriceDomainObject { - return $this->quantityAvailable; + $this->isAvailable = $isAvailable; + return $this; } - public function isAvailable(): ?bool + public function setOffSaleReason(?string $offSaleReason): TicketDomainObject { - return $this->isAvailable; + $this->offSaleReason = $offSaleReason; + + return $this; } - public function setIsAvailable(?bool $isAvailable): TicketPriceDomainObject + public function getOffSaleReason(): ?string { - $this->isAvailable = $isAvailable; - return $this; + return $this->offSaleReason; } } diff --git a/backend/app/Http/Actions/BaseAction.php b/backend/app/Http/Actions/BaseAction.php index bfd3f4b9..0a648701 100644 --- a/backend/app/Http/Actions/BaseAction.php +++ b/backend/app/Http/Actions/BaseAction.php @@ -11,6 +11,7 @@ use HiEvents\DomainObjects\Interfaces\IsSortable; use HiEvents\DomainObjects\UserDomainObject; use HiEvents\Exceptions\UnauthorizedException; +use HiEvents\Http\DTO\QueryParamsDTO; use HiEvents\Http\ResponseCodes; use HiEvents\Resources\BaseResource; use HiEvents\Services\Domain\Auth\AuthUserService; @@ -146,7 +147,7 @@ protected function getAuthenticatedAccountId(): int if (Auth::check()) { /** @var AuthUserService $service */ $service = app(AuthUserService::class); - $accountId = $service->getAuthenticatedAccountId(); + $accountId = $service->getAuthenticatedAccountId(); if ($accountId === null) { throw new UnauthorizedException(__('No account ID found in token')); @@ -191,4 +192,9 @@ public function getClientIp(Request $request): ?string return $request->getClientIp(); } + + public function getPaginationQueryParams(Request $request): QueryParamsDTO + { + return QueryParamsDTO::fromArray($request->query->all()); + } } diff --git a/backend/app/Http/Actions/CapacityAssignments/CreateCapacityAssignmentAction.php b/backend/app/Http/Actions/CapacityAssignments/CreateCapacityAssignmentAction.php new file mode 100644 index 00000000..0eae8c0d --- /dev/null +++ b/backend/app/Http/Actions/CapacityAssignments/CreateCapacityAssignmentAction.php @@ -0,0 +1,49 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + try { + $assignment = $this->createCapacityAssignmentHandler->handle( + UpsertCapacityAssignmentDTO::fromArray([ + 'name' => $request->validated('name'), + 'event_id' => $eventId, + 'capacity' => $request->validated('capacity'), + 'status' => $request->validated('status'), + 'ticket_ids' => $request->validated('ticket_ids'), + ]), + ); + } catch (UnrecognizedTicketIdException $exception) { + return $this->errorResponse( + message: $exception->getMessage(), + statusCode: Response::HTTP_UNPROCESSABLE_ENTITY, + ); + } + + return $this->resourceResponse( + resource: CapacityAssignmentResource::class, + data: $assignment, + ); + } +} diff --git a/backend/app/Http/Actions/CapacityAssignments/DeleteCapacityAssignmentAction.php b/backend/app/Http/Actions/CapacityAssignments/DeleteCapacityAssignmentAction.php new file mode 100644 index 00000000..0a11c156 --- /dev/null +++ b/backend/app/Http/Actions/CapacityAssignments/DeleteCapacityAssignmentAction.php @@ -0,0 +1,29 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $this->deleteCapacityAssignmentHandler->handle( + $capacityAssignmentId, + $eventId, + ); + + return $this->noContentResponse(); + } +} diff --git a/backend/app/Http/Actions/CapacityAssignments/GetCapacityAssignmentAction.php b/backend/app/Http/Actions/CapacityAssignments/GetCapacityAssignmentAction.php new file mode 100644 index 00000000..15b61aaa --- /dev/null +++ b/backend/app/Http/Actions/CapacityAssignments/GetCapacityAssignmentAction.php @@ -0,0 +1,31 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + return $this->resourceResponse( + resource: CapacityAssignmentResource::class, + data: $this->getCapacityAssignmentsHandler->handle( + capacityAssignmentId: $capacityAssignmentId, + eventId: $eventId, + ), + ); + } +} diff --git a/backend/app/Http/Actions/CapacityAssignments/GetCapacityAssignmentsAction.php b/backend/app/Http/Actions/CapacityAssignments/GetCapacityAssignmentsAction.php new file mode 100644 index 00000000..35ae5893 --- /dev/null +++ b/backend/app/Http/Actions/CapacityAssignments/GetCapacityAssignmentsAction.php @@ -0,0 +1,37 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + return $this->filterableResourceResponse( + resource: CapacityAssignmentResource::class, + data: $this->getCapacityAssignmentsHandler->handle( + GetCapacityAssignmentsDTO::fromArray([ + 'eventId' => $eventId, + 'queryParams' => $this->getPaginationQueryParams($request), + ]), + ), + domainObject: CapacityAssignmentDomainObject::class, + ); + } +} diff --git a/backend/app/Http/Actions/CapacityAssignments/UpdateCapacityAssignmentAction.php b/backend/app/Http/Actions/CapacityAssignments/UpdateCapacityAssignmentAction.php new file mode 100644 index 00000000..0deb55fb --- /dev/null +++ b/backend/app/Http/Actions/CapacityAssignments/UpdateCapacityAssignmentAction.php @@ -0,0 +1,51 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + try { + $assignment = $this->updateCapacityAssignmentHandler->handle( + UpsertCapacityAssignmentDTO::fromArray([ + 'id' => $capacityAssignmentId, + 'name' => $request->validated('name'), + 'event_id' => $eventId, + 'capacity' => $request->validated('capacity'), + 'applies_to' => $request->validated('applies_to'), + 'status' => $request->validated('status'), + 'ticket_ids' => $request->validated('ticket_ids'), + ]), + ); + } catch (UnrecognizedTicketIdException $exception) { + return $this->errorResponse( + message: $exception->getMessage(), + statusCode: Response::HTTP_UNPROCESSABLE_ENTITY, + ); + } + + return $this->resourceResponse( + resource: CapacityAssignmentResource::class, + data: $assignment, + ); + } +} diff --git a/backend/app/Http/Actions/Orders/CreateOrderActionPublic.php b/backend/app/Http/Actions/Orders/CreateOrderActionPublic.php index 9c1ee8d7..30bf5597 100644 --- a/backend/app/Http/Actions/Orders/CreateOrderActionPublic.php +++ b/backend/app/Http/Actions/Orders/CreateOrderActionPublic.php @@ -34,7 +34,7 @@ public function __construct( */ public function __invoke(CreateOrderRequest $request, int $eventId): JsonResponse { - $this->orderCreateRequestValidationService->validateRequest($eventId, $request->all()); + $this->orderCreateRequestValidationService->validateRequestData($eventId, $request->all()); $order = $this->orderHandler->handle( $eventId, diff --git a/backend/app/Http/Actions/Tickets/GetTicketsAction.php b/backend/app/Http/Actions/Tickets/GetTicketsAction.php index ea1d7c2f..e962de26 100644 --- a/backend/app/Http/Actions/Tickets/GetTicketsAction.php +++ b/backend/app/Http/Actions/Tickets/GetTicketsAction.php @@ -4,34 +4,30 @@ namespace HiEvents\Http\Actions\Tickets; -use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; use HiEvents\DomainObjects\EventDomainObject; -use HiEvents\DomainObjects\TaxAndFeesDomainObject; use HiEvents\DomainObjects\TicketDomainObject; -use HiEvents\DomainObjects\TicketPriceDomainObject; use HiEvents\Http\Actions\BaseAction; -use HiEvents\Http\DTO\QueryParamsDTO; -use HiEvents\Repository\Interfaces\TicketRepositoryInterface; use HiEvents\Resources\Ticket\TicketResource; +use HiEvents\Services\Handlers\Ticket\GetTicketsHandler; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; class GetTicketsAction extends BaseAction { - private TicketRepositoryInterface $ticketRepository; - - public function __construct(TicketRepositoryInterface $ticketRepository) + public function __construct( + private readonly GetTicketsHandler $getTicketsHandler, + ) { - $this->ticketRepository = $ticketRepository; } public function __invoke(int $eventId, Request $request): JsonResponse { $this->isActionAuthorized($eventId, EventDomainObject::class); - $tickets = $this->ticketRepository - ->loadRelation(TicketPriceDomainObject::class) - ->loadRelation(TaxAndFeesDomainObject::class) - ->findByEventId($eventId, QueryParamsDTO::fromArray($request->query->all())); + $tickets = $this->getTicketsHandler->handle( + eventId: $eventId, + queryParamsDTO: $this->getPaginationQueryParams($request), + ); return $this->filterableResourceResponse( resource: TicketResource::class, diff --git a/backend/app/Http/Request/CapacityAssigment/UpsertCapacityAssignmentRequest.php b/backend/app/Http/Request/CapacityAssigment/UpsertCapacityAssignmentRequest.php new file mode 100644 index 00000000..8aaea8b7 --- /dev/null +++ b/backend/app/Http/Request/CapacityAssigment/UpsertCapacityAssignmentRequest.php @@ -0,0 +1,28 @@ + RulesHelper::REQUIRED_STRING, + 'capacity' => ['nullable', 'numeric', 'min:1'], + 'status' => [Rule::in(CapacityAssignmentStatus::valuesArray())], + 'ticket_ids' => ['required', 'array'], + ]; + } + + public function messages(): array + { + return [ + 'ticket_ids.required' => __('Please select at least one ticket.'), + ]; + } +} diff --git a/backend/app/Models/CapacityAssignment.php b/backend/app/Models/CapacityAssignment.php new file mode 100644 index 00000000..7359f945 --- /dev/null +++ b/backend/app/Models/CapacityAssignment.php @@ -0,0 +1,32 @@ +belongsTo(Event::class); + } + + public function tickets(): BelongsToMany + { + return $this->belongsToMany( + related: Ticket::class, + table: 'ticket_capacity_assignments', + ); + } +} diff --git a/backend/app/Models/Event.php b/backend/app/Models/Event.php index 5fe2e610..e559180b 100644 --- a/backend/app/Models/Event.php +++ b/backend/app/Models/Event.php @@ -48,6 +48,7 @@ public static function boot() { parent::boot(); + // todo - move into a domain service static::creating( static function (Event $event) { $event->user_id = auth()->user()->id; diff --git a/backend/app/Models/Ticket.php b/backend/app/Models/Ticket.php index add6831a..1d406188 100644 --- a/backend/app/Models/Ticket.php +++ b/backend/app/Models/Ticket.php @@ -37,4 +37,9 @@ public function tax_and_fees(): BelongsToMany { return $this->belongsToMany(TaxAndFee::class, 'ticket_taxes_and_fees'); } + + public function capacity_assignments(): BelongsToMany + { + return $this->belongsToMany(CapacityAssignment::class, 'ticket_capacity_assignments'); + } } diff --git a/backend/app/Providers/RepositoryServiceProvider.php b/backend/app/Providers/RepositoryServiceProvider.php index 5fae882c..d5317967 100644 --- a/backend/app/Providers/RepositoryServiceProvider.php +++ b/backend/app/Providers/RepositoryServiceProvider.php @@ -7,6 +7,7 @@ use HiEvents\Repository\Eloquent\AccountRepository; use HiEvents\Repository\Eloquent\AccountUserRepository; use HiEvents\Repository\Eloquent\AttendeeRepository; +use HiEvents\Repository\Eloquent\CapacityAssignmentRepository; use HiEvents\Repository\Eloquent\EventDailyStatisticRepository; use HiEvents\Repository\Eloquent\EventRepository; use HiEvents\Repository\Eloquent\EventSettingsRepository; @@ -21,6 +22,7 @@ use HiEvents\Repository\Eloquent\PromoCodeRepository; use HiEvents\Repository\Eloquent\QuestionAnswerRepository; use HiEvents\Repository\Eloquent\QuestionRepository; +use HiEvents\Repository\Eloquent\ReservationRepository; use HiEvents\Repository\Eloquent\StripePaymentsRepository; use HiEvents\Repository\Eloquent\TaxAndFeeRepository; use HiEvents\Repository\Eloquent\TicketPriceRepository; @@ -29,6 +31,7 @@ use HiEvents\Repository\Interfaces\AccountRepositoryInterface; use HiEvents\Repository\Interfaces\AccountUserRepositoryInterface; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; +use HiEvents\Repository\Interfaces\CapacityAssignmentRepositoryInterface; use HiEvents\Repository\Interfaces\EventDailyStatisticRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\EventSettingsRepositoryInterface; @@ -43,6 +46,7 @@ use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface; use HiEvents\Repository\Interfaces\QuestionAnswerRepositoryInterface; use HiEvents\Repository\Interfaces\QuestionRepositoryInterface; +use HiEvents\Repository\Interfaces\ReservationRepositoryInterface; use HiEvents\Repository\Interfaces\StripePaymentsRepositoryInterface; use HiEvents\Repository\Interfaces\TaxAndFeeRepositoryInterface; use HiEvents\Repository\Interfaces\TicketPriceRepositoryInterface; @@ -78,6 +82,8 @@ class RepositoryServiceProvider extends ServiceProvider EventSettingsRepositoryInterface::class => EventSettingsRepository::class, OrganizerRepositoryInterface::class => OrganizerRepository::class, AccountUserRepositoryInterface::class => AccountUserRepository::class, + CapacityAssignmentRepositoryInterface::class => CapacityAssignmentRepository::class, + ReservationRepositoryInterface::class => ReservationRepository::class, ]; public function register(): void diff --git a/backend/app/Repository/Eloquent/AttendeeRepository.php b/backend/app/Repository/Eloquent/AttendeeRepository.php index 28b23669..87702d06 100644 --- a/backend/app/Repository/Eloquent/AttendeeRepository.php +++ b/backend/app/Repository/Eloquent/AttendeeRepository.php @@ -2,16 +2,16 @@ namespace HiEvents\Repository\Eloquent; -use Illuminate\Database\Eloquent\Builder; -use Illuminate\Pagination\LengthAwarePaginator; -use Illuminate\Support\Collection; -use Illuminate\Support\Facades\DB; use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\Generated\AttendeeDomainObjectAbstract; use HiEvents\DomainObjects\Status\OrderStatus; use HiEvents\Http\DTO\QueryParamsDTO; use HiEvents\Models\Attendee; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; class AttendeeRepository extends BaseRepository implements AttendeeRepositoryInterface { diff --git a/backend/app/Repository/Eloquent/BaseRepository.php b/backend/app/Repository/Eloquent/BaseRepository.php index 0bee98ac..da3ce5c3 100644 --- a/backend/app/Repository/Eloquent/BaseRepository.php +++ b/backend/app/Repository/Eloquent/BaseRepository.php @@ -15,7 +15,6 @@ use Illuminate\Support\Collection; use Illuminate\Support\Str; use HiEvents\DomainObjects\Interfaces\DomainObjectInterface; -use HiEvents\DomainObjects\Interfaces\HasDefaultEagerLoads; use HiEvents\Models\BaseModel; use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\RepositoryInterface; diff --git a/backend/app/Repository/Eloquent/CapacityAssignmentRepository.php b/backend/app/Repository/Eloquent/CapacityAssignmentRepository.php new file mode 100644 index 00000000..8cc1cab4 --- /dev/null +++ b/backend/app/Repository/Eloquent/CapacityAssignmentRepository.php @@ -0,0 +1,50 @@ +query)) { + $where[] = static function (Builder $builder) use ($params) { + $builder + ->where(CapacityAssignmentDomainObjectAbstract::NAME, 'ilike', '%' . $params->query . '%'); + }; + } + + $this->model = $this->model->orderBy( + $params->sort_by ?? CapacityAssignmentDomainObject::getDefaultSort(), + $params->sort_direction ?? CapacityAssignmentDomainObject::getDefaultSortDirection(), + ); + + return $this->paginateWhere( + where: $where, + limit: $params->per_page, + page: $params->page, + ); + } +} diff --git a/backend/app/Repository/Eloquent/EventRepository.php b/backend/app/Repository/Eloquent/EventRepository.php index 66f6e63c..6ed407fe 100644 --- a/backend/app/Repository/Eloquent/EventRepository.php +++ b/backend/app/Repository/Eloquent/EventRepository.php @@ -4,15 +4,15 @@ namespace HiEvents\Repository\Eloquent; -use Illuminate\Database\Eloquent\Builder; -use Illuminate\Pagination\LengthAwarePaginator; -use Illuminate\Support\Collection; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\Generated\EventDomainObjectAbstract; use HiEvents\DomainObjects\Status\OrderStatus; use HiEvents\Http\DTO\QueryParamsDTO; use HiEvents\Models\Event; use HiEvents\Repository\Interfaces\EventRepositoryInterface; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Support\Collection; class EventRepository extends BaseRepository implements EventRepositoryInterface { @@ -46,40 +46,4 @@ public function findEvents(array $where, QueryParamsDTO $params): LengthAwarePag page: $params->page, ); } - - public function getAvailableTicketQuantities(int $eventId): Collection - { - $reserved = OrderStatus::RESERVED->name; - - $query = << NOW() - AND orders.deleted_at IS NULL - GROUP BY order_items.ticket_id, order_items.ticket_price_id) AS reserved_quantities - ON ticket_prices.id = reserved_quantities.ticket_price_id - WHERE tickets.event_id = $eventId - AND tickets.deleted_at IS NULL - AND ticket_prices.deleted_at IS NULL - ORDER BY ticket_prices.id; -SQL; - - return collect($this->db->select($query)); - } } diff --git a/backend/app/Repository/Eloquent/OrderRepository.php b/backend/app/Repository/Eloquent/OrderRepository.php index c01a680a..43cec905 100644 --- a/backend/app/Repository/Eloquent/OrderRepository.php +++ b/backend/app/Repository/Eloquent/OrderRepository.php @@ -4,9 +4,6 @@ namespace HiEvents\Repository\Eloquent; -use Illuminate\Contracts\Pagination\LengthAwarePaginator; -use Illuminate\Database\Eloquent\Builder; -use Illuminate\Support\Facades\DB; use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\Generated\OrderDomainObjectAbstract; use HiEvents\DomainObjects\OrderDomainObject; @@ -16,6 +13,9 @@ use HiEvents\Models\Order; use HiEvents\Models\OrderItem; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; +use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Facades\DB; class OrderRepository extends BaseRepository implements OrderRepositoryInterface { diff --git a/backend/app/Repository/Eloquent/TicketRepository.php b/backend/app/Repository/Eloquent/TicketRepository.php index 1a6cc910..e6cc84a9 100644 --- a/backend/app/Repository/Eloquent/TicketRepository.php +++ b/backend/app/Repository/Eloquent/TicketRepository.php @@ -4,16 +4,20 @@ namespace HiEvents\Repository\Eloquent; -use Illuminate\Database\Eloquent\Builder; -use Illuminate\Pagination\LengthAwarePaginator; -use Illuminate\Support\Collection; use HiEvents\Constants; +use HiEvents\DomainObjects\CapacityAssignmentDomainObject; use HiEvents\DomainObjects\Generated\TicketDomainObjectAbstract; use HiEvents\DomainObjects\TaxAndFeesDomainObject; use HiEvents\DomainObjects\TicketDomainObject; use HiEvents\Http\DTO\QueryParamsDTO; +use HiEvents\Models\CapacityAssignment; use HiEvents\Models\Ticket; use HiEvents\Repository\Interfaces\TicketRepositoryInterface; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; +use RuntimeException; class TicketRepository extends BaseRepository implements TicketRepositoryInterface { @@ -65,7 +69,9 @@ public function getQuantityRemainingForTicketPrice(int $ticketId, int $ticketPri ) AS quantity_remaining, ticket_prices.initial_quantity_available IS NULL AS unlimited_tickets_available FROM ticket_prices - WHERE ticket_prices.id = :ticketPriceId AND ticket_prices.ticket_id = :ticketId + WHERE ticket_prices.id = :ticketPriceId + AND ticket_prices.ticket_id = :ticketId + AND ticket_prices.deleted_at IS NULL SQL; $result = $this->db->selectOne($query, [ @@ -73,6 +79,10 @@ public function getQuantityRemainingForTicketPrice(int $ticketId, int $ticketPri 'ticketId' => $ticketId ]); + if ($result === null) { + throw new RuntimeException('Ticket price not found'); + } + if ($result->unlimited_tickets_available) { return Constants::INFINITE; } @@ -87,6 +97,7 @@ public function getTaxesByTicketId(int $ticketId): Collection FROM ticket_taxes_and_fees ttf INNER JOIN taxes_and_fees tf ON tf.id = ttf.tax_and_fee_id WHERE ttf.ticket_id = :ticketId + AND tf.deleted_at IS NULL SQL; $taxAndFees = $this->db->select($query, [ @@ -103,6 +114,7 @@ public function getTicketsByTaxId(int $taxId): Collection FROM ticket_taxes_and_fees ttf INNER JOIN tickets t ON t.id = ttf.ticket_id WHERE ttf.tax_and_fee_id = :taxAndFeeId + AND t.deleted_at IS NULL SQL; $tickets = $this->model->select($query, [ @@ -112,11 +124,34 @@ public function getTicketsByTaxId(int $taxId): Collection return $this->handleResults($tickets, TicketDomainObject::class); } - public function addTaxToTicket(int $ticketId, array $taxIds): void + public function getCapacityAssignmentsByTicketId(int $ticketId): Collection + { + $capacityAssignments = CapacityAssignment::whereHas('tickets', static function($query) use ($ticketId) { + $query->where('ticket_id', $ticketId); + })->get(); + + return $this->handleResults($capacityAssignments, CapacityAssignmentDomainObject::class); + } + + public function addTaxesAndFeesToTicket(int $ticketId, array $taxIds): void { Ticket::findOrFail($ticketId)?->tax_and_fees()->sync($taxIds); } + public function addCapacityAssignmentToTickets(int $capacityAssignmentId, array $ticketIds): void + { + Ticket::whereIn('id', $ticketIds)->each(function (Ticket $ticket) use ($capacityAssignmentId) { + $ticket->capacity_assignments()->syncWithoutDetaching([$capacityAssignmentId]); + }); + } + + public function removeCapacityAssignmentFromTickets(int $capacityAssignmentId): void + { + $capacityAssignment = CapacityAssignment::find($capacityAssignmentId); + + $capacityAssignment?->tickets()->detach(); + } + public function sortTickets(int $eventId, array $orderedTicketIds): void { $parameters = [ diff --git a/backend/app/Repository/Interfaces/CapacityAssignmentRepositoryInterface.php b/backend/app/Repository/Interfaces/CapacityAssignmentRepositoryInterface.php new file mode 100644 index 00000000..ed74c0a6 --- /dev/null +++ b/backend/app/Repository/Interfaces/CapacityAssignmentRepositoryInterface.php @@ -0,0 +1,15 @@ + + */ +interface CapacityAssignmentRepositoryInterface extends RepositoryInterface +{ + public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator; +} diff --git a/backend/app/Repository/Interfaces/EventRepositoryInterface.php b/backend/app/Repository/Interfaces/EventRepositoryInterface.php index e77e035f..e353cecd 100644 --- a/backend/app/Repository/Interfaces/EventRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/EventRepositoryInterface.php @@ -15,7 +15,5 @@ */ interface EventRepositoryInterface extends RepositoryInterface { - public function getAvailableTicketQuantities(int $eventId): Collection; - public function findEvents(array $where, QueryParamsDTO $params): LengthAwarePaginator; } diff --git a/backend/app/Repository/Interfaces/OrderRepositoryInterface.php b/backend/app/Repository/Interfaces/OrderRepositoryInterface.php index 9baf7b10..d7abbbdb 100644 --- a/backend/app/Repository/Interfaces/OrderRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/OrderRepositoryInterface.php @@ -4,11 +4,11 @@ namespace HiEvents\Repository\Interfaces; -use Illuminate\Contracts\Pagination\LengthAwarePaginator; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\Http\DTO\QueryParamsDTO; use HiEvents\Repository\Eloquent\BaseRepository; +use Illuminate\Contracts\Pagination\LengthAwarePaginator; /** * @extends BaseRepository diff --git a/backend/app/Repository/Interfaces/TicketRepositoryInterface.php b/backend/app/Repository/Interfaces/TicketRepositoryInterface.php index 88fa56ad..3b4501c3 100644 --- a/backend/app/Repository/Interfaces/TicketRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/TicketRepositoryInterface.php @@ -41,12 +41,31 @@ public function getTaxesByTicketId(int $ticketId): Collection; */ public function getTicketsByTaxId(int $taxId): Collection; + /** + * @param int $ticketId + * @return Collection + */ + public function getCapacityAssignmentsByTicketId(int $ticketId): Collection; + /** * @param int $ticketId * @param array $taxIds * @return void */ - public function addTaxToTicket(int $ticketId, array $taxIds): void; + public function addTaxesAndFeesToTicket(int $ticketId, array $taxIds): void; + + /** + * @param array $ticketIds + * @param int $capacityAssignmentId + * @return void + */ + public function addCapacityAssignmentToTickets(int $capacityAssignmentId, array $ticketIds): void; + + /** + * @param int $capacityAssignmentId + * @return void + */ + public function removeCapacityAssignmentFromTickets(int $capacityAssignmentId): void; /** * @param int $eventId diff --git a/backend/app/Resources/CapacityAssignment/CapacityAssignmentResource.php b/backend/app/Resources/CapacityAssignment/CapacityAssignmentResource.php new file mode 100644 index 00000000..af6af462 --- /dev/null +++ b/backend/app/Resources/CapacityAssignment/CapacityAssignmentResource.php @@ -0,0 +1,37 @@ + $this->getId(), + 'name' => $this->getName(), + 'capacity' => $this->getCapacity(), + 'used_capacity' => $this->getUsedCapacity(), + 'percentage_used' => $this->getPercentageUsed(), + 'applies_to' => $this->getAppliesTo(), + 'status' => $this->getStatus(), + 'event_id' => $this->getEventId(), + $this->mergeWhen( + condition: $this->getTickets() !== null && $this->getAppliesTo() === CapacityAssignmentAppliesTo::TICKETS->name, + value: [ + 'tickets' => $this->getTickets()?->map(fn(TicketDomainObject $ticket) => [ + 'id' => $ticket->getId(), + 'title' => $ticket->getTitle(), + ]), + ]), + ]; + } +} diff --git a/backend/app/Resources/Ticket/TicketPriceResource.php b/backend/app/Resources/Ticket/TicketPriceResource.php index 37b7fac1..c941f00f 100644 --- a/backend/app/Resources/Ticket/TicketPriceResource.php +++ b/backend/app/Resources/Ticket/TicketPriceResource.php @@ -26,6 +26,8 @@ public function toArray(Request $request): array 'quantity_sold' => $this->getQuantitySold(), 'is_sold_out' => $this->isSoldOut(), 'is_hidden' => $this->getIsHidden(), + 'off_sale_reason' => $this->getOffSaleReason(), + 'price_including_taxes_and_fees' => $this->getPriceIncludingTaxAndServiceFee(), ]; } } diff --git a/backend/app/Resources/Ticket/TicketPriceResourcePublic.php b/backend/app/Resources/Ticket/TicketPriceResourcePublic.php index 26af5a7f..a510d64d 100644 --- a/backend/app/Resources/Ticket/TicketPriceResourcePublic.php +++ b/backend/app/Resources/Ticket/TicketPriceResourcePublic.php @@ -28,8 +28,6 @@ public function toArray(Request $request): array 'is_after_sale_end_date' => $this->isAfterSaleEndDate(), 'is_available' => $this->isAvailable(), 'is_sold_out' => $this->isSoldOut(), -// 'quantity_remaining' => $this->when($this->getShowQuantityRemaining(), $this->getQuantityAvailable()), - 'quantity_remaining' => $this->getQuantityAvailable(), ]; } diff --git a/backend/app/Services/Application/Locale/LocaleService.php b/backend/app/Services/Application/Locale/LocaleService.php index 1746ab03..674c8e19 100644 --- a/backend/app/Services/Application/Locale/LocaleService.php +++ b/backend/app/Services/Application/Locale/LocaleService.php @@ -13,7 +13,7 @@ public function __construct( { } - public function getLocaleOrDefault(string $locale): string + public function getLocaleOrDefault(?string $locale): string { $supportedLocales = Locale::getSupportedLocales(); diff --git a/backend/app/Services/Domain/CapacityAssignment/CapacityAssignmentTicketAssociationService.php b/backend/app/Services/Domain/CapacityAssignment/CapacityAssignmentTicketAssociationService.php new file mode 100644 index 00000000..c5a0f5a2 --- /dev/null +++ b/backend/app/Services/Domain/CapacityAssignment/CapacityAssignmentTicketAssociationService.php @@ -0,0 +1,53 @@ +databaseManager->transaction(function () use ($capacityAssignmentId, $ticketIds, $removePreviousAssignments) { + $this->associateTicketsWithCapacityAssignment( + capacityAssignmentId: $capacityAssignmentId, + ticketIds: $ticketIds, + removePreviousAssignments: $removePreviousAssignments, + ); + }); + } + + private function associateTicketsWithCapacityAssignment( + int $capacityAssignmentId, + ?array $ticketIds, + bool $removePreviousAssignments = true + ): void + { + if (empty($ticketIds)) { + return; + } + + if ($removePreviousAssignments) { + $this->ticketRepository->removeCapacityAssignmentFromTickets( + capacityAssignmentId: $capacityAssignmentId, + ); + } + + $this->ticketRepository->addCapacityAssignmentToTickets( + capacityAssignmentId: $capacityAssignmentId, + ticketIds: array_unique($ticketIds), + ); + } +} diff --git a/backend/app/Services/Domain/CapacityAssignment/CreateCapacityAssignmentService.php b/backend/app/Services/Domain/CapacityAssignment/CreateCapacityAssignmentService.php new file mode 100644 index 00000000..b5bb21a8 --- /dev/null +++ b/backend/app/Services/Domain/CapacityAssignment/CreateCapacityAssignmentService.php @@ -0,0 +1,74 @@ +eventTicketValidationService->validateTicketIds($ticketIds, $capacityAssignment->getEventId()); + + return $this->persistAssignmentAndAssociateTickets($capacityAssignment, $ticketIds); + } + + private function persistAssignmentAndAssociateTickets( + CapacityAssignmentDomainObject $capacityAssignment, + ?array $ticketIds, + ): CapacityAssignmentDomainObject + { + return $this->databaseManager->transaction(function () use ($capacityAssignment, $ticketIds) { + /** @var CapacityAssignmentDomainObject $capacityAssignment */ + $capacityAssignment = $this->capacityAssignmentRepository->create([ + CapacityAssignmentDomainObjectAbstract::NAME => $capacityAssignment->getName(), + CapacityAssignmentDomainObjectAbstract::EVENT_ID => $capacityAssignment->getEventId(), + CapacityAssignmentDomainObjectAbstract::CAPACITY => $capacityAssignment->getCapacity(), + CapacityAssignmentDomainObjectAbstract::APPLIES_TO => $capacityAssignment->getAppliesTo(), + CapacityAssignmentDomainObjectAbstract::STATUS => $capacityAssignment->getStatus(), + CapacityAssignmentDomainObjectAbstract::USED_CAPACITY => $this->getUsedCapacity($ticketIds), + ]); + + if ($capacityAssignment->getAppliesTo() === CapacityAssignmentAppliesTo::TICKETS->name) { + $this->capacityAssignmentTicketAssociationService->addCapacityToTickets( + capacityAssignmentId: $capacityAssignment->getId(), + ticketIds: $ticketIds, + removePreviousAssignments: false, + ); + } + + return $capacityAssignment; + }); + } + + private function getUsedCapacity(array $ticketIds): int + { + $ticketPrices = $this->ticketPriceRepository->findWhereIn('ticket_id', $ticketIds); + + return $ticketPrices->sum(fn(TicketPriceDomainObject $ticketPrice) => $ticketPrice->getQuantitySold()); + } +} diff --git a/backend/app/Services/Domain/CapacityAssignment/Exception/TicketsDoNotBelongToEventException.php b/backend/app/Services/Domain/CapacityAssignment/Exception/TicketsDoNotBelongToEventException.php new file mode 100644 index 00000000..8e2cbefb --- /dev/null +++ b/backend/app/Services/Domain/CapacityAssignment/Exception/TicketsDoNotBelongToEventException.php @@ -0,0 +1,10 @@ +eventTicketValidationService->validateTicketIds($ticketIds, $capacityAssignment->getEventId()); + } + + return $this->updateAssignmentAndAssociateTickets($capacityAssignment, $ticketIds); + } + + private function updateAssignmentAndAssociateTickets( + CapacityAssignmentDomainObject $capacityAssignment, + ?array $ticketIds + ): CapacityAssignmentDomainObject + { + return $this->databaseManager->transaction(function () use ($capacityAssignment, $ticketIds) { + /** @var CapacityAssignmentDomainObject $capacityAssignment */ + $this->capacityAssignmentRepository->updateWhere( + attributes: [ + CapacityAssignmentDomainObjectAbstract::NAME => $capacityAssignment->getName(), + CapacityAssignmentDomainObjectAbstract::EVENT_ID => $capacityAssignment->getEventId(), + CapacityAssignmentDomainObjectAbstract::CAPACITY => $capacityAssignment->getCapacity(), + CapacityAssignmentDomainObjectAbstract::APPLIES_TO => $capacityAssignment->getAppliesTo(), + CapacityAssignmentDomainObjectAbstract::STATUS => $capacityAssignment->getStatus(), + ], + where: [ + CapacityAssignmentDomainObjectAbstract::ID => $capacityAssignment->getId(), + CapacityAssignmentDomainObjectAbstract::EVENT_ID => $capacityAssignment->getEventId(), + ] + ); + + if ($capacityAssignment->getAppliesTo() === CapacityAssignmentAppliesTo::TICKETS->name) { + $this->capacityAssignmentTicketAssociationService->addCapacityToTickets( + capacityAssignmentId: $capacityAssignment->getId(), + ticketIds: $ticketIds, + ); + } + + return $capacityAssignment; + }); + } +} diff --git a/backend/app/Services/Domain/Order/OrderCancelService.php b/backend/app/Services/Domain/Order/OrderCancelService.php index c9317a32..204cf29a 100644 --- a/backend/app/Services/Domain/Order/OrderCancelService.php +++ b/backend/app/Services/Domain/Order/OrderCancelService.php @@ -11,7 +11,7 @@ use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; -use HiEvents\Services\Domain\Ticket\TicketQuantityService; +use HiEvents\Services\Domain\Ticket\TicketQuantityUpdateService; use Illuminate\Contracts\Mail\Mailer; use Illuminate\Database\DatabaseManager; use Throwable; @@ -24,7 +24,7 @@ public function __construct( private EventRepositoryInterface $eventRepository, private OrderRepositoryInterface $orderRepository, private DatabaseManager $databaseManager, - private TicketQuantityService $ticketQuantityService, + private TicketQuantityUpdateService $ticketQuantityService, ) { } @@ -75,7 +75,7 @@ private function adjustTicketQuantities(OrderDomainObject $order): void $ticketIdCountMap = $attendees->map(fn(AttendeeDomainObject $attendee) => $attendee->getTicketPriceId())->countBy(); foreach ($ticketIdCountMap as $ticketPriceId => $count) { - $this->ticketQuantityService->decreaseTicketPriceQuantitySold($ticketPriceId, $count); + $this->ticketQuantityService->decreaseQuantitySold($ticketPriceId, $count); } } diff --git a/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php b/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php index c0833551..61d80839 100644 --- a/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php +++ b/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php @@ -12,17 +12,22 @@ use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface; use HiEvents\Repository\Interfaces\TicketRepositoryInterface; +use HiEvents\Services\Domain\Ticket\AvailableTicketQuantitiesFetchService; +use HiEvents\Services\Domain\Ticket\DTO\AvailableTicketQuantitiesResponseDTO; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\ValidationException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; -readonly class OrderCreateRequestValidationService +class OrderCreateRequestValidationService { + private AvailableTicketQuantitiesResponseDTO $availableTicketQuantities; + public function __construct( - private TicketRepositoryInterface $ticketRepository, - private PromoCodeRepositoryInterface $promoCodeRepository, - private EventRepositoryInterface $eventRepository + readonly private TicketRepositoryInterface $ticketRepository, + readonly private PromoCodeRepositoryInterface $promoCodeRepository, + readonly private EventRepositoryInterface $eventRepository, + readonly private AvailableTicketQuantitiesFetchService $fetchAvailableTicketQuantitiesService, ) { } @@ -31,13 +36,21 @@ public function __construct( * @throws ValidationException * @throws Exception */ - public function validateRequest(int $eventId, array $data = []): void + public function validateRequestData(int $eventId, array $data = []): void { $this->validateTypes($data); $event = $this->eventRepository->findById($eventId); $this->validatePromoCode($eventId, $data); $this->validateTicketSelection($data); + + $this->availableTicketQuantities = $this->fetchAvailableTicketQuantitiesService + ->getAvailableTicketQuantities( + $event->getId(), + ignoreCache: true, + ); + + $this->validateOverallCapacity($data); $this->validateTicketDetails($event, $data); } @@ -139,22 +152,26 @@ private function validateSingleTicketDetails(EventDomainObject $event, int $tick ticketId: $ticketId, ticket: $ticket ); + $this->validateTicketQuantity( ticketIndex: $ticketIndex, ticketAndQuantities: $ticketAndQuantities, ticket: $ticket ); + $this->validateTicketTypeAndPrice( event: $event, ticketIndex: $ticketIndex, ticketAndQuantities: $ticketAndQuantities, ticket: $ticket ); + $this->validateSoldOutTickets( ticketId: $ticketId, ticketIndex: $ticketIndex, ticket: $ticket ); + $this->validatePriceIdAndQuantity( ticketIndex: $ticketIndex, ticketAndQuantities: $ticketAndQuantities, @@ -274,12 +291,11 @@ private function validatePriceIdAndQuantity(int $ticketIndex, array $ticketAndQu private function validateTicketPricesQuantity(array $quantities, TicketDomainObject $ticket, int $ticketIndex): void { foreach ($quantities as $ticketQuantity) { - $numberAvailable = $this->ticketRepository->getQuantityRemainingForTicketPrice( - ticketId: $ticket->getId(), - ticketPriceId: $ticketQuantity['price_id'] - ); - - $numberAvailable = max(0, $numberAvailable); + $numberAvailable = $this->availableTicketQuantities + ->ticketQuantities + ->where('ticket_id', $ticket->getId()) + ->where('price_id', $ticketQuantity['price_id']) + ->first()?->quantity_available; /** @var TicketPriceDomainObject $ticketPrice */ $ticketPrice = $ticket->getTicketPrices() @@ -303,4 +319,44 @@ private function validateTicketPricesQuantity(array $quantities, TicketDomainObj } } } + + /** + * @throws ValidationException + */ + private function validateOverallCapacity(array $data): void + { + foreach ($this->availableTicketQuantities->capacities as $capacity) { + if ($capacity->getTickets() === null) { + continue; + } + + $ticketIds = $capacity->getTickets()->map(fn(TicketDomainObject $ticket) => $ticket->getId()); + $totalQuantity = collect($data['tickets']) + ->filter(fn($ticket) => in_array($ticket['ticket_id'], $ticketIds->toArray(), true)) + ->sum(fn($ticket) => collect($ticket['quantities'])->sum('quantity')); + + $reservedTicketQuantities = $capacity->getTickets() + ->map(fn(TicketDomainObject $ticket) => $this + ->availableTicketQuantities + ->ticketQuantities + ->where('ticket_id', $ticket->getId()) + ->sum('quantity_reserved') + ) + ->sum(); + + if ($totalQuantity > ($capacity->getAvailableCapacity() - $reservedTicketQuantities)) { + if ($capacity->getAvailableCapacity() - $reservedTicketQuantities <= 0) { + throw ValidationException::withMessages([ + 'tickets' => __('Sorry, these tickets are sold out'), + ]); + } + + throw ValidationException::withMessages([ + 'tickets' => __('The maximum number of tickets available is :max', [ + 'max' => $capacity->getAvailableCapacity(), + ]), + ]); + } + } + } } diff --git a/backend/app/Services/Domain/Order/OrderManagementService.php b/backend/app/Services/Domain/Order/OrderManagementService.php index 998c110e..acfe5bc2 100644 --- a/backend/app/Services/Domain/Order/OrderManagementService.php +++ b/backend/app/Services/Domain/Order/OrderManagementService.php @@ -15,11 +15,11 @@ use Illuminate\Support\Collection; use Illuminate\Support\Str; -readonly class OrderManagementService +class OrderManagementService { public function __construct( - private OrderRepositoryInterface $orderRepository, - private TaxAndFeeOrderRollupService $taxAndFeeOrderRollupService, + readonly private OrderRepositoryInterface $orderRepository, + readonly private TaxAndFeeOrderRollupService $taxAndFeeOrderRollupService, ) { } diff --git a/backend/app/Services/Domain/Payment/Stripe/EventHandlers/PaymentIntentSucceededHandler.php b/backend/app/Services/Domain/Payment/Stripe/EventHandlers/PaymentIntentSucceededHandler.php index 96ba2ec7..8f7f37b2 100644 --- a/backend/app/Services/Domain/Payment/Stripe/EventHandlers/PaymentIntentSucceededHandler.php +++ b/backend/app/Services/Domain/Payment/Stripe/EventHandlers/PaymentIntentSucceededHandler.php @@ -56,7 +56,7 @@ public function handleEvent(PaymentIntent $paymentIntent): void $updatedOrder = $this->updateOrderStatuses($stripePayment); - $this->quantityUpdateService->updateTicketQuantities($updatedOrder); + $this->quantityUpdateService->updateQuantitiesFromOrder($updatedOrder); OrderStatusChangedEvent::dispatch($updatedOrder); }); diff --git a/backend/app/Services/Domain/Tax/TaxAndTicketAssociationService.php b/backend/app/Services/Domain/Tax/TaxAndTicketAssociationService.php index 25e3a705..1f5b643f 100644 --- a/backend/app/Services/Domain/Tax/TaxAndTicketAssociationService.php +++ b/backend/app/Services/Domain/Tax/TaxAndTicketAssociationService.php @@ -36,7 +36,7 @@ public function addTaxesToTicket(TaxAndTicketAssociateParams $params): Collectio throw new InvalidTaxOrFeeIdException(__('One or more tax IDs are invalid')); } - $this->ticketRepository->addTaxToTicket($params->ticketId, $params->taxAndFeeIds); + $this->ticketRepository->addTaxesAndFeesToTicket($params->ticketId, $params->taxAndFeeIds); return $taxesAndFees; } diff --git a/backend/app/Services/Domain/Ticket/AvailableTicketQuantitiesFetchService.php b/backend/app/Services/Domain/Ticket/AvailableTicketQuantitiesFetchService.php new file mode 100644 index 00000000..e3cca4ac --- /dev/null +++ b/backend/app/Services/Domain/Ticket/AvailableTicketQuantitiesFetchService.php @@ -0,0 +1,173 @@ +config->get('app.homepage_ticket_quantities_cache_ttl')) { + $cachedData = $this->getDataFromCache($eventId); + if ($cachedData) { + return $cachedData; + } + } + + $capacities = $this->capacityAssignmentRepository + ->loadRelation(TicketDomainObject::class) + ->findWhere([ + 'event_id' => $eventId, + 'applies_to' => CapacityAssignmentAppliesTo::TICKETS->name, + 'status' => CapacityAssignmentStatus::ACTIVE->name, + ]); + + $reservedTicketQuantities = $this->fetchReservedTicketQuantities($eventId); + $ticketCapacities = $this->calculateTicketCapacities($capacities); + + $quantities = $reservedTicketQuantities->map(function (AvailableTicketQuantitiesDTO $dto) use ($ticketCapacities) { + $ticketId = $dto->ticket_id; + if (isset($ticketCapacities[$ticketId])) { + $dto->quantity_available = min(array_merge([$dto->quantity_available], $ticketCapacities[$ticketId]->map->getAvailableCapacity()->toArray())); + $dto->capacities = $ticketCapacities[$ticketId]; + } + + return $dto; + }); + + $finalData = new AvailableTicketQuantitiesResponseDTO( + ticketQuantities: $quantities, + capacities: $capacities + ); + + if (!$ignoreCache && $this->config->get('app.homepage_ticket_quantities_cache_ttl')) { + $this->cache->put($this->getCacheKey($eventId), $finalData, $this->config->get('app.homepage_ticket_quantities_cache_ttl')); + } + + return $finalData; + } + + private function fetchReservedTicketQuantities(int $eventId): Collection + { + $result = $this->db->select(<< NOW() + AND orders.deleted_at IS NULL + THEN order_items.quantity + ELSE 0 + END + ) AS quantity_reserved + FROM tickets + JOIN ticket_prices ON tickets.id = ticket_prices.ticket_id + LEFT JOIN order_items ON order_items.ticket_id = tickets.id + AND order_items.ticket_price_id = ticket_prices.id + LEFT JOIN orders ON orders.id = order_items.order_id + AND orders.event_id = tickets.event_id + AND orders.deleted_at IS NULL + WHERE + tickets.event_id = :eventId + AND tickets.deleted_at IS NULL + AND ticket_prices.deleted_at IS NULL + GROUP BY tickets.id, ticket_prices.id + ) + SELECT + tickets.id AS ticket_id, + ticket_prices.id AS ticket_price_id, + tickets.title AS ticket_title, + ticket_prices.label AS price_label, + ticket_prices.initial_quantity_available, + ticket_prices.quantity_sold, + COALESCE( + ticket_prices.initial_quantity_available + - ticket_prices.quantity_sold + - COALESCE(reserved_quantities.quantity_reserved, 0), + 0) AS quantity_available, + COALESCE(reserved_quantities.quantity_reserved, 0) AS quantity_reserved, + CASE WHEN ticket_prices.initial_quantity_available IS NULL + THEN TRUE + ELSE FALSE + END AS unlimited_quantity_available + FROM tickets + JOIN ticket_prices ON tickets.id = ticket_prices.ticket_id + LEFT JOIN reserved_quantities ON tickets.id = reserved_quantities.ticket_id + AND ticket_prices.id = reserved_quantities.ticket_price_id + WHERE + tickets.event_id = :eventId + AND tickets.deleted_at IS NULL + AND ticket_prices.deleted_at IS NULL + GROUP BY tickets.id, ticket_prices.id, reserved_quantities.quantity_reserved; + SQL, [ + 'eventId' => $eventId, + 'reserved' => OrderStatus::RESERVED->name + ]); + + return collect($result)->map(fn($row) => AvailableTicketQuantitiesDTO::fromArray([ + 'ticket_id' => $row->ticket_id, + 'price_id' => $row->ticket_price_id, + 'ticket_title' => $row->ticket_title, + 'price_label' => $row->price_label, + 'quantity_available' => $row->unlimited_quantity_available ? Constants::INFINITE : $row->quantity_available, + 'initial_quantity_available' => $row->initial_quantity_available, + 'quantity_reserved' => $row->quantity_reserved, + 'capacities' => new Collection(), + ])); + } + + /** + * @param Collection $capacities + */ + private function calculateTicketCapacities(Collection $capacities): array + { + $ticketCapacities = []; + foreach ($capacities as $capacity) { + foreach ($capacity->getTickets() as $ticket) { + $ticketId = $ticket->getId(); + if (!isset($ticketCapacities[$ticketId])) { + $ticketCapacities[$ticketId] = collect(); + } + + $ticketCapacities[$ticketId]->push($capacity); + } + } + + return $ticketCapacities; + } + + private function getDataFromCache(int $eventId): ?AvailableTicketQuantitiesResponseDTO + { + return $this->cache->get($this->getCacheKey($eventId)); + } + + private function getCacheKey(int $eventId): string + { + return "event.$eventId.available_ticket_quantities"; + } +} diff --git a/backend/app/Services/Domain/Ticket/DTO/AvailableTicketQuantitiesDTO.php b/backend/app/Services/Domain/Ticket/DTO/AvailableTicketQuantitiesDTO.php new file mode 100644 index 00000000..2a677e48 --- /dev/null +++ b/backend/app/Services/Domain/Ticket/DTO/AvailableTicketQuantitiesDTO.php @@ -0,0 +1,22 @@ + */ + public Collection $ticketQuantities, + /** @var Collection */ + public ?Collection $capacities = null, + ) + { + } +} diff --git a/backend/app/Services/Domain/Ticket/EventTicketValidationService.php b/backend/app/Services/Domain/Ticket/EventTicketValidationService.php index e36f7c68..4ac741f0 100644 --- a/backend/app/Services/Domain/Ticket/EventTicketValidationService.php +++ b/backend/app/Services/Domain/Ticket/EventTicketValidationService.php @@ -6,10 +6,10 @@ use HiEvents\Repository\Interfaces\TicketRepositoryInterface; use HiEvents\Services\Domain\Ticket\Exception\UnrecognizedTicketIdException; -readonly class EventTicketValidationService +class EventTicketValidationService { public function __construct( - private TicketRepositoryInterface $ticketRepository, + readonly private TicketRepositoryInterface $ticketRepository, ) { } diff --git a/backend/app/Services/Domain/Ticket/TicketFilterService.php b/backend/app/Services/Domain/Ticket/TicketFilterService.php index c07ae759..f18ed90b 100644 --- a/backend/app/Services/Domain/Ticket/TicketFilterService.php +++ b/backend/app/Services/Domain/Ticket/TicketFilterService.php @@ -2,34 +2,48 @@ namespace HiEvents\Services\Domain\Ticket; -use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\Constants; use HiEvents\DomainObjects\PromoCodeDomainObject; use HiEvents\DomainObjects\TicketDomainObject; use HiEvents\DomainObjects\TicketPriceDomainObject; use HiEvents\Helper\Currency; -use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Services\Domain\Tax\TaxAndFeeCalculationService; +use HiEvents\Services\Domain\Ticket\DTO\AvailableTicketQuantitiesDTO; use Illuminate\Support\Collection; class TicketFilterService { public function __construct( - private readonly EventRepositoryInterface $eventRepository, - private readonly TaxAndFeeCalculationService $taxCalculationService, - private readonly TicketPriceService $ticketPriceService, + private readonly TaxAndFeeCalculationService $taxCalculationService, + private readonly TicketPriceService $ticketPriceService, + private readonly AvailableTicketQuantitiesFetchService $fetchAvailableTicketQuantitiesService, ) { } - public function filter(EventDomainObject $event, ?PromoCodeDomainObject $promoCode): ?Collection + /** + * @param Collection $tickets + * @param PromoCodeDomainObject|null $promoCode + * @param bool $hideSoldOutTickets + * @return Collection + */ + public function filter( + Collection $tickets, + ?PromoCodeDomainObject $promoCode = null, + bool $hideSoldOutTickets = true, + ): Collection { - $ticketQuantities = $this->eventRepository->getAvailableTicketQuantities($event->getId()); + if ($tickets->isEmpty()) { + return $tickets; + } - return $event->getTickets() - ?->map(fn(TicketDomainObject $ticket) => $this->processTicket($promoCode, $ticket, $ticketQuantities)) - ->reject(fn(TicketDomainObject $ticket) => $this->filterTicket($ticket, $promoCode)) - ->each(fn(TicketDomainObject $ticket) => $this->processTicketPrices($ticket)); + $ticketQuantities = $this->fetchAvailableTicketQuantitiesService + ->getAvailableTicketQuantities($tickets->first()->getEventId()); + return $tickets + ->map(fn(TicketDomainObject $ticket) => $this->processTicket($ticket, $ticketQuantities->ticketQuantities, $promoCode)) + ->reject(fn(TicketDomainObject $ticket) => $this->filterTicket($ticket, $promoCode, $hideSoldOutTickets)) + ->each(fn(TicketDomainObject $ticket) => $this->processTicketPrices($ticket, $hideSoldOutTickets)); } private function isHiddenByPromoCode(TicketDomainObject $ticket, ?PromoCodeDomainObject $promoCode): bool @@ -51,7 +65,17 @@ private function shouldTicketBeDiscounted(?PromoCodeDomainObject $promoCode, Tic && $promoCode->appliesToTicket($ticket); } - private function processTicket(?PromoCodeDomainObject $promoCode, TicketDomainObject $ticket, Collection $ticketQuantities): TicketDomainObject + /** + * @param PromoCodeDomainObject|null $promoCode + * @param TicketDomainObject $ticket + * @param Collection $ticketQuantities + * @return TicketDomainObject + */ + private function processTicket( + TicketDomainObject $ticket, + Collection $ticketQuantities, + ?PromoCodeDomainObject $promoCode = null, + ): TicketDomainObject { if ($this->shouldTicketBeDiscounted($promoCode, $ticket)) { $ticket->getTicketPrices()?->each(function (TicketPriceDomainObject $price) use ($ticket, $promoCode) { @@ -61,37 +85,50 @@ private function processTicket(?PromoCodeDomainObject $promoCode, TicketDomainOb } $ticket->getTicketPrices()?->map(function (TicketPriceDomainObject $price) use ($ticketQuantities) { + $availableQuantity = $ticketQuantities->where('price_id', $price->getId())->first()?->quantity_available; + $availableQuantity = $availableQuantity === Constants::INFINITE ? null : $availableQuantity; $price->setQuantityAvailable( - max($ticketQuantities->where('price_id', $price->getId())->first()?->quantity_available, 0) + max($availableQuantity, 0) ); }); return $ticket; } - private function filterTicket(TicketDomainObject $ticket, ?PromoCodeDomainObject $promoCode): bool + private function filterTicket( + TicketDomainObject $ticket, + ?PromoCodeDomainObject $promoCode = null, + bool $hideSoldOutTickets = true, + ): bool { + $hidden = false; + if ($this->isHiddenByPromoCode($ticket, $promoCode)) { - return true; + $ticket->setOffSaleReason(__('Ticket is hidden without promo code')); + $hidden = true; } if ($ticket->isSoldOut() && $ticket->getHideWhenSoldOut()) { - return true; + $ticket->setOffSaleReason(__('Ticket is sold out')); + $hidden = true; } if ($ticket->isBeforeSaleStartDate() && $ticket->getHideBeforeSaleStartDate()) { - return true; + $ticket->setOffSaleReason(__('Ticket is before sale start date')); + $hidden = true; } if ($ticket->isAfterSaleEndDate() && $ticket->getHideAfterSaleEndDate()) { - return true; + $ticket->setOffSaleReason(__('Ticket is after sale end date')); + $hidden = true; } if ($ticket->getIsHidden()) { - return true; + $ticket->setOffSaleReason(__('Ticket is hidden')); + $hidden = true; } - return false; + return $hidden && $hideSoldOutTickets; } private function processTicketPrice(TicketDomainObject $ticket, TicketPriceDomainObject $price): void @@ -106,37 +143,47 @@ private function processTicketPrice(TicketDomainObject $ticket, TicketPriceDomai $price->setIsAvailable($this->getPriceAvailability($price, $ticket)); } - private function filterTicketPrice(TicketDomainObject $ticket, TicketPriceDomainObject $price): bool + private function filterTicketPrice( + TicketDomainObject $ticket, + TicketPriceDomainObject $price, + bool $hideSoldOutTickets = true + ): bool { + $hidden = false; + if (!$ticket->isTieredType()) { return false; } if ($price->isBeforeSaleStartDate() && $ticket->getHideBeforeSaleStartDate()) { - return true; + $price->setOffSaleReason(__('Price is before sale start date')); + $hidden = true; } if ($price->isAfterSaleEndDate() && $ticket->getHideAfterSaleEndDate()) { - return true; + $price->setOffSaleReason(__('Price is after sale end date')); + $hidden = true; } if ($price->isSoldOut() && $ticket->getHideWhenSoldOut()) { - return true; + $price->setOffSaleReason(__('Price is sold out')); + $hidden = true; } if ($price->getIsHidden()) { - return true; + $price->setOffSaleReason(__('Price is hidden')); + $hidden = true; } - return false; + return $hidden && $hideSoldOutTickets; } - private function processTicketPrices(TicketDomainObject $ticket): void + private function processTicketPrices(TicketDomainObject $ticket, bool $hideSoldOutTickets = true): void { $ticket->setTicketPrices( $ticket->getTicketPrices() ?->each(fn(TicketPriceDomainObject $price) => $this->processTicketPrice($ticket, $price)) - ->reject(fn(TicketPriceDomainObject $price) => $this->filterTicketPrice($ticket, $price)) + ->reject(fn(TicketPriceDomainObject $price) => $this->filterTicketPrice($ticket, $price, $hideSoldOutTickets)) ); } diff --git a/backend/app/Services/Domain/Ticket/TicketQuantityService.php b/backend/app/Services/Domain/Ticket/TicketQuantityService.php deleted file mode 100644 index 76ccd183..00000000 --- a/backend/app/Services/Domain/Ticket/TicketQuantityService.php +++ /dev/null @@ -1,33 +0,0 @@ -ticketPriceRepository->updateWhere([ - 'quantity_sold' => DB::raw('quantity_sold + ' . $adjustment), - ], [ - 'id' => $priceId, - ]); - } - - public function decreaseTicketPriceQuantitySold(int $priceId, int $adjustment = 1): void - { - $this->ticketPriceRepository->updateWhere([ - 'quantity_sold' => DB::raw('quantity_sold - ' . $adjustment), - ], [ - 'id' => $priceId, - ]); - } -} diff --git a/backend/app/Services/Domain/Ticket/TicketQuantityUpdateService.php b/backend/app/Services/Domain/Ticket/TicketQuantityUpdateService.php index d38e210a..edfe2402 100644 --- a/backend/app/Services/Domain/Ticket/TicketQuantityUpdateService.php +++ b/backend/app/Services/Domain/Ticket/TicketQuantityUpdateService.php @@ -2,31 +2,117 @@ namespace HiEvents\Services\Domain\Ticket; -use InvalidArgumentException; -use HiEvents\DomainObjects\Generated\TicketPriceDomainObjectAbstract; +use HiEvents\DomainObjects\CapacityAssignmentDomainObject; +use HiEvents\DomainObjects\Generated\CapacityAssignmentDomainObjectAbstract; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; +use HiEvents\Repository\Interfaces\CapacityAssignmentRepositoryInterface; use HiEvents\Repository\Interfaces\TicketPriceRepositoryInterface; +use HiEvents\Repository\Interfaces\TicketRepositoryInterface; +use Illuminate\Database\DatabaseManager; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; +use InvalidArgumentException; -readonly class TicketQuantityUpdateService +class TicketQuantityUpdateService { - public function __construct(private TicketPriceRepositoryInterface $ticketPriceRepository) + public function __construct( + private readonly TicketPriceRepositoryInterface $ticketPriceRepository, + private readonly TicketRepositoryInterface $ticketRepository, + private readonly CapacityAssignmentRepositoryInterface $capacityAssignmentRepository, + private readonly DatabaseManager $databaseManager, + ) { } - public function updateTicketQuantities(OrderDomainObject $order): void + public function increaseQuantitySold(int $priceId, int $adjustment = 1): void { - if (!$order->getOrderItems() === null) { - throw new InvalidArgumentException(__('Order has no order items')); - } + $this->databaseManager->transaction(function () use ($priceId, $adjustment) { + $capacityAssignments = $this->getCapacityAssignments($priceId); + + $capacityAssignments->each(function (CapacityAssignmentDomainObjectAbstract $capacityAssignment) use ($adjustment) { + $this->increaseCapacityAssignmentUsedCapacity($capacityAssignment->getId(), $adjustment); + }); + + $this->ticketPriceRepository->updateWhere([ + 'quantity_sold' => DB::raw('quantity_sold + ' . $adjustment), + ], [ + 'id' => $priceId, + ]); + }); + } + + public function decreaseQuantitySold(int $priceId, int $adjustment = 1): void + { + $this->databaseManager->transaction(function () use ($priceId, $adjustment) { + $capacityAssignments = $this->getCapacityAssignments($priceId); + + $capacityAssignments->each(function (CapacityAssignmentDomainObjectAbstract $capacityAssignment) use ($adjustment) { + $this->decreaseCapacityAssignmentUsedCapacity($capacityAssignment->getId(), $adjustment); + }); + + $this->ticketPriceRepository->updateWhere([ + 'quantity_sold' => DB::raw('quantity_sold - ' . $adjustment), + ], [ + 'id' => $priceId, + ]); + }); + } + + /** + * @todo - this should be in a separate service. This service shouldn't know about orders + */ + public function updateQuantitiesFromOrder(OrderDomainObject $order): void + { + $this->databaseManager->transaction(function () use ($order) { + if (!$order->getOrderItems() === null) { + throw new InvalidArgumentException(__('Order has no order items')); + } + $this->updateTicketQuantities($order); + }); + } + + /** + * @param OrderDomainObject $order + * @return void + */ + private function updateTicketQuantities(OrderDomainObject $order): void + { /** @var OrderItemDomainObject $orderItem */ foreach ($order->getOrderItems() as $orderItem) { - $this->ticketPriceRepository->increment( - $orderItem->getTicketPriceId(), - TicketPriceDomainObjectAbstract::QUANTITY_SOLD, - $orderItem->getQuantity() - ); + $this->increaseQuantitySold($orderItem->getTicketPriceId(), $orderItem->getQuantity()); } } + + private function increaseCapacityAssignmentUsedCapacity(int $capacityAssignmentId, int $adjustment = 1): void + { + $this->capacityAssignmentRepository->updateWhere([ + CapacityAssignmentDomainObjectAbstract::USED_CAPACITY => DB::raw(CapacityAssignmentDomainObjectAbstract::USED_CAPACITY . ' + ' . $adjustment), + ], [ + 'id' => $capacityAssignmentId, + ]); + } + + private function decreaseCapacityAssignmentUsedCapacity(int $capacityAssignmentId, int $adjustment = 1): void + { + $this->capacityAssignmentRepository->updateWhere([ + CapacityAssignmentDomainObjectAbstract::USED_CAPACITY => DB::raw(CapacityAssignmentDomainObjectAbstract::USED_CAPACITY . ' - ' . $adjustment), + ], [ + 'id' => $capacityAssignmentId, + ]); + } + + /** + * @param int $priceId + * @return Collection + */ + private function getCapacityAssignments(int $priceId): Collection + { + $price = $this->ticketPriceRepository->findFirstWhere([ + 'id' => $priceId, + ]); + + return $this->ticketRepository->getCapacityAssignmentsByTicketId($price->getTicketId()); + } } diff --git a/backend/app/Services/Handlers/Attendee/CreateAttendeeHandler.php b/backend/app/Services/Handlers/Attendee/CreateAttendeeHandler.php index a315edb4..2bce36f0 100644 --- a/backend/app/Services/Handlers/Attendee/CreateAttendeeHandler.php +++ b/backend/app/Services/Handlers/Attendee/CreateAttendeeHandler.php @@ -26,7 +26,7 @@ use HiEvents\Repository\Interfaces\TicketRepositoryInterface; use HiEvents\Services\Domain\Order\OrderManagementService; use HiEvents\Services\Domain\Tax\TaxAndFeeRollupService; -use HiEvents\Services\Domain\Ticket\TicketQuantityService; +use HiEvents\Services\Domain\Ticket\TicketQuantityUpdateService; use HiEvents\Services\Handlers\Attendee\DTO\CreateAttendeeDTO; use HiEvents\Services\Handlers\Attendee\DTO\CreateAttendeeTaxAndFeeDTO; use Illuminate\Database\DatabaseManager; @@ -35,18 +35,18 @@ use RuntimeException; use Throwable; -readonly class CreateAttendeeHandler +class CreateAttendeeHandler { public function __construct( - private AttendeeRepositoryInterface $attendeeRepository, - private OrderRepositoryInterface $orderRepository, - private TicketRepositoryInterface $ticketRepository, - private EventRepositoryInterface $eventRepository, - private TicketQuantityService $ticketQuantityAdjustmentService, - private DatabaseManager $databaseManager, - private TaxAndFeeRepositoryInterface $taxAndFeeRepository, - private TaxAndFeeRollupService $taxAndFeeRollupService, - private OrderManagementService $orderManagementService, + private readonly AttendeeRepositoryInterface $attendeeRepository, + private readonly OrderRepositoryInterface $orderRepository, + private readonly TicketRepositoryInterface $ticketRepository, + private readonly EventRepositoryInterface $eventRepository, + private readonly TicketQuantityUpdateService $ticketQuantityAdjustmentService, + private readonly DatabaseManager $databaseManager, + private readonly TaxAndFeeRepositoryInterface $taxAndFeeRepository, + private readonly TaxAndFeeRollupService $taxAndFeeRollupService, + private readonly OrderManagementService $orderManagementService, ) { } @@ -226,7 +226,7 @@ private function createAttendee(OrderDomainObject $order, CreateAttendeeDTO $att private function fireEventsAndUpdateQuantities(CreateAttendeeDTO $attendeeDTO, OrderDomainObject $order): void { - $this->ticketQuantityAdjustmentService->increaseTicketPriceQuantitySold( + $this->ticketQuantityAdjustmentService->increaseQuantitySold( priceId: $attendeeDTO->ticket_price_id, ); diff --git a/backend/app/Services/Handlers/Attendee/EditAttendeeHandler.php b/backend/app/Services/Handlers/Attendee/EditAttendeeHandler.php index ea11affd..48f3d739 100644 --- a/backend/app/Services/Handlers/Attendee/EditAttendeeHandler.php +++ b/backend/app/Services/Handlers/Attendee/EditAttendeeHandler.php @@ -10,7 +10,7 @@ use HiEvents\Exceptions\NoTicketsAvailableException; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; use HiEvents\Repository\Interfaces\TicketRepositoryInterface; -use HiEvents\Services\Domain\Ticket\TicketQuantityService; +use HiEvents\Services\Domain\Ticket\TicketQuantityUpdateService; use HiEvents\Services\Handlers\Attendee\DTO\EditAttendeeDTO; use Illuminate\Database\DatabaseManager; use Illuminate\Validation\ValidationException; @@ -18,25 +18,13 @@ class EditAttendeeHandler { - private AttendeeRepositoryInterface $attendeeRepository; - - private TicketRepositoryInterface $ticketRepository; - - private TicketQuantityService $ticketQuantityService; - - private DatabaseManager $databaseManager; - public function __construct( - AttendeeRepositoryInterface $attendeeRepository, - TicketRepositoryInterface $ticketRepository, - TicketQuantityService $ticketQuantityService, - DatabaseManager $databaseManager, + private readonly AttendeeRepositoryInterface $attendeeRepository, + private readonly TicketRepositoryInterface $ticketRepository, + private readonly TicketQuantityUpdateService $ticketQuantityService, + private readonly DatabaseManager $databaseManager, ) { - $this->attendeeRepository = $attendeeRepository; - $this->ticketRepository = $ticketRepository; - $this->ticketQuantityService = $ticketQuantityService; - $this->databaseManager = $databaseManager; } /** @@ -59,8 +47,8 @@ public function handle(EditAttendeeDTO $editAttendeeDTO): AttendeeDomainObject private function adjustTicketQuantities(AttendeeDomainObject $attendee, EditAttendeeDTO $editAttendeeDTO): void { if ($attendee->getTicketPriceId() !== $editAttendeeDTO->ticket_price_id) { - $this->ticketQuantityService->decreaseTicketPriceQuantitySold($editAttendeeDTO->ticket_price_id); - $this->ticketQuantityService->increaseTicketPriceQuantitySold($attendee->getTicketPriceId()); + $this->ticketQuantityService->decreaseQuantitySold($editAttendeeDTO->ticket_price_id); + $this->ticketQuantityService->increaseQuantitySold($attendee->getTicketPriceId()); } } diff --git a/backend/app/Services/Handlers/Attendee/PartialEditAttendeeHandler.php b/backend/app/Services/Handlers/Attendee/PartialEditAttendeeHandler.php index 51c77f1a..5717bddf 100644 --- a/backend/app/Services/Handlers/Attendee/PartialEditAttendeeHandler.php +++ b/backend/app/Services/Handlers/Attendee/PartialEditAttendeeHandler.php @@ -3,8 +3,9 @@ namespace HiEvents\Services\Handlers\Attendee; use HiEvents\DomainObjects\AttendeeDomainObject; +use HiEvents\DomainObjects\Status\AttendeeStatus; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; -use HiEvents\Services\Domain\Ticket\TicketQuantityService; +use HiEvents\Services\Domain\Ticket\TicketQuantityUpdateService; use HiEvents\Services\Handlers\Attendee\DTO\PartialEditAttendeeDTO; use Illuminate\Database\DatabaseManager; use Symfony\Component\Routing\Exception\ResourceNotFoundException; @@ -14,7 +15,7 @@ class PartialEditAttendeeHandler { public function __construct( private readonly AttendeeRepositoryInterface $attendeeRepository, - private readonly TicketQuantityService $ticketQuantityService, + private readonly TicketQuantityUpdateService $ticketQuantityService, private readonly DatabaseManager $databaseManager ) { @@ -41,10 +42,8 @@ private function updateAttendee(PartialEditAttendeeDTO $data): AttendeeDomainObj throw new ResourceNotFoundException(); } - //if status has changed, adjust ticket quantity if ($data->status && $data->status !== $attendee->getStatus()) { - $this->ticketQuantityService->decreaseTicketPriceQuantitySold($attendee->getTicketPriceId()); - $this->ticketQuantityService->increaseTicketPriceQuantitySold($attendee->getTicketPriceId()); + $this->adjustTicketQuantity($data, $attendee); } return $this->attendeeRepository->updateByIdWhere( @@ -61,4 +60,16 @@ private function updateAttendee(PartialEditAttendeeDTO $data): AttendeeDomainObj 'event_id' => $data->event_id, ]); } + + /** + * @todo - we should check ticket availability before updating the ticket quantity + */ + private function adjustTicketQuantity(PartialEditAttendeeDTO $data, AttendeeDomainObject $attendee): void + { + if ($data->status === AttendeeStatus::ACTIVE->name) { + $this->ticketQuantityService->increaseQuantitySold($attendee->getTicketPriceId()); + } elseif ($data->status === AttendeeStatus::CANCELLED->name) { + $this->ticketQuantityService->decreaseQuantitySold($attendee->getTicketPriceId()); + } + } } diff --git a/backend/app/Services/Handlers/CapacityAssignment/CreateCapacityAssignmentHandler.php b/backend/app/Services/Handlers/CapacityAssignment/CreateCapacityAssignmentHandler.php new file mode 100644 index 00000000..bfa77f47 --- /dev/null +++ b/backend/app/Services/Handlers/CapacityAssignment/CreateCapacityAssignmentHandler.php @@ -0,0 +1,36 @@ +setName($data->name) + ->setEventId($data->event_id) + ->setCapacity($data->capacity) + ->setAppliesTo(CapacityAssignmentAppliesTo::TICKETS->name) + ->setStatus($data->status->name); + + return $this->createCapacityAssignmentService->createCapacityAssignment( + $capacityAssignment, + $data->ticket_ids, + ); + } +} diff --git a/backend/app/Services/Handlers/CapacityAssignment/DTO/GetCapacityAssignmentsDTO.php b/backend/app/Services/Handlers/CapacityAssignment/DTO/GetCapacityAssignmentsDTO.php new file mode 100644 index 00000000..7cc7da5e --- /dev/null +++ b/backend/app/Services/Handlers/CapacityAssignment/DTO/GetCapacityAssignmentsDTO.php @@ -0,0 +1,16 @@ +databaseManager->transaction(function () use ($id, $eventId) { + $this->ticketRepository->removeCapacityAssignmentFromTickets( + capacityAssignmentId: $id, + ); + + $this->capacityAssignmentRepository->deleteWhere([ + 'id' => $id, + 'event_id' => $eventId, + ]); + }); + } +} diff --git a/backend/app/Services/Handlers/CapacityAssignment/GetCapacityAssignmentHandler.php b/backend/app/Services/Handlers/CapacityAssignment/GetCapacityAssignmentHandler.php new file mode 100644 index 00000000..cbdf770f --- /dev/null +++ b/backend/app/Services/Handlers/CapacityAssignment/GetCapacityAssignmentHandler.php @@ -0,0 +1,33 @@ +capacityAssignmentRepository + ->loadRelation(TicketDomainObject::class) + ->findFirstWhere([ + 'event_id' => $eventId, + 'id' => $capacityAssignmentId, + ]); + + if ($capacityAssignment === null) { + throw new ResourceNotFoundException('Capacity assignment not found'); + } + + return $capacityAssignment; + } +} diff --git a/backend/app/Services/Handlers/CapacityAssignment/GetCapacityAssignmentsHandler.php b/backend/app/Services/Handlers/CapacityAssignment/GetCapacityAssignmentsHandler.php new file mode 100644 index 00000000..e4d0434b --- /dev/null +++ b/backend/app/Services/Handlers/CapacityAssignment/GetCapacityAssignmentsHandler.php @@ -0,0 +1,27 @@ +capacityAssignmentRepository + ->loadRelation(TicketDomainObject::class) + ->findByEventId( + eventId: $dto->eventId, + params: $dto->queryParams, + ); + } +} diff --git a/backend/app/Services/Handlers/CapacityAssignment/UpdateCapacityAssignmentHandler.php b/backend/app/Services/Handlers/CapacityAssignment/UpdateCapacityAssignmentHandler.php new file mode 100644 index 00000000..9c330d85 --- /dev/null +++ b/backend/app/Services/Handlers/CapacityAssignment/UpdateCapacityAssignmentHandler.php @@ -0,0 +1,37 @@ +setId($data->id) + ->setName($data->name) + ->setEventId($data->event_id) + ->setCapacity($data->capacity) + ->setAppliesTo(CapacityAssignmentAppliesTo::TICKETS->name) + ->setStatus($data->status->name); + + return $this->updateCapacityAssignmentService->updateCapacityAssignment( + $capacityAssignment, + $data->ticket_ids, + ); + } +} diff --git a/backend/app/Services/Handlers/Event/GetPublicEventHandler.php b/backend/app/Services/Handlers/Event/GetPublicEventHandler.php index 92f1a178..888f6c92 100644 --- a/backend/app/Services/Handlers/Event/GetPublicEventHandler.php +++ b/backend/app/Services/Handlers/Event/GetPublicEventHandler.php @@ -17,13 +17,13 @@ use HiEvents\Services\Domain\Ticket\TicketFilterService; use HiEvents\Services\Handlers\Event\DTO\GetPublicEventDTO; -readonly class GetPublicEventHandler +class GetPublicEventHandler { public function __construct( - private EventRepositoryInterface $eventRepository, - private PromoCodeRepositoryInterface $promoCodeRepository, - private TicketFilterService $ticketFilterService, - private EventPageViewIncrementService $eventPageViewIncrementService, + private readonly EventRepositoryInterface $eventRepository, + private readonly PromoCodeRepositoryInterface $promoCodeRepository, + private readonly TicketFilterService $ticketFilterService, + private readonly EventPageViewIncrementService $eventPageViewIncrementService, ) { } @@ -55,6 +55,6 @@ public function handle(GetPublicEventDTO $data): EventDomainObject $this->eventPageViewIncrementService->increment($data->eventId, $data->ipAddress); } - return $event->setTickets($this->ticketFilterService->filter($event, $promoCodeDomainObject)); + return $event->setTickets($this->ticketFilterService->filter($event->getTickets(), $promoCodeDomainObject)); } } diff --git a/backend/app/Services/Handlers/Order/CompleteOrderHandler.php b/backend/app/Services/Handlers/Order/CompleteOrderHandler.php index d68363b5..bfe94f6b 100644 --- a/backend/app/Services/Handlers/Order/CompleteOrderHandler.php +++ b/backend/app/Services/Handlers/Order/CompleteOrderHandler.php @@ -74,7 +74,7 @@ public function handle(string $orderShortId, CompleteOrderDTO $orderData): Order * @see PaymentIntentSucceededHandler */ if (!$order->isPaymentRequired()) { - $this->ticketQuantityUpdateService->updateTicketQuantities($updatedOrder); + $this->ticketQuantityUpdateService->updateQuantitiesFromOrder($updatedOrder); } OrderStatusChangedEvent::dispatch($updatedOrder); diff --git a/backend/app/Services/Handlers/Ticket/GetTicketsHandler.php b/backend/app/Services/Handlers/Ticket/GetTicketsHandler.php new file mode 100644 index 00000000..898e952d --- /dev/null +++ b/backend/app/Services/Handlers/Ticket/GetTicketsHandler.php @@ -0,0 +1,37 @@ +ticketRepository + ->loadRelation(TicketPriceDomainObject::class) + ->loadRelation(TaxAndFeesDomainObject::class) + ->findByEventId($eventId, $queryParamsDTO); + + $filteredTickets = $this->ticketFilterService->filter( + tickets: $ticketPaginator->getCollection(), + hideSoldOutTickets: false, + ); + + $ticketPaginator->setCollection($filteredTickets); + + return $ticketPaginator; + } +} diff --git a/backend/composer.json b/backend/composer.json index a4cd71d9..c2f373bf 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -25,6 +25,7 @@ }, "require-dev": { "fakerphp/faker": "^1.9.1", + "gettext/gettext": "^5.7", "laravel/pint": "^1.0", "laravel/sail": "^1.22", "mockery/mockery": "^1.4.4", diff --git a/backend/composer.lock b/backend/composer.lock index 731cf7d6..ea65c93a 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "43724230ba59b855b143ce8ccfb1ab91", + "content-hash": "da22882240b65224977eabdf78856ddd", "packages": [ { "name": "aws/aws-crt-php", @@ -7889,6 +7889,154 @@ ], "time": "2023-11-03T12:00:00+00:00" }, + { + "name": "gettext/gettext", + "version": "v5.7.1", + "source": { + "type": "git", + "url": "https://github.com/php-gettext/Gettext.git", + "reference": "a9f89e0cc9d9a67b422632b594b5f1afb16eccfc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-gettext/Gettext/zipball/a9f89e0cc9d9a67b422632b594b5f1afb16eccfc", + "reference": "a9f89e0cc9d9a67b422632b594b5f1afb16eccfc", + "shasum": "" + }, + "require": { + "gettext/languages": "^2.3", + "php": "^7.2|^8.0" + }, + "require-dev": { + "brick/varexporter": "^0.3.5", + "friendsofphp/php-cs-fixer": "^3.2", + "oscarotero/php-cs-fixer-config": "^2.0", + "phpunit/phpunit": "^8.0|^9.0", + "squizlabs/php_codesniffer": "^3.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Gettext\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oscar Otero", + "email": "oom@oscarotero.com", + "homepage": "http://oscarotero.com", + "role": "Developer" + } + ], + "description": "PHP gettext manager", + "homepage": "https://github.com/php-gettext/Gettext", + "keywords": [ + "JS", + "gettext", + "i18n", + "mo", + "po", + "translation" + ], + "support": { + "email": "oom@oscarotero.com", + "issues": "https://github.com/php-gettext/Gettext/issues", + "source": "https://github.com/php-gettext/Gettext/tree/v5.7.1" + }, + "funding": [ + { + "url": "https://paypal.me/oscarotero", + "type": "custom" + }, + { + "url": "https://github.com/oscarotero", + "type": "github" + }, + { + "url": "https://www.patreon.com/misteroom", + "type": "patreon" + } + ], + "time": "2024-07-24T22:05:18+00:00" + }, + { + "name": "gettext/languages", + "version": "2.10.0", + "source": { + "type": "git", + "url": "https://github.com/php-gettext/Languages.git", + "reference": "4d61d67fe83a2ad85959fe6133d6d9ba7dddd1ab" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-gettext/Languages/zipball/4d61d67fe83a2ad85959fe6133d6d9ba7dddd1ab", + "reference": "4d61d67fe83a2ad85959fe6133d6d9ba7dddd1ab", + "shasum": "" + }, + "require": { + "php": ">=5.3" + }, + "require-dev": { + "phpunit/phpunit": "^4.8 || ^5.7 || ^6.5 || ^7.5 || ^8.4" + }, + "bin": [ + "bin/export-plural-rules" + ], + "type": "library", + "autoload": { + "psr-4": { + "Gettext\\Languages\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michele Locati", + "email": "mlocati@gmail.com", + "role": "Developer" + } + ], + "description": "gettext languages with plural rules", + "homepage": "https://github.com/php-gettext/Languages", + "keywords": [ + "cldr", + "i18n", + "internationalization", + "l10n", + "language", + "languages", + "localization", + "php", + "plural", + "plural rules", + "plurals", + "translate", + "translations", + "unicode" + ], + "support": { + "issues": "https://github.com/php-gettext/Languages/issues", + "source": "https://github.com/php-gettext/Languages/tree/2.10.0" + }, + "funding": [ + { + "url": "https://paypal.me/mlocati", + "type": "custom" + }, + { + "url": "https://github.com/mlocati", + "type": "github" + } + ], + "time": "2022-10-18T15:00:10+00:00" + }, { "name": "hamcrest/hamcrest-php", "version": "v2.0.1", diff --git a/backend/config/app.php b/backend/config/app.php index b570f80d..3e61b362 100644 --- a/backend/config/app.php +++ b/backend/config/app.php @@ -22,6 +22,14 @@ */ 'homepage_views_update_batch_size' => env('APP_HOMEPAGE_VIEWS_UPDATE_BATCH_SIZE', 8), + /** + * The number of seconds to cache the ticket quantities on the homepage + * It is recommended to cache this value for a short period of time for high traffic sites + * + * Set to null to disable caching + */ + 'homepage_ticket_quantities_cache_ttl' => env('APP_HOMEPAGE_TICKET_QUANTITIES_CACHE_TTL', 2), + 'frontend_urls' => [ 'confirm_email_address' => '/manage/profile/confirm-email-address/%s', 'reset_password' => '/auth/reset-password/%s', diff --git a/backend/database/migrations/2024_07_14_031511_create_capacity_assignments_and_associated_tables.php b/backend/database/migrations/2024_07_14_031511_create_capacity_assignments_and_associated_tables.php new file mode 100644 index 00000000..77839e52 --- /dev/null +++ b/backend/database/migrations/2024_07_14_031511_create_capacity_assignments_and_associated_tables.php @@ -0,0 +1,55 @@ +id(); + $table->foreignId('event_id')->constrained('events')->onDelete('cascade'); + $table->string('name'); + $table->integer('capacity')->nullable()->default(null); + $table->integer('used_capacity')->default(0); + $table->string('applies_to')->default(CapacityAssignmentAppliesTo::EVENT->name); + $table->string('status')->default(CapacityAssignmentStatus::ACTIVE->name); + $table->timestamps(); + $table->softDeletes(); + + $table->index('event_id'); + $table->index('applies_to'); + $table->index('status'); + }); + + Schema::create('ticket_capacity_assignments', function (Blueprint $table) { + $table->id(); + $table->foreignId('ticket_id')->constrained('tickets')->onDelete('cascade'); + $table->foreignId('capacity_assignment_id')->constrained('capacity_assignments')->onDelete('cascade'); + $table->timestamps(); + $table->softDeletes(); + + $table->index('ticket_id'); + $table->index('capacity_assignment_id'); + + $table->unique(['ticket_id', 'capacity_assignment_id']); + }); + + Schema::table('ticket_prices', function (Blueprint $table) { + $table->integer('quantity_available')->default(null)->nullable(); + }); + } + + public function down(): void + { + Schema::table('ticket_prices', function (Blueprint $table) { + $table->dropColumn('quantity_available'); + }); + + Schema::dropIfExists('ticket_capacity_assignments'); + Schema::dropIfExists('capacity_assignments'); + } +}; diff --git a/backend/database/migrations/2024_07_19_033929_add_missing_indexes.php b/backend/database/migrations/2024_07_19_033929_add_missing_indexes.php new file mode 100644 index 00000000..897f64a0 --- /dev/null +++ b/backend/database/migrations/2024_07_19_033929_add_missing_indexes.php @@ -0,0 +1,21 @@ +index(['event_id', 'status', 'reserved_until', 'deleted_at']); + }); + } + + public function down(): void + { + Schema::table('orders', function (Blueprint $table) { + $table->dropIndex(['event_id', 'status', 'reserved_until', 'deleted_at']); + }); + } +}; diff --git a/backend/lang/de.json b/backend/lang/de.json index 06a6978e..651f0b86 100644 --- a/backend/lang/de.json +++ b/backend/lang/de.json @@ -249,5 +249,24 @@ "Sent Date Oldest": "Gesendet Datum Älteste", "Sent Date Newest": "Gesendet Datum Neueste", "Subject A-Z": "Betreff A-Z", - "Subject Z-A": "Betreff Z-A" + "Subject Z-A": "Betreff Z-A", + "Name A-Z": "Name A-Z", + "Name Z-A": "Name Z-A", + "Updated oldest first": "Aktualisiert älteste zuerst", + "Updated newest first": "Aktualisiert neueste zuerst", + "Most capacity used": "Meiste Kapazität genutzt", + "Least capacity used": "Wenigste Kapazität genutzt", + "Least capacity": "Wenigste Kapazität", + "Most capacity": "Meiste Kapazität", + "Sorry, these tickets are sold out": "Entschuldigung, diese Tickets sind ausverkauft", + "The maximum number of tickets available is :max": "Die maximale Anzahl verfügbarer Tickets ist :max", + "Ticket is hidden without promo code": "Ticket ist ohne Promo-Code versteckt", + "Ticket is sold out": "Ticket ist ausverkauft", + "Ticket is before sale start date": "Ticket ist vor dem Verkaufsstartdatum", + "Ticket is after sale end date": "Ticket ist nach dem Verkaufsenddatum", + "Ticket is hidden": "Ticket ist versteckt", + "Price is before sale start date": "Preis ist vor dem Verkaufsstartdatum", + "Price is after sale end date": "Preis ist nach dem Verkaufsenddatum", + "Price is sold out": "Preis ist ausverkauft", + "Price is hidden": "Preis ist versteckt" } diff --git a/backend/lang/es.json b/backend/lang/es.json index 95c8667d..1ee27f6f 100644 --- a/backend/lang/es.json +++ b/backend/lang/es.json @@ -249,5 +249,24 @@ "Sent Date Oldest": "Fecha de Envío Más Antigua", "Sent Date Newest": "Fecha de Envío Más Reciente", "Subject A-Z": "Asunto A-Z", - "Subject Z-A": "Asunto Z-A" + "Subject Z-A": "Asunto Z-A", + "Name A-Z": "Nombre A-Z", + "Name Z-A": "Nombre Z-A", + "Updated oldest first": "Actualizado del más antiguo al más reciente", + "Updated newest first": "Actualizado del más reciente al más antiguo", + "Most capacity used": "Mayor capacidad utilizada", + "Least capacity used": "Menor capacidad utilizada", + "Least capacity": "Menor capacidad", + "Most capacity": "Mayor capacidad", + "Sorry, these tickets are sold out": "Lo sentimos, estas entradas están agotadas", + "The maximum number of tickets available is :max": "El número máximo de entradas disponibles es :max", + "Ticket is hidden without promo code": "El boleto está oculto sin código promocional", + "Ticket is sold out": "El boleto está agotado", + "Ticket is before sale start date": "El boleto es antes de la fecha de inicio de la venta", + "Ticket is after sale end date": "El boleto es después de la fecha de finalización de la venta", + "Ticket is hidden": "El boleto está oculto", + "Price is before sale start date": "El precio es antes de la fecha de inicio de la venta", + "Price is after sale end date": "El precio es después de la fecha de finalización de la venta", + "Price is sold out": "El precio está agotado", + "Price is hidden": "El precio está oculto" } diff --git a/backend/lang/fr.json b/backend/lang/fr.json index 83b2a817..2f3ceaf6 100644 --- a/backend/lang/fr.json +++ b/backend/lang/fr.json @@ -249,5 +249,24 @@ "Sent Date Oldest": "Date d'Envoi la Plus Ancienne", "Sent Date Newest": "Date d'Envoi la Plus Récente", "Subject A-Z": "Objet A-Z", - "Subject Z-A": "Objet Z-A" + "Subject Z-A": "Objet Z-A", + "Name A-Z": "Nom A-Z", + "Name Z-A": "Nom Z-A", + "Updated oldest first": "Mis à jour du plus ancien au plus récent", + "Updated newest first": "Mis à jour du plus récent au plus ancien", + "Most capacity used": "Capacité la plus utilisée", + "Least capacity used": "Capacité la moins utilisée", + "Least capacity": "Capacité la plus faible", + "Most capacity": "Capacité la plus élevée", + "Sorry, these tickets are sold out": "Désolé, ces billets sont épuisés", + "The maximum number of tickets available is :max": "Le nombre maximum de billets disponibles est :max", + "Ticket is hidden without promo code": "Le billet est caché sans code promo", + "Ticket is sold out": "Le billet est épuisé", + "Ticket is before sale start date": "Le billet est avant la date de début de la vente", + "Ticket is after sale end date": "Le billet est après la date de fin de la vente", + "Ticket is hidden": "Le billet est caché", + "Price is before sale start date": "Le prix est avant la date de début de la vente", + "Price is after sale end date": "Le prix est après la date de fin de la vente", + "Price is sold out": "Le prix est épuisé", + "Price is hidden": "Le prix est caché" } diff --git a/backend/lang/pt-br.json b/backend/lang/pt-br.json index 909a1f28..1e90d39f 100644 --- a/backend/lang/pt-br.json +++ b/backend/lang/pt-br.json @@ -249,5 +249,24 @@ "Sent Date Oldest": "Data de Envio Mais Antiga", "Sent Date Newest": "Data de Envio Mais Recente", "Subject A-Z": "Assunto A-Z", - "Subject Z-A": "Assunto Z-A" + "Subject Z-A": "Assunto Z-A", + "Name A-Z": "Nome A-Z", + "Name Z-A": "Nome Z-A", + "Updated oldest first": "Atualizado do mais antigo para o mais recente", + "Updated newest first": "Atualizado do mais recente para o mais antigo", + "Most capacity used": "Maior capacidade utilizada", + "Least capacity used": "Menor capacidade utilizada", + "Least capacity": "Menor capacidade", + "Most capacity": "Maior capacidade", + "Sorry, these tickets are sold out": "Desculpe, esses ingressos estão esgotados", + "The maximum number of tickets available is :max": "O número máximo de ingressos disponíveis é :max", + "Ticket is hidden without promo code": "O ingresso está oculto sem código promocional", + "Ticket is sold out": "O ingresso está esgotado", + "Ticket is before sale start date": "O ingresso é antes da data de início da venda", + "Ticket is after sale end date": "O ingresso é após a data de término da venda", + "Ticket is hidden": "O ingresso está oculto", + "Price is before sale start date": "O preço é antes da data de início da venda", + "Price is after sale end date": "O preço é após a data de término da venda", + "Price is sold out": "O preço está esgotado", + "Price is hidden": "O preço está oculto" } diff --git a/backend/lang/pt.json b/backend/lang/pt.json index 09f56102..5b60a2a3 100644 --- a/backend/lang/pt.json +++ b/backend/lang/pt.json @@ -249,5 +249,24 @@ "Sent Date Oldest": "Data de Envio Mais Antiga", "Sent Date Newest": "Data de Envio Mais Recente", "Subject A-Z": "Assunto A-Z", - "Subject Z-A": "Assunto Z-A" + "Subject Z-A": "Assunto Z-A", + "Name A-Z": "Nome A-Z", + "Name Z-A": "Nome Z-A", + "Updated oldest first": "Atualizado do mais antigo para o mais recente", + "Updated newest first": "Atualizado do mais recente para o mais antigo", + "Most capacity used": "Maior capacidade utilizada", + "Least capacity used": "Menor capacidade utilizada", + "Least capacity": "Menor capacidade", + "Most capacity": "Maior capacidade", + "Sorry, these tickets are sold out": "Desculpe, esses ingressos estão esgotados", + "The maximum number of tickets available is :max": "O número máximo de ingressos disponíveis é :max", + "Ticket is hidden without promo code": "O ingresso está oculto sem código promocional", + "Ticket is sold out": "O ingresso está esgotado", + "Ticket is before sale start date": "O ingresso é antes da data de início da venda", + "Ticket is after sale end date": "O ingresso é após a data de término da venda", + "Ticket is hidden": "O ingresso está oculto", + "Price is before sale start date": "O preço é antes da data de início da venda", + "Price is after sale end date": "O preço é após a data de término da venda", + "Price is sold out": "O preço está esgotado", + "Price is hidden": "O preço está oculto" } diff --git a/backend/lang/ru.json b/backend/lang/ru.json index a043a270..4df9b1c6 100644 --- a/backend/lang/ru.json +++ b/backend/lang/ru.json @@ -264,5 +264,24 @@ "Sent Date Newest": "", "Subject A-Z": "", "Subject Z-A": "", - "There are no tickets available. If you would like to assign this ticket to this attendee, please adjust the ticket\\'s available quantity.": "" + "There are no tickets available. If you would like to assign this ticket to this attendee, please adjust the ticket\\'s available quantity.": "", + "Name A-Z": "", + "Name Z-A": "", + "Updated oldest first": "", + "Updated newest first": "", + "Most capacity used": "", + "Least capacity used": "", + "Least capacity": "", + "Most capacity": "", + "Sorry, these tickets are sold out": "", + "The maximum number of tickets available is :max": "", + "Ticket is hidden without promo code": "", + "Ticket is sold out": "", + "Ticket is before sale start date": "", + "Ticket is after sale end date": "", + "Ticket is hidden": "", + "Price is before sale start date": "", + "Price is after sale end date": "", + "Price is sold out": "", + "Price is hidden": "" } \ No newline at end of file diff --git a/backend/lang/zh-cn.json b/backend/lang/zh-cn.json index 2feacde2..96013b68 100644 --- a/backend/lang/zh-cn.json +++ b/backend/lang/zh-cn.json @@ -249,5 +249,24 @@ "Sent Date Oldest": "发送日期最早", "Sent Date Newest": "发送日期最新", "Subject A-Z": "主题 A-Z", - "Subject Z-A": "主题 Z-A" + "Subject Z-A": "主题 Z-A", + "Name A-Z": "名称 A-Z", + "Name Z-A": "名称 Z-A", + "Updated oldest first": "更新从旧到新", + "Updated newest first": "更新从新到旧", + "Most capacity used": "使用最多的容量", + "Least capacity used": "使用最少的容量", + "Least capacity": "最小容量", + "Most capacity": "最大容量", + "Sorry, these tickets are sold out": "对不起,这些票已售完", + "The maximum number of tickets available is :max": "可用的最大票数是 :max", + "Ticket is hidden without promo code": "没有促销代码时票被隐藏", + "Ticket is sold out": "票已售完", + "Ticket is before sale start date": "票在销售开始日期之前", + "Ticket is after sale end date": "票在销售结束日期之后", + "Ticket is hidden": "票被隐藏", + "Price is before sale start date": "价格在销售开始日期之前", + "Price is after sale end date": "价格在销售结束日期之后", + "Price is sold out": "价格已售完", + "Price is hidden": "价格被隐藏" } diff --git a/backend/routes/api.php b/backend/routes/api.php index 5cf492ba..8133e0aa 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -21,6 +21,11 @@ use HiEvents\Http\Actions\Auth\RefreshTokenAction; use HiEvents\Http\Actions\Auth\ResetPasswordAction; use HiEvents\Http\Actions\Auth\ValidateResetPasswordTokenAction; +use HiEvents\Http\Actions\CapacityAssignments\CreateCapacityAssignmentAction; +use HiEvents\Http\Actions\CapacityAssignments\DeleteCapacityAssignmentAction; +use HiEvents\Http\Actions\CapacityAssignments\GetCapacityAssignmentAction; +use HiEvents\Http\Actions\CapacityAssignments\GetCapacityAssignmentsAction; +use HiEvents\Http\Actions\CapacityAssignments\UpdateCapacityAssignmentAction; use HiEvents\Http\Actions\Common\Webhooks\StripeIncomingWebhookAction; use HiEvents\Http\Actions\Events\CreateEventAction; use HiEvents\Http\Actions\Events\DuplicateEventAction; @@ -207,6 +212,12 @@ function (Router $router): void { $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->post('/events/{event_id}/capacity-assignments', CreateCapacityAssignmentAction::class); + $router->get('/events/{event_id}/capacity-assignments', GetCapacityAssignmentsAction::class); + $router->get('/events/{event_id}/capacity-assignments/{capacity_assignment_id}', GetCapacityAssignmentAction::class); + $router->put('/events/{event_id}/capacity-assignments/{capacity_assignment_id}', UpdateCapacityAssignmentAction::class); + $router->delete('/events/{event_id}/capacity-assignments/{capacity_assignment_id}', DeleteCapacityAssignmentAction::class); } ); diff --git a/backend/tests/Unit/Services/Domain/Order/OrderCancelServiceTest.php b/backend/tests/Unit/Services/Domain/Order/OrderCancelServiceTest.php index 664ad269..b64d1f5f 100644 --- a/backend/tests/Unit/Services/Domain/Order/OrderCancelServiceTest.php +++ b/backend/tests/Unit/Services/Domain/Order/OrderCancelServiceTest.php @@ -11,7 +11,7 @@ use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Services\Domain\Order\OrderCancelService; -use HiEvents\Services\Domain\Ticket\TicketQuantityService; +use HiEvents\Services\Domain\Ticket\TicketQuantityUpdateService; use Illuminate\Contracts\Mail\Mailer; use Illuminate\Database\DatabaseManager; use Illuminate\Support\Collection; @@ -26,7 +26,7 @@ class OrderCancelServiceTest extends TestCase private EventRepositoryInterface $eventRepository; private OrderRepositoryInterface $orderRepository; private DatabaseManager $databaseManager; - private TicketQuantityService $ticketQuantityService; + private TicketQuantityUpdateService $ticketQuantityService; private OrderCancelService $service; protected function setUp(): void @@ -38,7 +38,7 @@ protected function setUp(): void $this->eventRepository = m::mock(EventRepositoryInterface::class); $this->orderRepository = m::mock(OrderRepositoryInterface::class); $this->databaseManager = m::mock(DatabaseManager::class); - $this->ticketQuantityService = m::mock(TicketQuantityService::class); + $this->ticketQuantityService = m::mock(TicketQuantityUpdateService::class); $this->service = new OrderCancelService( mailer: $this->mailer, @@ -66,7 +66,7 @@ public function testCancelOrder(): void $this->attendeeRepository->shouldReceive('findWhere')->once()->andReturn($attendees); $this->attendeeRepository->shouldReceive('updateWhere')->once(); - $this->ticketQuantityService->shouldReceive('decreaseTicketPriceQuantitySold')->twice(); + $this->ticketQuantityService->shouldReceive('decreaseQuantitySold')->twice(); $this->orderRepository->shouldReceive('updateWhere')->once(); diff --git a/backend/tests/Unit/Services/Handlers/Event/GetPublicEventHandlerTest.php b/backend/tests/Unit/Services/Handlers/Event/GetPublicEventHandlerTest.php index 2ceb332f..a96224f2 100644 --- a/backend/tests/Unit/Services/Handlers/Event/GetPublicEventHandlerTest.php +++ b/backend/tests/Unit/Services/Handlers/Event/GetPublicEventHandlerTest.php @@ -41,12 +41,14 @@ protected function setUp(): void public function testHandleWithoutPromoCodeAndUnauthenticatedUser(): void { $data = new GetPublicEventDTO(eventId: 1, isAuthenticated: false, ipAddress: '127.0.0.1', promoCode: null); + $tickets = collect(); $event = m::mock(EventDomainObject::class); $event->shouldReceive('setTickets')->once()->andReturnSelf(); + $event->shouldReceive('getTickets')->once()->andReturn($tickets); $this->setupEventRepositoryMock($event, $data->eventId); $this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturnNull(); - $this->ticketFilterService->shouldReceive('filter')->once()->with($event, null)->andReturn(collect()); + $this->ticketFilterService->shouldReceive('filter')->once()->with($tickets, null)->andReturn(collect()); $this->eventPageViewIncrementService->shouldReceive('increment')->once()->with($data->eventId, $data->ipAddress); $this->handler->handle($data); @@ -56,13 +58,15 @@ public function testHandleWithInvalidPromoCode(): void { $data = new GetPublicEventDTO(eventId: 1, isAuthenticated: false, ipAddress: '127.0.0.1', promoCode: 'INVALID'); $event = m::mock(EventDomainObject::class); + $tickets = collect(); $event->shouldReceive('setTickets')->once()->andReturnSelf(); + $event->shouldReceive('getTickets')->once()->andReturn($tickets); $promoCode = m::mock(PromoCodeDomainObject::class)->makePartial(); $promoCode->shouldReceive('isValid')->andReturn(false); $this->setupEventRepositoryMock($event, $data->eventId); $this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturn($promoCode); - $this->ticketFilterService->shouldReceive('filter')->once()->with($event, null)->andReturn(collect()); + $this->ticketFilterService->shouldReceive('filter')->once()->with($tickets, null)->andReturn(collect()); $this->eventPageViewIncrementService->shouldReceive('increment')->once()->with($data->eventId, $data->ipAddress); $this->handler->handle($data); @@ -71,14 +75,16 @@ public function testHandleWithInvalidPromoCode(): void public function testHandleWithValidPromoCode(): void { $data = new GetPublicEventDTO(eventId: 1, isAuthenticated: false, ipAddress: '127.0.0.1', promoCode: 'VALID'); + $tickets = collect(); $event = m::mock(EventDomainObject::class); $event->shouldReceive('setTickets')->once()->andReturnSelf(); + $event->shouldReceive('getTickets')->once()->andReturn($tickets); $promoCode = m::mock(PromoCodeDomainObject::class)->makePartial(); $promoCode->shouldReceive('isValid')->andReturn(true); $this->setupEventRepositoryMock($event, $data->eventId); $this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturn($promoCode); - $this->ticketFilterService->shouldReceive('filter')->once()->with($event, $promoCode)->andReturn(collect()); + $this->ticketFilterService->shouldReceive('filter')->once()->with($tickets, $promoCode)->andReturn(collect()); $this->eventPageViewIncrementService->shouldReceive('increment')->once()->with($data->eventId, $data->ipAddress); $this->handler->handle($data); diff --git a/frontend/public/blank-slate/capacity-assignments.svg b/frontend/public/blank-slate/capacity-assignments.svg new file mode 100644 index 00000000..93c55952 --- /dev/null +++ b/frontend/public/blank-slate/capacity-assignments.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/scripts/list_untranslated_string.sh b/frontend/scripts/list_untranslated_string.sh new file mode 100755 index 00000000..bf8cc08f --- /dev/null +++ b/frontend/scripts/list_untranslated_string.sh @@ -0,0 +1,48 @@ +#!/bin/bash + +# This script lists all untranslated strings in a .po file. + +# arbitrary translation file +poFile="../src/locales/es.po" + +if [ -f "$poFile" ]; then + echo "Checking file: $poFile" + + awk ' + BEGIN { RS=""; FS="\n" } + { + msgid = ""; msgstr = ""; references = ""; + in_msgid = 0; in_msgstr = 0; + + for (i = 1; i <= NF; i++) { + if ($i ~ /^msgid "/) { + msgid = $i; in_msgid = 1; in_msgstr = 0; + } else if ($i ~ /^msgstr "/) { + msgstr = $i; in_msgstr = 1; in_msgid = 0; + } else if (in_msgid && $i ~ /^"/) { + msgid = msgid "\n" $i; + } else if (in_msgstr && $i ~ /^"/) { + msgstr = msgstr "\n" $i; + } else if ($i ~ /^#:/) { + references = $i; + } else { + in_msgid = 0; in_msgstr = 0; + } + } + + # Normalize msgstr and msgid to make comparison easier + gsub(/\n/, "", msgid); + gsub(/\n/, "", msgstr); + + if (msgstr == "msgstr \"\"") { + if (references != "") { + print references; + } + print msgid; + print msgstr "\n"; + } + } + ' "$poFile" +else + echo "File not found: $poFile" +fi diff --git a/frontend/src/api/capacity-assignment.client.ts b/frontend/src/api/capacity-assignment.client.ts new file mode 100644 index 00000000..2819cbbf --- /dev/null +++ b/frontend/src/api/capacity-assignment.client.ts @@ -0,0 +1,32 @@ +import {api} from "./client"; +import { + CapacityAssignment, + CapacityAssignmentRequest, + GenericDataResponse, + GenericPaginatedResponse, + IdParam, QueryFilters, +} from "../types"; +import {queryParamsHelper} from "../utilites/queryParamsHelper.ts"; + +export const capacityAssignmentClient = { + create: async (eventId: IdParam, capacityAssignment: CapacityAssignmentRequest) => { + const response = await api.post>(`events/${eventId}/capacity-assignments`, capacityAssignment); + return response.data; + }, + update: async (eventId: IdParam, capacityAssignmentId: IdParam, capacityAssignment: CapacityAssignmentRequest) => { + const response = await api.put>(`events/${eventId}/capacity-assignments/${capacityAssignmentId}`, capacityAssignment); + return response.data; + }, + all: async (eventId: IdParam, pagination: QueryFilters) => { + const response = await api.get>(`events/${eventId}/capacity-assignments` + queryParamsHelper.buildQueryString(pagination)); + return response.data; + }, + get: async (eventId: IdParam, capacityAssignmentId: IdParam) => { + const response = await api.get>(`events/${eventId}/capacity-assignments/${capacityAssignmentId}`); + return response.data; + }, + delete: async (eventId: IdParam, capacityAssignmentId: IdParam) => { + const response = await api.delete>(`events/${eventId}/capacity-assignments/${capacityAssignmentId}`); + return response.data; + }, +} diff --git a/frontend/src/components/common/ActionMenu/index.tsx b/frontend/src/components/common/ActionMenu/index.tsx new file mode 100644 index 00000000..1cda6ba7 --- /dev/null +++ b/frontend/src/components/common/ActionMenu/index.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import {Button, Group, Menu} from '@mantine/core'; +import {IconDotsVertical} from '@tabler/icons-react'; + +interface MenuItem { + label: string; + icon: React.ReactNode; + onClick: () => void; + color?: string; + visible?: boolean; +} + +export interface ActionMenuItemsGroup { + label: string; + items: MenuItem[]; + showDividerAbove?: boolean; +} + + interface ActionMenuProps { + itemsGroups: ActionMenuItemsGroup[]; +} + +export const ActionMenu: React.FC = ({itemsGroups}) => { + return ( + + + + + + + + {itemsGroups.map((group, groupIndex) => ( + + {group.showDividerAbove && } + {group.label} + {group.items.map((item, itemIndex) => item.visible !== false && ( + + {item.label} + + ))} + + + ))} + + + + ); +}; diff --git a/frontend/src/components/common/AttendeeTable/index.tsx b/frontend/src/components/common/AttendeeTable/index.tsx index a70fcd9c..5efc602c 100644 --- a/frontend/src/components/common/AttendeeTable/index.tsx +++ b/frontend/src/components/common/AttendeeTable/index.tsx @@ -1,14 +1,6 @@ -import {Anchor, Avatar, Badge, Button, Group, Menu, Table as MantineTable,} from '@mantine/core'; +import {Anchor, Avatar, Badge, Button, Table as MantineTable,} from '@mantine/core'; import {Attendee, MessageType} from "../../../types.ts"; -import { - IconDotsVertical, - IconEye, - IconMailForward, - IconPencil, - IconPlus, - IconSend, - IconTrash -} from "@tabler/icons-react"; +import {IconEye, IconMailForward, IconPencil, IconPlus, IconSend, IconTrash} from "@tabler/icons-react"; import {getInitials, getTicketFromEvent} from "../../../utilites/helpers.ts"; import {Table, TableHead} from "../Table"; import {useDisclosure} from "@mantine/hooks"; @@ -26,6 +18,7 @@ import {t, Trans} from "@lingui/macro"; import {confirmationDialog} from "../../../utilites/confirmationDialog.tsx"; import {useResendAttendeeTicket} from "../../../mutations/useResendAttendeeTicket.ts"; import {ViewAttendeeModal} from "../../modals/ViewAttendeeModal"; +import {ActionMenu} from '../ActionMenu/index.tsx'; interface AttendeeTableProps { attendees: Attendee[]; @@ -79,7 +72,7 @@ export const AttendeeTable = ({attendees, openCreateModal}: AttendeeTableProps) /> } - const handleCancel = (attendee: Attendee) => () => { + const handleCancel = (attendee: Attendee) => { const message = attendee.status === 'CANCELLED' ? t`Are you sure you want to activate this attendee?` : t`Are you sure you want to cancel this attendee? This will void their ticket` @@ -89,7 +82,7 @@ export const AttendeeTable = ({attendees, openCreateModal}: AttendeeTableProps) attendeeId: attendee.id, eventId: eventId, attendeeData: { - status: attendee.status === 'CANCELLED' ? 'active' : 'cancelled' + status: attendee.status === 'CANCELLED' ? 'ACTIVE' : 'CANCELLED' } }, { onSuccess: () => { @@ -163,56 +156,45 @@ export const AttendeeTable = ({attendees, openCreateModal}: AttendeeTableProps) color={attendee.status === 'CANCELLED' ? 'red' : 'green'}>{attendee.status} - - - - - - - - {t`Manage`} - } - onClick={() => handleModalClick(attendee, viewModalOpen)} - > - {t`View attendee`} - - - } - onClick={() => handleModalClick(attendee, messageModal)}> - {t`Message attendee`} - - } - onClick={() => handleModalClick(attendee, editModal)} - > - {t`Edit attendee`} - - - {attendee.status === 'ACTIVE' && ( - } - onClick={() => handleResendTicket(attendee)} - > - {t`Resend ticket email`} - - )} - - - - {t`Danger zone`} - } - onClick={handleCancel(attendee)} - > - {attendee.status === 'CANCELLED' ? t`Activate` : t`Cancel`} {t`ticket`} - - - - + , + onClick: () => handleModalClick(attendee, viewModalOpen), + }, + { + label: t`Message attendee`, + icon: , + onClick: () => handleModalClick(attendee, messageModal), + }, + { + label: t`Edit attendee`, + icon: , + onClick: () => handleModalClick(attendee, editModal), + }, + { + label: t`Resend ticket email`, + icon: , + onClick: () => handleResendTicket(attendee), + visible: attendee.status === 'ACTIVE', + }, + ], + }, + { + label: t`Danger Zone`, + items: [ + { + label: attendee.status === 'CANCELLED' ? t`Activate` : t`Cancel` + ` ` + t`ticket`, + icon: , + onClick: () => handleCancel(attendee), + color: attendee.status === 'CANCELLED' ? 'green' : 'red', + }, + ], + }, + ]}/> ); diff --git a/frontend/src/components/common/CapacityAssignmentList/CapacityAssignmentList.module.scss b/frontend/src/components/common/CapacityAssignmentList/CapacityAssignmentList.module.scss new file mode 100644 index 00000000..f9555532 --- /dev/null +++ b/frontend/src/components/common/CapacityAssignmentList/CapacityAssignmentList.module.scss @@ -0,0 +1,99 @@ +@import '../../../styles/mixins'; + +.toolbar { + display: flex; + justify-content: space-between; + align-items: center; + + @include respond-below(md) { + flex-direction: column; + align-items: flex-start; + } + + .search { + width: 80%; + margin-right: 20px; + max-width: 320px; + + @include respond-below(md) { + width: 100%; + margin-bottom: 20px; + max-width: 100%; + } + } + + .button { + display: flex; + place-content: flex-end; + + button { + height: 42px; + } + + @include respond-below(md) { + width: 100%; + } + } +} + +.capacityAssignmentList { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 20px; + margin-top: 20px; + + @include respond-below(md) { + + } + + .capacityCard { + display: flex; + flex-direction: column; + justify-content: space-between; + margin-bottom: 0 !important; + + .capacityAssignmentHeader { + display: flex; + justify-content: space-between; + align-items: center; + border-radius: 5px; + margin-bottom: 20px; + + .capacityAssignmentAppliesTo { + .appliesToText { + display: flex; + color: #999; + justify-content: center; + align-items: center; + } + } + } + + .capacityAssignmentName { + font-size: 1.2rem; + font-weight: bold; + margin-bottom: 10px; + } + + .capacityAssignmentInfo { + display: flex; + justify-content: space-between; + align-items: center; + + .capacityAssignmentCapacity { + margin-bottom: 4px; + width: 120px; + + .capacityText { + margin-top: 10px; + color: #999; + } + } + + .capacityAssignmentActions { + + } + } + } +} + diff --git a/frontend/src/components/common/CapacityAssignmentList/index.tsx b/frontend/src/components/common/CapacityAssignmentList/index.tsx new file mode 100644 index 00000000..6223024b --- /dev/null +++ b/frontend/src/components/common/CapacityAssignmentList/index.tsx @@ -0,0 +1,188 @@ +import {CapacityAssignment, IdParam} from "../../../types"; +import {Badge, Button, Progress} from "@mantine/core"; +import {t, Trans} from "@lingui/macro"; +import {IconHelp, IconPencil, IconPlus, IconTrash} from "@tabler/icons-react"; +import Truncate from "../Truncate"; +import {NoResultsSplash} from "../NoResultsSplash"; +import classes from './CapacityAssignmentList.module.scss'; +import {Card} from "../Card"; +import {Popover} from "../Popover"; +import {useState} from "react"; +import {ActionMenu} from "../ActionMenu"; +import {useDisclosure} from "@mantine/hooks"; +import {EditCapacityAssignmentModal} from "../../modals/EditCapacityAssignmentModal"; +import {useDeleteCapacityAssignment} from "../../../mutations/useDeleteCapacityAssignment"; +import {showError, showSuccess} from "../../../utilites/notifications.tsx"; +import {confirmationDialog} from "../../../utilites/confirmationDialog.tsx"; + +interface CapacityAssignmentListProps { + capacityAssignments: CapacityAssignment[]; + openCreateModal: () => void; +} + +export const CapacityAssignmentList = ({capacityAssignments, openCreateModal}: CapacityAssignmentListProps) => { + const [editModalOpen, {open: openEditModal, close: closeEditModal}] = useDisclosure(false); + const [selectedCapacityAssignmentId, setSelectedCapacityAssignmentId] = useState(); + const deleteMutation = useDeleteCapacityAssignment(); + + const handleDeleteTicket = (capacityAssignmentId: IdParam, eventId: IdParam) => { + deleteMutation.mutate({capacityAssignmentId, eventId}, { + onSuccess: () => { + showSuccess(t`Capacity Assignment deleted successfully`); + }, + onError: (error: any) => { + showError(error.message); + } + }); + } + + if (capacityAssignments.length === 0) { + return ( + +

+ +

+ Capacity assignments let you manage capacity across tickets or an entire event. Ideal + for multi-day events, workshops, and more, where controlling attendance is crucial. +

+

+ For instance, you can associate a capacity assignment with Day One and All + Days ticket. Once the capacity is reached, both tickets will automatically stop + being available for sale. +

+ +

+ + + )} + /> + ); + } + + return ( + <> +
+ {capacityAssignments.map((assignment) => { + const capacityPercentage = assignment.capacity + ? (assignment.used_capacity / assignment.capacity) * 100 + : 0; + const capacityColor = capacityPercentage > 80 ? 'red' : capacityPercentage > 50 ? 'yellow' : 'green'; + + return ( + +
+
+ {assignment.tickets && ( + ( +
+ {ticket.title} +
+ ))} + position={'bottom'} + withArrow + > +
+
+ {assignment.tickets.length > 1 && + Applies to {assignment.tickets.length} tickets} + {assignment.tickets.length === 1 && t`Applies to 1 ticket`} +
+   + +
+
+ )} +
+ +
+ + {assignment.status} + +
+
+
+ + + +
+ +
+
+ {assignment.capacity ? ( +
+ +
+ {assignment.used_capacity}/{assignment.capacity} +
+
+ ) : ( +
+ {assignment.used_capacity}/∞ +
+ )} +
+
+ , + onClick: () => { + setSelectedCapacityAssignmentId(assignment.id as IdParam); + openEditModal(); + } + }, + ], + }, + { + label: t`Danger zone`, + items: [ + { + label: t`Delete Capacity`, + icon: , + onClick: () => { + confirmationDialog( + t`Are you sure you would like to delete this Capacity Assignment?`, + () => { + handleDeleteTicket( + assignment.id as IdParam, + assignment.event_id as IdParam, + ); + }) + }, + color: 'red', + }, + ], + }, + ]} + /> +
+
+
+ ); + })} +
+ {(editModalOpen && selectedCapacityAssignmentId) + && } + + ); +}; diff --git a/frontend/src/components/common/CheckoutQuestion/index.tsx b/frontend/src/components/common/CheckoutQuestion/index.tsx index 99ab16e4..2a55f123 100644 --- a/frontend/src/components/common/CheckoutQuestion/index.tsx +++ b/frontend/src/components/common/CheckoutQuestion/index.tsx @@ -147,6 +147,8 @@ const AddressInput = ({question, name, form}: QuestionInputProps) => { return ( <>

{question.title}

+
+ diff --git a/frontend/src/components/common/GlobalMenu/index.tsx b/frontend/src/components/common/GlobalMenu/index.tsx index 5edf0e0c..b156b60f 100644 --- a/frontend/src/components/common/GlobalMenu/index.tsx +++ b/frontend/src/components/common/GlobalMenu/index.tsx @@ -1,6 +1,6 @@ import {Avatar, Menu, UnstyledButton} from "@mantine/core"; -import {getInitials, iHavePurchasedALicence, isHiEvents} from "../../../utilites/helpers.ts"; -import {IconLogout, IconMoneybag, IconSettingsCog, IconSpeakerphone, IconUser} from "@tabler/icons-react"; +import {getInitials} from "../../../utilites/helpers.ts"; +import {IconLifebuoy, IconLogout, IconSettingsCog, IconSpeakerphone, IconUser} from "@tabler/icons-react"; import {useGetMe} from "../../../queries/useGetMe.ts"; import {NavLink} from "react-router-dom"; import {t} from "@lingui/macro"; @@ -20,14 +20,12 @@ export const GlobalMenu = () => { icon: IconSettingsCog, link: `/account/settings`, }, - ...((iHavePurchasedALicence() || isHiEvents()) ? [] : [ - { - label: t`Purchase License`, - icon: IconMoneybag, - link: 'https://hi.events/licensing?utm_source=app-top-menu', - target: '_blank', - } - ]), + { + label: t`Help & Support`, + icon: IconLifebuoy, + link: 'https://hi.events/docs?utm_source=app-top-menu-help-support', + target: '_blank', + }, { label: t`Feedback`, icon: IconSpeakerphone, diff --git a/frontend/src/components/common/InputLabelWithHelp/InputLabelWithHelp.module.scss b/frontend/src/components/common/InputLabelWithHelp/InputLabelWithHelp.module.scss new file mode 100644 index 00000000..5301fec7 --- /dev/null +++ b/frontend/src/components/common/InputLabelWithHelp/InputLabelWithHelp.module.scss @@ -0,0 +1,13 @@ + +.labelWrapper { + display: flex; + align-items: center; + justify-content: center; +} + +.helpIcon { + margin-left: 5px; + cursor: pointer; + display: flex; + align-items: center; +} diff --git a/frontend/src/components/common/InputLabelWithHelp/index.tsx b/frontend/src/components/common/InputLabelWithHelp/index.tsx new file mode 100644 index 00000000..ef0ffc55 --- /dev/null +++ b/frontend/src/components/common/InputLabelWithHelp/index.tsx @@ -0,0 +1,21 @@ +import {Popover} from "../Popover"; +import {IconInfoCircle} from "@tabler/icons-react"; +import classes from "./InputLabelWithHelp.module.scss"; + +interface InputLabelWithHelpProps { + label: string; + helpText: React.ReactNode; +} + +export const InputLabelWithHelp = ({label, helpText}: InputLabelWithHelpProps) => { + return ( +
+ +
+ + + +
+
+ ); +} diff --git a/frontend/src/components/common/Modal/Modal.module.scss b/frontend/src/components/common/Modal/Modal.module.scss new file mode 100644 index 00000000..e8807cd1 --- /dev/null +++ b/frontend/src/components/common/Modal/Modal.module.scss @@ -0,0 +1,6 @@ +.modalTitle { + font-size: 1.5rem; + font-weight: 600; + margin-top: 15px; + margin-bottom: 5px; +} diff --git a/frontend/src/components/common/Modal/index.tsx b/frontend/src/components/common/Modal/index.tsx index fb79a3cd..67421e28 100644 --- a/frontend/src/components/common/Modal/index.tsx +++ b/frontend/src/components/common/Modal/index.tsx @@ -1,5 +1,6 @@ -import {Modal as MantineModal, ModalProps as MantineModalProps, Title} from "@mantine/core"; +import {Modal as MantineModal, ModalProps as MantineModalProps} from "@mantine/core"; import React from "react"; +import classes from "./Modal.module.scss"; interface ModalProps { heading?: string | React.ReactNode, @@ -14,21 +15,16 @@ export const Modal = (props: MantineModalProps & ModalProps) => { blur: 3, }} size={'xl'} - withCloseButton={false} + withCloseButton={true} + title={props.heading} + closeOnClickOutside={false} + classNames={{ + title: classes.modalTitle, + }} > - {props.heading && ( - - - {props.heading} - - {props.withCloseButton && } - - )} -
{props.children}
- ) -} \ No newline at end of file +} diff --git a/frontend/src/components/common/NoResultsSplash/index.tsx b/frontend/src/components/common/NoResultsSplash/index.tsx index 89cacfe1..05109a93 100644 --- a/frontend/src/components/common/NoResultsSplash/index.tsx +++ b/frontend/src/components/common/NoResultsSplash/index.tsx @@ -14,7 +14,7 @@ export const NoResultsSplash = ({ heading = t`'There\'s nothing to show yet'`, children, subHeading, - imageHref = '/no-results-empty-boxes.svg' + imageHref = '/no-results-empty-boxes.svg', }: NoResultsSplashProps) => { const [searchParams] = useSearchParams(); const hasSearchQuery = !!searchParams.get('query'); @@ -34,4 +34,4 @@ export const NoResultsSplash = ({ {children && children}
) -} \ No newline at end of file +} diff --git a/frontend/src/components/common/PageTitle/index.tsx b/frontend/src/components/common/PageTitle/index.tsx index c13fc16a..1c3db165 100644 --- a/frontend/src/components/common/PageTitle/index.tsx +++ b/frontend/src/components/common/PageTitle/index.tsx @@ -9,4 +9,4 @@ export const PageTitle = ({children}: PageTitleProps) => { return (

{children}

); -} \ No newline at end of file +} diff --git a/frontend/src/components/common/Popover/index.tsx b/frontend/src/components/common/Popover/index.tsx index 558b96e8..ea111410 100644 --- a/frontend/src/components/common/Popover/index.tsx +++ b/frontend/src/components/common/Popover/index.tsx @@ -20,4 +20,4 @@ export const Popover = ({children, title, ...props}: PopoverProps) => { ); -} \ No newline at end of file +} diff --git a/frontend/src/components/forms/CapaciyAssigmentForm/index.tsx b/frontend/src/components/forms/CapaciyAssigmentForm/index.tsx new file mode 100644 index 00000000..4d974dfd --- /dev/null +++ b/frontend/src/components/forms/CapaciyAssigmentForm/index.tsx @@ -0,0 +1,69 @@ +import {InputGroup} from "../../common/InputGroup"; +import {MultiSelect, NumberInput, Switch, TextInput} from "@mantine/core"; +import {t} from "@lingui/macro"; +import {UseFormReturnType} from "@mantine/form"; +import {CapacityAssignmentRequest, Ticket} from "../../../types.ts"; +import {CustomSelect, ItemProps} from "../../common/CustomSelect"; +import {IconCheck, IconTicket, IconX} from "@tabler/icons-react"; + +interface CapaciyAssigmentFormProps { + form: UseFormReturnType; + tickets: Ticket[], +} + +export const CapaciyAssigmentForm = ({form, tickets}: CapaciyAssigmentFormProps) => { + const statusOptions: ItemProps[] = [ + { + icon: , + label: t`Active`, + value: 'ACTIVE', + description: t`Enable this capacity to stop ticket sales when the limit is reached`, + }, + { + icon: , + label: t`Inactive`, + value: 'INACTIVE', + description: t`Disable this capacity track capacity without stopping ticket sales`, + }, + ]; + + return ( + <> + + + + + + { + return { + value: String(ticket.id), + label: ticket.title, + } + })} + leftSection={} + {...form.getInputProps('ticket_ids')} + /> + + + + ); +} diff --git a/frontend/src/components/forms/PromoCodeForm/index.tsx b/frontend/src/components/forms/PromoCodeForm/index.tsx index c6bc5c97..1f513122 100644 --- a/frontend/src/components/forms/PromoCodeForm/index.tsx +++ b/frontend/src/components/forms/PromoCodeForm/index.tsx @@ -30,7 +30,7 @@ export const PromoCodeForm = ({form}: PromoCodeFormProps) => { return ( <> - + } title={t`TIP`}> {t`A promo code with no discount can be used to reveal hidden tickets.`} @@ -64,6 +64,7 @@ export const PromoCodeForm = ({form}: PromoCodeFormProps) => { { diff --git a/frontend/src/components/forms/TaxAndFeeForm/index.tsx b/frontend/src/components/forms/TaxAndFeeForm/index.tsx index 77a5e477..6f8d18ec 100644 --- a/frontend/src/components/forms/TaxAndFeeForm/index.tsx +++ b/frontend/src/components/forms/TaxAndFeeForm/index.tsx @@ -70,6 +70,7 @@ export const TaxAndFeeForm = ({form}: { form: UseFormReturnType }) => {...form.getInputProps('rate')} label={form.values.calculation_type === TaxAndFeeCalculationType.Percentage ? t`Percentage Amount` : t`Amount`} placeholder={form.values.calculation_type === TaxAndFeeCalculationType.Percentage ? '23' : '2.50'} + leftSection={form.values.calculation_type === TaxAndFeeCalculationType.Percentage ? '%' : ''} description={form.values.calculation_type === TaxAndFeeCalculationType.Percentage ? t`eg. 23.5 for 23.5%` : t`eg. 2.50 for $2.50`} required max={form.values.calculation_type === TaxAndFeeCalculationType.Percentage ? 100 : undefined} diff --git a/frontend/src/components/forms/TicketForm/index.tsx b/frontend/src/components/forms/TicketForm/index.tsx index ad946a30..e3daecb2 100644 --- a/frontend/src/components/forms/TicketForm/index.tsx +++ b/frontend/src/components/forms/TicketForm/index.tsx @@ -38,6 +38,7 @@ import {Editor} from "../../common/Editor"; import {InputGroup} from "../../common/InputGroup"; import {showError} from "../../../utilites/notifications.tsx"; import classNames from "classnames"; +import {InputLabelWithHelp} from "../../common/InputLabelWithHelp"; interface TicketFormProps { form: UseFormReturnType, @@ -226,12 +227,39 @@ export const TicketForm = ({form, ticket}: TicketFormProps) => { disabled={isFreeTicket} leftSection={event?.currency ? getCurrencySymbol(event.currency) : ''} {...form.getInputProps('prices.0.price')} - label={isDonationTicket ? t`Minimum Price` : t`Price`} + label={ +

+ Please enter the price excluding taxes and fees. +

+

+ Taxes and fees can be added below. +

+ + )} + />} placeholder="19.99"/> + label={ +

+ The number of tickets available for this ticket +

+

+ This value can be overridden if there are Capacity + Limits associated with this ticket. +

+ + )} + />} + /> )} diff --git a/frontend/src/components/layouts/Event/index.tsx b/frontend/src/components/layouts/Event/index.tsx index cf3f35c4..3a0f094c 100644 --- a/frontend/src/components/layouts/Event/index.tsx +++ b/frontend/src/components/layouts/Event/index.tsx @@ -16,7 +16,7 @@ import { IconStar, IconTicket, IconUserQuestion, - IconUsers, + IconUsers, IconUsersGroup, IconWebhook } from "@tabler/icons-react"; import {useState} from "react"; @@ -44,14 +44,15 @@ const EventLayout = () => { {link: 'questions', label: t`Questions`, icon: IconUserQuestion}, {link: 'promo-codes', label: t`Promo Codes`, icon: IconDiscount2}, {link: 'messages', label: t`Messages`, icon: IconSend}, + {link: 'capacity-assignments', label: t`Capacity`, icon: IconUsersGroup}, {label: t`Tools`}, {link: 'homepage-designer', label: t`Homepage Designer`, icon: IconPaint}, {link: 'widget', label: t`Widget Embed`, icon: IconDeviceTabletCode}, {link: 'check-in', label: t`Check-In`, icon: IconQrcode}, // { link: 'check-in', label: t`Interested People`, icon: IconBellHeart, comingSoon: true }, - {link: '/', label: t`Affiliates`, icon: IconAffiliate, comingSoon: true}, - {link: '/', label: t`Integrations`, icon: IconWebhook, comingSoon: true}, + // {link: '/', label: t`Affiliates`, icon: IconAffiliate, comingSoon: true}, + // {link: '/', label: t`Integrations`, icon: IconWebhook, comingSoon: true}, ]; const [sidebarOpen, setSidebarOpen] = useState(false); const {eventId} = useParams(); @@ -202,4 +203,4 @@ const EventLayout = () => { ) } -export default EventLayout; \ No newline at end of file +export default EventLayout; diff --git a/frontend/src/components/modals/CreateCapacityAssignmentModal/index.tsx b/frontend/src/components/modals/CreateCapacityAssignmentModal/index.tsx new file mode 100644 index 00000000..cf6b30a6 --- /dev/null +++ b/frontend/src/components/modals/CreateCapacityAssignmentModal/index.tsx @@ -0,0 +1,84 @@ +import {CapacityAssignmentRequest, GenericModalProps, Ticket} from "../../../types.ts"; +import {Modal} from "../../common/Modal"; +import {t} from "@lingui/macro"; +import {CapaciyAssigmentForm} from "../../forms/CapaciyAssigmentForm"; +import {useForm} from "@mantine/form"; +import {Button} from "@mantine/core"; +import {useCreateCapacityAssignment} from "../../../mutations/useCreateCapacityAssignment.ts"; +import {showSuccess} from "../../../utilites/notifications.tsx"; +import {useParams} from "react-router-dom"; +import {useFormErrorResponseHandler} from "../../../hooks/useFormErrorResponseHandler.tsx"; +import {useGetEvent} from "../../../queries/useGetEvent.ts"; +import {NoResultsSplash} from "../../common/NoResultsSplash"; +import {IconPlus} from "@tabler/icons-react"; + +export const CreateCapacityAssignmentModal = ({onClose}: GenericModalProps) => { + const {eventId} = useParams(); + const errorHandler = useFormErrorResponseHandler(); + const {data: event} = useGetEvent(eventId); + const form = useForm({ + initialValues: { + name: '', + capacity: undefined, + status: 'ACTIVE', + ticket_ids: [], + } + }); + const createMutation = useCreateCapacityAssignment(); + const eventHasTickets = event?.tickets && event.tickets.length > 0; + + const handleSubmit = (requestData: CapacityAssignmentRequest) => { + createMutation.mutate({ + eventId: eventId, + capacityAssignmentData: requestData, + }, { + onSuccess: () => { + showSuccess(t`Capacity Assignment created successfully`); + onClose(); + }, + onError: (error) => errorHandler(form, error), + }) + } + + const NoTickets = () => { + return ( + +

+ {t`You'll need at a ticket before you can create a capacity assignment.`} +

+ + + )} + /> + ); + } + + return ( + + {!eventHasTickets && } + {eventHasTickets && ( +
+ {event && } + + + )} +
+ ); +} diff --git a/frontend/src/components/modals/CreateTicketModal/index.tsx b/frontend/src/components/modals/CreateTicketModal/index.tsx index 05f3a817..719f07d4 100644 --- a/frontend/src/components/modals/CreateTicketModal/index.tsx +++ b/frontend/src/components/modals/CreateTicketModal/index.tsx @@ -58,6 +58,7 @@ export const CreateTicketModal = ({onClose}: GenericModalProps) => { if (error?.response?.data?.errors) { form.setErrors(error.response.data.errors); } + notifications.show({ message: t`Unable to create ticket. Please check the your details`, color: 'red', diff --git a/frontend/src/components/modals/EditCapacityAssignmentModal/index.tsx b/frontend/src/components/modals/EditCapacityAssignmentModal/index.tsx new file mode 100644 index 00000000..f65993e9 --- /dev/null +++ b/frontend/src/components/modals/EditCapacityAssignmentModal/index.tsx @@ -0,0 +1,80 @@ +import {CapacityAssignmentRequest, GenericModalProps, IdParam, Ticket} from "../../../types.ts"; +import {Modal} from "../../common/Modal"; +import {t} from "@lingui/macro"; +import {CapaciyAssigmentForm} from "../../forms/CapaciyAssigmentForm"; +import {useForm} from "@mantine/form"; +import {Button} from "@mantine/core"; +import {showSuccess} from "../../../utilites/notifications.tsx"; +import {useParams} from "react-router-dom"; +import {useFormErrorResponseHandler} from "../../../hooks/useFormErrorResponseHandler.tsx"; +import {useGetEvent} from "../../../queries/useGetEvent.ts"; +import {useEditCapacityAssignment} from "../../../mutations/useEditCapacityAssignment.ts"; +import {useGetEventCapacityAssignment} from "../../../queries/useGetCapacityAssignment.ts"; +import {useEffect} from "react"; + +interface EditCapacityAssignmentModalProps { + capacityAssignmentId: IdParam; +} + +export const EditCapacityAssignmentModal = ({ + onClose, + capacityAssignmentId + }: GenericModalProps & EditCapacityAssignmentModalProps) => { + const {eventId} = useParams(); + const errorHandler = useFormErrorResponseHandler(); + const {data: capacityAssignment} = useGetEventCapacityAssignment( + capacityAssignmentId, + eventId + ); + const {data: event} = useGetEvent(eventId); + const form = useForm({ + initialValues: { + name: '', + capacity: undefined, + status: 'ACTIVE', + ticket_ids: [], + } + }); + const editMutation = useEditCapacityAssignment(); + + const handleSubmit = (requestData: CapacityAssignmentRequest) => { + editMutation.mutate({ + eventId: eventId, + capacityAssignmentData: requestData, + capacityAssignmentId: capacityAssignmentId, + }, { + onSuccess: () => { + showSuccess(t`Successfully updated Capacity Assignment`); + onClose(); + }, + onError: (error) => errorHandler(form, error), + }) + } + + useEffect(() => { + if (capacityAssignment) { + form.setValues({ + name: capacityAssignment.name, + capacity: capacityAssignment.capacity, + status: capacityAssignment.status, + ticket_ids: capacityAssignment.tickets?.map(ticket => String(ticket.id)), + }); + } + }, [capacityAssignment]); + + return ( + +
+ {event && } + + +
+ ); +} + diff --git a/frontend/src/components/modals/SendMessageModal/index.tsx b/frontend/src/components/modals/SendMessageModal/index.tsx index 837bc130..85b117db 100644 --- a/frontend/src/components/modals/SendMessageModal/index.tsx +++ b/frontend/src/components/modals/SendMessageModal/index.tsx @@ -128,6 +128,12 @@ export const SendMessageModal = (props: EventMessageModalProps) => { heading={t`Send a message`} >
+ + {!isAccountVerified && ( + }> + {t`You need to verify your account before you can send messages.`} + + )}
{!isPreselectedRecipient && (