Skip to content

Commit 2f12e59

Browse files
authored
Feature: Occurrence improvements (#1212)
1 parent c383205 commit 2f12e59

66 files changed

Lines changed: 6811 additions & 5598 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/app/DomainObjects/EventDomainObject.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -297,12 +297,11 @@ public function getLifecycleStatus(): string
297297
return EventLifecycleStatus::ONGOING->name;
298298
}
299299

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

304304
return EventLifecycleStatus::ENDED->name;
305-
306305
}
307306

308307
public function isRecurring(): bool

backend/app/Repository/Eloquent/EventRepository.php

Lines changed: 48 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -51,25 +51,57 @@ public function findEventsForOrganizer(int $organizerId, int $accountId, QueryPa
5151

5252
public function findEvents(array $where, QueryParamsDTO $params): LengthAwarePaginator
5353
{
54-
if (!empty($params->query)) {
54+
if (! empty($params->query)) {
5555
$where[] = static function (Builder $builder) use ($params) {
5656
$builder
57-
->where(EventDomainObjectAbstract::TITLE, 'ilike', '%' . $params->query . '%');
57+
->where(EventDomainObjectAbstract::TITLE, 'ilike', '%'.$params->query.'%');
5858
};
5959
}
6060

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

63-
if (!empty($params->filter_fields) && !$upcomingEventsFilter) {
64+
if (! empty($params->filter_fields)) {
6465
$this->applyFilterFields($params, EventDomainObject::getAllowedFilterFields());
6566
}
6667

67-
// Apply custom filter for upcoming events, as it keeps things less complex on the front-end
6868
if ($upcomingEventsFilter) {
69+
$where[] = static function (Builder $builder) {
70+
$builder
71+
->where(EventDomainObjectAbstract::STATUS, '!=', EventStatus::ARCHIVED->getName())
72+
->where(function (Builder $eventQuery) {
73+
$eventQuery
74+
->whereNotExists(function ($query) {
75+
$query->select(DB::raw(1))
76+
->from('event_occurrences')
77+
->whereColumn('event_occurrences.event_id', 'events.id')
78+
->whereNull('event_occurrences.deleted_at');
79+
})
80+
->orWhereExists(function ($query) {
81+
$query->select(DB::raw(1))
82+
->from('event_occurrences')
83+
->whereColumn('event_occurrences.event_id', 'events.id')
84+
->whereNull('event_occurrences.deleted_at')
85+
->where(function ($q) {
86+
$q->whereNull('event_occurrences.end_date')
87+
->orWhere('event_occurrences.end_date', '>=', now());
88+
});
89+
});
90+
});
91+
};
92+
}
93+
94+
if ($endedEventsFilter) {
6995
$where[] = static function (Builder $builder) {
7096
$builder
7197
->where(EventDomainObjectAbstract::STATUS, '!=', EventStatus::ARCHIVED->getName())
7298
->whereExists(function ($query) {
99+
$query->select(DB::raw(1))
100+
->from('event_occurrences')
101+
->whereColumn('event_occurrences.event_id', 'events.id')
102+
->whereNull('event_occurrences.deleted_at');
103+
})
104+
->whereNotExists(function ($query) {
73105
$query->select(DB::raw(1))
74106
->from('event_occurrences')
75107
->whereColumn('event_occurrences.event_id', 'events.id')
@@ -80,11 +112,6 @@ public function findEvents(array $where, QueryParamsDTO $params): LengthAwarePag
80112
});
81113
});
82114
};
83-
84-
$organizerId = $params->filter_fields->first(fn($filter) => $filter->field === EventDomainObjectAbstract::ORGANIZER_ID)?->value;
85-
if ($organizerId) {
86-
$this->model = $this->model->where(EventDomainObjectAbstract::ORGANIZER_ID, $organizerId);
87-
}
88115
}
89116

90117
$this->model = $this->model->orderBy(
@@ -135,9 +162,9 @@ public function getAllEventsForAdmin(
135162

136163
if ($search) {
137164
$this->model = $this->model->where(function ($q) use ($search) {
138-
$q->where(EventDomainObjectAbstract::TITLE, 'ilike', '%' . $search . '%')
165+
$q->where(EventDomainObjectAbstract::TITLE, 'ilike', '%'.$search.'%')
139166
->orWhereHas('organizer', function ($orgQuery) use ($search) {
140-
$orgQuery->where('name', 'ilike', '%' . $search . '%');
167+
$orgQuery->where('name', 'ilike', '%'.$search.'%');
141168
});
142169
});
143170
}
@@ -159,15 +186,15 @@ public function getSitemapEvents(int $page, int $perPage): LengthAwarePaginator
159186
{
160187
return $this->handleResults($this->model
161188
->select([
162-
'events.' . EventDomainObjectAbstract::ID,
163-
'events.' . EventDomainObjectAbstract::TITLE,
164-
'events.' . EventDomainObjectAbstract::UPDATED_AT,
189+
'events.'.EventDomainObjectAbstract::ID,
190+
'events.'.EventDomainObjectAbstract::TITLE,
191+
'events.'.EventDomainObjectAbstract::UPDATED_AT,
165192
])
166193
->join('event_settings', 'events.id', '=', 'event_settings.event_id')
167-
->where('events.' . EventDomainObjectAbstract::STATUS, EventStatus::LIVE->name)
168-
->where('event_settings.' . EventSettingDomainObjectAbstract::ALLOW_SEARCH_ENGINE_INDEXING, true)
169-
->whereNull('events.' . EventDomainObjectAbstract::DELETED_AT)
170-
->orderBy('events.' . EventDomainObjectAbstract::ID)
194+
->where('events.'.EventDomainObjectAbstract::STATUS, EventStatus::LIVE->name)
195+
->where('event_settings.'.EventSettingDomainObjectAbstract::ALLOW_SEARCH_ENGINE_INDEXING, true)
196+
->whereNull('events.'.EventDomainObjectAbstract::DELETED_AT)
197+
->orderBy('events.'.EventDomainObjectAbstract::ID)
171198
->paginate($perPage, ['*'], 'page', $page));
172199
}
173200

@@ -186,9 +213,9 @@ public function getSitemapEventCount(): int
186213
return $this->model
187214
->newQuery()
188215
->join('event_settings', 'events.id', '=', 'event_settings.event_id')
189-
->where('events.' . EventDomainObjectAbstract::STATUS, EventStatus::LIVE->name)
190-
->where('event_settings.' . EventSettingDomainObjectAbstract::ALLOW_SEARCH_ENGINE_INDEXING, true)
191-
->whereNull('events.' . EventDomainObjectAbstract::DELETED_AT)
216+
->where('events.'.EventDomainObjectAbstract::STATUS, EventStatus::LIVE->name)
217+
->where('event_settings.'.EventSettingDomainObjectAbstract::ALLOW_SEARCH_ENGINE_INDEXING, true)
218+
->whereNull('events.'.EventDomainObjectAbstract::DELETED_AT)
192219
->count();
193220
}
194221
}

backend/app/Services/Application/Handlers/EventOccurrence/UpdateProductVisibilityHandler.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ public function handle(UpdateProductVisibilityDTO $dto): Collection
5050
ProductDomainObjectAbstract::EVENT_ID => $dto->event_id,
5151
]);
5252

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

5656
$invalidIds = array_diff($selectedProductIds, $allProductIds);

backend/app/Services/Domain/EventOccurrence/OccurrencePurchaseEligibilityService.php

Lines changed: 30 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,6 @@
1010
use HiEvents\Repository\Interfaces\ProductOccurrenceVisibilityRepositoryInterface;
1111
use Illuminate\Validation\ValidationException;
1212

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

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

6341
if ($occurrence->isCancelled()) {
6442
throw ValidationException::withMessages([
65-
'event_occurrence_id' => __('This event occurrence has been cancelled'),
43+
'event_occurrence_id' => $this->purchasabilityMessage(
44+
$eventId,
45+
__('This event occurrence has been cancelled'),
46+
__('This event has been cancelled'),
47+
),
6648
]);
6749
}
6850

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

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

@@ -94,7 +75,11 @@ public function assertOccurrencePurchasable(
9475
$available = $occurrence->getCapacity() - $occurrence->getUsedCapacity() - $reservedForOccurrence;
9576
if ($additionalQuantity > $available) {
9677
throw ValidationException::withMessages([
97-
'event_occurrence_id' => __('Not enough capacity available for this occurrence'),
78+
'event_occurrence_id' => $this->purchasabilityMessage(
79+
$eventId,
80+
__('Not enough capacity available for this occurrence'),
81+
__('Not enough capacity available for this event'),
82+
),
9883
]);
9984
}
10085
}
@@ -103,11 +88,6 @@ public function assertOccurrencePurchasable(
10388
}
10489

10590
/**
106-
* Verifies the product is visible on the occurrence. Visibility rules are an
107-
* allow-list — an occurrence with no rules is treated as "all products
108-
* visible" (the default), matching `ProductFilterService::filterByOccurrenceVisibility`
109-
* so the validator and the storefront filter agree.
110-
*
11191
* @param int[] $productIds
11292
*
11393
* @throws ValidationException
@@ -137,4 +117,14 @@ public function assertProductsVisibleOnOccurrence(int $occurrenceId, array $prod
137117
}
138118
}
139119
}
120+
121+
private function purchasabilityMessage(int $eventId, string $multiOccurrence, string $singleOccurrence): string
122+
{
123+
return $this->eventHasMultipleOccurrences($eventId) ? $multiOccurrence : $singleOccurrence;
124+
}
125+
126+
private function eventHasMultipleOccurrences(int $eventId): bool
127+
{
128+
return $this->occurrenceRepository->countWhere(['event_id' => $eventId]) > 1;
129+
}
140130
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Feature\Repository\Eloquent;
6+
7+
use HiEvents\DomainObjects\EventDomainObject;
8+
use HiEvents\Http\DTO\QueryParamsDTO;
9+
use HiEvents\Models\User;
10+
use HiEvents\Repository\Eloquent\EventRepository;
11+
use Illuminate\Foundation\Testing\DatabaseTransactions;
12+
use Illuminate\Support\Facades\DB;
13+
use Tests\TestCase;
14+
15+
class EventRepositoryTest extends TestCase
16+
{
17+
use DatabaseTransactions;
18+
19+
private int $accountId;
20+
21+
private int $organizerId;
22+
23+
private int $userId;
24+
25+
private int $eventWithoutOccurrencesId;
26+
27+
private int $eventWithFutureOccurrenceId;
28+
29+
private int $eventWithPastOccurrenceId;
30+
31+
protected function setUp(): void
32+
{
33+
parent::setUp();
34+
35+
$user = User::factory()->withAccount()->create();
36+
$this->userId = $user->id;
37+
$this->accountId = $user->accounts()->first()->id;
38+
39+
$now = now()->toDateTimeString();
40+
41+
$this->organizerId = DB::table('organizers')->insertGetId([
42+
'account_id' => $this->accountId,
43+
'name' => 'Events Organizer',
44+
'email' => 'events-organizer@example.test',
45+
'currency' => 'USD',
46+
'timezone' => 'UTC',
47+
'created_at' => $now,
48+
'updated_at' => $now,
49+
]);
50+
51+
$this->eventWithoutOccurrencesId = $this->createEvent('Event without occurrences');
52+
$this->eventWithFutureOccurrenceId = $this->createEvent('Event with future occurrence');
53+
$this->eventWithPastOccurrenceId = $this->createEvent('Event with past occurrence');
54+
55+
$this->createOccurrence($this->eventWithFutureOccurrenceId, now()->addDay(), now()->addDay()->addHours(2));
56+
$this->createOccurrence($this->eventWithPastOccurrenceId, now()->subDays(2), now()->subDays(2)->addHours(2));
57+
}
58+
59+
public function test_upcoming_filter_includes_events_with_no_occurrences(): void
60+
{
61+
$ids = $this->findEventIds('upcoming');
62+
63+
$this->assertContains($this->eventWithoutOccurrencesId, $ids);
64+
$this->assertContains($this->eventWithFutureOccurrenceId, $ids);
65+
$this->assertNotContains($this->eventWithPastOccurrenceId, $ids);
66+
}
67+
68+
public function test_ended_filter_only_includes_events_whose_occurrences_have_all_passed(): void
69+
{
70+
$ids = $this->findEventIds('ended');
71+
72+
$this->assertContains($this->eventWithPastOccurrenceId, $ids);
73+
$this->assertNotContains($this->eventWithoutOccurrencesId, $ids);
74+
$this->assertNotContains($this->eventWithFutureOccurrenceId, $ids);
75+
}
76+
77+
private function findEventIds(string $eventsStatus): array
78+
{
79+
$params = QueryParamsDTO::fromArray([
80+
'eventsStatus' => $eventsStatus,
81+
'sort_by' => 'created_at',
82+
'sort_direction' => 'desc',
83+
'per_page' => 100,
84+
]);
85+
86+
$result = $this->app->make(EventRepository::class)->findEvents(
87+
where: [
88+
'account_id' => $this->accountId,
89+
'organizer_id' => $this->organizerId,
90+
],
91+
params: $params,
92+
);
93+
94+
return collect($result->items())
95+
->map(fn (EventDomainObject $event) => $event->getId())
96+
->all();
97+
}
98+
99+
private function createEvent(string $title): int
100+
{
101+
$now = now()->toDateTimeString();
102+
103+
return DB::table('events')->insertGetId([
104+
'title' => $title,
105+
'status' => 'DRAFT',
106+
'account_id' => $this->accountId,
107+
'user_id' => $this->userId,
108+
'organizer_id' => $this->organizerId,
109+
'currency' => 'USD',
110+
'timezone' => 'UTC',
111+
'short_id' => 'evt_'.uniqid(),
112+
'created_at' => $now,
113+
'updated_at' => $now,
114+
]);
115+
}
116+
117+
private function createOccurrence(int $eventId, $startDate, $endDate): void
118+
{
119+
$now = now()->toDateTimeString();
120+
121+
DB::table('event_occurrences')->insert([
122+
'short_id' => 'occ_'.uniqid(),
123+
'event_id' => $eventId,
124+
'start_date' => $startDate->toDateTimeString(),
125+
'end_date' => $endDate->toDateTimeString(),
126+
'status' => 'ACTIVE',
127+
'used_capacity' => 0,
128+
'is_overridden' => false,
129+
'created_at' => $now,
130+
'updated_at' => $now,
131+
]);
132+
}
133+
}

0 commit comments

Comments
 (0)