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
13 changes: 13 additions & 0 deletions backend/app/DomainObjects/EventDomainObject.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ class EventDomainObject extends Generated\EventDomainObjectAbstract implements I

private ?string $occurrencesMonth = null;

private ?string $lifecycleStatus = null;

public static function getAllowedFilterFields(): array
{
return [
Expand Down Expand Up @@ -308,8 +310,19 @@ public function isEventOngoing(): bool
);
}

public function setLifecycleStatus(string $lifecycleStatus): self
{
$this->lifecycleStatus = $lifecycleStatus;

return $this;
}

public function getLifecycleStatus(): string
{
if ($this->lifecycleStatus !== null) {
return $this->lifecycleStatus;
}

if ($this->isEventOngoing()) {
return EventLifecycleStatus::ONGOING->name;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use HiEvents\DomainObjects\ProductCategoryDomainObject;
use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\DomainObjects\ProductPriceDomainObject;
use HiEvents\DomainObjects\Status\EventLifecycleStatus;
use HiEvents\DomainObjects\Status\EventOccurrenceStatus;
use HiEvents\DomainObjects\TaxAndFeesDomainObject;
use HiEvents\Repository\Eloquent\Value\OrderAndDirection;
Expand Down Expand Up @@ -122,8 +123,13 @@ private function setRecurringEventOccurrences(
: [...$occurrenceWhere, PublicOccurrenceVisibilityService::hasRemainingCapacity()];

$nextBookable = $this->findEdgeOccurrence($nextBookableWhere, 'asc');
$lastUpcoming = $this->findEdgeOccurrence($occurrenceWhere, 'desc');
$event->setNextOccurrenceStartDate($nextBookable?->getStartDate());
$event->setLastOccurrenceStartDate($this->findEdgeOccurrence($occurrenceWhere, 'desc')?->getStartDate());
$event->setLastOccurrenceStartDate($lastUpcoming?->getStartDate());

if ($lastUpcoming === null) {
$this->setLifecycleStatusForEndedEvent($event, $eventId);
}

$anchorOccurrence = $verifiedOccurrence ?? $nextBookable;
if ($anchorOccurrence === null && ! $hideSoldOutOccurrences) {
Expand Down Expand Up @@ -164,6 +170,18 @@ static function ($query): void {
}
}

private function setLifecycleStatusForEndedEvent(EventDomainObject $event, int $eventId): void
{
$latestOccurrence = $this->findEdgeOccurrence([
EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId,
[EventOccurrenceDomainObjectAbstract::STATUS, '!=', EventOccurrenceStatus::CANCELLED->name],
], 'desc');

if ($latestOccurrence?->isPast()) {
$event->setLifecycleStatus(EventLifecycleStatus::ENDED->name);
}
}

private function fetchOccurrences(array $where, ?EventOccurrenceDomainObject $verifiedOccurrence): PublicOccurrenceFetchResultDTO
{
$occurrences = $this->occurrenceRepository
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use HiEvents\DomainObjects\ProductCategoryDomainObject;
use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\DomainObjects\PromoCodeDomainObject;
use HiEvents\DomainObjects\Status\EventLifecycleStatus;
use HiEvents\DomainObjects\Status\EventOccurrenceStatus;
use HiEvents\Repository\Eloquent\Value\OrderAndDirection;
use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
Expand Down Expand Up @@ -592,6 +593,78 @@ public function test_handle_sets_null_occurrences_month_when_anchor_month_trunca
$this->assertCount(GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES, $result->getEventOccurrences());
}

public function test_handle_marks_recurring_event_ended_when_all_occurrences_are_past(): void
{
$data = new GetPublicEventDTO(eventId: 1, isAuthenticated: true, ipAddress: '127.0.0.1', promoCode: null);
$event = (new EventDomainObject)
->setType(EventType::RECURRING->name)
->setTimezone('UTC')
->setEventSettings((new EventSettingDomainObject)->setHideSoldOutOccurrences(false))
->setProductCategories(collect());

$this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
$this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf();
$this->eventRepository->shouldReceive('findById')->with($data->eventId)->andReturn($event);
$this->expectLatestOccurrenceQuery($this->makeOccurrence(3, '2024-01-01 10:00:00'));
$this->expectEdgeOccurrenceQueries();
$this->occurrenceRepository
->shouldReceive('findWhere')
->with(m::any(), m::any(), m::any(), GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES + 1)
->andReturn(collect());
$this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturnNull();
$this->ticketFilterService->shouldReceive('filter')->once()->withAnyArgs()->andReturn(collect());
$this->eventPageViewIncrementService->shouldNotReceive('increment');

$result = $this->handler->handle($data);

$this->assertSame(EventLifecycleStatus::ENDED->name, $result->getLifecycleStatus());
}

public function test_handle_does_not_mark_recurring_event_ended_when_upcoming_occurrences_are_hidden(): void
{
$data = new GetPublicEventDTO(eventId: 1, isAuthenticated: true, ipAddress: '127.0.0.1', promoCode: null);
$event = (new EventDomainObject)
->setType(EventType::RECURRING->name)
->setTimezone('UTC')
->setEventSettings((new EventSettingDomainObject)->setHideSoldOutOccurrences(true))
->setProductCategories(collect());

$this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
$this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf();
$this->eventRepository->shouldReceive('findById')->with($data->eventId)->andReturn($event);
$this->expectLatestOccurrenceQuery($this->makeOccurrence(3, '2099-01-01 10:00:00'));
$this->expectEdgeOccurrenceQueries();
$this->occurrenceRepository
->shouldReceive('findWhere')
->with(m::any(), m::any(), m::any(), GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES + 1)
->andReturn(collect());
$this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturnNull();
$this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturnNull();
$this->ticketFilterService->shouldReceive('filter')->once()->withAnyArgs()->andReturn(collect());
$this->eventPageViewIncrementService->shouldNotReceive('increment');

$result = $this->handler->handle($data);

$this->assertSame(EventLifecycleStatus::UPCOMING->name, $result->getLifecycleStatus());
}

private function expectLatestOccurrenceQuery(EventOccurrenceDomainObject $latestOccurrence): void
{
$this->occurrenceRepository
->shouldReceive('findWhere')
->once()
->with(
m::on(static fn (array $where): bool => ! collect($where)->contains(
static fn ($condition): bool => $condition instanceof Closure
)),
m::any(),
m::on(static fn (array $orders): bool => ($orders[0] ?? null) instanceof OrderAndDirection
&& $orders[0]->getDirection() === OrderAndDirection::DIRECTION_DESC),
1,
)
->andReturn(collect([$latestOccurrence]));
}

private function expectEdgeOccurrenceQueries(
?EventOccurrenceDomainObject $nextBookable = null,
?EventOccurrenceDomainObject $lastOccurrence = null,
Expand Down
10 changes: 10 additions & 0 deletions e2e/api/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ import type {
CreateTaxOrFeePayload,
CreateWebhookPayload,
EmailTemplate,
EventImageType,
EventRecord,
EventSettings,
EventStatus,
ImageRecord,
InviteUserPayload,
Me,
Occurrence,
Expand Down Expand Up @@ -130,6 +132,14 @@ export class ApiClient {
return unwrap<EventRecord>(this.request.post('events', { headers: jsonHeaders, data: payload }));
}

uploadEventImage(
eventId: number,
image: { name: string; mimeType: string; buffer: Buffer },
type: EventImageType = 'EVENT_COVER',
): Promise<ImageRecord> {
return unwrap<ImageRecord>(this.request.post(`events/${eventId}/images`, { multipart: { image, type } }));
}

listProductCategories(eventId: number): Promise<ProductCategory[]> {
return unwrap<ProductCategory[]>(this.request.get(`events/${eventId}/product-categories`, { headers: jsonHeaders }));
}
Expand Down
39 changes: 38 additions & 1 deletion e2e/api/factory.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import type { APIRequestContext } from '@playwright/test';
import type { ApiClient } from './api-client';
import type {
Expand Down Expand Up @@ -29,6 +31,7 @@ export interface SeededEvent {

interface SeedOptions {
organizerId: number;
startDate?: string;
price?: number;
productType?: ProductPriceType;
eventType?: EventType;
Expand All @@ -55,6 +58,19 @@ const futureStartDate = (): string => {
return date.toISOString();
};

const pastStartDate = (): string => {
const date = new Date();
date.setDate(date.getDate() - 30);
date.setHours(21, 0, 0, 0);
return date.toISOString();
};

const coverImage = (): { name: string; mimeType: string; buffer: Buffer } => ({
name: 'event-cover.png',
mimeType: 'image/png',
buffer: readFileSync(fileURLToPath(new URL('../fixtures/assets/event-cover.png', import.meta.url))),
});

export async function createLiveEventWithProduct(api: ApiClient, opts: SeedOptions): Promise<SeededEvent> {
const {
organizerId,
Expand All @@ -70,7 +86,7 @@ export async function createLiveEventWithProduct(api: ApiClient, opts: SeedOptio
title,
type: eventType,
organizer_id: organizerId,
start_date: futureStartDate(),
start_date: opts.startDate ?? futureStartDate(),
category,
currency: 'USD',
timezone: 'UTC',
Expand Down Expand Up @@ -306,6 +322,27 @@ export async function createSoldOutEvent(
return { ...event, consumedOrder };
}

export async function createPastEventWithCoverImage(
api: ApiClient,
organizerId: number,
opts: { title?: string; eventType?: EventType } = {},
): Promise<SeededEvent> {
const event = await createLiveEventWithProduct(api, {
organizerId,
startDate: pastStartDate(),
title: opts.title,
eventType: opts.eventType,
});

if (opts.eventType === 'RECURRING') {
await api.createOccurrence(event.eventId, { start_date: pastStartDate() });
}

await api.uploadEventImage(event.eventId, coverImage());

return event;
}

export async function createRecurringLiveEvent(
api: ApiClient,
organizerId: number,
Expand Down
8 changes: 8 additions & 0 deletions e2e/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ export interface EventRecord {
status: EventStatus;
}

export type EventImageType = 'EVENT_COVER' | 'TICKET_LOGO';

export interface ImageRecord {
id: number;
url: string;
type: EventImageType;
}

export interface ProductCategory {
id: number;
name: string;
Expand Down
Binary file added e2e/fixtures/assets/event-cover.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
35 changes: 35 additions & 0 deletions e2e/tests/events/past-event-page.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { test, expect } from '../../fixtures';
import { PublicEventPage } from '../../pages/public-event.page';
import { createFreshOrganizer, createPastEventWithCoverImage } from '../../api/factory';
import { uniqueName } from '../../utils/unique';

test.describe('past event page', () => {
test('a visitor sees a sales ended badge and no way to buy', async ({ page, api }) => {
const organizer = await createFreshOrganizer(api, uniqueName('E2E Past Org'));
await api.updateOrganizerStatus(organizer.id, 'LIVE');
const event = await createPastEventWithCoverImage(api, organizer.id, { title: uniqueName('E2E Past Event') });

const publicPage = new PublicEventPage(page);
await publicPage.goto(event.eventId, event.slug);

await expect(page.getByRole('heading', { name: event.title })).toBeVisible();
await expect(page.getByText('Sales ended', { exact: true })).toBeVisible();
await expect(page.getByText('Ticket sales have ended for this event')).toBeVisible();
await expect(page.getByText(event.productTitle)).toHaveCount(0);
});

test('a visitor sees a sales ended badge on a recurring event whose dates have all passed', async ({ page, api }) => {
const organizer = await createFreshOrganizer(api, uniqueName('E2E Past Recurring Org'));
await api.updateOrganizerStatus(organizer.id, 'LIVE');
const event = await createPastEventWithCoverImage(api, organizer.id, {
title: uniqueName('E2E Past Recurring Event'),
eventType: 'RECURRING',
});

const publicPage = new PublicEventPage(page);
await publicPage.goto(event.eventId, event.slug);

await expect(page.getByRole('heading', { name: event.title })).toBeVisible();
await expect(page.getByText('Sales ended', { exact: true })).toBeVisible();
});
});
10 changes: 8 additions & 2 deletions frontend/src/components/layouts/EventHomepage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import "../../../styles/widget/default.scss";
import React, {useCallback, useEffect, useRef, useState} from "react";
import {EventDocumentHead} from "../../common/EventDocumentHead";
import {eventCoverImage, eventHomepageUrl, imageUrl, organizerHomepageUrl} from "../../../utilites/urlHelper.ts";
import {Event, EventOccurrence, EventType, OrganizerStatus} from "../../../types.ts";
import {Event, EventLifecycleStatus, EventOccurrence, EventType, OrganizerStatus} from "../../../types.ts";
import {EventNotAvailable} from "./EventNotAvailable";
import {
IconArrowUpRight,
Expand Down Expand Up @@ -188,7 +188,13 @@ const EventHomepage = ({...loaderData}: EventHomepageProps) => {
config: socialMediaConfig[platform as keyof typeof socialMediaConfig]
})) : [];

const eventHasEnded = event.lifecycle_status === EventLifecycleStatus.ENDED;

const getStatusBadge = () => {
if (eventHasEnded) {
return {text: t`Sales ended`};
}

const products = event.products || event.product_categories?.flatMap(c => c.products || []) || [];

if (products.length > 0 && products.every(p => p.is_sold_out)) {
Expand Down Expand Up @@ -714,7 +720,7 @@ const EventHomepage = ({...loaderData}: EventHomepageProps) => {
: continueButtonText}
</button>
)}
{!showFloatingCheckoutButton && showScrollButton && (
{!showFloatingCheckoutButton && showScrollButton && !eventHasEnded && (
<button
className={classes.scrollToTicketsButton}
onClick={scrollToTickets}
Expand Down
Loading
Loading