From 630786cc78e8c301e9fc1640965261c409c94a4e Mon Sep 17 00:00:00 2001 From: HedgeWonder <343986160+HedgeWonder@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:49:53 +0100 Subject: [PATCH] feat(events): add shareable event slugs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/API.md | 15 ++++++- docs/bruno/events/Find By Slug.bru | 8 ++++ .../20261023000000_event_slug/migration.sql | 19 +++++++++ prisma/schema.prisma | 1 + prisma/seed.ts | 1 + src/events/events.controller.spec.ts | 15 +++++++ src/events/events.controller.ts | 10 +++++ src/events/events.service.spec.ts | 40 +++++++++++++++++++ src/events/events.service.ts | 33 ++++++++++++++- test/factories/event.factory.ts | 4 +- 10 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 docs/bruno/events/Find By Slug.bru create mode 100644 prisma/migrations/20261023000000_event_slug/migration.sql diff --git a/docs/API.md b/docs/API.md index d553a12..890d5fc 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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 `. Scanner devices authenticate `POST /v1/tickets/:ticketId/scanner-check-in` with their own device token instead (see that route). @@ -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", @@ -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 diff --git a/docs/bruno/events/Find By Slug.bru b/docs/bruno/events/Find By Slug.bru new file mode 100644 index 0000000..fcf71b7 --- /dev/null +++ b/docs/bruno/events/Find By Slug.bru @@ -0,0 +1,8 @@ +meta { + name: Find By Slug + type: http +} + +get { + url: {{baseUrl}}/v1/events/slug/{{slug}} +} diff --git a/prisma/migrations/20261023000000_event_slug/migration.sql b/prisma/migrations/20261023000000_event_slug/migration.sql new file mode 100644 index 0000000..a73bbe2 --- /dev/null +++ b/prisma/migrations/20261023000000_event_slug/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fc7c9cc..7f4f444 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/prisma/seed.ts b/prisma/seed.ts index 4e7f134..f0eebb4 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -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, diff --git a/src/events/events.controller.spec.ts b/src/events/events.controller.spec.ts index a6d56e7..0344307 100644 --- a/src/events/events.controller.spec.ts +++ b/src/events/events.controller.spec.ts @@ -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(), @@ -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' }); diff --git a/src/events/events.controller.ts b/src/events/events.controller.ts index 1de7aea..2cccbf3 100644 --- a/src/events/events.controller.ts +++ b/src/events/events.controller.ts @@ -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) diff --git a/src/events/events.service.spec.ts b/src/events/events.service.spec.ts index ca2a22a..b259590 100644 --- a/src/events/events.service.spec.ts +++ b/src/events/events.service.spec.ts @@ -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', @@ -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', diff --git a/src/events/events.service.ts b/src/events/events.service.ts index 659ac57..16a3e08 100644 --- a/src/events/events.service.ts +++ b/src/events/events.service.ts @@ -1,4 +1,4 @@ -import { randomBytes } from 'node:crypto'; +import { randomBytes, randomUUID } from 'node:crypto'; import { BadRequestException, ConflictException, @@ -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( event: T, ): event is T & { ticketTypes: Array<{ price: bigint }> } { @@ -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, @@ -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 }, diff --git a/test/factories/event.factory.ts b/test/factories/event.factory.ts index 787462a..a029cdb 100644 --- a/test/factories/event.factory.ts +++ b/test/factories/event.factory.ts @@ -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 { 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',