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
15 changes: 14 additions & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ parsing, not database work.
version `1`).

**Authentication.** Everything except `/v1/health`, `/v1/industries`,
`/v1/auth/*`, `GET /v1/events`, `GET /v1/events/:eventId` and
`/v1/auth/*`, `GET /v1/events`, `GET /v1/events/slug/:slug`,
`GET /v1/events/:eventId` and
`/v1/stellar/*` needs `Authorization: Bearer <JWT>`. Scanner devices
authenticate `POST /v1/tickets/:ticketId/scanner-check-in` with their own
device token instead (see that route).
Expand Down Expand Up @@ -536,6 +537,7 @@ curl -s "$API/v1/events?q=jazz&category=CONCERTS&from=2026-11-01T00:00:00Z&to=20
{
"id": "7a1f3c5e-9b2d-4e6f-8a0c-1d2e3f4a5b6c",
"name": "Launch Night",
"slug": "launch-night-2026-12-01-7a1f3c5e-9b2d-4e6f-8a0c-1d2e3f4a5b6c",
"description": "A night of live music at The Fillmore.",
"imageUrl": "https://cdn.example.com/launch-night.jpg",
"timezone": "America/Los_Angeles",
Expand All @@ -555,6 +557,17 @@ curl -s "$API/v1/events?q=jazz&category=CONCERTS&from=2026-11-01T00:00:00Z&to=20
(Other event columns omitted here for brevity; the response carries the
full row.)

### `GET /v1/events/slug/:slug`

Looks up an event using its shareable slug, generated from its name and start
date. Published events are public; non-published events are visible only to
members of their owning organization. Missing, deleted, and unauthorized
events return `404`.

```bash
curl -s "$API/v1/events/slug/launch-night-2026-12-01-7a1f3c5e-9b2d-4e6f-8a0c-1d2e3f4a5b6c"
```

### `GET /v1/events/:eventId`

One event row with its `organization` row embedded. `PUBLISHED` events are
Expand Down
8 changes: 8 additions & 0 deletions docs/bruno/events/Find By Slug.bru
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
meta {
name: Find By Slug
type: http
}

get {
url: {{baseUrl}}/v1/events/slug/{{slug}}
}
19 changes: 19 additions & 0 deletions prisma/migrations/20261023000000_event_slug/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
ALTER TABLE "Event"
ADD COLUMN "slug" TEXT;

UPDATE "Event"
SET "slug" =
COALESCE(
NULLIF(
trim(BOTH '-' FROM regexp_replace(lower("name"), '[^a-z0-9]+', '-', 'g')),
''
),
'event'
)
|| '-' || to_char("startsAt", 'YYYY-MM-DD')
|| '-' || "id";

ALTER TABLE "Event"
ALTER COLUMN "slug" SET NOT NULL;

CREATE UNIQUE INDEX "Event_slug_key" ON "Event"("slug");
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ model Event {
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
organizationId String
name String
slug String @unique
description String
imageUrl String?
category Industry
Expand Down
1 change: 1 addition & 0 deletions prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ async function main() {
data: {
organizationId: org.id,
name: 'StellarFest 2027',
slug: 'stellarfest-2027-03-15-demo',
description: 'A demo concert powered by StellarTickets.',
imageUrl: null,
category: Industry.CONCERTS,
Expand Down
15 changes: 15 additions & 0 deletions src/events/events.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ describe('EventsController', () => {
let app: INestApplication;
const service = {
findPublished: jest.fn(),
getBySlug: jest.fn(),
getWithOrg: jest.fn(),
getWithReconciliation: jest.fn(),
findForOrganization: jest.fn(),
Expand Down Expand Up @@ -128,6 +129,20 @@ describe('EventsController', () => {
expect((res.body as { message: string }).message).toBe('Event not found');
});

it('GET /events/slug/:slug returns an event and propagates not-found', async () => {
service.getBySlug.mockResolvedValueOnce({
id: 'e1',
slug: 'launch-night',
});
await http().get('/events/slug/launch-night').expect(200);
expect(service.getBySlug).toHaveBeenCalledWith('launch-night', undefined);

service.getBySlug.mockRejectedValueOnce(
new NotFoundException('Event not found'),
);
await http().get('/events/slug/missing').expect(404);
});

it('GET /events/:eventId forwards the reconcile flag', async () => {
service.getWithReconciliation.mockResolvedValueOnce({ id: 'e1' });

Expand Down
10 changes: 10 additions & 0 deletions src/events/events.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ export class EventsController {
return this.eventsService.findPublished(query);
}

@Get('events/slug/:slug')
@UseGuards(OptionalJwtAuthGuard)
@UseInterceptors(ResponseCacheInterceptor)
findBySlug(
@Param('slug') slug: string,
@CurrentUser() user: CurrentUserPayload | undefined,
) {
return this.eventsService.getBySlug(slug, user?.userId);
}

@Get('events/:eventId')
@UseGuards(OptionalJwtAuthGuard)
@UseInterceptors(ResponseCacheInterceptor)
Expand Down
40 changes: 40 additions & 0 deletions src/events/events.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ describe('EventsService', () => {
expect(prisma.event.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
id: expect.any(String),
slug: expect.stringMatching(
/^radiohead-live-\d{4}-\d{2}-\d{2}-[0-9a-f-]{36}$/,
),
description: 'A live performance.',
imageUrl: 'https://example.com/radiohead.jpg',
timezone: 'Europe/London',
Expand Down Expand Up @@ -788,6 +792,42 @@ describe('EventsService', () => {
});
});

describe('getBySlug', () => {
it('returns a published event looked up by its slug', async () => {
prisma.event.findUnique.mockResolvedValue({
id: 'event-1',
slug: 'radiohead-live-2026-12-01',
status: 'PUBLISHED',
chainEventId: null,
organization: { stellarAccount: 'GORG' },
});

await expect(
service.getBySlug('radiohead-live-2026-12-01'),
).resolves.toMatchObject({
id: 'event-1',
slug: 'radiohead-live-2026-12-01',
});
expect(prisma.event.findUnique).toHaveBeenCalledWith({
where: { slug: 'radiohead-live-2026-12-01' },
include: { organization: true },
});
});

it('hides drafts from anonymous callers', async () => {
prisma.event.findUnique.mockResolvedValue({
id: 'event-1',
status: 'DRAFT',
organizationId: 'org-1',
organization: { stellarAccount: 'GORG' },
});

await expect(
service.getBySlug('radiohead-live'),
).rejects.toMatchObject({ status: 404 });
});
});

describe('findPublished', () => {
const publishedWhere = {
status: 'PUBLISHED',
Expand Down
33 changes: 32 additions & 1 deletion src/events/events.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { randomBytes } from 'node:crypto';
import { randomBytes, randomUUID } from 'node:crypto';
import {
BadRequestException,
ConflictException,
Expand Down Expand Up @@ -28,6 +28,17 @@ import type { SettlementAsset } from '../config/settlement-asset';
export type OnChainReconciliationStatus =
'PUBLISHED' | 'NOT_FOUND' | 'NOT_PUBLISHED';

function createEventSlug(name: string, startsAt: Date, id: string): string {
const nameSlug =
name
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '') || 'event';
return `${nameSlug}-${startsAt.toISOString().slice(0, 10)}-${id}`;
}

function hasPricedTicketTypes<T extends object>(
event: T,
): event is T & { ticketTypes: Array<{ price: bigint }> } {
Expand Down Expand Up @@ -66,10 +77,13 @@ export class EventsService {
const org = await this.organizations.findOne(organizationId);

const event = await this.prisma.$transaction(async (tx) => {
const id = randomUUID();
const created = await tx.event.create({
data: {
id,
organizationId,
name: dto.name,
slug: createEventSlug(dto.name, dto.startsAt, id),
description: dto.description,
imageUrl: dto.imageUrl,
category: dto.category,
Expand Down Expand Up @@ -328,6 +342,23 @@ export class EventsService {
});
}

async getBySlug(slug: string, viewerId?: string) {
const event = await this.prisma.event.findUnique({
where: { slug },
include: { organization: true },
});
if (!event || (event as { deletedAt?: Date | null }).deletedAt) {
throw new NotFoundException('Event not found');
}
if (
event.status !== EventStatus.PUBLISHED &&
!(await this.canView(event, viewerId))
) {
throw new NotFoundException('Event not found');
}
return this.withEventPricing(event);
}

/** A viewer may see a non-published event only if they belong to its organization. */
private async canView(
event: { organizationId: string },
Expand Down
4 changes: 3 additions & 1 deletion test/factories/event.factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ let ticketTypeCounter = 0;
/** Returns a fully-valid `Event` row, with realistic defaults, merged with `overrides`. */
export function createEvent(overrides: Partial<Event> = {}): Event {
const n = ++eventCounter;
const id = overrides.id ?? `event-${n}`;
return {
id: `event-${n}`,
id,
organizationId: `org-${n}`,
name: `Test Event ${n}`,
slug: `test-event-${n}-${id}`,
description: `Description for test event ${n}`,
imageUrl: null,
category: 'CONCERTS',
Expand Down