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
3 changes: 1 addition & 2 deletions backend/app/DomainObjects/EventDomainObject.php
Original file line number Diff line number Diff line change
Expand Up @@ -297,12 +297,11 @@ public function getLifecycleStatus(): string
return EventLifecycleStatus::ONGOING->name;
}

if ($this->isEventInFuture()) {
if ($this->isEventInFuture() || $this->getStartDate() === null) {
return EventLifecycleStatus::UPCOMING->name;
}

return EventLifecycleStatus::ENDED->name;

}

public function isRecurring(): bool
Expand Down
69 changes: 48 additions & 21 deletions backend/app/Repository/Eloquent/EventRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,25 +51,57 @@ public function findEventsForOrganizer(int $organizerId, int $accountId, QueryPa

public function findEvents(array $where, QueryParamsDTO $params): LengthAwarePaginator
{
if (!empty($params->query)) {
if (! empty($params->query)) {
$where[] = static function (Builder $builder) use ($params) {
$builder
->where(EventDomainObjectAbstract::TITLE, 'ilike', '%' . $params->query . '%');
->where(EventDomainObjectAbstract::TITLE, 'ilike', '%'.$params->query.'%');
};
}

$upcomingEventsFilter = $params->query_params->get('eventsStatus') === 'upcoming';
$endedEventsFilter = $params->query_params->get('eventsStatus') === 'ended';

if (!empty($params->filter_fields) && !$upcomingEventsFilter) {
if (! empty($params->filter_fields)) {
$this->applyFilterFields($params, EventDomainObject::getAllowedFilterFields());
}

// Apply custom filter for upcoming events, as it keeps things less complex on the front-end
if ($upcomingEventsFilter) {
$where[] = static function (Builder $builder) {
$builder
->where(EventDomainObjectAbstract::STATUS, '!=', EventStatus::ARCHIVED->getName())
->where(function (Builder $eventQuery) {
$eventQuery
->whereNotExists(function ($query) {
$query->select(DB::raw(1))
->from('event_occurrences')
->whereColumn('event_occurrences.event_id', 'events.id')
->whereNull('event_occurrences.deleted_at');
})
->orWhereExists(function ($query) {
$query->select(DB::raw(1))
->from('event_occurrences')
->whereColumn('event_occurrences.event_id', 'events.id')
->whereNull('event_occurrences.deleted_at')
->where(function ($q) {
$q->whereNull('event_occurrences.end_date')
->orWhere('event_occurrences.end_date', '>=', now());
});
});
});
};
}

if ($endedEventsFilter) {
$where[] = static function (Builder $builder) {
$builder
->where(EventDomainObjectAbstract::STATUS, '!=', EventStatus::ARCHIVED->getName())
->whereExists(function ($query) {
$query->select(DB::raw(1))
->from('event_occurrences')
->whereColumn('event_occurrences.event_id', 'events.id')
->whereNull('event_occurrences.deleted_at');
})
->whereNotExists(function ($query) {
$query->select(DB::raw(1))
->from('event_occurrences')
->whereColumn('event_occurrences.event_id', 'events.id')
Expand All @@ -80,11 +112,6 @@ public function findEvents(array $where, QueryParamsDTO $params): LengthAwarePag
});
});
};

$organizerId = $params->filter_fields->first(fn($filter) => $filter->field === EventDomainObjectAbstract::ORGANIZER_ID)?->value;
if ($organizerId) {
$this->model = $this->model->where(EventDomainObjectAbstract::ORGANIZER_ID, $organizerId);
}
}

$this->model = $this->model->orderBy(
Expand Down Expand Up @@ -135,9 +162,9 @@ public function getAllEventsForAdmin(

if ($search) {
$this->model = $this->model->where(function ($q) use ($search) {
$q->where(EventDomainObjectAbstract::TITLE, 'ilike', '%' . $search . '%')
$q->where(EventDomainObjectAbstract::TITLE, 'ilike', '%'.$search.'%')
->orWhereHas('organizer', function ($orgQuery) use ($search) {
$orgQuery->where('name', 'ilike', '%' . $search . '%');
$orgQuery->where('name', 'ilike', '%'.$search.'%');
});
});
}
Expand All @@ -159,15 +186,15 @@ public function getSitemapEvents(int $page, int $perPage): LengthAwarePaginator
{
return $this->handleResults($this->model
->select([
'events.' . EventDomainObjectAbstract::ID,
'events.' . EventDomainObjectAbstract::TITLE,
'events.' . EventDomainObjectAbstract::UPDATED_AT,
'events.'.EventDomainObjectAbstract::ID,
'events.'.EventDomainObjectAbstract::TITLE,
'events.'.EventDomainObjectAbstract::UPDATED_AT,
])
->join('event_settings', 'events.id', '=', 'event_settings.event_id')
->where('events.' . EventDomainObjectAbstract::STATUS, EventStatus::LIVE->name)
->where('event_settings.' . EventSettingDomainObjectAbstract::ALLOW_SEARCH_ENGINE_INDEXING, true)
->whereNull('events.' . EventDomainObjectAbstract::DELETED_AT)
->orderBy('events.' . EventDomainObjectAbstract::ID)
->where('events.'.EventDomainObjectAbstract::STATUS, EventStatus::LIVE->name)
->where('event_settings.'.EventSettingDomainObjectAbstract::ALLOW_SEARCH_ENGINE_INDEXING, true)
->whereNull('events.'.EventDomainObjectAbstract::DELETED_AT)
->orderBy('events.'.EventDomainObjectAbstract::ID)
->paginate($perPage, ['*'], 'page', $page));
}

Expand All @@ -186,9 +213,9 @@ public function getSitemapEventCount(): int
return $this->model
->newQuery()
->join('event_settings', 'events.id', '=', 'event_settings.event_id')
->where('events.' . EventDomainObjectAbstract::STATUS, EventStatus::LIVE->name)
->where('event_settings.' . EventSettingDomainObjectAbstract::ALLOW_SEARCH_ENGINE_INDEXING, true)
->whereNull('events.' . EventDomainObjectAbstract::DELETED_AT)
->where('events.'.EventDomainObjectAbstract::STATUS, EventStatus::LIVE->name)
->where('event_settings.'.EventSettingDomainObjectAbstract::ALLOW_SEARCH_ENGINE_INDEXING, true)
->whereNull('events.'.EventDomainObjectAbstract::DELETED_AT)
->count();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public function handle(UpdateProductVisibilityDTO $dto): Collection
ProductDomainObjectAbstract::EVENT_ID => $dto->event_id,
]);

$allProductIds = $allProducts->pluck('id')->sort()->values()->toArray();
$allProductIds = $allProducts->map(fn ($product) => $product->getId())->sort()->values()->toArray();
$selectedProductIds = collect($dto->product_ids)->sort()->values()->toArray();

$invalidIds = array_diff($selectedProductIds, $allProductIds);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,6 @@
use HiEvents\Repository\Interfaces\ProductOccurrenceVisibilityRepositoryInterface;
use Illuminate\Validation\ValidationException;

/**
* Single source of truth for "can this occurrence currently receive a purchase?".
*
* Used by both the public checkout validator and manual-attendee creation so the
* two paths cannot drift apart. Intentionally narrow:
*
* - status / visibility / capacity checks live here
* - per-product per-price availability and quantity math live in the existing
* `AvailableProductQuantitiesFetchService`; callers compose the two
*
* Consolidating here matters because the manual-attendee path previously skipped
* all of these checks, letting organisers issue tickets against cancelled or
* sold-out occurrences and bypass occurrence-scoped product visibility rules.
*/
class OccurrencePurchaseEligibilityService
{
public function __construct(
Expand All @@ -33,14 +19,6 @@ public function __construct(
) {}

/**
* Loads and validates the occurrence is eligible to receive an additional
* purchase of the given quantity. Returns the loaded domain object on
* success — saves the caller a duplicate fetch.
*
* `$overrideCapacity` is for organiser-initiated manual creation only; it
* skips the capacity ceiling but still enforces status (a cancelled
* occurrence has no seats to override into).
*
* @throws ValidationException
*/
public function assertOccurrencePurchasable(
Expand All @@ -62,28 +40,31 @@ public function assertOccurrencePurchasable(

if ($occurrence->isCancelled()) {
throw ValidationException::withMessages([
'event_occurrence_id' => __('This event occurrence has been cancelled'),
'event_occurrence_id' => $this->purchasabilityMessage(
$eventId,
__('This event occurrence has been cancelled'),
__('This event has been cancelled'),
),
]);
}

// Past dates are blocked even when capacity is overridden — selling or
// manually issuing tickets for a session that has already ended is
// never the intended behaviour, and the public payload already filters
// these out so any request reaching here is stale or hand-crafted.
if ($occurrence->isPast()) {
throw ValidationException::withMessages([
'event_occurrence_id' => __('This event occurrence has already ended'),
'event_occurrence_id' => $this->purchasabilityMessage(
$eventId,
__('This event occurrence has already ended'),
__('This event has already ended'),
),
]);
}

// SOLD_OUT is a capacity-derived status (ProductQuantityUpdateService
// flips it whenever used_capacity >= capacity), so blocking it here
// before the capacity-override branch would defeat the override flag in
// the most common case it was added for. Treat SOLD_OUT as a normal
// capacity gate that the override can bypass.
if (! $overrideCapacity && $occurrence->isSoldOut()) {
throw ValidationException::withMessages([
'event_occurrence_id' => __('This event occurrence is sold out'),
'event_occurrence_id' => $this->purchasabilityMessage(
$eventId,
__('This event occurrence is sold out'),
__('This event is sold out'),
),
]);
}

Expand All @@ -94,7 +75,11 @@ public function assertOccurrencePurchasable(
$available = $occurrence->getCapacity() - $occurrence->getUsedCapacity() - $reservedForOccurrence;
if ($additionalQuantity > $available) {
throw ValidationException::withMessages([
'event_occurrence_id' => __('Not enough capacity available for this occurrence'),
'event_occurrence_id' => $this->purchasabilityMessage(
$eventId,
__('Not enough capacity available for this occurrence'),
__('Not enough capacity available for this event'),
),
]);
}
}
Expand All @@ -103,11 +88,6 @@ public function assertOccurrencePurchasable(
}

/**
* Verifies the product is visible on the occurrence. Visibility rules are an
* allow-list — an occurrence with no rules is treated as "all products
* visible" (the default), matching `ProductFilterService::filterByOccurrenceVisibility`
* so the validator and the storefront filter agree.
*
* @param int[] $productIds
*
* @throws ValidationException
Expand Down Expand Up @@ -137,4 +117,14 @@ public function assertProductsVisibleOnOccurrence(int $occurrenceId, array $prod
}
}
}

private function purchasabilityMessage(int $eventId, string $multiOccurrence, string $singleOccurrence): string
{
return $this->eventHasMultipleOccurrences($eventId) ? $multiOccurrence : $singleOccurrence;
}

private function eventHasMultipleOccurrences(int $eventId): bool
{
return $this->occurrenceRepository->countWhere(['event_id' => $eventId]) > 1;
}
}
133 changes: 133 additions & 0 deletions backend/tests/Feature/Repository/Eloquent/EventRepositoryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<?php

declare(strict_types=1);

namespace Tests\Feature\Repository\Eloquent;

use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\Http\DTO\QueryParamsDTO;
use HiEvents\Models\User;
use HiEvents\Repository\Eloquent\EventRepository;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;

class EventRepositoryTest extends TestCase
{
use DatabaseTransactions;

private int $accountId;

private int $organizerId;

private int $userId;

private int $eventWithoutOccurrencesId;

private int $eventWithFutureOccurrenceId;

private int $eventWithPastOccurrenceId;

protected function setUp(): void
{
parent::setUp();

$user = User::factory()->withAccount()->create();
$this->userId = $user->id;
$this->accountId = $user->accounts()->first()->id;

$now = now()->toDateTimeString();

$this->organizerId = DB::table('organizers')->insertGetId([
'account_id' => $this->accountId,
'name' => 'Events Organizer',
'email' => 'events-organizer@example.test',
'currency' => 'USD',
'timezone' => 'UTC',
'created_at' => $now,
'updated_at' => $now,
]);

$this->eventWithoutOccurrencesId = $this->createEvent('Event without occurrences');
$this->eventWithFutureOccurrenceId = $this->createEvent('Event with future occurrence');
$this->eventWithPastOccurrenceId = $this->createEvent('Event with past occurrence');

$this->createOccurrence($this->eventWithFutureOccurrenceId, now()->addDay(), now()->addDay()->addHours(2));
$this->createOccurrence($this->eventWithPastOccurrenceId, now()->subDays(2), now()->subDays(2)->addHours(2));
}

public function test_upcoming_filter_includes_events_with_no_occurrences(): void
{
$ids = $this->findEventIds('upcoming');

$this->assertContains($this->eventWithoutOccurrencesId, $ids);
$this->assertContains($this->eventWithFutureOccurrenceId, $ids);
$this->assertNotContains($this->eventWithPastOccurrenceId, $ids);
}

public function test_ended_filter_only_includes_events_whose_occurrences_have_all_passed(): void
{
$ids = $this->findEventIds('ended');

$this->assertContains($this->eventWithPastOccurrenceId, $ids);
$this->assertNotContains($this->eventWithoutOccurrencesId, $ids);
$this->assertNotContains($this->eventWithFutureOccurrenceId, $ids);
}

private function findEventIds(string $eventsStatus): array
{
$params = QueryParamsDTO::fromArray([
'eventsStatus' => $eventsStatus,
'sort_by' => 'created_at',
'sort_direction' => 'desc',
'per_page' => 100,
]);

$result = $this->app->make(EventRepository::class)->findEvents(
where: [
'account_id' => $this->accountId,
'organizer_id' => $this->organizerId,
],
params: $params,
);

return collect($result->items())
->map(fn (EventDomainObject $event) => $event->getId())
->all();
}

private function createEvent(string $title): int
{
$now = now()->toDateTimeString();

return DB::table('events')->insertGetId([
'title' => $title,
'status' => 'DRAFT',
'account_id' => $this->accountId,
'user_id' => $this->userId,
'organizer_id' => $this->organizerId,
'currency' => 'USD',
'timezone' => 'UTC',
'short_id' => 'evt_'.uniqid(),
'created_at' => $now,
'updated_at' => $now,
]);
}

private function createOccurrence(int $eventId, $startDate, $endDate): void
{
$now = now()->toDateTimeString();

DB::table('event_occurrences')->insert([
'short_id' => 'occ_'.uniqid(),
'event_id' => $eventId,
'start_date' => $startDate->toDateTimeString(),
'end_date' => $endDate->toDateTimeString(),
'status' => 'ACTIVE',
'used_capacity' => 0,
'is_overridden' => false,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
Loading
Loading