Skip to content

Commit 93fb35c

Browse files
daveearleyclaude
andauthored
Add event lineup and schedule, and customisable homepage sections (#1406)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 35778b1 commit 93fb35c

337 files changed

Lines changed: 61269 additions & 5992 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.

‎CLAUDE.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,21 @@ Gotchas:
166166
- Code lives under `backend/ee/Seating/` and `frontend/src/ee/seating/`. A product is seated iff a price **band** links to it (never call a band a tier), and `seat_claims` has no status column — HELD/SOLD/BLOCKED derive from the owning order via `SeatClaimRepository`, so never add a sweeper or stored status
167167
- Any new path that sells a ticket must claim a seat (under `SeatingEventLockService::lock`, order lock first) or refuse seated products, and anything that voids a ticket must release its claim. Rules that exist in both PHP and TS are pinned to `backend/tests/Fixtures/seating/` — change the fixture first
168168

169+
#### Lineup & schedule
170+
- Profiles (speakers, artists, hosts) belong to the organizer and are reused across events. `event_lineup_entries` puts a profile on an event (billing tier, role, `is_hidden`), `event_spaces` are stages/rooms (optionally tied to one of the event's `event_locations`), and `event_schedule_items` are time slots linked to lineup entries through `event_schedule_item_performers`
171+
- Writing a profile needs organizer access; event-only members can add existing profiles to their event's lineup and schedule. Hidden entries, and untitled slots whose people are all hidden, are filtered for the public endpoint in `GetEventLineupHandler`
172+
- Profile photos are `PROFILE_IMAGE` images on the generic `/images` endpoint, ordered by `images.sort_order` (nulls last, so new uploads go to the end); the first is the main photo. Link types come from `ProfileLinkType::fromUrl`, never from the client
173+
- Schedule days use a 05:00 boundary (`DAY_BOUNDARY_HOUR` in `utilites/lineupSchedule.ts`, kept free of dependencies so e2e unit specs can import it) so late sets stay on their night. Per-date lineups for recurring events are derived, never stored: a date shows the slots that fall on it plus people with no slots at all (`lineupForOccurrence`)
174+
- Duplicating an event copies its lineup through `EventLineupCloneService`; single events shift slot times by the start-date offset, recurring events keep them
175+
- The run-sheet export (`LineupExport`) and the import share column names: the browser parses CSV/XLSX itself (`utilites/spreadsheetReader.ts`, XLSX via `DecompressionStream` + `DOMParser`, no library) and `utilites/lineupImport.ts` (dependency-free, unit-tested from e2e) builds the preview; the server only receives clean JSON. Header labels are translated with the Lingui context "Lineup spreadsheet column", whose translations must equal the backend's `__()` header strings so a localized export re-imports. People and stages match by `nameKey` (lowercase, collapsed whitespace) on both sides — keep `ImportLineupHandler::key` and `nameKey` identical or the preview lies. The preview mirrors every server validation limit so one bad cell never fails the whole import. `ImportLineupHandler` locks the event row, writes through repositories in bulk (not the per-row upsert handlers — far too slow for 1,000 slots), skips slots that already exist, and dispatches the spam check once. Import needs organizer write access because it creates profiles
176+
- Lineup highlights in order and ticket emails come from `EmailLineupHighlightsService`, gated on the LINEUP/SCHEDULE homepage sections, and per-date only for recurring events (the same rule the widget strip and event page use). Mailables keep the DTO in a non-readonly property with a `null` default so mails queued before a deploy still unserialize
177+
178+
#### Homepage sections
179+
- The event homepage renders `event_settings.homepage_sections` in order: one JSON list of `{id, type, enabled, heading, …}`. A `null` list, or a missing built-in, falls back to the default order (`resolveHomepageSections`), so events that never saved sections look exactly as before. The hero and footer are fixed. `TICKETS` is always present and enabled, enforced server-side
180+
- The About section renders `event.description`; never copy the description into a section (SEO, listings, emails and the API all read it). Extra copy belongs in `TEXT` sections
181+
- `HomepageSectionsSanitizer` is the only writer: it purifies HTML, de-duplicates built-ins, keeps only gallery/sponsor image ids that are this event's `EVENT_GALLERY`/`EVENT_SPONSOR_LOGO` images and only allows YouTube/Vimeo video links. New section text must also be added to `EventSpamCheckContentService::homepageSectionContent`
182+
- Organizer pages use the same system via `organizer_settings.homepage_sections` (`sanitizeForOrganizer`, built-ins `EVENTS` (always shown)/`ABOUT`/`CONTACT`/`PAST_EVENTS`, images `ORGANIZER_GALLERY`/`ORGANIZER_SPONSOR_LOGO`). Layout rules live in `utilites/homepageSectionLayout.ts` (dependency-free, unit-tested from e2e): a leading run of ABOUT/CONTACT renders inside the profile card, and PAST_EVENTS directly after EVENTS is the Past tab, elsewhere its own list — so the default order reproduces the classic organizer page
183+
169184
#### Abuse & spam
170185
- Treat every user-typed string that can reach an email to an unverified address as a spam vector. Never greet with a user-supplied name in account/auth emails, and validate person-name fields with `PersonNameRule` (blocks URLs, phone numbers, markup; allows every script)
171186
- Buyer/attendee mail that carries event content implements `ContainsEventContent` (+ `ProvidesEventContent`). `BaseMail::send()` swaps it for the content-free `UnpublishedEventUpdateMail` while the event is DRAFT or PENDING_MANUAL_REVIEW, unless every recipient is a verified member of the event's account. New event-scoped mailables must implement the interface
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
<?php
2+
3+
namespace HiEvents\DomainObjects\Enums;
4+
5+
enum HomepageSectionType: string
6+
{
7+
use BaseEnum;
8+
9+
case TICKETS = 'TICKETS';
10+
case ABOUT = 'ABOUT';
11+
case LINEUP = 'LINEUP';
12+
case SCHEDULE = 'SCHEDULE';
13+
case LOCATION = 'LOCATION';
14+
case ORGANIZER = 'ORGANIZER';
15+
case EVENTS = 'EVENTS';
16+
case CONTACT = 'CONTACT';
17+
case PAST_EVENTS = 'PAST_EVENTS';
18+
case TEXT = 'TEXT';
19+
case GALLERY = 'GALLERY';
20+
case FAQ = 'FAQ';
21+
case VIDEO = 'VIDEO';
22+
case SPONSORS = 'SPONSORS';
23+
24+
/**
25+
* @return self[]
26+
*/
27+
public static function eventBuiltIns(): array
28+
{
29+
return [self::TICKETS, self::ABOUT, self::LINEUP, self::SCHEDULE, self::LOCATION, self::ORGANIZER];
30+
}
31+
32+
/**
33+
* @return self[]
34+
*/
35+
public static function organizerBuiltIns(): array
36+
{
37+
return [self::EVENTS, self::ABOUT, self::CONTACT, self::PAST_EVENTS];
38+
}
39+
40+
/**
41+
* @return self[]
42+
*/
43+
public static function customTypes(): array
44+
{
45+
return [self::TEXT, self::GALLERY, self::FAQ, self::VIDEO, self::SPONSORS];
46+
}
47+
48+
/**
49+
* @return string[]
50+
*/
51+
public static function eventValues(): array
52+
{
53+
return array_map(static fn (self $type) => $type->value, [...self::eventBuiltIns(), ...self::customTypes()]);
54+
}
55+
56+
/**
57+
* @return string[]
58+
*/
59+
public static function organizerValues(): array
60+
{
61+
return array_map(static fn (self $type) => $type->value, [...self::organizerBuiltIns(), ...self::customTypes()]);
62+
}
63+
64+
public function isEnabledIn(mixed $sections): bool
65+
{
66+
if (! is_array($sections)) {
67+
return true;
68+
}
69+
70+
foreach ($sections as $section) {
71+
if (($section['type'] ?? null) === $this->value) {
72+
return (bool) ($section['enabled'] ?? true);
73+
}
74+
}
75+
76+
return true;
77+
}
78+
79+
public static function publicSections(mixed $sections): mixed
80+
{
81+
if (! is_array($sections)) {
82+
return $sections;
83+
}
84+
85+
return array_values(array_filter(
86+
$sections,
87+
static fn ($section) => ($section['enabled'] ?? true)
88+
|| ! in_array(self::tryFrom($section['type'] ?? ''), self::customTypes(), true),
89+
));
90+
}
91+
92+
public static function isVideoUrl(string $url): bool
93+
{
94+
$host = strtolower((string) parse_url($url, PHP_URL_HOST));
95+
$host = preg_replace('/^(www|m)\./', '', $host);
96+
97+
return in_array($host, ['youtube.com', 'youtu.be', 'vimeo.com', 'player.vimeo.com'], true);
98+
}
99+
}

‎backend/app/DomainObjects/Enums/ImageType.php‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use HiEvents\DomainObjects\EventDomainObject;
66
use HiEvents\DomainObjects\OrganizerDomainObject;
7+
use HiEvents\DomainObjects\ProfileDomainObject;
78
use HiEvents\DomainObjects\UserDomainObject;
89
use InvalidArgumentException;
910

@@ -16,16 +17,24 @@ enum ImageType
1617
// Event images
1718
case EVENT_COVER;
1819
case TICKET_LOGO;
20+
case EVENT_GALLERY;
21+
case EVENT_SPONSOR_LOGO;
1922

2023
// Organizer images
2124
case ORGANIZER_LOGO;
2225
case ORGANIZER_COVER;
26+
case ORGANIZER_GALLERY;
27+
case ORGANIZER_SPONSOR_LOGO;
28+
29+
case PROFILE_IMAGE;
2330

2431
public static function eventImageTypes(): array
2532
{
2633
return [
2734
self::EVENT_COVER,
2835
self::TICKET_LOGO,
36+
self::EVENT_GALLERY,
37+
self::EVENT_SPONSOR_LOGO,
2938
];
3039
}
3140

@@ -34,6 +43,15 @@ public static function organizerImageTypes(): array
3443
return [
3544
self::ORGANIZER_LOGO,
3645
self::ORGANIZER_COVER,
46+
self::ORGANIZER_GALLERY,
47+
self::ORGANIZER_SPONSOR_LOGO,
48+
];
49+
}
50+
51+
public static function profileImageTypes(): array
52+
{
53+
return [
54+
self::PROFILE_IMAGE,
3755
];
3856
}
3957

@@ -52,6 +70,11 @@ public static function getMinimumDimensionsMap(ImageType $imageType): array
5270
self::TICKET_LOGO->name => [100, 100],
5371
self::ORGANIZER_LOGO->name => [100, 100],
5472
self::ORGANIZER_COVER->name => [600, 50],
73+
self::PROFILE_IMAGE->name => [200, 200],
74+
self::EVENT_GALLERY->name => [400, 300],
75+
self::EVENT_SPONSOR_LOGO->name => [80, 40],
76+
self::ORGANIZER_GALLERY->name => [400, 300],
77+
self::ORGANIZER_SPONSOR_LOGO->name => [80, 40],
5578
];
5679

5780
return $map[$imageType->name] ?? $map[self::GENERIC->name];
@@ -67,6 +90,10 @@ public function getEntityType(): string
6790
return OrganizerDomainObject::class;
6891
}
6992

93+
if (in_array($this, self::profileImageTypes())) {
94+
return ProfileDomainObject::class;
95+
}
96+
7097
if (in_array($this, self::genericImageTypes())) {
7198
return UserDomainObject::class;
7299
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
namespace HiEvents\DomainObjects\Enums;
4+
5+
enum LineupBillingTier: string
6+
{
7+
use BaseEnum;
8+
9+
case HEADLINER = 'HEADLINER';
10+
case FEATURED = 'FEATURED';
11+
case STANDARD = 'STANDARD';
12+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?php
2+
3+
namespace HiEvents\DomainObjects\Enums;
4+
5+
enum ProfileLinkType: string
6+
{
7+
use BaseEnum;
8+
9+
case SPOTIFY = 'SPOTIFY';
10+
case APPLE_MUSIC = 'APPLE_MUSIC';
11+
case SOUNDCLOUD = 'SOUNDCLOUD';
12+
case BANDCAMP = 'BANDCAMP';
13+
case YOUTUBE = 'YOUTUBE';
14+
case INSTAGRAM = 'INSTAGRAM';
15+
case TIKTOK = 'TIKTOK';
16+
case X = 'X';
17+
case FACEBOOK = 'FACEBOOK';
18+
case LINKEDIN = 'LINKEDIN';
19+
case GITHUB = 'GITHUB';
20+
case WEBSITE = 'WEBSITE';
21+
22+
public static function fromUrl(string $url): self
23+
{
24+
$host = strtolower((string) parse_url($url, PHP_URL_HOST));
25+
26+
return match (true) {
27+
self::hostMatches($host, ['spotify.com', 'spotify.link']) => self::SPOTIFY,
28+
self::hostMatches($host, ['music.apple.com']) => self::APPLE_MUSIC,
29+
self::hostMatches($host, ['soundcloud.com']) => self::SOUNDCLOUD,
30+
self::hostMatches($host, ['bandcamp.com']) => self::BANDCAMP,
31+
self::hostMatches($host, ['youtube.com', 'youtu.be']) => self::YOUTUBE,
32+
self::hostMatches($host, ['instagram.com']) => self::INSTAGRAM,
33+
self::hostMatches($host, ['tiktok.com']) => self::TIKTOK,
34+
self::hostMatches($host, ['x.com', 'twitter.com']) => self::X,
35+
self::hostMatches($host, ['facebook.com', 'fb.com']) => self::FACEBOOK,
36+
self::hostMatches($host, ['linkedin.com']) => self::LINKEDIN,
37+
self::hostMatches($host, ['github.com']) => self::GITHUB,
38+
default => self::WEBSITE,
39+
};
40+
}
41+
42+
/**
43+
* @param string[] $urls
44+
* @return array<int, array{type: string, url: string}>
45+
*/
46+
public static function linksFromUrls(array $urls): array
47+
{
48+
return collect($urls)
49+
->map(fn (string $url) => trim($url))
50+
->filter()
51+
->unique()
52+
->values()
53+
->map(fn (string $url) => [
54+
'type' => self::fromUrl($url)->value,
55+
'url' => $url,
56+
])
57+
->all();
58+
}
59+
60+
private static function hostMatches(string $host, array $domains): bool
61+
{
62+
foreach ($domains as $domain) {
63+
if ($host === $domain || str_ends_with($host, '.'.$domain)) {
64+
return true;
65+
}
66+
}
67+
68+
return false;
69+
}
70+
}

‎backend/app/DomainObjects/EventDomainObject.php‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ class EventDomainObject extends Generated\EventDomainObjectAbstract implements I
3535

3636
private ?Collection $affiliates = null;
3737

38+
private ?Collection $eventLineupEntries = null;
39+
40+
private ?Collection $eventSpaces = null;
41+
42+
private ?Collection $eventScheduleItems = null;
43+
3844
private ?Collection $eventOccurrences = null;
3945

4046
private ?EventSettingDomainObject $settings = null;
@@ -482,4 +488,40 @@ public function setEventLocation(?EventLocationDomainObject $eventLocation): sel
482488

483489
return $this;
484490
}
491+
492+
public function getEventLineupEntries(): ?Collection
493+
{
494+
return $this->eventLineupEntries;
495+
}
496+
497+
public function setEventLineupEntries(?Collection $eventLineupEntries): self
498+
{
499+
$this->eventLineupEntries = $eventLineupEntries;
500+
501+
return $this;
502+
}
503+
504+
public function getEventSpaces(): ?Collection
505+
{
506+
return $this->eventSpaces;
507+
}
508+
509+
public function setEventSpaces(?Collection $eventSpaces): self
510+
{
511+
$this->eventSpaces = $eventSpaces;
512+
513+
return $this;
514+
}
515+
516+
public function getEventScheduleItems(): ?Collection
517+
{
518+
return $this->eventScheduleItems;
519+
}
520+
521+
public function setEventScheduleItems(?Collection $eventScheduleItems): self
522+
{
523+
$this->eventScheduleItems = $eventScheduleItems;
524+
525+
return $this;
526+
}
485527
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
namespace HiEvents\DomainObjects;
4+
5+
class EventLineupEntryDomainObject extends Generated\EventLineupEntryDomainObjectAbstract
6+
{
7+
private ?ProfileDomainObject $profile = null;
8+
9+
private ?EventDomainObject $event = null;
10+
11+
public function getEvent(): ?EventDomainObject
12+
{
13+
return $this->event;
14+
}
15+
16+
public function setEvent(?EventDomainObject $event): self
17+
{
18+
$this->event = $event;
19+
20+
return $this;
21+
}
22+
23+
public function getProfile(): ?ProfileDomainObject
24+
{
25+
return $this->profile;
26+
}
27+
28+
public function setProfile(?ProfileDomainObject $profile): self
29+
{
30+
$this->profile = $profile;
31+
32+
return $this;
33+
}
34+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
namespace HiEvents\DomainObjects;
4+
5+
use Illuminate\Support\Collection;
6+
7+
class EventScheduleItemDomainObject extends Generated\EventScheduleItemDomainObjectAbstract
8+
{
9+
private ?Collection $performers = null;
10+
11+
public function getPerformers(): ?Collection
12+
{
13+
return $this->performers;
14+
}
15+
16+
public function setPerformers(?Collection $performers): self
17+
{
18+
$this->performers = $performers;
19+
20+
return $this;
21+
}
22+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<?php
2+
3+
namespace HiEvents\DomainObjects;
4+
5+
class EventScheduleItemPerformerDomainObject extends Generated\EventScheduleItemPerformerDomainObjectAbstract {}

0 commit comments

Comments
 (0)