From 5c755ab4963fe14531447a137b22feb7ed6900a9 Mon Sep 17 00:00:00 2001 From: Oliver Baer Date: Mon, 15 Jun 2026 14:20:27 +0200 Subject: [PATCH 01/30] docs(events): Spec 2 design for e-Ventschau Events module Multistage timetable, artist-or-free-text appearance slots (role-as-edge), display-only price tiers (category + early-bird windows), and homepage line-up coupled to the main event's appearances. Builds on Spec 1 (Artist). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-06-15-e-ventschau-events-module-design.md | 282 ++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-15-e-ventschau-events-module-design.md diff --git a/docs/superpowers/specs/2026-06-15-e-ventschau-events-module-design.md b/docs/superpowers/specs/2026-06-15-e-ventschau-events-module-design.md new file mode 100644 index 0000000..51879ec --- /dev/null +++ b/docs/superpowers/specs/2026-06-15-e-ventschau-events-module-design.md @@ -0,0 +1,282 @@ +# e-Ventschau — Events Module (Spec 2) — Design + +**Date:** 2026-06-15 +**Project:** minicms (tenant `e-ventschau`) +**Builds on:** Spec 1 — Artist module (`2026-06-14-e-ventschau-artist-module-design.md`, shipped & live) +**Status:** Design — awaiting user review before plan. + +--- + +## 1. Goal + +A first-class, tenant-scoped **Events** entity with a multistage **timetable**, slots that link an `Artist` **or** carry a free-text title, and **display-only** price tiers. The module is wired so the **homepage line-up derives from the main event's appearances** (single source of truth) and the Artist detail page's stubbed "Auftritte" section finally renders. + +## 2. Locked decisions (from brainstorming) + +| # | Decision | Choice | +|---|----------|--------| +| Core | Event modelling | **Multiple events (list)** — first-class listable entity, like `Artist`/`Vendor` | +| Core | Timetable granularity | **Multistage + real clock times** (Bühne × Zeit grid) | +| Core | Price tiers | **Category + time-window**, display-only (no checkout) | +| Slots | Timetable slot content | **Artist-link OR free title** (`artistId` nullable + `title` fallback) | +| Integration | Homepage | **Couple line-up to the main event's appearances** (single source of truth) | +| Edge model | Headliner/Support | **Role-as-edge** on `Appearance` — `Artist` stays a stable identity, no per-year duplicates | + +**Resolved flags (from design review):** +- ① Location = plain text + maps link (`locationName/locationAddress/locationUrl`). No lat/lng, no embedded map. +- ② `Stage` belongs to a single Event (no cross-event reuse). Mirrors `ArtistMedia`: child has no `tenantId`, scoped via parent, cascade-deletes. +- ③ Timetable = responsive: desktop grid (Bühne × Zeit, day-tabs), mobile agenda-list. ASCII mockups in §7. +- ④ `/programm-2026` is a markdown DB page linked from homepage CTAs (×2), header nav, footer. → `/events/[slug]` becomes the canonical program page; repoint all four links; `/programm-2026` becomes a redirect to the festival event (preserves URL/SEO). +- ⑤ Endpoint strategy: price tiers via replace-all-in-`$transaction` (flat, few rows). Stages + appearances via **granular sub-resource endpoints** (per-row add/edit/delete) — a 2-day multistage timetable is too many rows for fat-replace and admins need per-slot editing. +- ⑥ `SliderItemType.EVENT` + slider support = **optional final batch** (parity with ARTIST), trimmable. + +## 3. Tech stack & conventions (unchanged from Spec 1) + +Next.js 15 App Router · React 19 · TypeScript · Tailwind 3.4 (liquid-glass) · Prisma 6 (Neon Postgres) · NextAuth 4 (JWT). Multi-tenant via `getTenant()`. Plate.js for rich bodies. Cloudinary media. `react-markdown` **without** `rehype-raw`. Build gate: `npx tsc --noEmit` + `npm run build` (no test framework; `next lint` is unusable). + +--- + +## 4. Data model (Prisma) + +All new models follow the Artist conventions: `@@unique([tenantId, slug])` (scoped — **never** Vendor's global `@unique`), `isPublished @default(false)`, `createdById/updatedById`, markdown body + `*Json` Plate mirror. + +```prisma +enum SliderItemType { + PAGE + PRODUCT + VENDOR + MEDIA + ARTIST + EVENT // ← added (optional batch ⑥) +} + +model Event { + id String @id @default(cuid()) + tenantId String + slug String + title String + subtitle String? + eventType String @default("festival") // festival | concert | workshop | other — stored as String (no Prisma enum, YAGNI), validated against an allowlist in event-validation + startDate DateTime + endDate DateTime? + locationName String? + locationAddress String? + locationUrl String? // maps link (validated https) + heroImage String? // Cloudinary + excerpt String? @db.Text + description String? @db.Text // markdown + descriptionJson Json? // Plate mirror + editorMode String? @default("markdown") + ticketUrl String? // external ticket shop (https) + metaTitle String? + metaDescription String? @db.Text + isPublished Boolean @default(false) + isFeatured Boolean @default(false) // marks the homepage main event + isActive Boolean @default(true) + sortOrder Int @default(0) + createdById String? + updatedById String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + tenant Tenant @relation(fields: [tenantId], references: [id]) + stages Stage[] + appearances Appearance[] + priceTiers PriceTier[] + sliderItems SliderItem[] // optional batch ⑥ + + @@unique([tenantId, slug]) + @@index([tenantId]) + @@index([startDate]) +} + +model Stage { + id String @id @default(cuid()) + eventId String + name String + color String? // optional grid-column accent (hex) + sortOrder Int @default(0) + + event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + appearances Appearance[] + + @@index([eventId]) +} + +model Appearance { + id String @id @default(cuid()) + eventId String + stageId String + artistId String? // nullable — free-text slots have title instead + title String? // fallback for non-artist slots ("Opening Act", "Umbaupause") + role String @default("support") // headliner | support | guest | break + startTime DateTime + endTime DateTime? + note String? + sortOrder Int @default(0) + + event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + stage Stage @relation(fields: [stageId], references: [id], onDelete: Cascade) + artist Artist? @relation(fields: [artistId], references: [id], onDelete: SetNull) + + @@index([eventId]) + @@index([stageId]) + @@index([artistId]) +} + +model PriceTier { + id String @id @default(cuid()) + eventId String + name String + description String? + price Float? // null = "auf Anfrage" + currency String @default("EUR") + validFrom DateTime? // early-bird window start + validUntil DateTime? // early-bird window end + isSoldOut Boolean @default(false) + isActive Boolean @default(true) + buyUrl String? // external buy link (https) + sortOrder Int @default(0) + + event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + + @@index([eventId]) +} +``` + +**Back-relations to add:** `Tenant.events Event[]`; `Artist.appearances Appearance[]`; `SliderItem.eventId String?` + `event Event? @relation(...)` (optional batch ⑥). + +**Application-level invariants (DB can't express cleanly):** +- An `Appearance` must have **either** `artistId` **or** a non-empty `title`. +- `endTime` (when set) must be `> startTime`. +- A linked `artistId` must belong to the **same tenant** as the event (IDOR guard). + +## 5. Validation — `src/lib/event-validation.ts` + +**Shared-validator extraction (DRY, zero breakage):** move `SLUG_RE`, `normalizeSlug`, `isValidSlug`, `safeHttpsUrl`, `safeCloudinaryUrl` from `artist-validation.ts` into a new `src/lib/slug.ts`, and **re-export them from `artist-validation.ts`** so all existing artist imports keep working unchanged. The umlaut-before-NFKD order (`ä→ae` … then `.normalize('NFKD')`) **must be preserved verbatim** — it is a fixed bug (`Motörhead → motoerhead`). + +`event-validation.ts` then imports the shared primitives and adds event-specific helpers: +- `sanitizeEventInput` — slug via `normalizeSlug`/`isValidSlug`; `ticketUrl`/`locationUrl` via `safeHttpsUrl`; `heroImage` via `safeCloudinaryUrl`; `eventType` ∈ allowlist; `endDate ≥ startDate`. +- `ALLOWED_ROLES = ['headliner','support','guest','break']`. +- `sanitizeAppearance(raw)` — enforces artistId-XOR-title, role allowlist, `endTime > startTime`, string length caps on `title`/`note`. Returns `null` to drop invalid rows. +- `sanitizePriceTier(raw)` — `price ≥ 0` (or null), `currency` ∈ `['EUR','USD','CHF','GBP']`, `buyUrl` via `safeHttpsUrl`, `validUntil ≥ validFrom`. + +## 6. Library — `src/lib/events.ts` (mirrors `src/lib/artists.ts`) + +All getters tenant-scoped via `getTenant()`, wrapped in try/catch returning `[]`/`null` on error (same defensive pattern as `artists.ts`). + +``` +getPublishedEvents() // list: where isPublished+isActive, order startDate asc +getPublishedEventBySlug(slug) // detail: include stages(order sortOrder), priceTiers(order sortOrder), + // appearances(include artist; order startTime, sortOrder). null for drafts. +getEventsForAdmin() // all tenant events + _count of appearances/stages +getFeaturedEvent() // published, prefer isFeatured, then earliest upcoming startDate +getFeaturedEventLineup() // featured event → distinct artist-linked appearances → ArtistSummary[] + // (headliners first); [] if none +getArtistAppearances(artistId) // published events only: appearances + event + stage, order startTime + // → feeds Artist "Auftritte" section +``` + +`getFeaturedEventLineup()` returns the existing `ArtistSummary` shape (`src/components/artists/ArtistCard.tsx`) so the homepage can reuse artist styling; cards link to `/kuenstler/[slug]` and pull `origin`/`genres` from the `Artist` record. + +## 7. Public pages + +### `/events` — `src/app/(public)/events/page.tsx` +`force-dynamic`. Lists `getPublishedEvents()` as cards (heroImage, title, date range, venue, excerpt). Empty-state when none. + +### `/events/[slug]` — `src/app/(public)/events/[slug]/page.tsx` +`React.cache(getPublishedEventBySlug)`, `notFound()` on null. Sections in order: +1. **Hero** — heroImage, title, subtitle, date range, venue (link to `locationUrl`). +2. **Beschreibung** — `MarkdownContent` (no `rehype-raw`). +3. **Timetable** — see mockups below. +4. **Line-up** — artist-linked appearances → `ArtistCard` grid → `/kuenstler/[slug]`. +5. **Preise** — `PriceTier` cards: name, price+currency, early-bird window, sold-out badge, `buyUrl` button (external, `rel="noopener noreferrer nofollow"`). Display-only. +6. **Ticket-CTA** — `ticketUrl` button. +7. `buildEventJsonLd` → `MusicEvent` (performers + offers), via `safeJsonLd`. + +### Timetable rendering (③) + +**Desktop — grid (Bühne × Zeit), day-tabs when multi-day:** +``` +┌───────────────────────────────────────────────────┐ +│ [ Fr 07.08. ] [ Sa 08.08. ] ← Tag-Tabs │ +├──────────┬───────────────────┬────────────────────┤ +│ Zeit │ Hauptbühne │ Zeltbühne │ +├──────────┼───────────────────┼────────────────────┤ +│ 18:00 │ Nanny Goats │ │ +│ 19:00 │ ROVAR │ The Klaxon │ +│ 20:30 │ Jed Thomas Band │ Killabeatmaker │ +│ 22:00 │ ★ Thorbjørn R. │ │ +└──────────┴───────────────────┴────────────────────┘ + ★ = Headliner · klickbare Artist-Slots → /kuenstler/[slug] +``` + +**Mobile — agenda list (grouped day → time):** +``` +── Fr 07.08. ─────────────────── +18:00 Nanny Goats · Hauptbühne +19:00 ROVAR · Hauptbühne +19:00 The Klaxon · Zeltbühne +20:30 Jed Thomas Band · Hauptbühne +22:00 ★Thorbjørn Risager · Hauptbühne +── Sa 08.08. ─────────────────── +… +``` + +Free-text slots (`title`, no `artistId`) render as non-clickable muted rows. Days derived from distinct `startTime` dates; single-day events skip the tabs. Grid time-rows derived from the sorted distinct start times (not a fixed 24h axis). + +### Artist "Auftritte" (wire up the Spec 1 stub) +`src/app/(public)/kuenstler/[slug]/page.tsx` line ~78 currently has the placeholder comment. Render `getArtistAppearances(artist.id)`: each row = event title + date + stage + time, linking to `/events/[slug]`. Hidden when empty. + +## 8. Homepage coupling (decision #5) + `/programm-2026` (④) + +- `(public)/page.tsx`: delete the hardcoded `lineup2026` array; render `getFeaturedEventLineup()`. Keep the existing card markup but source `name`/`origin`/`genres`/`slug` from the result. Graceful fallback (render nothing / generic message) when no featured event. +- Make `/events/e-ventschau-2026` the canonical program page. Repoint the four "Programm" links: `(public)/page.tsx` ×2, `components/layout/HeaderClient.tsx:94`, `components/layout/Footer.tsx:26`. +- `/programm-2026`: convert to a redirect to the festival event detail (`src/app/(public)/programm-2026/page.tsx` → `redirect()` ), preserving the public URL. The old markdown `Page` row can stay unpublished as archive. + +## 9. SEO & sitemap +- `buildEventJsonLd(event)` in `src/lib/seo.ts` → schema.org `MusicEvent`: `name`, `startDate`, `endDate`, `location` (Place w/ name+address), `performer` (artist appearances), `offers` (price tiers: price, priceCurrency, availability, url, validFrom). Escaped via existing `safeJsonLd`. +- `src/app/sitemap.ts`: add published events as `/events/`. + +## 10. Admin + +- `AdminNav.tsx` — add "Events" entry (`CalendarIcon`), route `/admin/events`. +- `/admin/events/page.tsx` — list with publish/feature toggles + delete-with-confirm + "Neu" link. Toggles check HTTP status before optimistic update (Spec 1 lesson). +- `/admin/events/new/page.tsx` — minimal create (title + slug) → POST → redirect to edit. +- `/admin/events/[id]/page.tsx` — `useParams()` (not `use(params)`); core fields, Plate description (dynamic import `.then(m => ({ default: m.PlateEditor }))`, `TElement` from `@udecode/plate`, serialization from `@/components/admin/editor/serialization/{markdownToPlate,plateToMarkdown}`), SEO fields, MediaPicker for hero; **Stage manager**, **Timetable builder** (appearances: stage select + artist select/free-title + start/end time + role), **Price-tier editor**. All inputs labelled (a11y lesson). + +## 11. API routes + +Role-gated `['ADMIN','SUPER_ADMIN'].includes(token.role)` via `getSessionToken()` (copied from the artists route). Every `[id]`/sub-resource handler does `findUnique` then rejects if `tenantId !== tenant.id` (IDOR). PUT uses an explicit field-allowlist. + +- `api/admin/events/route.ts` — `GET` (list), `POST` (create; scoped slug uniqueness). +- `api/admin/events/[id]/route.ts` — `GET`, `PUT` (core fields + price-tier replace-all in `$transaction`), `DELETE`. +- `api/admin/events/[id]/stages/route.ts` (`POST`) + `stages/[stageId]/route.ts` (`PUT`,`DELETE`). +- `api/admin/events/[id]/appearances/route.ts` (`POST`) + `appearances/[appId]/route.ts` (`PUT`,`DELETE`). Validates artistId-XOR-title, role, time order, and artist-tenant ownership. + +## 12. Seed (`prisma/seed.ts`) + +Seed the **e-Ventschau 2026** festival: `slug: e-ventschau-2026`, 7.–8. Aug 2026, Resthof Thiele / Ventschau, `isFeatured + isPublished`. Two stages (`Hauptbühne`, `Zeltbühne`). Appearances linking the 7 existing artists (`thorbjorn-risager, lebron-johnson, killabeatmaker, jed-thomas-band, rovar, nanny-goats, the-klaxon`) across both days with roles + plausible times (Thorbjørn Risager = `headliner`). A few price tiers (Festival 2-Tage, Tagesticket, Early-Bird window, VIP). Add an "Events" menu item. Idempotent upsert by `[tenantId, slug]`, like the artist seed. + +## 13. Optional batch ⑥ — Slider EVENT parity +Extend `SliderItemType` with `EVENT`, add `SliderItem.eventId`, and add the auto/manual `EVENT` branch in `api/sliders/route.ts` + `src/lib/sliders.ts` + admin sliders — mirroring the ARTIST branch with a `select` (id, title, slug, heroImage, startDate). **Trimmable**: the module is fully functional without it. + +## 14. Security & accessibility carry-overs (apply every Spec 1 lesson) +- Bodies render via `react-markdown` **without** `rehype-raw`; JSON-LD via `safeJsonLd`. +- `safeHttpsUrl` for `ticketUrl`/`buyUrl`/`locationUrl`; `safeCloudinaryUrl` for `heroImage`. +- Role-gated APIs, IDOR checks on every event + sub-resource, PUT field-allowlists, scoped slug uniqueness, artist-tenant ownership on linking. +- All admin form inputs labelled; toggles expose state; motion-safe guards on hover transitions; external links `rel="noopener noreferrer nofollow"`. + +## 15. Build order (the plan will expand each into TDD-style tasks) +1. **Schema** — models + enum + back-relations; `db:push`; regenerate client. +2. **Shared slug extraction** — `slug.ts` + re-export; verify artist build green. +3. **Validation + lib** — `event-validation.ts`, `events.ts`. +4. **API** — events CRUD + stage/appearance sub-resources + tier replace-all. +5. **Public** — `/events`, `/events/[slug]` (timetable, lineup, prices, JSON-LD), Artist "Auftritte" wire-up, sitemap. +6. **Admin** — list/new/edit (stage manager + timetable builder + tier editor), nav. +7. **Homepage coupling** — `getFeaturedEventLineup`, repoint "Programm" links, `/programm-2026` redirect. +8. **Seed** — festival 2026 + appearances + tiers + menu item. +9. **(Optional) Slider EVENT parity.** + +## 16. Out of scope (backlog, unchanged) +Ticketing/checkout, payment, seat/capacity management, newsletter, analytics dashboards, social-sync, recurring-event automation, multi-language. From 17aabb93db831833f16e75af3fc6d4aa01552a4f Mon Sep 17 00:00:00 2001 From: Oliver Baer Date: Tue, 16 Jun 2026 12:42:31 +0200 Subject: [PATCH 02/30] docs(events): Spec 2 implementation plan (25 tasks) --- .../plans/2026-06-15-events-modul.md | 2839 +++++++++++++++++ 1 file changed, 2839 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-15-events-modul.md diff --git a/docs/superpowers/plans/2026-06-15-events-modul.md b/docs/superpowers/plans/2026-06-15-events-modul.md new file mode 100644 index 0000000..b83ab27 --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-events-modul.md @@ -0,0 +1,2839 @@ +# Events-Modul (Spec 2) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a first-class, tenant-scoped Events module (multistage timetable, artist-or-free-text appearance slots, display-only price tiers) for e-Ventschau, with the homepage line-up coupled to the featured event and the Artist "Auftritte" stub finally rendered. + +**Architecture:** Mirror the shipped Artist module 1:1. `Event ≈ Artist` (tenant-scoped, `@@unique([tenantId, slug])`, markdown+Plate body, publish/feature flags). Nested children `Stage` / `Appearance` / `PriceTier ≈ ArtistMedia` (cascade-delete, no `tenantId`, scoped via parent). Price tiers persist via a replace-all `$transaction` inside the event PUT; stages and appearances persist via granular per-row sub-resource endpoints (a 2-day timetable is too many rows for fat-replace). All security/a11y lessons from Spec 1 carry over verbatim. + +**Tech Stack:** Next.js 15 App Router · React 19 · TypeScript · Tailwind 3.4 (liquid-glass) · Prisma 6 (Neon Postgres) · NextAuth 4 (JWT) · Plate.js · Cloudinary · `react-markdown` **without** `rehype-raw`. + +**Spec:** `docs/superpowers/specs/2026-06-15-e-ventschau-events-module-design.md` + +--- + +## Conventions for every task (read once) + +- **Working dir:** `/Users/oliverbaer/Projects/minicms`. Branch: `feat/events-module`. Git author **must** stay `oliver.baer@gmail.com`. +- **No test framework exists.** Two verification modes are used in this plan: + 1. **Pure-logic tasks** (`slug.ts`, `event-validation.ts`): a real executable assertion test run with `npx tsx ` (uses `node:assert/strict`, zero new deps). This gives genuine red→green. + 2. **DB / route / page / UI tasks**: `npx tsc --noEmit` (must print nothing / exit 0) and `npm run build` (must succeed) are the hard gates, plus a concrete runtime check (curl with expected status, a Prisma query, or a browser visual) where runnable. `npm run lint` is **unusable** (interactive) — never use it. +- **Never commit** `.env` / `.env.local` / `.mcp.json` (all gitignored — verify with `git status` before any `git add -A`; prefer explicit `git add `). +- **House style to copy exactly:** German user-facing strings & error messages; `String(x).trim()` / `x ? String(x) : null` coercion; booleans `=== true`; URLs through `safeHttpsUrl`/`safeCloudinaryUrl`; conditional-spread for optional JSON-LD keys; `getTenant()` + early-return in every data path; tenant-scoped `where` on every query. +- **Role gate is `['ADMIN','SUPER_ADMIN']`** (NOT `EDITOR`) — this is the minicms set. +- **Schema requirement that DELETE depends on:** Stage/Appearance/PriceTier relations to Event use `onDelete: Cascade`; Appearance→Artist uses `onDelete: SetNull`. Without these the DELETE route FK-errors. + +--- + +## File Structure (what gets created / modified) + +**Created:** +- `prisma/schema.prisma` (modified — 4 models + 2 back-relations) +- `src/lib/slug.ts` — extracted shared slug/URL validators +- `src/lib/event-validation.ts` — event/appearance/tier/stage sanitizers +- `src/lib/events.ts` — tenant-scoped getters + `EventWithRelations`/`EventSummary` types +- `src/lib/admin-auth.ts` — shared `getSessionToken`/`authTenant` for the 5 new event routes +- `src/lib/__tests__/slug.test.ts`, `src/lib/__tests__/event-validation.test.ts` — executable assertion tests +- `src/app/api/admin/events/route.ts` — GET list + POST create +- `src/app/api/admin/events/[id]/route.ts` — GET + PUT (+ priceTier replace-all) + DELETE +- `src/app/api/admin/events/[id]/stages/route.ts` + `stages/[stageId]/route.ts` +- `src/app/api/admin/events/[id]/appearances/route.ts` + `appearances/[appId]/route.ts` +- `src/components/events/EventCard.tsx` — list card + `EventSummary` type +- `src/components/events/EventTimetable.tsx` — responsive timetable (desktop grid + mobile agenda) +- `src/components/admin/events/StageManager.tsx`, `src/components/admin/events/TimetableBuilder.tsx` +- `src/app/(public)/events/page.tsx` + `src/app/(public)/events/[slug]/page.tsx` +- `src/app/(public)/programm-2026/page.tsx` — redirect to the festival event +- `src/app/admin/events/page.tsx` + `new/page.tsx` + `[id]/page.tsx` + +**Modified:** +- `src/lib/artist-validation.ts` — re-export from `slug.ts` (zero breakage) +- `src/lib/seo.ts` — add `buildEventJsonLd` +- `src/app/sitemap.ts` — add published events +- `src/components/admin/AdminNav.tsx` — add Events entry +- `src/app/(public)/kuenstler/[slug]/page.tsx` — wire up "Auftritte" +- `src/app/(public)/page.tsx` — homepage line-up coupling + Programm links +- `src/components/layout/HeaderClient.tsx`, `src/components/layout/Footer.tsx` — repoint Programm links +- `prisma/seed.ts` — festival 2026 + nested children + Events menu item + repoint Programm hrefs + +--- + +## Task 1: Prisma schema — Event / Stage / Appearance / PriceTier + +**Files:** +- Modify: `prisma/schema.prisma` + +The existing `Artist` model (`@@unique([tenantId, slug])`, `bio`/`bioJson`/`editorMode`) and `ArtistMedia` (`onDelete: Cascade`, no `tenantId`) are the templates. `Tenant` already has `artists Artist[]` (line ~74). `SliderItemType` currently has `PAGE PRODUCT VENDOR MEDIA ARTIST` — **do not** touch the enum or `SliderItem` here (that is the optional Task 25). + +- [ ] **Step 1: Append the four models** at the end of `prisma/schema.prisma` + +```prisma +model Event { + id String @id @default(cuid()) + tenantId String + slug String + title String + subtitle String? + eventType String @default("festival") + startDate DateTime + endDate DateTime? + locationName String? + locationAddress String? + locationUrl String? + heroImage String? + excerpt String? @db.Text + description String? @db.Text + descriptionJson Json? + editorMode String? @default("markdown") + ticketUrl String? + metaTitle String? + metaDescription String? @db.Text + isPublished Boolean @default(false) + isFeatured Boolean @default(false) + isActive Boolean @default(true) + sortOrder Int @default(0) + createdById String? + updatedById String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + tenant Tenant @relation(fields: [tenantId], references: [id]) + stages Stage[] + appearances Appearance[] + priceTiers PriceTier[] + + @@unique([tenantId, slug]) + @@index([tenantId]) + @@index([startDate]) +} + +model Stage { + id String @id @default(cuid()) + eventId String + name String + color String? + sortOrder Int @default(0) + + event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + appearances Appearance[] + + @@index([eventId]) +} + +model Appearance { + id String @id @default(cuid()) + eventId String + stageId String + artistId String? + title String? + role String @default("support") + startTime DateTime + endTime DateTime? + note String? + sortOrder Int @default(0) + + event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + stage Stage @relation(fields: [stageId], references: [id], onDelete: Cascade) + artist Artist? @relation(fields: [artistId], references: [id], onDelete: SetNull) + + @@index([eventId]) + @@index([stageId]) + @@index([artistId]) +} + +model PriceTier { + id String @id @default(cuid()) + eventId String + name String + description String? + price Float? + currency String @default("EUR") + validFrom DateTime? + validUntil DateTime? + isSoldOut Boolean @default(false) + isActive Boolean @default(true) + buyUrl String? + sortOrder Int @default(0) + + event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + + @@index([eventId]) +} +``` + +- [ ] **Step 2: Add the `Tenant.events` back-relation.** Find the line ` artists Artist[]` inside `model Tenant {` and add directly beneath it: + +```prisma + events Event[] +``` + +- [ ] **Step 3: Add the `Artist.appearances` back-relation.** Inside `model Artist {`, find the relations block: + +```prisma + tenant Tenant @relation(fields: [tenantId], references: [id]) + media ArtistMedia[] + sliderItems SliderItem[] +``` + +Add one line so it becomes: + +```prisma + tenant Tenant @relation(fields: [tenantId], references: [id]) + media ArtistMedia[] + sliderItems SliderItem[] + appearances Appearance[] +``` + +- [ ] **Step 4: Validate the schema** + +Run: `npx prisma validate` +Expected: `The schema at prisma/schema.prisma is valid 🚀` + +- [ ] **Step 5: Push to the Neon DB and regenerate the client** + +Run: `npm run db:push && npx prisma generate` +Expected: `Your database is now in sync with your Prisma schema.` followed by `Generated Prisma Client`. (Prisma CLI reads `.env`, not `.env.local` — ensure `.env` has `DATABASE_URL`.) + +- [ ] **Step 6: Type-check that the generated client exposes the new models** + +Run: `npx tsc --noEmit` +Expected: exit 0, no output. (Confirms `prisma.event` / `prisma.stage` / `prisma.appearance` / `prisma.priceTier` are now typed.) + +- [ ] **Step 7: Commit** + +```bash +git add prisma/schema.prisma +git commit -m "feat(events): add Event/Stage/Appearance/PriceTier schema" +``` + +--- + +## Task 2: Shared slug/URL validators — `src/lib/slug.ts` + +**Files:** +- Create: `src/lib/slug.ts` +- Modify: `src/lib/artist-validation.ts` +- Test: `src/lib/__tests__/slug.test.ts` + +DRY extraction with zero breakage: move `SLUG_RE`, `normalizeSlug`, `isValidSlug`, `safeHttpsUrl`, `safeCloudinaryUrl` into `slug.ts`, then re-export them from `artist-validation.ts` so every existing artist import keeps working. **The umlaut-before-NFKD order is load-bearing** (`Motörhead → motoerhead`, not `motorhead`) — copy verbatim. + +- [ ] **Step 1: Write the failing test** at `src/lib/__tests__/slug.test.ts` + +```ts +import assert from 'node:assert/strict' +import { normalizeSlug, isValidSlug, safeHttpsUrl, safeCloudinaryUrl } from '../slug' + +// umlaut replacement happens BEFORE NFKD — the canonical regression +assert.equal(normalizeSlug('Motörhead'), 'motoerhead') +assert.equal(normalizeSlug('Thorbjørn Risager & The Black Tornado'), 'thorbjorn-risager-the-black-tornado') +assert.equal(normalizeSlug(' Über Größe '), 'ueber-groesse') +assert.equal(normalizeSlug('a'.repeat(200)).length, 96) + +assert.equal(isValidSlug('e-ventschau-2026'), true) +assert.equal(isValidSlug('-bad'), false) +assert.equal(isValidSlug('Bad Caps'), false) +assert.equal(isValidSlug(''), false) + +assert.equal(safeHttpsUrl('https://example.com/x'), 'https://example.com/x') +assert.equal(safeHttpsUrl('http://example.com'), null) // non-https rejected +assert.equal(safeHttpsUrl('https://user:pw@example.com'), null) // userinfo rejected +assert.equal(safeHttpsUrl('not a url'), null) + +assert.equal(safeCloudinaryUrl('https://res.cloudinary.com/x/y.jpg'), 'https://res.cloudinary.com/x/y.jpg') +assert.equal(safeCloudinaryUrl('https://evil.com/y.jpg'), null) // wrong host rejected + +console.log('✓ slug.test.ts — all assertions passed') +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npx tsx src/lib/__tests__/slug.test.ts` +Expected: FAIL — `Cannot find module '../slug'` (file does not exist yet). + +- [ ] **Step 3: Create `src/lib/slug.ts`** (verbatim — umlaut replace then NFKD) + +```ts +// Shared slug + URL validators (extracted from artist-validation for reuse by the events module). + +export const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ + +export function normalizeSlug(input: string): string { + return (input || '') + .toLowerCase() + .replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss') + .normalize('NFKD') + .replace(/[̀-ͯ]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 96) +} + +export function isValidSlug(slug: string): boolean { + return !!slug && slug.length <= 96 && SLUG_RE.test(slug) +} + +export function safeHttpsUrl(input: unknown): string | null { + if (typeof input !== 'string' || !input.trim()) return null + try { + const u = new URL(input.trim()) + if (u.protocol !== 'https:') return null + if (u.username || u.password) return null + return u.toString() + } catch { return null } +} + +export function safeCloudinaryUrl(input: unknown): string | null { + const url = safeHttpsUrl(input) + if (!url) return null + try { return new URL(url).hostname === 'res.cloudinary.com' ? url : null } catch { return null } +} +``` + +> NOTE: the original `artist-validation.ts` used a literal combining-marks character class. `[̀-ͯ]` is the identical Unicode range written in escape form (safer in source). It strips the same diacritics after NFKD. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx tsx src/lib/__tests__/slug.test.ts` +Expected: `✓ slug.test.ts — all assertions passed` (exit 0). + +- [ ] **Step 5: Re-export from `artist-validation.ts`** — replace its local definitions of `SLUG_RE`, `normalizeSlug`, `isValidSlug`, `safeHttpsUrl`, `safeCloudinaryUrl` (lines 3–34) with a re-export. Delete those five definitions and put this at the top of the file (keep everything from `SocialLink` onward unchanged): + +```ts +// Security validators for artist input (see spec §9). +// Slug + URL primitives live in ./slug and are re-exported here for backwards compatibility. +export { SLUG_RE, normalizeSlug, isValidSlug, safeHttpsUrl, safeCloudinaryUrl } from './slug' +import { safeHttpsUrl, safeCloudinaryUrl } from './slug' +``` + +> The `import { safeHttpsUrl, safeCloudinaryUrl }` line is required because `sanitizeSocials` and `sanitizeGalleryItem` (further down the file) call them internally. The `export { ... } from './slug'` re-publishes them for external importers. + +- [ ] **Step 6: Verify the artist module still type-checks and builds** + +Run: `npx tsc --noEmit` +Expected: exit 0, no output (confirms no artist importer broke). + +- [ ] **Step 7: Commit** + +```bash +git add src/lib/slug.ts src/lib/artist-validation.ts src/lib/__tests__/slug.test.ts +git commit -m "refactor(events): extract shared slug/url validators to slug.ts" +``` + +--- + +## Task 3: Event validation — `src/lib/event-validation.ts` + +**Files:** +- Create: `src/lib/event-validation.ts` +- Test: `src/lib/__tests__/event-validation.test.ts` + +Pure sanitizers for event scalar input + nested rows. Enforces: `eventType` ∈ allowlist; appearance `artistId` XOR `title` (artist link wins when both sent, title nulled); role ∈ allowlist; `endTime > startTime`; price `≥ 0` or null; `currency` ∈ allowlist; `validUntil ≥ validFrom`. The DB-dependent **artist-tenant-ownership** check is NOT here (it needs Prisma) — it lives in the appearances route (Task 9). + +- [ ] **Step 1: Write the failing test** at `src/lib/__tests__/event-validation.test.ts` + +```ts +import assert from 'node:assert/strict' +import { + sanitizeEventType, ALLOWED_ROLES, ALLOWED_CURRENCIES, + sanitizeStage, sanitizeAppearance, sanitizePriceTier, +} from '../event-validation' + +// eventType allowlist +assert.equal(sanitizeEventType('concert'), 'concert') +assert.equal(sanitizeEventType('WORKSHOP'), 'workshop') +assert.equal(sanitizeEventType('garbage'), 'festival') // fallback default + +// stage +assert.deepEqual(sanitizeStage({ name: 'Hauptbühne', color: '#b87333' }, 2), + { name: 'Hauptbühne', color: '#b87333', sortOrder: 2 }) +assert.equal(sanitizeStage({ name: '' }, 0), null) // name required +assert.equal(sanitizeStage({ name: 'X', color: 'red' }, 0)!.color, null) // non-hex dropped + +// appearance — artist link wins over title (XOR enforced post-sanitize) +const a1 = sanitizeAppearance({ stageId: 's1', artistId: 'art1', title: 'ignored', role: 'headliner', startTime: '2026-08-07T20:00:00Z' }, 0)! +assert.equal(a1.artistId, 'art1') +assert.equal(a1.title, null) +assert.equal(a1.role, 'headliner') + +// appearance — free-text slot (no artist) +const a2 = sanitizeAppearance({ stageId: 's1', title: 'Umbaupause', role: 'break', startTime: '2026-08-07T21:00:00Z' }, 1)! +assert.equal(a2.artistId, null) +assert.equal(a2.title, 'Umbaupause') + +// appearance — neither artist nor title → dropped +assert.equal(sanitizeAppearance({ stageId: 's1', startTime: '2026-08-07T20:00:00Z' }, 0), null) +// appearance — no stage → dropped +assert.equal(sanitizeAppearance({ artistId: 'a', startTime: '2026-08-07T20:00:00Z' }, 0), null) +// appearance — endTime <= startTime → dropped +assert.equal(sanitizeAppearance({ stageId: 's1', artistId: 'a', startTime: '2026-08-07T20:00:00Z', endTime: '2026-08-07T19:00:00Z' }, 0), null) +// appearance — bad role falls back to support +assert.equal(sanitizeAppearance({ stageId: 's1', artistId: 'a', role: 'nonsense', startTime: '2026-08-07T20:00:00Z' }, 0)!.role, 'support') + +// price tier +const p1 = sanitizePriceTier({ name: 'Festival 2-Tage', price: 49, currency: 'EUR' }, 0)! +assert.equal(p1.price, 49) +assert.equal(p1.currency, 'EUR') +assert.equal(sanitizePriceTier({ name: 'X', price: -5 }, 0)!.price, null) // negative → null ("auf Anfrage") +assert.equal(sanitizePriceTier({ name: 'X', currency: 'XYZ' }, 0)!.currency, 'EUR') // bad currency → default +assert.equal(sanitizePriceTier({ name: '' }, 0), null) // name required +assert.equal(sanitizePriceTier({ name: 'X', buyUrl: 'http://x.com' }, 0)!.buyUrl, null) // non-https dropped +assert.equal(sanitizePriceTier({ name: 'X', validFrom: '2026-02-01', validUntil: '2026-01-01' }, 0), null) // window inverted → dropped + +assert.ok(ALLOWED_ROLES.includes('guest')) +assert.ok(ALLOWED_CURRENCIES.includes('CHF')) + +console.log('✓ event-validation.test.ts — all assertions passed') +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npx tsx src/lib/__tests__/event-validation.test.ts` +Expected: FAIL — `Cannot find module '../event-validation'`. + +- [ ] **Step 3: Create `src/lib/event-validation.ts`** + +```ts +// Security validators for event input (see spec §5). Pure functions; DB-dependent +// checks (artist-tenant ownership) live in the appearances route. +import { normalizeSlug, isValidSlug, safeHttpsUrl, safeCloudinaryUrl } from './slug' +export { normalizeSlug, isValidSlug, safeHttpsUrl, safeCloudinaryUrl } + +export const ALLOWED_EVENT_TYPES = ['festival', 'concert', 'workshop', 'other'] as const +export const ALLOWED_ROLES = ['headliner', 'support', 'guest', 'break'] as const +export const ALLOWED_CURRENCIES = ['EUR', 'USD', 'CHF', 'GBP'] as const + +const HEX_COLOR_RE = /^#[0-9a-fA-F]{3,8}$/ + +function parseDate(input: unknown): Date | null { + if (input === null || input === undefined || input === '') return null + const d = new Date(String(input)) + return isNaN(d.getTime()) ? null : d +} + +export function sanitizeEventType(input: unknown): string { + const t = String(input || '').toLowerCase().trim() + return (ALLOWED_EVENT_TYPES as readonly string[]).includes(t) ? t : 'festival' +} + +export type StageInput = { name: string; color: string | null; sortOrder: number } + +export function sanitizeStage(raw: unknown, index = 0): StageInput | null { + if (!raw || typeof raw !== 'object') return null + const r = raw as Record + const name = String(r.name || '').trim().slice(0, 120) + if (!name) return null + const colorRaw = String(r.color || '').trim() + const color = HEX_COLOR_RE.test(colorRaw) ? colorRaw : null + const sortOrder = Number.isFinite(Number(r.sortOrder)) ? Number(r.sortOrder) : index + return { name, color, sortOrder } +} + +export type AppearanceInput = { + stageId: string + artistId: string | null + title: string | null + role: string + startTime: Date + endTime: Date | null + note: string | null + sortOrder: number +} + +export function sanitizeAppearance(raw: unknown, index = 0): AppearanceInput | null { + if (!raw || typeof raw !== 'object') return null + const r = raw as Record + const stageId = String(r.stageId || '').trim() + if (!stageId) return null + + let artistId: string | null = r.artistId ? String(r.artistId).trim() : null + let title: string | null = r.title ? String(r.title).slice(0, 200).trim() : null + // XOR: artist link wins; title is only the fallback for non-artist slots. + if (artistId) title = null + else if (!title) return null + + const startTime = parseDate(r.startTime) + if (!startTime) return null + const endTime = parseDate(r.endTime) + if (endTime && endTime <= startTime) return null + + const roleRaw = String(r.role || 'support').toLowerCase().trim() + const role = (ALLOWED_ROLES as readonly string[]).includes(roleRaw) ? roleRaw : 'support' + const note = r.note ? String(r.note).slice(0, 500).trim() : null + const sortOrder = Number.isFinite(Number(r.sortOrder)) ? Number(r.sortOrder) : index + + return { stageId, artistId, title, role, startTime, endTime, note, sortOrder } +} + +export type PriceTierInput = { + name: string + description: string | null + price: number | null + currency: string + validFrom: Date | null + validUntil: Date | null + isSoldOut: boolean + isActive: boolean + buyUrl: string | null + sortOrder: number +} + +export function sanitizePriceTier(raw: unknown, index = 0): PriceTierInput | null { + if (!raw || typeof raw !== 'object') return null + const r = raw as Record + const name = String(r.name || '').trim().slice(0, 120) + if (!name) return null + + const priceNum = Number(r.price) + const price = Number.isFinite(priceNum) && priceNum >= 0 ? priceNum : null + + const currencyRaw = String(r.currency || 'EUR').toUpperCase().trim() + const currency = (ALLOWED_CURRENCIES as readonly string[]).includes(currencyRaw) ? currencyRaw : 'EUR' + + const validFrom = parseDate(r.validFrom) + const validUntil = parseDate(r.validUntil) + if (validFrom && validUntil && validUntil < validFrom) return null + + const description = r.description ? String(r.description).slice(0, 500).trim() : null + const buyUrl = safeHttpsUrl(r.buyUrl) + const sortOrder = Number.isFinite(Number(r.sortOrder)) ? Number(r.sortOrder) : index + + return { + name, description, price, currency, validFrom, validUntil, + isSoldOut: r.isSoldOut === true, + isActive: r.isActive !== false, + buyUrl, sortOrder, + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx tsx src/lib/__tests__/event-validation.test.ts` +Expected: `✓ event-validation.test.ts — all assertions passed` (exit 0). + +- [ ] **Step 5: Type-check** + +Run: `npx tsc --noEmit` +Expected: exit 0. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/event-validation.ts src/lib/__tests__/event-validation.test.ts +git commit -m "feat(events): event/stage/appearance/tier validation" +``` + +--- + +## Task 4: Data library — `src/lib/events.ts` + +**Files:** +- Create: `src/lib/events.ts` + +Mirrors `src/lib/artists.ts`: tenant-scoped getters, try/catch → `[]`/`null`. `getFeaturedEventLineup()` returns `ArtistSummary[]` (the homepage reuses artist card styling). All public getters filter `isPublished + isActive`. + +- [ ] **Step 1: Create `src/lib/events.ts`** + +```ts +import { prisma } from './prisma' +import { getTenant } from './tenant' +import type { Prisma } from '@prisma/client' +import type { ArtistSummary } from '@/components/artists/ArtistCard' + +export type EventWithRelations = Prisma.EventGetPayload<{ + include: { + stages: true + priceTiers: true + appearances: { include: { artist: { select: { slug: true; name: true } }; stage: true } } + } +}> + +export type EventSummary = { + slug: string + title: string + subtitle: string | null + startDate: Date + endDate: Date | null + locationName: string | null + heroImage: string | null + excerpt: string | null + isFeatured: boolean +} + +const ROLE_RANK: Record = { headliner: 0, support: 1, guest: 2, break: 3 } + +export async function getPublishedEvents(): Promise { + try { + const tenant = await getTenant() + if (!tenant) return [] + return await prisma.event.findMany({ + where: { tenantId: tenant.id, isPublished: true, isActive: true }, + select: { + slug: true, title: true, subtitle: true, startDate: true, endDate: true, + locationName: true, heroImage: true, excerpt: true, isFeatured: true, + }, + orderBy: [{ startDate: 'asc' }], + }) + } catch (e) { console.error('getPublishedEvents failed', e); return [] } +} + +export async function getPublishedEventBySlug(slug: string): Promise { + try { + const tenant = await getTenant() + if (!tenant) return null + const event = await prisma.event.findUnique({ + where: { tenantId_slug: { tenantId: tenant.id, slug } }, + include: { + stages: { orderBy: { sortOrder: 'asc' } }, + priceTiers: { where: { isActive: true }, orderBy: { sortOrder: 'asc' } }, + appearances: { + include: { artist: { select: { slug: true, name: true } }, stage: true }, + orderBy: [{ startTime: 'asc' }, { sortOrder: 'asc' }], + }, + }, + }) + if (!event || !event.isPublished || !event.isActive) return null + return event + } catch (e) { console.error('getPublishedEventBySlug failed', e); return null } +} + +export async function getEventsForAdmin() { + const tenant = await getTenant() + if (!tenant) return [] + return prisma.event.findMany({ + where: { tenantId: tenant.id }, + include: { _count: { select: { stages: true, appearances: true, priceTiers: true } } }, + orderBy: [{ isFeatured: 'desc' }, { startDate: 'asc' }], + }) +} + +export async function getFeaturedEvent() { + try { + const tenant = await getTenant() + if (!tenant) return null + return await prisma.event.findFirst({ + where: { tenantId: tenant.id, isPublished: true, isActive: true }, + orderBy: [{ isFeatured: 'desc' }, { startDate: 'asc' }], + }) + } catch (e) { console.error('getFeaturedEvent failed', e); return null } +} + +export async function getFeaturedEventLineup(): Promise { + try { + const tenant = await getTenant() + if (!tenant) return [] + const event = await prisma.event.findFirst({ + where: { tenantId: tenant.id, isPublished: true, isActive: true }, + orderBy: [{ isFeatured: 'desc' }, { startDate: 'asc' }], + select: { id: true }, + }) + if (!event) return [] + const appearances = await prisma.appearance.findMany({ + where: { + eventId: event.id, + artistId: { not: null }, + artist: { isPublished: true, isActive: true }, + }, + include: { + artist: { select: { slug: true, name: true, origin: true, genres: true, heroImage: true, excerpt: true, isFeatured: true } }, + }, + orderBy: [{ startTime: 'asc' }], + }) + const sorted = [...appearances].sort((a, b) => (ROLE_RANK[a.role] ?? 9) - (ROLE_RANK[b.role] ?? 9)) + const seen = new Set() + const lineup: ArtistSummary[] = [] + for (const ap of sorted) { + if (!ap.artist || seen.has(ap.artist.slug)) continue + seen.add(ap.artist.slug) + lineup.push({ + slug: ap.artist.slug, + name: ap.artist.name, + origin: ap.artist.origin, + genres: ap.artist.genres, + heroImage: ap.artist.heroImage, + excerpt: ap.artist.excerpt, + isFeatured: ap.artist.isFeatured, + }) + } + return lineup + } catch (e) { console.error('getFeaturedEventLineup failed', e); return [] } +} + +export type ArtistAppearance = Prisma.AppearanceGetPayload<{ + include: { event: { select: { slug: true; title: true; startDate: true } }; stage: { select: { name: true } } } +}> + +export async function getArtistAppearances(artistId: string): Promise { + try { + const tenant = await getTenant() + if (!tenant) return [] + return await prisma.appearance.findMany({ + where: { artistId, event: { tenantId: tenant.id, isPublished: true, isActive: true } }, + include: { + event: { select: { slug: true, title: true, startDate: true } }, + stage: { select: { name: true } }, + }, + orderBy: [{ startTime: 'asc' }], + }) + } catch (e) { console.error('getArtistAppearances failed', e); return [] } +} +``` + +- [ ] **Step 2: Type-check** + +Run: `npx tsc --noEmit` +Expected: exit 0. (Confirms `ArtistSummary` import resolves and the `Prisma.EventGetPayload` includes are valid.) + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/events.ts +git commit -m "feat(events): tenant-scoped event getters + lineup coupling" +``` + +--- + +## Task 5: SEO — `buildEventJsonLd` in `src/lib/seo.ts` + +**Files:** +- Modify: `src/lib/seo.ts` + +`seo.ts` exports `buildMetadata`, `buildArtistJsonLd` (MusicGroup), `buildVendorJsonLd` (nested PostalAddress), `websiteJsonLd`, `organizationJsonLd`. The `` component (`src/components/JsonLd.tsx`) already escapes `<`,`>`,`&` to unicode — so builders return **plain objects**; never hand-serialize. Add a `MusicEvent` builder following the conditional-spread house style, with ISO date coercion. + +- [ ] **Step 1: Append `buildEventJsonLd`** to `src/lib/seo.ts` (after `buildArtistJsonLd`) + +```ts +export function buildEventJsonLd(event: { + title: string + slug: string + startDate: string | Date + endDate?: string | Date | null + excerpt?: string | null + heroImage?: string | null + locationName?: string | null + locationAddress?: string | null + performers?: { name: string; slug: string }[] | null + priceTiers?: { name: string; price: number | null; currency?: string | null; buyUrl?: string | null }[] | null +}) { + const iso = (d: string | Date) => (typeof d === 'string' ? d : d.toISOString()) + return { + '@context': 'https://schema.org', + '@type': 'MusicEvent', + name: event.title, + url: `${SITE_URL}/events/${event.slug}`, + startDate: iso(event.startDate), + eventStatus: 'https://schema.org/EventScheduled', + eventAttendanceMode: 'https://schema.org/OfflineEventAttendanceMode', + ...(event.endDate ? { endDate: iso(event.endDate) } : {}), + ...(event.excerpt ? { description: event.excerpt } : {}), + ...(event.heroImage ? { image: event.heroImage } : {}), + ...(event.locationName + ? { + location: { + '@type': 'Place', + name: event.locationName, + ...(event.locationAddress ? { address: event.locationAddress } : {}), + }, + } + : {}), + ...(event.performers?.length + ? { performer: event.performers.map((p) => ({ '@type': 'MusicGroup', name: p.name, url: `${SITE_URL}/kuenstler/${p.slug}` })) } + : {}), + ...(event.priceTiers?.length + ? { + offers: event.priceTiers.map((t) => ({ + '@type': 'Offer', + name: t.name, + ...(t.price !== null && t.price !== undefined ? { price: t.price, priceCurrency: t.currency || 'EUR' } : {}), + availability: 'https://schema.org/InStock', + url: t.buyUrl || `${SITE_URL}/events/${event.slug}`, + })), + } + : {}), + } +} +``` + +> `SITE_URL` is the existing module-level const — reuse it; do not re-derive. The `` component does the escaping, so this returns a plain object. + +- [ ] **Step 2: Type-check** + +Run: `npx tsc --noEmit` +Expected: exit 0. + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/seo.ts +git commit -m "feat(events): buildEventJsonLd (schema.org MusicEvent)" +``` + +--- + +## Task 6: API — events collection route (GET list + POST create) + +**Files:** +- Create: `src/lib/admin-auth.ts` +- Create: `src/app/api/admin/events/route.ts` + +DRY improvement: the 5 new event routes share one auth helper module (`admin-auth.ts`) instead of inlining `getSessionToken` five times. The artist routes keep their inline copy (don't refactor shipped code). + +- [ ] **Step 1: Create `src/lib/admin-auth.ts`** + +```ts +import { cookies } from 'next/headers' +import { getToken } from 'next-auth/jwt' +import { NextResponse } from 'next/server' +import { getTenant } from './tenant' +import type { Tenant } from '@prisma/client' +import type { JWT } from 'next-auth/jwt' + +export async function getSessionToken() { + const cookieStore = await cookies() + return getToken({ + req: { + cookies: Object.fromEntries(cookieStore.getAll().map((c) => [c.name, c.value])), + } as any, + secret: process.env.NEXTAUTH_SECRET, + }) +} + +export type AuthContext = { token: JWT; tenant: Tenant } + +export async function authTenant(): Promise<{ error: NextResponse } | AuthContext> { + const token = await getSessionToken() + if (!token || !['ADMIN', 'SUPER_ADMIN'].includes(token.role as string)) { + return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + } + const tenant = await getTenant() + if (!tenant) return { error: NextResponse.json({ error: 'Tenant not found' }, { status: 404 }) } + return { token, tenant } +} +``` + +- [ ] **Step 2: Create `src/app/api/admin/events/route.ts`** + +```ts +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { authTenant } from '@/lib/admin-auth' +import { getEventsForAdmin } from '@/lib/events' +import { normalizeSlug, isValidSlug, sanitizeEventType } from '@/lib/event-validation' + +export async function GET() { + const ctx = await authTenant() + if ('error' in ctx) return ctx.error + return NextResponse.json(await getEventsForAdmin()) +} + +export async function POST(req: NextRequest) { + const ctx = await authTenant() + if ('error' in ctx) return ctx.error + + const body = await req.json() + const title = String(body.title || '').trim() + let slug = normalizeSlug(body.slug || body.title || '') + if (!title || !slug) return NextResponse.json({ error: 'Titel und Slug sind erforderlich' }, { status: 400 }) + if (!isValidSlug(slug)) return NextResponse.json({ error: 'Ungültiger Slug' }, { status: 400 }) + + const startDate = body.startDate ? new Date(String(body.startDate)) : null + if (!startDate || isNaN(startDate.getTime())) { + return NextResponse.json({ error: 'Startdatum ist erforderlich' }, { status: 400 }) + } + + let suffix = 0 + while (await prisma.event.findUnique({ where: { tenantId_slug: { tenantId: ctx.tenant.id, slug } } })) { + suffix += 1 + slug = `${normalizeSlug(body.slug || body.title)}-${suffix}` + } + + const event = await prisma.event.create({ + data: { + tenantId: ctx.tenant.id, + title, slug, + eventType: sanitizeEventType(body.eventType), + startDate, + isPublished: false, + isActive: true, + createdById: (ctx.token.sub as string) || null, + updatedById: (ctx.token.sub as string) || null, + }, + }) + return NextResponse.json(event, { status: 201 }) +} +``` + +- [ ] **Step 3: Type-check + build** + +Run: `npx tsc --noEmit && npm run build` +Expected: both succeed (build compiles the new route). + +- [ ] **Step 4: Runtime smoke (unauthenticated → 401)** + +Run (dev server in another terminal via `npm run dev`): +`curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/admin/events` +Expected: `401` (no session cookie → `authTenant` rejects). This confirms the gate fires. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/admin-auth.ts src/app/api/admin/events/route.ts +git commit -m "feat(events): admin events collection route + shared auth helper" +``` + +--- + +## Task 7: API — events item route (GET + PUT + DELETE) + +**Files:** +- Create: `src/app/api/admin/events/[id]/route.ts` + +Mirrors the artist `[id]` route: async params, fetch-then-tenant-check IDOR guard, PUT field-allowlist, **price-tier replace-all** inside a single `$transaction`, DELETE relies on schema cascade → 204. Stages/appearances are NOT touched here (granular routes, Tasks 8–9). + +- [ ] **Step 1: Create `src/app/api/admin/events/[id]/route.ts`** + +```ts +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { authTenant } from '@/lib/admin-auth' +import { + normalizeSlug, isValidSlug, safeHttpsUrl, safeCloudinaryUrl, + sanitizeEventType, sanitizePriceTier, +} from '@/lib/event-validation' + +const EVENT_INCLUDE = { + stages: { orderBy: { sortOrder: 'asc' as const } }, + priceTiers: { orderBy: { sortOrder: 'asc' as const } }, + appearances: { + include: { artist: { select: { slug: true, name: true } }, stage: true }, + orderBy: [{ startTime: 'asc' as const }, { sortOrder: 'asc' as const }], + }, +} + +export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const ctx = await authTenant() + if ('error' in ctx) return ctx.error + const event = await prisma.event.findUnique({ where: { id }, include: EVENT_INCLUDE }) + if (!event || event.tenantId !== ctx.tenant.id) return NextResponse.json({ error: 'Event not found' }, { status: 404 }) + return NextResponse.json(event) +} + +export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const ctx = await authTenant() + if ('error' in ctx) return ctx.error + const existing = await prisma.event.findUnique({ where: { id } }) + if (!existing || existing.tenantId !== ctx.tenant.id) return NextResponse.json({ error: 'Event not found' }, { status: 404 }) + + const body = await req.json() + const data: Record = { updatedById: (ctx.token.sub as string) || null } + + if (body.title !== undefined) data.title = String(body.title).trim() + if (body.subtitle !== undefined) data.subtitle = body.subtitle ? String(body.subtitle) : null + if (body.eventType !== undefined) data.eventType = sanitizeEventType(body.eventType) + if (body.startDate !== undefined) { + const d = new Date(String(body.startDate)) + if (isNaN(d.getTime())) return NextResponse.json({ error: 'Ungültiges Startdatum' }, { status: 400 }) + data.startDate = d + } + if (body.endDate !== undefined) { + if (!body.endDate) data.endDate = null + else { + const d = new Date(String(body.endDate)) + if (isNaN(d.getTime())) return NextResponse.json({ error: 'Ungültiges Enddatum' }, { status: 400 }) + data.endDate = d + } + } + if (body.locationName !== undefined) data.locationName = body.locationName ? String(body.locationName) : null + if (body.locationAddress !== undefined) data.locationAddress = body.locationAddress ? String(body.locationAddress) : null + if (body.locationUrl !== undefined) data.locationUrl = safeHttpsUrl(body.locationUrl) + if (body.heroImage !== undefined) data.heroImage = safeCloudinaryUrl(body.heroImage) + if (body.excerpt !== undefined) data.excerpt = body.excerpt ? String(body.excerpt) : null + if (body.description !== undefined) data.description = body.description ? String(body.description) : null + if (body.descriptionJson !== undefined) data.descriptionJson = Array.isArray(body.descriptionJson) ? body.descriptionJson : null + if (body.editorMode !== undefined) { + const mode = String(body.editorMode) + data.editorMode = ['markdown', 'wysiwyg'].includes(mode) ? mode : 'markdown' + } + if (body.ticketUrl !== undefined) data.ticketUrl = safeHttpsUrl(body.ticketUrl) + if (body.metaTitle !== undefined) data.metaTitle = body.metaTitle ? String(body.metaTitle) : null + if (body.metaDescription !== undefined) data.metaDescription = body.metaDescription ? String(body.metaDescription) : null + if (body.isPublished !== undefined) data.isPublished = body.isPublished === true + if (body.isActive !== undefined) data.isActive = body.isActive === true + if (body.isFeatured !== undefined) data.isFeatured = body.isFeatured === true + if (body.sortOrder !== undefined) data.sortOrder = Number(body.sortOrder) || 0 + + if (body.slug !== undefined) { + const slug = normalizeSlug(body.slug) + if (!isValidSlug(slug)) return NextResponse.json({ error: 'Ungültiger Slug' }, { status: 400 }) + const clash = await prisma.event.findUnique({ where: { tenantId_slug: { tenantId: ctx.tenant.id, slug } } }) + if (clash && clash.id !== id) return NextResponse.json({ error: 'Slug bereits vergeben' }, { status: 409 }) + data.slug = slug + } + + if (Array.isArray(body.priceTiers)) { + const clean = body.priceTiers + .map((t: unknown, i: number) => sanitizePriceTier(t, i)) + .filter(Boolean) as NonNullable>[] + await prisma.$transaction([ + prisma.priceTier.deleteMany({ where: { eventId: id } }), + prisma.event.update({ where: { id }, data }), + prisma.priceTier.createMany({ + data: clean.map((t, i) => ({ + eventId: id, name: t.name, description: t.description, price: t.price, currency: t.currency, + validFrom: t.validFrom, validUntil: t.validUntil, isSoldOut: t.isSoldOut, isActive: t.isActive, + buyUrl: t.buyUrl, sortOrder: i, + })), + }), + ]) + } else { + await prisma.event.update({ where: { id }, data }) + } + + const updated = await prisma.event.findUnique({ where: { id }, include: EVENT_INCLUDE }) + return NextResponse.json(updated) +} + +export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const ctx = await authTenant() + if ('error' in ctx) return ctx.error + const existing = await prisma.event.findUnique({ where: { id } }) + if (!existing || existing.tenantId !== ctx.tenant.id) return NextResponse.json({ error: 'Event not found' }, { status: 404 }) + await prisma.event.delete({ where: { id } }) + return new NextResponse(null, { status: 204 }) +} +``` + +- [ ] **Step 2: Type-check + build** + +Run: `npx tsc --noEmit && npm run build` +Expected: both succeed. + +- [ ] **Step 3: Runtime smoke (unauthenticated → 401)** + +Run: `curl -s -o /dev/null -w "%{http_code}\n" -X DELETE http://localhost:3000/api/admin/events/nonexistent` +Expected: `401`. + +- [ ] **Step 4: Commit** + +```bash +git add src/app/api/admin/events/[id]/route.ts +git commit -m "feat(events): admin event item route (GET/PUT/DELETE + tier replace-all)" +``` + +--- + +## Task 8: API — stages sub-resource routes + +**Files:** +- Create: `src/app/api/admin/events/[id]/stages/route.ts` +- Create: `src/app/api/admin/events/[id]/stages/[stageId]/route.ts` + +Granular per-row endpoints (spec ⑤). Every handler verifies the parent event belongs to the tenant (IDOR), and the `[stageId]` handlers additionally verify the stage belongs to that event. + +- [ ] **Step 1: Create `src/app/api/admin/events/[id]/stages/route.ts`** + +```ts +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { authTenant } from '@/lib/admin-auth' +import { sanitizeStage } from '@/lib/event-validation' + +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const ctx = await authTenant() + if ('error' in ctx) return ctx.error + const event = await prisma.event.findUnique({ where: { id } }) + if (!event || event.tenantId !== ctx.tenant.id) return NextResponse.json({ error: 'Event not found' }, { status: 404 }) + + const body = await req.json() + const clean = sanitizeStage(body, Number(body.sortOrder) || 0) + if (!clean) return NextResponse.json({ error: 'Name ist erforderlich' }, { status: 400 }) + + const stage = await prisma.stage.create({ + data: { eventId: id, name: clean.name, color: clean.color, sortOrder: clean.sortOrder }, + }) + return NextResponse.json(stage, { status: 201 }) +} +``` + +- [ ] **Step 2: Create `src/app/api/admin/events/[id]/stages/[stageId]/route.ts`** + +```ts +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { authTenant } from '@/lib/admin-auth' +import { sanitizeStage } from '@/lib/event-validation' + +async function loadStage(eventId: string, stageId: string, tenantId: string) { + const stage = await prisma.stage.findUnique({ where: { id: stageId }, include: { event: true } }) + if (!stage || stage.eventId !== eventId || stage.event.tenantId !== tenantId) return null + return stage +} + +export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string; stageId: string }> }) { + const { id, stageId } = await params + const ctx = await authTenant() + if ('error' in ctx) return ctx.error + if (!(await loadStage(id, stageId, ctx.tenant.id))) return NextResponse.json({ error: 'Stage not found' }, { status: 404 }) + + const body = await req.json() + const clean = sanitizeStage(body, Number(body.sortOrder) || 0) + if (!clean) return NextResponse.json({ error: 'Name ist erforderlich' }, { status: 400 }) + + const stage = await prisma.stage.update({ + where: { id: stageId }, + data: { name: clean.name, color: clean.color, sortOrder: clean.sortOrder }, + }) + return NextResponse.json(stage) +} + +export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string; stageId: string }> }) { + const { id, stageId } = await params + const ctx = await authTenant() + if ('error' in ctx) return ctx.error + if (!(await loadStage(id, stageId, ctx.tenant.id))) return NextResponse.json({ error: 'Stage not found' }, { status: 404 }) + await prisma.stage.delete({ where: { id: stageId } }) + return new NextResponse(null, { status: 204 }) +} +``` + +> Deleting a stage cascade-deletes its appearances (schema `onDelete: Cascade` on `Appearance.stage`). The admin UI must reload appearances after a stage delete. + +- [ ] **Step 3: Type-check + build** + +Run: `npx tsc --noEmit && npm run build` +Expected: both succeed. + +- [ ] **Step 4: Commit** + +```bash +git add src/app/api/admin/events/[id]/stages +git commit -m "feat(events): granular stage sub-resource routes" +``` + +--- + +## Task 9: API — appearances sub-resource routes + +**Files:** +- Create: `src/app/api/admin/events/[id]/appearances/route.ts` +- Create: `src/app/api/admin/events/[id]/appearances/[appId]/route.ts` + +Granular per-slot endpoints. Beyond the event IDOR check, these enforce: the chosen `stageId` belongs to this event, and any `artistId` belongs to the **same tenant** (cross-tenant IDOR guard — the one DB-dependent invariant the pure sanitizer can't do). + +- [ ] **Step 1: Create `src/app/api/admin/events/[id]/appearances/route.ts`** + +```ts +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { authTenant } from '@/lib/admin-auth' +import { sanitizeAppearance } from '@/lib/event-validation' + +// Verifies the stage belongs to the event and (if linked) the artist belongs to the tenant. +async function validateRefs(eventId: string, tenantId: string, stageId: string, artistId: string | null) { + const stage = await prisma.stage.findUnique({ where: { id: stageId } }) + if (!stage || stage.eventId !== eventId) return 'Bühne gehört nicht zu diesem Event' + if (artistId) { + const artist = await prisma.artist.findUnique({ where: { id: artistId } }) + if (!artist || artist.tenantId !== tenantId) return 'Künstler nicht gefunden' + } + return null +} + +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const ctx = await authTenant() + if ('error' in ctx) return ctx.error + const event = await prisma.event.findUnique({ where: { id } }) + if (!event || event.tenantId !== ctx.tenant.id) return NextResponse.json({ error: 'Event not found' }, { status: 404 }) + + const body = await req.json() + const clean = sanitizeAppearance(body, Number(body.sortOrder) || 0) + if (!clean) return NextResponse.json({ error: 'Bühne, Startzeit und Künstler oder Titel sind erforderlich' }, { status: 400 }) + + const refErr = await validateRefs(id, ctx.tenant.id, clean.stageId, clean.artistId) + if (refErr) return NextResponse.json({ error: refErr }, { status: 400 }) + + const appearance = await prisma.appearance.create({ + data: { + eventId: id, stageId: clean.stageId, artistId: clean.artistId, title: clean.title, + role: clean.role, startTime: clean.startTime, endTime: clean.endTime, note: clean.note, sortOrder: clean.sortOrder, + }, + }) + return NextResponse.json(appearance, { status: 201 }) +} +``` + +- [ ] **Step 2: Create `src/app/api/admin/events/[id]/appearances/[appId]/route.ts`** + +```ts +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { authTenant } from '@/lib/admin-auth' +import { sanitizeAppearance } from '@/lib/event-validation' + +async function loadAppearance(eventId: string, appId: string, tenantId: string) { + const appearance = await prisma.appearance.findUnique({ where: { id: appId }, include: { event: true } }) + if (!appearance || appearance.eventId !== eventId || appearance.event.tenantId !== tenantId) return null + return appearance +} + +async function validateRefs(eventId: string, tenantId: string, stageId: string, artistId: string | null) { + const stage = await prisma.stage.findUnique({ where: { id: stageId } }) + if (!stage || stage.eventId !== eventId) return 'Bühne gehört nicht zu diesem Event' + if (artistId) { + const artist = await prisma.artist.findUnique({ where: { id: artistId } }) + if (!artist || artist.tenantId !== tenantId) return 'Künstler nicht gefunden' + } + return null +} + +export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string; appId: string }> }) { + const { id, appId } = await params + const ctx = await authTenant() + if ('error' in ctx) return ctx.error + if (!(await loadAppearance(id, appId, ctx.tenant.id))) return NextResponse.json({ error: 'Appearance not found' }, { status: 404 }) + + const body = await req.json() + const clean = sanitizeAppearance(body, Number(body.sortOrder) || 0) + if (!clean) return NextResponse.json({ error: 'Bühne, Startzeit und Künstler oder Titel sind erforderlich' }, { status: 400 }) + + const refErr = await validateRefs(id, ctx.tenant.id, clean.stageId, clean.artistId) + if (refErr) return NextResponse.json({ error: refErr }, { status: 400 }) + + const appearance = await prisma.appearance.update({ + where: { id: appId }, + data: { + stageId: clean.stageId, artistId: clean.artistId, title: clean.title, + role: clean.role, startTime: clean.startTime, endTime: clean.endTime, note: clean.note, sortOrder: clean.sortOrder, + }, + }) + return NextResponse.json(appearance) +} + +export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string; appId: string }> }) { + const { id, appId } = await params + const ctx = await authTenant() + if ('error' in ctx) return ctx.error + if (!(await loadAppearance(id, appId, ctx.tenant.id))) return NextResponse.json({ error: 'Appearance not found' }, { status: 404 }) + await prisma.appearance.delete({ where: { id: appId } }) + return new NextResponse(null, { status: 204 }) +} +``` + +- [ ] **Step 3: Type-check + build** + +Run: `npx tsc --noEmit && npm run build` +Expected: both succeed. + +- [ ] **Step 4: Commit** + +```bash +git add src/app/api/admin/events/[id]/appearances +git commit -m "feat(events): granular appearance sub-resource routes (XOR + IDOR guards)" +``` + +--- + +## Task 10: `EventCard` component + `EventSummary` + +**Files:** +- Create: `src/components/events/EventCard.tsx` + +Server-compatible card (no `'use client'`, like `ArtistCard`). Renders heroImage-or-placeholder, date range, venue, title, excerpt, "Hauptevent" pill when featured. Imports `EventSummary` from `@/lib/events`. + +- [ ] **Step 1: Create `src/components/events/EventCard.tsx`** + +```tsx +import Link from 'next/link' +import Image from 'next/image' +import type { EventSummary } from '@/lib/events' + +function formatRange(start: Date, end: Date | null): string { + const fmt = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: 'long', year: 'numeric' }) + if (!end || start.toDateString() === end.toDateString()) return fmt.format(start) + const dayFmt = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: 'long' }) + return `${dayFmt.format(start)} – ${fmt.format(end)}` +} + +export default function EventCard({ event, priority = false }: { event: EventSummary; priority?: boolean }) { + return ( + +
+ {event.heroImage ? ( + {event.title} + ) : ( +
+ )} +
+
+

{formatRange(event.startDate, event.endDate)}

+

{event.title}

+ {event.isFeatured && ( + Hauptevent + )} + {event.locationName &&

{event.locationName}

} + {event.excerpt &&

{event.excerpt}

} +
+ + ) +} +``` + +- [ ] **Step 2: Type-check** + +Run: `npx tsc --noEmit` +Expected: exit 0. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/events/EventCard.tsx +git commit -m "feat(events): EventCard list component" +``` + +--- + +## Task 11: Public `/events` list page + +**Files:** +- Create: `src/app/(public)/events/page.tsx` + +`force-dynamic`. Lists `getPublishedEvents()` as `EventCard`s; empty-state when none. + +- [ ] **Step 1: Create `src/app/(public)/events/page.tsx`** + +```tsx +import type { Metadata } from 'next' +import { getPublishedEvents } from '@/lib/events' +import { buildMetadata } from '@/lib/seo' +import EventCard from '@/components/events/EventCard' + +export const dynamic = 'force-dynamic' + +export async function generateMetadata(): Promise { + return buildMetadata(null, '/events', { + title: 'Events & Programm – e-Ventschau', + description: 'Alle Veranstaltungen des e-Ventschau-Benefiz-Festivals – Termine, Line-up und Tickets.', + }) +} + +export default async function EventsIndexPage() { + const events = await getPublishedEvents() + return ( +
+
+

Events

+

Termine, Line-up und Tickets für die e-Ventschau.

+
+ {events.length === 0 ? ( +

Aktuell sind keine Veranstaltungen angekündigt.

+ ) : ( +
+ {events.map((event, i) => ( + + ))} +
+ )} +
+ ) +} +``` + +- [ ] **Step 2: Build** + +Run: `npm run build` +Expected: success; build output lists `/events` as a route. + +- [ ] **Step 3: Runtime check** + +Run (dev server up): `curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/events` +Expected: `200`. (Page renders the empty-state until Task 24 seeds events.) + +- [ ] **Step 4: Commit** + +```bash +git add src/app/(public)/events/page.tsx +git commit -m "feat(events): public /events list page" +``` + +--- + +## Task 12: `EventTimetable` component (responsive) + +**Files:** +- Create: `src/components/events/EventTimetable.tsx` + +Server component. Desktop: grid (Bühne × Zeit) with day-tabs for multi-day (CSS-only tabs via radio inputs so it stays server-rendered); mobile: agenda list grouped by day → time. Artist-linked slots link to `/kuenstler/[slug]`; free-text slots render muted/non-clickable; headliners get a ★. + +- [ ] **Step 1: Create `src/components/events/EventTimetable.tsx`** + +```tsx +import Link from 'next/link' +import type { EventWithRelations } from '@/lib/events' + +type Appearance = EventWithRelations['appearances'][number] + +const dayKey = (d: Date) => d.toISOString().slice(0, 10) +const dayLabel = (d: Date) => new Intl.DateTimeFormat('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit' }).format(d) +const timeLabel = (d: Date) => new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit' }).format(d) + +function slotLabel(a: Appearance) { + return a.artist ? a.artist.name : (a.title ?? '') +} + +function Slot({ a }: { a: Appearance }) { + const isHeadliner = a.role === 'headliner' + const star = isHeadliner ? '★ ' : '' + if (a.artist) { + return ( + + {star}{a.artist.name} + + ) + } + return {a.title} +} + +export default function EventTimetable({ event }: { event: EventWithRelations }) { + const { stages, appearances } = event + if (appearances.length === 0) return null + + // group by day + const days = Array.from(new Set(appearances.map((a) => dayKey(a.startTime)))).sort() + const sortedStages = [...stages].sort((s1, s2) => s1.sortOrder - s2.sortOrder) + + return ( +
+

Timetable

+ + {/* Mobile: agenda list */} +
+ {days.map((day) => { + const rows = appearances.filter((a) => dayKey(a.startTime) === day) + return ( +
+

+ {dayLabel(new Date(day + 'T00:00:00'))} +

+
    + {rows.map((a) => ( +
  • + {timeLabel(a.startTime)} + {a.role === 'headliner' ? '★ ' : ''}{slotLabel(a) || '—'} + {a.stage?.name} +
  • + ))} +
+
+ ) + })} +
+ + {/* Desktop: grid per day (Bühne × Zeit) */} +
+ {days.map((day) => { + const dayRows = appearances.filter((a) => dayKey(a.startTime) === day) + const times = Array.from(new Set(dayRows.map((a) => a.startTime.toISOString()))).sort() + return ( +
+

{dayLabel(new Date(day + 'T00:00:00'))}

+ + + + + {sortedStages.map((s) => ( + + ))} + + + + {times.map((t) => ( + + + {sortedStages.map((s) => { + const cell = dayRows.filter((a) => a.stageId === s.id && a.startTime.toISOString() === t) + return ( + + ) + })} + + ))} + +
Zeit + {s.name} +
+ {timeLabel(new Date(t))} + + {cell.map((a) => )} +
+
+ ) + })} +
+

★ = Headliner · klickbare Slots führen zur Künstler-Seite

+
+ ) +} +``` + +> Grid time-rows are derived from the sorted distinct start times of that day (not a fixed 24h axis). Single-day events render exactly one day block (no tab chrome needed). `overflow-x-auto` keeps wide multistage grids usable on tablets. + +- [ ] **Step 2: Type-check** + +Run: `npx tsc --noEmit` +Expected: exit 0. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/events/EventTimetable.tsx +git commit -m "feat(events): responsive timetable (desktop grid + mobile agenda)" +``` + +--- + +## Task 13: Public `/events/[slug]` detail page + +**Files:** +- Create: `src/app/(public)/events/[slug]/page.tsx` + +`React.cache(getPublishedEventBySlug)`, `notFound()` on null, async params. Sections: Hero → Beschreibung (`MarkdownContent`, no rehype-raw) → Timetable → Line-up (`ArtistCard` grid) → Preise (PriceTier cards, external `buyUrl` with `rel="noopener noreferrer nofollow"`) → Ticket-CTA → JSON-LD via ``. + +- [ ] **Step 1: Create `src/app/(public)/events/[slug]/page.tsx`** + +```tsx +import type { Metadata } from 'next' +import { cache } from 'react' +import { notFound } from 'next/navigation' +import Image from 'next/image' +import Link from 'next/link' +import { getPublishedEventBySlug } from '@/lib/events' +import { buildMetadata, buildEventJsonLd } from '@/lib/seo' +import JsonLd from '@/components/JsonLd' +import MarkdownContent from '@/components/MarkdownContent' +import ArtistCard from '@/components/artists/ArtistCard' +import EventTimetable from '@/components/events/EventTimetable' + +export const dynamic = 'force-dynamic' + +const getEvent = cache(getPublishedEventBySlug) + +type Props = { params: Promise<{ slug: string }> } + +function formatRange(start: Date, end: Date | null): string { + const fmt = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: 'long', year: 'numeric' }) + if (!end || start.toDateString() === end.toDateString()) return fmt.format(start) + const dayFmt = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: 'long' }) + return `${dayFmt.format(start)} – ${fmt.format(end)}` +} + +function formatPrice(price: number | null, currency: string): string { + if (price === null) return 'auf Anfrage' + return new Intl.NumberFormat('de-DE', { style: 'currency', currency }).format(price) +} + +export async function generateMetadata({ params }: Props): Promise { + const { slug } = await params + const event = await getEvent(slug) + if (!event) return buildMetadata(null, `/events/${slug}`, { title: 'Event nicht gefunden', description: '' }) + return buildMetadata(null, `/events/${slug}`, { + title: event.metaTitle || `${event.title} – e-Ventschau`, + description: event.metaDescription || event.excerpt || `${event.title} beim e-Ventschau-Festival.`, + ogImage: event.heroImage || undefined, + }) +} + +export default async function EventDetailPage({ params }: Props) { + const { slug } = await params + const event = await getEvent(slug) + if (!event) notFound() + + const lineup = event.appearances.filter((a) => a.artist) + const seenArtists = new Set() + const uniqueLineup = lineup.filter((a) => { + if (!a.artist || seenArtists.has(a.artist.slug)) return false + seenArtists.add(a.artist.slug) + return true + }) + + return ( +
+ ({ name: a.artist!.name, slug: a.artist!.slug })), + priceTiers: event.priceTiers.map((t) => ({ name: t.name, price: t.price, currency: t.currency, buyUrl: t.buyUrl })), + })} + /> + + {/* Hero */} +
+ {event.heroImage && ( +
+ {event.title} +
+ )} +

{formatRange(event.startDate, event.endDate)}

+

{event.title}

+ {event.subtitle &&

{event.subtitle}

} + {event.locationName && ( +

+ {event.locationUrl ? ( + + {event.locationName} + + ) : event.locationName} + {event.locationAddress ? ` · ${event.locationAddress}` : ''} +

+ )} +
+ + {/* Beschreibung */} + {event.description && ( +
+ +
+ )} + + {/* Timetable */} + + + {/* Line-up */} + {uniqueLineup.length > 0 && ( +
+

Line-up

+
+ {uniqueLineup.map((a) => ( + + ))} +
+
+ )} + + {/* Preise */} + {event.priceTiers.length > 0 && ( +
+

Preise

+
+ {event.priceTiers.map((t) => ( +
+
+

{t.name}

+ {formatPrice(t.price, t.currency)} +
+ {t.description &&

{t.description}

} + {t.isSoldOut && Ausverkauft} + {t.buyUrl && !t.isSoldOut && ( + + Ticket kaufen + + )} +
+ ))} +
+
+ )} + + {/* Ticket-CTA */} + {event.ticketUrl && ( +
+ + Tickets sichern + +
+ )} +
+ ) +} +``` + +> The Line-up reuses `ArtistCard` with a minimal `ArtistSummary` (`slug`/`name`/`isFeatured`). The card's other fields are optional and degrade gracefully (placeholder image, no genres line) — acceptable here since the full artist record lives at `/kuenstler/[slug]`. + +- [ ] **Step 2: Build** + +Run: `npm run build` +Expected: success; `/events/[slug]` appears in the route list. + +- [ ] **Step 3: Runtime check (draft/missing → 404)** + +Run (dev server up): `curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/events/does-not-exist` +Expected: `404` (the `notFound()` path). Full render is verified after seeding (Task 24). + +- [ ] **Step 4: Commit** + +```bash +git add src/app/(public)/events/[slug]/page.tsx +git commit -m "feat(events): public event detail page (timetable/lineup/prices/JSON-LD)" +``` + +--- + +## Task 14: Wire up the Artist "Auftritte" section + +**Files:** +- Modify: `src/app/(public)/kuenstler/[slug]/page.tsx` + +Replace the Spec-1 placeholder comment (the last child of the outer `
`, after the Galerie block) with a guarded section fed by `getArtistAppearances(artist.id)`. This is a separate query from the cached `getArtist` (different data), so it does not violate the React.cache de-dupe contract. + +- [ ] **Step 1: Add the import** near the other `@/lib` imports at the top of the file + +```tsx +import { getArtistAppearances } from '@/lib/events' +``` + +- [ ] **Step 2: Load appearances** in the page body, right after `if (!artist) notFound()` + +```tsx + const appearances = await getArtistAppearances(artist.id) +``` + +- [ ] **Step 3: Replace the placeholder comment** — find: + +```tsx + {/* "Auftritte"-Sektion wird erst in Spec 2 (Events) befüllt — bis dahin bewusst nicht gerendert. */} +``` + +and replace with: + +```tsx + {appearances.length > 0 && ( +
+

Auftritte

+
    + {appearances.map((a) => { + const date = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: 'long', year: 'numeric' }).format(a.startTime) + const time = new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit' }).format(a.startTime) + return ( +
  • + + {a.event.title} + +

    {date} · {time} Uhr · {a.stage.name}

    +
  • + ) + })} +
+
+ )} +``` + +> If `Link` is not already imported in this file, add `import Link from 'next/link'` at the top. (Check the existing imports first — the artist detail page already uses links to socials/gallery, but verify.) + +- [ ] **Step 4: Build** + +Run: `npm run build` +Expected: success. + +- [ ] **Step 5: Runtime check (still renders for an existing artist)** + +Run (dev server up): `curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/kuenstler/rovar` +Expected: `200`. (The section stays hidden until appearances are seeded in Task 24; this confirms no regression.) + +- [ ] **Step 6: Commit** + +```bash +git add "src/app/(public)/kuenstler/[slug]/page.tsx" +git commit -m "feat(events): wire up artist Auftritte section" +``` + +--- + +## Task 15: Sitemap — add published events + +**Files:** +- Modify: `src/app/sitemap.ts` + +Mirror the artist block: fetch published+active events, map to `/events/` entries (`changeFrequency: 'monthly' as const`, `priority: 0.6`). + +- [ ] **Step 1: Add the events fetch** after the existing `artists` fetch in `sitemap.ts` + +```ts + const events = await prisma.event.findMany({ + where: { tenantId: tenant.id, isPublished: true, isActive: true }, + select: { slug: true, updatedAt: true }, + }) +``` + +- [ ] **Step 2: Add the events spread** to the returned array, alongside the existing `...artists.map(...)` spread + +```ts + ...events.map((e) => ({ + url: `${SITE_URL}/events/${e.slug}`, + lastModified: e.updatedAt, + changeFrequency: 'monthly' as const, + priority: 0.6, + })), +``` + +- [ ] **Step 3: Build** + +Run: `npm run build` +Expected: success. + +- [ ] **Step 4: Commit** + +```bash +git add src/app/sitemap.ts +git commit -m "feat(events): add events to sitemap" +``` + +--- + +## Task 16: AdminNav — Events entry + +**Files:** +- Modify: `src/components/admin/AdminNav.tsx` + +Add `CalendarIcon` to the heroicons barrel import and a `{ name: 'Events', href: '/admin/events', icon: CalendarIcon }` item directly after the Künstler entry in the `inhalte` group. Active-highlight and auto-expand work automatically. + +- [ ] **Step 1: Add `CalendarIcon`** to the existing `@heroicons/react/24/outline` import block + +```ts + CalendarIcon, +``` + +- [ ] **Step 2: Add the Events nav item** directly after `{ name: 'Künstler', href: '/admin/artists', icon: MusicalNoteIcon },` + +```ts + { name: 'Events', href: '/admin/events', icon: CalendarIcon }, +``` + +- [ ] **Step 3: Type-check** + +Run: `npx tsc --noEmit` +Expected: exit 0. + +- [ ] **Step 4: Commit** + +```bash +git add src/components/admin/AdminNav.tsx +git commit -m "feat(events): admin nav Events entry" +``` + +--- + +## Task 17: Admin `/admin/events` list page + +**Files:** +- Create: `src/app/admin/events/page.tsx` + +`'use client'`. Loads `GET /api/admin/events`, renders rows with publish/feature toggles (**status-check-before-state-update, not optimistic**), delete-with-confirm, and a "Neu" link. Mirrors the artist list page. + +- [ ] **Step 1: Create `src/app/admin/events/page.tsx`** + +```tsx +'use client' + +import { useEffect, useState } from 'react' +import Link from 'next/link' + +type EventRow = { + id: string + title: string + slug: string + startDate: string + isPublished: boolean + isActive: boolean + isFeatured: boolean + _count?: { stages: number; appearances: number; priceTiers: number } +} + +export default function AdminEventsPage() { + const [events, setEvents] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + fetch('/api/admin/events') + .then((r) => r.json()) + .then((d) => setEvents(Array.isArray(d) ? d : [])) + .finally(() => setLoading(false)) + }, []) + + async function toggleField(id: string, field: 'isPublished' | 'isActive' | 'isFeatured', current: boolean) { + const res = await fetch(`/api/admin/events/${id}`, { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ [field]: !current }), + }) + if (!res.ok) return + setEvents((prev) => prev.map((e) => (e.id === id ? { ...e, [field]: !current } : e))) + } + + async function remove(id: string) { + if (!confirm('Veranstaltung wirklich löschen? Bühnen, Timetable und Preise werden mitgelöscht.')) return + const res = await fetch(`/api/admin/events/${id}`, { method: 'DELETE' }) + if (!res.ok) return + setEvents((prev) => prev.filter((e) => e.id !== id)) + } + + return ( +
+
+

Events

+ + Neues Event +
+ + {loading ? ( +

Lädt…

+ ) : events.length === 0 ? ( +

Noch keine Events angelegt.

+ ) : ( +
+ {events.map((e) => ( +
+
+

{e.title}

+

+ /{e.slug} · {new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium' }).format(new Date(e.startDate))} + {e._count ? ` · ${e._count.appearances} Slots · ${e._count.stages} Bühnen` : ''} +

+
+
+ + + Bearbeiten + +
+
+ ))} +
+ )} +
+ ) +} +``` + +- [ ] **Step 2: Build** + +Run: `npm run build` +Expected: success. + +- [ ] **Step 3: Commit** + +```bash +git add src/app/admin/events/page.tsx +git commit -m "feat(events): admin events list page" +``` + +--- + +## Task 18: Admin `/admin/events/new` page + +**Files:** +- Create: `src/app/admin/events/new/page.tsx` + +Minimal create (title + slug + startDate) → POST → redirect to edit. Auto-slug from title while slug is empty. + +- [ ] **Step 1: Create `src/app/admin/events/new/page.tsx`** + +```tsx +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' + +export default function NewEventPage() { + const router = useRouter() + const [form, setForm] = useState({ title: '', slug: '', startDate: '' }) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + + function update(field: keyof typeof form, value: string) { + setForm((p) => { + const next = { ...p, [field]: value } + if (field === 'title' && !p.slug) { + next.slug = value.toLowerCase() + .replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss') + .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') + } + return next + }) + } + + async function submit(e: React.FormEvent) { + e.preventDefault() + setSaving(true); setError('') + const res = await fetch('/api/admin/events', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(form), + }) + setSaving(false) + if (!res.ok) { setError((await res.json().catch(() => ({}))).error || 'Fehler'); return } + const event = await res.json() + router.push(`/admin/events/${event.id}`) + } + + return ( +
+

Neues Event

+
+ + + + {error &&

{error}

} + +
+
+ ) +} +``` + +- [ ] **Step 2: Build** + +Run: `npm run build` +Expected: success. + +- [ ] **Step 3: Commit** + +```bash +git add src/app/admin/events/new/page.tsx +git commit -m "feat(events): admin event create page" +``` + +--- + +## Task 21: Admin `/admin/events/[id]` edit page — core + description + hero + SEO + price tiers + +> **Depends on Tasks 19 (StageManager) and 20 (TimetableBuilder)** — implement those first; this page imports both. + +**Files:** +- Create: `src/app/admin/events/[id]/page.tsx` + +`useParams()` (not `use(params)`). Dynamic Plate editor for `description`, MediaPicker for hero, SEO fields, and a **price-tier editor** (local array → sent in the main PUT body, replace-all). Stage manager + timetable builder are the child components from Tasks 19–20. All inputs labelled. + +- [ ] **Step 1: Create `src/app/admin/events/[id]/page.tsx`** + +```tsx +'use client' + +import { useEffect, useState } from 'react' +import { useParams } from 'next/navigation' +import dynamic from 'next/dynamic' +import type { TElement } from '@udecode/plate' +import { markdownToPlate } from '@/components/admin/editor/serialization/markdownToPlate' +import { plateToMarkdown } from '@/components/admin/editor/serialization/plateToMarkdown' +import MediaPickerDialog from '@/components/admin/MediaPickerDialog' +import StageManager from '@/components/admin/events/StageManager' +import TimetableBuilder from '@/components/admin/events/TimetableBuilder' + +const PlateEditor = dynamic( + () => import('@/components/admin/editor/PlateEditor').then((m) => ({ default: m.PlateEditor })), + { ssr: false }, +) + +type TierRow = { + id?: string; name: string; description?: string | null; price?: number | null + currency?: string; validFrom?: string | null; validUntil?: string | null + isSoldOut?: boolean; isActive?: boolean; buyUrl?: string | null +} + +const EVENT_TYPES = ['festival', 'concert', 'workshop', 'other'] + +function toLocalInput(value: string | null | undefined): string { + if (!value) return '' + const d = new Date(value) + if (isNaN(d.getTime())) return '' + // datetime-local wants YYYY-MM-DDTHH:mm in local time + const off = d.getTimezoneOffset() + return new Date(d.getTime() - off * 60000).toISOString().slice(0, 16) +} + +export default function EditEventPage() { + const params = useParams() + const id = params.id as string + + const [form, setForm] = useState({ + title: '', slug: '', subtitle: '', eventType: 'festival', startDate: '', endDate: '', + locationName: '', locationAddress: '', locationUrl: '', ticketUrl: '', + heroImage: '', excerpt: '', metaTitle: '', metaDescription: '', + isPublished: false, isFeatured: false, isActive: true, + }) + const [descJson, setDescJson] = useState(null) + const [tiers, setTiers] = useState([]) + const [pickHero, setPickHero] = useState(false) + const [saved, setSaved] = useState(false) + const [saveError, setSaveError] = useState('') + + useEffect(() => { + fetch(`/api/admin/events/${id}`).then((r) => r.json()).then((e) => { + setForm({ + title: e.title || '', slug: e.slug || '', subtitle: e.subtitle || '', + eventType: e.eventType || 'festival', + startDate: toLocalInput(e.startDate), endDate: toLocalInput(e.endDate), + locationName: e.locationName || '', locationAddress: e.locationAddress || '', locationUrl: e.locationUrl || '', + ticketUrl: e.ticketUrl || '', heroImage: e.heroImage || '', excerpt: e.excerpt || '', + metaTitle: e.metaTitle || '', metaDescription: e.metaDescription || '', + isPublished: !!e.isPublished, isFeatured: !!e.isFeatured, isActive: e.isActive !== false, + }) + setDescJson(e.descriptionJson || markdownToPlate(e.description || '')) + setTiers(Array.isArray(e.priceTiers) ? e.priceTiers.map((t: TierRow) => ({ + ...t, validFrom: toLocalInput(t.validFrom), validUntil: toLocalInput(t.validUntil), + })) : []) + }) + }, [id]) + + function set(field: K, value: (typeof form)[K]) { + setForm((p) => ({ ...p, [field]: value })) + } + + async function save() { + setSaveError('') + const description = descJson ? plateToMarkdown(descJson) : '' + const res = await fetch(`/api/admin/events/${id}`, { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...form, + startDate: form.startDate ? new Date(form.startDate).toISOString() : undefined, + endDate: form.endDate ? new Date(form.endDate).toISOString() : null, + description, descriptionJson: descJson, editorMode: 'wysiwyg', + priceTiers: tiers.map((t) => ({ + ...t, + price: (t.price === null || t.price === undefined) ? null : Number(t.price), + validFrom: t.validFrom ? new Date(t.validFrom).toISOString() : null, + validUntil: t.validUntil ? new Date(t.validUntil).toISOString() : null, + })), + }), + }) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + setSaveError(body.error || `Fehler ${res.status}`) + return + } + setSaved(true); setTimeout(() => setSaved(false), 2000) + } + + return ( +
+

Event bearbeiten

+ + {/* Core fields */} +
+ + + + +
+ + + + + + +