Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions backend/app/Exceptions/CannotChangeEventTypeException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

namespace HiEvents\Exceptions;

use Exception;

class CannotChangeEventTypeException extends Exception {}
8 changes: 8 additions & 0 deletions backend/app/Exceptions/InvitationPendingException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

namespace HiEvents\Exceptions;

class InvitationPendingException extends CodedAccessException
{
public const ERROR_CODE = 'INVITATION_PENDING';
}
6 changes: 6 additions & 0 deletions backend/app/Http/Actions/Auth/LoginAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace HiEvents\Http\Actions\Auth;

use HiEvents\Exceptions\InvitationPendingException;
use HiEvents\Exceptions\UnauthorizedException;
use HiEvents\Http\Request\Auth\LoginRequest;
use HiEvents\Http\ResponseCodes;
Expand Down Expand Up @@ -31,6 +32,11 @@ public function __invoke(LoginRequest $request): JsonResponse
accountId: (int) $request->validated('account_id'),
trustedDeviceToken: $request->cookie(TrustedDeviceService::COOKIE_NAME),
));
} catch (InvitationPendingException $e) {
return $this->jsonResponse([
'message' => $e->getMessage(),
'error_code' => InvitationPendingException::ERROR_CODE,
], ResponseCodes::HTTP_UNAUTHORIZED);
} catch (UnauthorizedException $e) {
return $this->errorResponse(
message: $e->getMessage(),
Expand Down
44 changes: 44 additions & 0 deletions backend/app/Http/Actions/Events/UpdateEventTypeAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

namespace HiEvents\Http\Actions\Events;

use HiEvents\DomainObjects\Enums\EventType;
use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\Exceptions\CannotChangeEventTypeException;
use HiEvents\Http\Actions\BaseAction;
use HiEvents\Http\Request\Event\UpdateEventTypeRequest;
use HiEvents\Resources\Event\EventResource;
use HiEvents\Services\Application\Handlers\Event\DTO\UpdateEventTypeDTO;
use HiEvents\Services\Application\Handlers\Event\UpdateEventTypeHandler;
use Illuminate\Http\JsonResponse;
use Illuminate\Validation\ValidationException;
use Throwable;

class UpdateEventTypeAction extends BaseAction
{
public function __construct(
private readonly UpdateEventTypeHandler $handler,
) {}

/**
* @throws ValidationException|Throwable
*/
public function __invoke(UpdateEventTypeRequest $request, int $eventId): JsonResponse
{
$this->isActionAuthorized($eventId, EventDomainObject::class);

try {
$event = $this->handler->handle(new UpdateEventTypeDTO(
event_id: $eventId,
account_id: $this->getAuthenticatedAccountId(),
type: EventType::fromName($request->validated('type')),
));
} catch (CannotChangeEventTypeException $exception) {
throw ValidationException::withMessages([
'type' => $exception->getMessage(),
]);
}

return $this->resourceResponse(EventResource::class, $event);
}
}
17 changes: 17 additions & 0 deletions backend/app/Http/Request/Event/UpdateEventTypeRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

namespace HiEvents\Http\Request\Event;

use HiEvents\DomainObjects\Enums\EventType;
use HiEvents\Http\Request\BaseRequest;
use Illuminate\Validation\Rule;

class UpdateEventTypeRequest extends BaseRequest
{
public function rules(): array
{
return [
'type' => ['required', Rule::in(EventType::valuesArray())],
];
}
}
3 changes: 2 additions & 1 deletion backend/app/Repository/Eloquent/OrderRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,8 @@ public function findByOrganizerId(int $organizerId, int $accountId, QueryParamsD
->select('orders.*')
->join('events', 'orders.event_id', '=', 'events.id')
->where('events.organizer_id', $organizerId)
->where('events.account_id', $accountId);
->where('events.account_id', $accountId)
->whereNull('events.deleted_at');

$sortBy = $this->validateSortColumn($params->sort_by, OrderDomainObject::class);
$this->model = $this->model->orderBy(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace HiEvents\Services\Application\Handlers\Event\DTO;

use HiEvents\DataTransferObjects\BaseDataObject;
use HiEvents\DomainObjects\Enums\EventType;

class UpdateEventTypeDTO extends BaseDataObject
{
public function __construct(
public readonly int $event_id,
public readonly int $account_id,
public readonly EventType $type,
) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
<?php

declare(strict_types=1);

namespace HiEvents\Services\Application\Handlers\Event;

use HiEvents\DomainObjects\Enums\EventType;
use HiEvents\DomainObjects\Enums\ProductQuantityAppliesTo;
use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\DomainObjects\EventLocationDomainObject;
use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\Generated\CheckInListDomainObjectAbstract;
use HiEvents\DomainObjects\Generated\EventDomainObjectAbstract;
use HiEvents\DomainObjects\Generated\EventOccurrenceDomainObjectAbstract;
use HiEvents\DomainObjects\Generated\OrderDomainObjectAbstract;
use HiEvents\DomainObjects\Generated\ProductDomainObjectAbstract;
use HiEvents\DomainObjects\Generated\ProductOccurrenceVisibilityDomainObjectAbstract;
use HiEvents\DomainObjects\Generated\ProductPriceDomainObjectAbstract;
use HiEvents\DomainObjects\Generated\ProductPriceOccurrenceOverrideDomainObjectAbstract;
use HiEvents\DomainObjects\Generated\WaitlistEntryDomainObjectAbstract;
use HiEvents\DomainObjects\LocationDomainObject;
use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\DomainObjects\Status\OrderStatus;
use HiEvents\Events\Dispatcher;
use HiEvents\Events\EventUpdateEvent;
use HiEvents\Exceptions\CannotChangeEventTypeException;
use HiEvents\Exceptions\ResourceNotFoundException;
use HiEvents\Jobs\Event\Webhook\DispatchEventWebhookJob;
use HiEvents\Repository\Eloquent\Value\Relationship;
use HiEvents\Repository\Interfaces\CheckInListRepositoryInterface;
use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductOccurrenceVisibilityRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductPriceOccurrenceOverrideRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductRepositoryInterface;
use HiEvents\Repository\Interfaces\WaitlistEntryRepositoryInterface;
use HiEvents\Services\Application\Handlers\Event\DTO\UpdateEventTypeDTO;
use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType;
use HiEvents\Services\Infrastructure\Lock\TransactionLockService;
use Illuminate\Database\DatabaseManager;
use Illuminate\Database\Eloquent\Builder;
use Throwable;

class UpdateEventTypeHandler
{
public function __construct(
private readonly EventRepositoryInterface $eventRepository,
private readonly EventOccurrenceRepositoryInterface $occurrenceRepository,
private readonly OrderRepositoryInterface $orderRepository,
private readonly ProductPriceOccurrenceOverrideRepositoryInterface $priceOverrideRepository,
private readonly ProductOccurrenceVisibilityRepositoryInterface $productVisibilityRepository,
private readonly ProductRepositoryInterface $productRepository,
private readonly ProductPriceRepositoryInterface $productPriceRepository,
private readonly CheckInListRepositoryInterface $checkInListRepository,
private readonly WaitlistEntryRepositoryInterface $waitlistEntryRepository,
private readonly TransactionLockService $transactionLockService,
private readonly Dispatcher $dispatcher,
private readonly DatabaseManager $databaseManager,
) {}

/**
* @throws CannotChangeEventTypeException
* @throws ResourceNotFoundException
* @throws Throwable
*/
public function handle(UpdateEventTypeDTO $dto): EventDomainObject
{
$changed = $this->databaseManager->transaction(function () use ($dto): bool {
$this->transactionLockService->lockEvent($dto->event_id);

$event = $this->eventRepository->findFirstWhere([
EventDomainObjectAbstract::ID => $dto->event_id,
EventDomainObjectAbstract::ACCOUNT_ID => $dto->account_id,
]);

if ($event === null) {
throw new ResourceNotFoundException(__('Event :id not found', ['id' => $dto->event_id]));
}

if ($event->getType() === $dto->type->name) {
return false;
}

if ($dto->type === EventType::SINGLE) {
$this->convertToSingle($event);
} else {
$this->convertToRecurring($event);
}

return true;
});

$event = $this->loadEvent($dto);

if ($changed) {
$this->dispatcher->dispatchEvent(new EventUpdateEvent($event));
DispatchEventWebhookJob::dispatch($event->getId(), DomainEventType::EVENT_UPDATED);
}

return $event;
}

/**
* @throws CannotChangeEventTypeException
*/
private function convertToSingle(EventDomainObject $event): void
{
$occurrences = $this->occurrenceRepository->findWhere([
EventOccurrenceDomainObjectAbstract::EVENT_ID => $event->getId(),
]);

if ($occurrences->isEmpty()) {
throw new CannotChangeEventTypeException(
__('Add a date before changing to a single event.'),
);
}

if ($occurrences->count() > 1) {
throw new CannotChangeEventTypeException(
__('Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.'),
);
}

/** @var EventOccurrenceDomainObject $occurrence */
$occurrence = $occurrences->first();

if ($occurrence->isCancelled()) {
throw new CannotChangeEventTypeException(
__('The remaining date is cancelled. Reactivate it before changing to a single event.'),
);
}

if ($this->hasOrders($event->getId())) {
throw new CannotChangeEventTypeException(
__('A recurring event with orders cannot be changed to a single event.'),
);
}

$this->priceOverrideRepository->deleteWhere([
ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID => $occurrence->getId(),
]);

$this->productVisibilityRepository->deleteWhere([
ProductOccurrenceVisibilityDomainObjectAbstract::EVENT_OCCURRENCE_ID => $occurrence->getId(),
]);

$this->occurrenceRepository->updateFromArray($occurrence->getId(), [
EventOccurrenceDomainObjectAbstract::CAPACITY => null,
EventOccurrenceDomainObjectAbstract::LABEL => null,
EventOccurrenceDomainObjectAbstract::EVENT_LOCATION_ID => null,
EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN => false,
]);

$this->checkInListRepository->updateWhere(
attributes: [CheckInListDomainObjectAbstract::EVENT_OCCURRENCE_ID => null],
where: [CheckInListDomainObjectAbstract::EVENT_ID => $event->getId()],
);

$this->applyEventWideProductQuantities($event->getId());

$this->eventRepository->updateFromArray($event->getId(), [
EventDomainObjectAbstract::TYPE => EventType::SINGLE->name,
EventDomainObjectAbstract::RECURRENCE_RULE => null,
EventDomainObjectAbstract::EVENT_LOCATION_ID => $occurrence->getEventLocationId() ?? $event->getEventLocationId(),
]);
}

private function convertToRecurring(EventDomainObject $event): void
{
$occurrences = $this->occurrenceRepository->findWhere([
EventOccurrenceDomainObjectAbstract::EVENT_ID => $event->getId(),
]);

if ($occurrences->count() === 1) {
$this->waitlistEntryRepository->updateWhere(
attributes: [WaitlistEntryDomainObjectAbstract::EVENT_OCCURRENCE_ID => $occurrences->first()->getId()],
where: [
WaitlistEntryDomainObjectAbstract::EVENT_ID => $event->getId(),
[WaitlistEntryDomainObjectAbstract::EVENT_OCCURRENCE_ID, 'null', null],
],
);
}

$this->eventRepository->updateFromArray($event->getId(), [
EventDomainObjectAbstract::TYPE => EventType::RECURRING->name,
]);
}

private function applyEventWideProductQuantities(int $eventId): void
{
$productIds = $this->productRepository
->findWhere([ProductDomainObjectAbstract::EVENT_ID => $eventId])
->map(fn (ProductDomainObject $product) => $product->getId())
->all();

if ($productIds === []) {
return;
}

$this->productPriceRepository->updateWhere(
attributes: [ProductPriceDomainObjectAbstract::QUANTITY_APPLIES_TO => ProductQuantityAppliesTo::EVENT->name],
where: [[ProductPriceDomainObjectAbstract::PRODUCT_ID, 'in', $productIds]],
);
}

private function hasOrders(int $eventId): bool
{
return $this->orderRepository->findFirstWhere([
OrderDomainObjectAbstract::EVENT_ID => $eventId,
static fn (Builder $query) => $query
->whereIn(OrderDomainObjectAbstract::STATUS, [
OrderStatus::COMPLETED->name,
OrderStatus::CANCELLED->name,
OrderStatus::AWAITING_OFFLINE_PAYMENT->name,
])
->orWhere(static fn (Builder $reserved) => $reserved
->where(OrderDomainObjectAbstract::STATUS, OrderStatus::RESERVED->name)
->where(OrderDomainObjectAbstract::RESERVED_UNTIL, '>', now())),
]) !== null;
}

private function loadEvent(UpdateEventTypeDTO $dto): EventDomainObject
{
return $this->eventRepository
->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, nested: [
new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
], name: 'event_location'))
->loadRelation(new Relationship(domainObject: EventOccurrenceDomainObject::class, nested: [
new Relationship(domainObject: EventLocationDomainObject::class, nested: [
new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
], name: 'event_location'),
]))
->findFirstWhere([
EventDomainObjectAbstract::ID => $dto->event_id,
EventDomainObjectAbstract::ACCOUNT_ID => $dto->account_id,
]);
}
}
Loading
Loading