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
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,11 @@ Gotchas:

#### E2E Tests
- There is a Playwright E2E suite in `e2e/` (see `e2e/README.md`). It runs the real stack (Laravel + SSR frontend + Postgres + Redis + Mailpit) in Docker.
- **To test uncommitted changes, run specs against the dev stack** — the hermetic e2e stack bakes source into images and `docker compose up` never rebuilds them. From `e2e/`:
```bash
E2E_BASE_URL=https://localhost:8443 MAILPIT_URL=http://localhost:8025 E2E_SAAS_MODE=true npx playwright test <spec>
```
`E2E_SAAS_MODE=true` is required (the dev stack requires email verification; the fixture only confirms via Mailpit in SaaS mode), a queue worker must be running to deliver the verification emails, and superadmin-dependent specs need a one-time `php artisan dev:bootstrap --email=superadmin@e2e.test --password='SuperAdminPass123!'`. See "Against the running dev stack" in `e2e/README.md`.
- **When you add or meaningfully change a user-facing flow, add or update an E2E spec for it where practical.** Follow the existing pattern: arrange data via the API/`factory`, drive only the flow under test through the UI with a thin page object, and assert on real page content (the created/edited item appears), not just a URL change. Tag fast, load-bearing checks with `@smoke`.
- Not everything needs E2E — reserve it for real user journeys (create/edit/complete flows). Pure logic belongs in backend unit/feature tests instead.

Expand Down
2 changes: 2 additions & 0 deletions backend/app/Console/Commands/BootstrapDevDataCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use HiEvents\DomainObjects\Enums\EventType;
use HiEvents\DomainObjects\Enums\ProductPriceType;
use HiEvents\DomainObjects\Enums\ProductType;
use HiEvents\DomainObjects\Enums\PromoCodeDiscountAppliesToEnum;
use HiEvents\DomainObjects\Enums\PromoCodeDiscountTypeEnum;
use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\DomainObjects\Status\EventStatus;
Expand Down Expand Up @@ -131,6 +132,7 @@ public function handle(
discount: 10.0,
expiry_date: null,
max_allowed_usages: null,
discount_applies_to: PromoCodeDiscountAppliesToEnum::EACH_PRODUCT,
));

$affiliate = $createAffiliateHandler->handle($singleEvent->getId(), $account->getId(), new UpsertAffiliateDTO(
Expand Down
11 changes: 11 additions & 0 deletions backend/app/DomainObjects/Enums/PromoCodeDiscountAppliesToEnum.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace HiEvents\DomainObjects\Enums;

enum PromoCodeDiscountAppliesToEnum
{
use BaseEnum;

case EACH_PRODUCT;
case ORDER;
}
97 changes: 52 additions & 45 deletions backend/app/DomainObjects/EventDomainObject.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

namespace HiEvents\DomainObjects;

use Carbon\Carbon;
use HiEvents\DomainObjects\Enums\EventType;
use HiEvents\DomainObjects\Interfaces\IsFilterable;
use HiEvents\DomainObjects\Interfaces\IsSortable;
Expand Down Expand Up @@ -48,6 +47,12 @@ class EventDomainObject extends Generated\EventDomainObjectAbstract implements I

private bool $upcomingOccurrencesSoldOut = false;

private ?string $nextOccurrenceStartDate = null;

private ?string $lastOccurrenceStartDate = null;

private ?string $occurrencesMonth = null;

public static function getAllowedFilterFields(): array
{
return [
Expand Down Expand Up @@ -236,49 +241,54 @@ public function getEndDate(): ?string
);
}

public function getNextOccurrenceStartDate(): ?string
public function setNextOccurrenceStartDate(?string $nextOccurrenceStartDate): self
{
if ($this->eventOccurrences === null || $this->eventOccurrences->isEmpty()) {
return null;
}
$this->nextOccurrenceStartDate = $nextOccurrenceStartDate;

$nextOccurrence = $this->eventOccurrences
->filter(fn (EventOccurrenceDomainObject $o) => $o->getStatus() === EventOccurrenceStatus::ACTIVE->name)
->filter(fn (EventOccurrenceDomainObject $o) => ! $o->isPast())
->sortBy(fn (EventOccurrenceDomainObject $o) => $o->getStartDate())
->first();
return $this;
}

return $nextOccurrence?->getStartDate();
public function setLastOccurrenceStartDate(?string $lastOccurrenceStartDate): self
{
$this->lastOccurrenceStartDate = $lastOccurrenceStartDate;

return $this;
}

public function isEventInPast(): bool
public function getLastOccurrenceStartDate(): ?string
{
$endDate = $this->getEndDate();
if ($endDate === null) {
return false;
}
return $this->lastOccurrenceStartDate;
}

$parsed = Carbon::parse($endDate);
if ($this->getTimezone()) {
$parsed->setTimezone($this->getTimezone());
}
public function setOccurrencesMonth(?string $occurrencesMonth): self
{
$this->occurrencesMonth = $occurrencesMonth;

return $parsed->isPast();
return $this;
}

public function isEventInFuture(): bool
public function getOccurrencesMonth(): ?string
{
$startDate = $this->getStartDate();
if ($startDate === null) {
return false;
return $this->occurrencesMonth;
}

public function getNextOccurrenceStartDate(): ?string
{
if ($this->nextOccurrenceStartDate !== null) {
return $this->nextOccurrenceStartDate;
}

$parsed = Carbon::parse($startDate);
if ($this->getTimezone()) {
$parsed->setTimezone($this->getTimezone());
if ($this->eventOccurrences === null || $this->eventOccurrences->isEmpty()) {
return null;
}

return $parsed->isFuture();
$nextOccurrence = $this->eventOccurrences
->filter(fn (EventOccurrenceDomainObject $o) => $o->getStatus() === EventOccurrenceStatus::ACTIVE->name)
->filter(fn (EventOccurrenceDomainObject $o) => ! $o->isPast())
->sortBy(fn (EventOccurrenceDomainObject $o) => $o->getStartDate())
->first();

return $nextOccurrence?->getStartDate();
}

public function isEventOngoing(): bool
Expand All @@ -287,20 +297,11 @@ public function isEventOngoing(): bool
return false;
}

foreach ($this->eventOccurrences as $occurrence) {
if ($occurrence->getStatus() !== EventOccurrenceStatus::ACTIVE->name) {
continue;
}

$start = Carbon::parse($occurrence->getStartDate(), 'UTC');
$end = $occurrence->getEndDate() ? Carbon::parse($occurrence->getEndDate(), 'UTC') : null;

if ($start->isPast() && ($end === null || $end->isFuture())) {
return true;
}
}

return false;
return $this->eventOccurrences->contains(
fn (EventOccurrenceDomainObject $o) => $o->getStatus() === EventOccurrenceStatus::ACTIVE->name
&& ! $o->isFuture()
&& ! $o->isPast()
);
}

public function getLifecycleStatus(): string
Expand All @@ -309,11 +310,17 @@ public function getLifecycleStatus(): string
return EventLifecycleStatus::ONGOING->name;
}

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

return EventLifecycleStatus::ENDED->name;
$hasOccurrenceStillToCome = $this->eventOccurrences->contains(
fn (EventOccurrenceDomainObject $o) => ! $o->isPast()
);

return $hasOccurrenceStillToCome
? EventLifecycleStatus::UPCOMING->name
: EventLifecycleStatus::ENDED->name;
}

public function isRecurring(): bool
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ abstract class PromoCodeDomainObjectAbstract extends \HiEvents\DomainObjects\Abs
final public const CREATED_AT = 'created_at';
final public const UPDATED_AT = 'updated_at';
final public const DELETED_AT = 'deleted_at';
final public const DISCOUNT_APPLIES_TO = 'discount_applies_to';

protected int $id;
protected int $event_id;
Expand All @@ -37,6 +38,7 @@ abstract class PromoCodeDomainObjectAbstract extends \HiEvents\DomainObjects\Abs
protected string $created_at;
protected ?string $updated_at = null;
protected ?string $deleted_at = null;
protected string $discount_applies_to = 'EACH_PRODUCT';

public function toArray(): array
{
Expand All @@ -54,6 +56,7 @@ public function toArray(): array
'created_at' => $this->created_at ?? null,
'updated_at' => $this->updated_at ?? null,
'deleted_at' => $this->deleted_at ?? null,
'discount_applies_to' => $this->discount_applies_to ?? null,
];
}

Expand Down Expand Up @@ -199,4 +202,15 @@ public function getDeletedAt(): ?string
{
return $this->deleted_at;
}

public function setDiscountAppliesTo(string $discount_applies_to): self
{
$this->discount_applies_to = $discount_applies_to;
return $this;
}

public function getDiscountAppliesTo(): string
{
return $this->discount_applies_to;
}
}
8 changes: 7 additions & 1 deletion backend/app/DomainObjects/PromoCodeDomainObject.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace HiEvents\DomainObjects;

use Carbon\Carbon;
use HiEvents\DomainObjects\Enums\PromoCodeDiscountAppliesToEnum;
use HiEvents\DomainObjects\Enums\PromoCodeDiscountTypeEnum;
use HiEvents\DomainObjects\Interfaces\IsSortable;
use HiEvents\DomainObjects\SortingAndFiltering\AllowedSorts;
Expand Down Expand Up @@ -59,7 +60,6 @@ public function isValid(): bool

public function appliesToProduct(ProductDomainObject $product): bool
{
// If there's no product IDs we apply the promo to all products
if (! $this->getApplicableProductIds()) {
return true;
}
Expand All @@ -81,4 +81,10 @@ public function isNoDiscountCode(): bool
{
return $this->getDiscountType() === PromoCodeDiscountTypeEnum::NONE->name;
}

public function isOrderLevelDiscount(): bool
{
return $this->isFixedDiscount()
&& $this->getDiscountAppliesTo() === PromoCodeDiscountAppliesToEnum::ORDER->name;
}
}
2 changes: 2 additions & 0 deletions backend/app/Exports/PromoCodesExport.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public function headings(): array
'Code',
'Discount',
'Discount Type',
'Discount Applies To',
'Max Allowed Uses',
'Expiry Date',
'Event ID',
Expand All @@ -49,6 +50,7 @@ public function map($discountCode): array
$discountCode->getCode(),
$discountCode->getDiscount(),
$discountCode->getDiscountType(),
$discountCode->getDiscountAppliesTo(),
$discountCode->getMaxAllowedUsages(),
$discountCode->getExpiryDate(),
$discountCode->getEventId(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

namespace HiEvents\Http\Actions\EventOccurrences;

use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\Exceptions\InvalidOccurrenceDatesException;
use HiEvents\Http\Actions\Events\BasePublicEventAction;
use HiEvents\Resources\EventOccurrence\EventOccurrenceResourcePublic;
use HiEvents\Services\Application\Handlers\EventOccurrence\DTO\GetPublicEventOccurrencesDTO;
use HiEvents\Services\Application\Handlers\EventOccurrence\GetPublicEventOccurrencesHandler;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Validation\ValidationException;

class GetEventOccurrencesPublicAction extends BasePublicEventAction
{
public function __construct(
private readonly GetPublicEventOccurrencesHandler $handler,
) {}

/**
* @throws ValidationException
*/
public function __invoke(int $eventId, Request $request): Response|JsonResponse
{
$startDateFrom = $request->query('start_date_from');
$startDateTo = $request->query('start_date_to');

try {
$result = $this->handler->handle(new GetPublicEventOccurrencesDTO(
eventId: $eventId,
startDateFrom: is_string($startDateFrom) ? $startDateFrom : null,
startDateTo: is_string($startDateTo) ? $startDateTo : null,
));
} catch (InvalidOccurrenceDatesException $exception) {
throw ValidationException::withMessages([
'start_date_from' => $exception->getMessage(),
]);
}

if (! $this->canUserViewEvent($result->event)) {
return $this->notFoundResponse();
}

$showCapacity = $result->event->getEventSettings()?->getShowAvailableOccurrenceCapacity() ?? false;

return $this->jsonResponse([
'data' => $result->occurrences->map(
fn (EventOccurrenceDomainObject $occurrence) => new EventOccurrenceResourcePublic($occurrence, $showCapacity)
)->values(),
]);
}
}
36 changes: 36 additions & 0 deletions backend/app/Http/Actions/Events/BasePublicEventAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace HiEvents\Http\Actions\Events;

use HiEvents\DomainObjects\Enums\Role;
use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\DomainObjects\Status\EventStatus;
use HiEvents\Http\Actions\BaseAction;
use Illuminate\Support\Facades\Log;

abstract class BasePublicEventAction extends BaseAction
{
protected function canUserViewEvent(EventDomainObject $event): bool
{
if ($event->getStatus() === EventStatus::LIVE->name) {
return true;
}

if ($this->isUserAuthenticated() && $event->getAccountId() === $this->getAuthenticatedAccountId()) {
return true;
}

if ($this->isUserAuthenticated() && $this->getAuthenticatedUserRole() === Role::SUPERADMIN) {
Log::debug(__('Superadmin user is viewing non-live event with ID :eventId', [
'eventId' => $event->getId(),
'accountId' => $this->getAuthenticatedAccountId(),
]));

return true;
}

return false;
}
}
Loading
Loading