diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..8c52ff9
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,12 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_style = space
+indent_size = 2
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.md]
+trim_trailing_whitespace = false
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b72aaf5
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,18 @@
+node_modules/
+dist/
+.next/
+out/
+.turbo/
+coverage/
+*.log
+
+# env files (keep .env.example tracked)
+.env
+.env.local
+.env.*.local
+apps/*/.env
+
+# prisma
+apps/api/prisma/*.db
+
+.DS_Store
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 0000000..2bd5a0a
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+22
diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 0000000..bccee91
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1,6 @@
+{
+ "semi": true,
+ "singleQuote": false,
+ "trailingComma": "all",
+ "printWidth": 100
+}
diff --git a/README.md b/README.md
index f8e0690..c538994 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,72 @@
-# tournamentify
\ No newline at end of file
+# Tournamentify
+
+Bau deinen eigenen, anpassbaren Turnierbaum – schnell und ohne Hürde. Account-Extras:
+eigene Designs, Setup-Import/Export, History. Ziel-Formate: Single/Double-Elimination,
+Round-Robin, Gruppen+KO und Swiss.
+
+Vollständiger Plan: [`docs/PLAN.md`](docs/PLAN.md).
+
+## Stack
+
+- **Frontend** `apps/web` — Next.js (App Router), Tailwind + shadcn/ui, TanStack Query,
+ Zustand, next-intl (DE/EN), Auth.js. Dient zugleich als **BFF** (Proxy zur API).
+- **Backend** `apps/api` — NestJS, REST, Zod-Validierung, Prisma, brackets-manager, pino.
+- **Shared** `packages/shared` — Zod-Schemas (Domain + JSON-Import/Export).
+- **Infra** `infra/` — Docker Compose + Caddy (eigener VPS).
+
+```
+apps/
+ web/ Next.js + BFF
+ api/ NestJS + Prisma
+packages/
+ shared/ Zod-Schemas (Single Source of Truth)
+infra/ docker-compose, Caddyfile, Dockerfiles
+docs/ PLAN.md
+```
+
+## Setup (lokal)
+
+Voraussetzungen: Node ≥ 20 (`.nvmrc` → 22), pnpm 11, Docker.
+
+```bash
+pnpm install
+
+# env-Dateien anlegen (Beispiele kopieren)
+cp apps/api/.env.example apps/api/.env
+cp apps/web/.env.example apps/web/.env
+
+# Postgres starten
+docker compose -f infra/docker-compose.yml up -d db
+
+# Prisma-Client + erste Migration
+pnpm --filter @tournamentify/api prisma:generate
+pnpm --filter @tournamentify/api prisma:migrate
+
+# Shared einmal bauen, dann alles im Watch-Modus
+pnpm --filter @tournamentify/shared build
+pnpm dev
+```
+
+- Web: http://localhost:3000
+- API (privat, via BFF): http://localhost:3001/health
+- BFF-Proxy: `GET /api/bff/health` (Next → Nest)
+
+## Skripte (root)
+
+| Befehl | Wirkung |
+|-------------------|---------|
+| `pnpm dev` | Turbo: alle Apps im Dev-Modus |
+| `pnpm build` | Alles bauen (shared zuerst) |
+| `pnpm typecheck` | TS-Checks |
+| `pnpm lint` | Linting |
+| `pnpm test` | Vitest |
+
+## Deployment
+
+`infra/docker-compose.yml` startet `db`, `api`, `web` und `caddy`. Nur Caddy ist nach
+außen offen (TLS); `api` liegt im privaten Netz und ist nur über den BFF erreichbar.
+Für Prod in `apps/web/.env` `API_INTERNAL_URL=http://api:3001` setzen und in der
+`Caddyfile` die Domain eintragen.
+
+> **Status:** M0-Gerüst. Auth.js, Bracket-Engine, Editor & Live-Updates folgen ab M1
+> (siehe Fahrplan in `docs/PLAN.md`).
\ No newline at end of file
diff --git a/apps/api/.env.example b/apps/api/.env.example
new file mode 100644
index 0000000..3e98480
--- /dev/null
+++ b/apps/api/.env.example
@@ -0,0 +1,8 @@
+NODE_ENV=development
+PORT=3001
+
+# Local dev DB (matches infra/docker-compose.yml). In compose the host is "db".
+DATABASE_URL=postgresql://tournamentify:tournamentify@localhost:5432/tournamentify?schema=public
+
+# Shared secret the BFF (Next) presents to Nest. Must match apps/web BFF_SERVICE_TOKEN.
+BFF_SERVICE_TOKEN=dev-service-token-change-me
diff --git a/apps/api/nest-cli.json b/apps/api/nest-cli.json
new file mode 100644
index 0000000..f9aa683
--- /dev/null
+++ b/apps/api/nest-cli.json
@@ -0,0 +1,8 @@
+{
+ "$schema": "https://json.schemastore.org/nest-cli",
+ "collection": "@nestjs/schematics",
+ "sourceRoot": "src",
+ "compilerOptions": {
+ "deleteOutDir": true
+ }
+}
diff --git a/apps/api/package.json b/apps/api/package.json
new file mode 100644
index 0000000..b979c46
--- /dev/null
+++ b/apps/api/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "@tournamentify/api",
+ "version": "0.0.0",
+ "private": true,
+ "scripts": {
+ "dev": "nest start --watch",
+ "build": "nest build",
+ "start": "node dist/main.js",
+ "lint": "eslint \"src/**/*.ts\"",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run",
+ "prisma:generate": "prisma generate",
+ "prisma:migrate": "prisma migrate dev"
+ },
+ "dependencies": {
+ "@nestjs/common": "^11.0.1",
+ "@nestjs/core": "^11.0.1",
+ "@nestjs/platform-express": "^11.0.1",
+ "@prisma/client": "^6.1.0",
+ "@tournamentify/shared": "workspace:*",
+ "brackets-manager": "^1.6.3",
+ "nestjs-pino": "^4.2.0",
+ "pino-http": "^10.3.0",
+ "reflect-metadata": "^0.2.2",
+ "rxjs": "^7.8.1",
+ "zod": "^3.24.1"
+ },
+ "devDependencies": {
+ "@nestjs/cli": "^11.0.0",
+ "@nestjs/schematics": "^11.0.0",
+ "@types/node": "^22.10.2",
+ "pino-pretty": "^13.0.0",
+ "prisma": "^6.1.0",
+ "typescript": "^5.7.2",
+ "vitest": "^2.1.8"
+ }
+}
diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma
new file mode 100644
index 0000000..0c890bc
--- /dev/null
+++ b/apps/api/prisma/schema.prisma
@@ -0,0 +1,175 @@
+// Normalisiertes Turnier-Modell (brackets-manager-kompatibel).
+// Gruppen+KO = Tournament mit zwei Stages. Bracket-Seiten / RR-Gruppen = Group.
+// Opponent-Slots auf Match speichern entweder einen Seed/Participant oder eine
+// Source-Referenz (winner_of / loser_of / group_rank) als JSON.
+
+generator client {
+ provider = "prisma-client-js"
+}
+
+datasource db {
+ provider = "postgresql"
+ url = env("DATABASE_URL")
+}
+
+enum UserTier {
+ FREE
+ PRO
+}
+
+enum StageType {
+ SINGLE_ELIMINATION
+ DOUBLE_ELIMINATION
+ ROUND_ROBIN
+ SWISS
+}
+
+enum TournamentStatus {
+ DRAFT
+ RUNNING
+ COMPLETED
+}
+
+enum MatchStatus {
+ PENDING
+ RUNNING
+ COMPLETED
+}
+
+enum CapabilityType {
+ VIEW
+ SCORE
+}
+
+model User {
+ id String @id @default(cuid())
+ email String @unique
+ authProviderId String? @unique
+ tier UserTier @default(FREE)
+ createdAt DateTime @default(now())
+ tournaments Tournament[]
+ series Series[]
+ themes SavedTheme[]
+}
+
+// Ligen/Serien-Radar — Schema offen halten, Feature kommt später (M4).
+model Series {
+ id String @id @default(cuid())
+ ownerUserId String
+ owner User @relation(fields: [ownerUserId], references: [id], onDelete: Cascade)
+ name String
+ createdAt DateTime @default(now())
+ tournaments Tournament[]
+}
+
+model Tournament {
+ id String @id @default(cuid())
+ name String
+ status TournamentStatus @default(DRAFT)
+ // Owner ist entweder ein registrierter User ODER ein anonymer Gast-Token.
+ ownerUserId String?
+ owner User? @relation(fields: [ownerUserId], references: [id], onDelete: SetNull)
+ anonOwnerToken String? @unique
+ seriesId String?
+ series Series? @relation(fields: [seriesId], references: [id], onDelete: SetNull)
+ settings Json @default("{}")
+ designTokens Json @default("{}")
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ stages Stage[]
+ participants Participant[]
+ links CapabilityLink[]
+
+ @@index([ownerUserId])
+ @@index([seriesId])
+}
+
+model Stage {
+ id String @id @default(cuid())
+ tournamentId String
+ tournament Tournament @relation(fields: [tournamentId], references: [id], onDelete: Cascade)
+ type StageType
+ number Int
+ settings Json @default("{}")
+ groups Group[]
+
+ @@unique([tournamentId, number])
+}
+
+model Group {
+ id String @id @default(cuid())
+ stageId String
+ stage Stage @relation(fields: [stageId], references: [id], onDelete: Cascade)
+ number Int
+ rounds Round[]
+
+ @@unique([stageId, number])
+}
+
+model Round {
+ id String @id @default(cuid())
+ groupId String
+ group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
+ number Int
+ nameOverride String?
+ bestOf Int @default(1)
+ matches Match[]
+
+ @@unique([groupId, number])
+}
+
+model Match {
+ id String @id @default(cuid())
+ roundId String
+ round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
+ number Int
+ status MatchStatus @default(PENDING)
+ opponent1 Json?
+ opponent2 Json?
+ games MatchGame[]
+
+ @@unique([roundId, number])
+}
+
+model MatchGame {
+ id String @id @default(cuid())
+ matchId String
+ match Match @relation(fields: [matchId], references: [id], onDelete: Cascade)
+ number Int
+ scores Json @default("{}")
+
+ @@unique([matchId, number])
+}
+
+// Teilnehmer sind vorerst ad-hoc pro Turnier. Für Ligen/Cross-Turnier-Stats
+// käme später eine separate ParticipantIdentity dazu.
+model Participant {
+ id String @id @default(cuid())
+ tournamentId String
+ tournament Tournament @relation(fields: [tournamentId], references: [id], onDelete: Cascade)
+ name String
+ seed Int?
+
+ @@index([tournamentId])
+}
+
+model CapabilityLink {
+ id String @id @default(cuid())
+ tournamentId String
+ tournament Tournament @relation(fields: [tournamentId], references: [id], onDelete: Cascade)
+ type CapabilityType
+ token String @unique
+ expiresAt DateTime?
+ createdAt DateTime @default(now())
+
+ @@index([tournamentId])
+}
+
+model SavedTheme {
+ id String @id @default(cuid())
+ ownerUserId String
+ owner User @relation(fields: [ownerUserId], references: [id], onDelete: Cascade)
+ name String
+ tokens Json @default("{}")
+ createdAt DateTime @default(now())
+}
diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts
new file mode 100644
index 0000000..4198800
--- /dev/null
+++ b/apps/api/src/app.module.ts
@@ -0,0 +1,18 @@
+import { Module } from "@nestjs/common";
+import { LoggerModule } from "nestjs-pino";
+import { HealthController } from "./health.controller";
+import { PrismaService } from "./prisma/prisma.service";
+
+@Module({
+ imports: [
+ LoggerModule.forRoot({
+ pinoHttp: {
+ transport:
+ process.env.NODE_ENV !== "production" ? { target: "pino-pretty" } : undefined,
+ },
+ }),
+ ],
+ controllers: [HealthController],
+ providers: [PrismaService],
+})
+export class AppModule {}
diff --git a/apps/api/src/health.controller.ts b/apps/api/src/health.controller.ts
new file mode 100644
index 0000000..b538970
--- /dev/null
+++ b/apps/api/src/health.controller.ts
@@ -0,0 +1,9 @@
+import { Controller, Get } from "@nestjs/common";
+
+@Controller("health")
+export class HealthController {
+ @Get()
+ check() {
+ return { status: "ok", service: "tournamentify-api" };
+ }
+}
diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts
new file mode 100644
index 0000000..3388726
--- /dev/null
+++ b/apps/api/src/main.ts
@@ -0,0 +1,16 @@
+import "reflect-metadata";
+import { NestFactory } from "@nestjs/core";
+import { Logger } from "nestjs-pino";
+import { AppModule } from "./app.module";
+
+async function bootstrap() {
+ const app = await NestFactory.create(AppModule, { bufferLogs: true });
+ app.useLogger(app.get(Logger));
+
+ const port = process.env.PORT ?? 3001;
+ // Listen on all interfaces so the container is reachable on the private
+ // compose network (the BFF in Next is the only thing that talks to us).
+ await app.listen(port, "0.0.0.0");
+}
+
+void bootstrap();
diff --git a/apps/api/src/prisma/prisma.service.ts b/apps/api/src/prisma/prisma.service.ts
new file mode 100644
index 0000000..d86f65d
--- /dev/null
+++ b/apps/api/src/prisma/prisma.service.ts
@@ -0,0 +1,13 @@
+import { Injectable, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
+import { PrismaClient } from "@prisma/client";
+
+@Injectable()
+export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
+ async onModuleInit() {
+ await this.$connect();
+ }
+
+ async onModuleDestroy() {
+ await this.$disconnect();
+ }
+}
diff --git a/apps/api/tsconfig.build.json b/apps/api/tsconfig.build.json
new file mode 100644
index 0000000..8be3699
--- /dev/null
+++ b/apps/api/tsconfig.build.json
@@ -0,0 +1,4 @@
+{
+ "extends": "./tsconfig.json",
+ "exclude": ["node_modules", "dist", "test", "**/*.spec.ts", "**/*.test.ts"]
+}
diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json
new file mode 100644
index 0000000..598d736
--- /dev/null
+++ b/apps/api/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "module": "CommonJS",
+ "moduleResolution": "Node",
+ "target": "ES2022",
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "emitDecoratorMetadata": true,
+ "experimentalDecorators": true,
+ "declaration": false,
+ "sourceMap": true,
+ "incremental": true,
+ "noUncheckedIndexedAccess": false
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/apps/web/.env.example b/apps/web/.env.example
new file mode 100644
index 0000000..bece70a
--- /dev/null
+++ b/apps/web/.env.example
@@ -0,0 +1,10 @@
+# Public base URL of the web app
+NEXTAUTH_URL=http://localhost:3000
+AUTH_SECRET=change-me-generate-with-openssl-rand-base64-32
+
+# Internal address of the Nest API — the BFF target. Never exposed to the browser.
+# In docker-compose this becomes http://api:3001 (private network).
+API_INTERNAL_URL=http://localhost:3001
+
+# Shared secret the BFF presents to Nest. Must match apps/api BFF_SERVICE_TOKEN.
+BFF_SERVICE_TOKEN=dev-service-token-change-me
diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json
new file mode 100644
index 0000000..871b39c
--- /dev/null
+++ b/apps/web/messages/de.json
@@ -0,0 +1,6 @@
+{
+ "home": {
+ "title": "Tournamentify",
+ "subtitle": "Bau deinen eigenen Turnierbaum – schnell und ganz nach deinen Vorstellungen."
+ }
+}
diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json
new file mode 100644
index 0000000..60bd5c7
--- /dev/null
+++ b/apps/web/messages/en.json
@@ -0,0 +1,6 @@
+{
+ "home": {
+ "title": "Tournamentify",
+ "subtitle": "Build your own tournament bracket – fast and exactly your way."
+ }
+}
diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs
new file mode 100644
index 0000000..1ba57cb
--- /dev/null
+++ b/apps/web/next.config.mjs
@@ -0,0 +1,11 @@
+import createNextIntlPlugin from "next-intl/plugin";
+
+const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
+
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ reactStrictMode: true,
+ transpilePackages: ["@tournamentify/shared"],
+};
+
+export default withNextIntl(nextConfig);
diff --git a/apps/web/package.json b/apps/web/package.json
new file mode 100644
index 0000000..15aa485
--- /dev/null
+++ b/apps/web/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "@tournamentify/web",
+ "version": "0.0.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev -p 3000",
+ "build": "next build",
+ "start": "next start -p 3000",
+ "lint": "next lint",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@tanstack/react-query": "^5.62.7",
+ "@tournamentify/shared": "workspace:*",
+ "next": "^15.1.3",
+ "next-auth": "^5.0.0-beta.25",
+ "next-intl": "^3.26.3",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "zustand": "^5.0.2"
+ },
+ "devDependencies": {
+ "@types/node": "^22.10.2",
+ "@types/react": "^19.0.2",
+ "@types/react-dom": "^19.0.2",
+ "autoprefixer": "^10.4.20",
+ "postcss": "^8.4.49",
+ "tailwindcss": "^3.4.17",
+ "typescript": "^5.7.2"
+ }
+}
diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs
new file mode 100644
index 0000000..2aa7205
--- /dev/null
+++ b/apps/web/postcss.config.mjs
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/apps/web/src/app/[locale]/layout.tsx b/apps/web/src/app/[locale]/layout.tsx
new file mode 100644
index 0000000..9e3615f
--- /dev/null
+++ b/apps/web/src/app/[locale]/layout.tsx
@@ -0,0 +1,26 @@
+import { NextIntlClientProvider } from "next-intl";
+import { getMessages } from "next-intl/server";
+import { notFound } from "next/navigation";
+import { routing } from "@/i18n/routing";
+import "../globals.css";
+
+export default async function LocaleLayout({
+ children,
+ params,
+}: {
+ children: React.ReactNode;
+ params: Promise<{ locale: string }>;
+}) {
+ const { locale } = await params;
+ if (!(routing.locales as readonly string[]).includes(locale)) notFound();
+
+ const messages = await getMessages();
+
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/apps/web/src/app/[locale]/page.tsx b/apps/web/src/app/[locale]/page.tsx
new file mode 100644
index 0000000..0f51490
--- /dev/null
+++ b/apps/web/src/app/[locale]/page.tsx
@@ -0,0 +1,12 @@
+import { useTranslations } from "next-intl";
+
+export default function Home() {
+ const t = useTranslations("home");
+
+ return (
+
+ {t("title")}
+ {t("subtitle")}
+
+ );
+}
diff --git a/apps/web/src/app/api/bff/[...path]/route.ts b/apps/web/src/app/api/bff/[...path]/route.ts
new file mode 100644
index 0000000..76aaab5
--- /dev/null
+++ b/apps/web/src/app/api/bff/[...path]/route.ts
@@ -0,0 +1,46 @@
+import { NextRequest } from "next/server";
+
+/**
+ * BFF proxy. The browser only ever talks to Next; this route forwards to the
+ * private Nest API with a service token (and, from M1, the authenticated user
+ * context). Response bodies are streamed through untouched, so Server-Sent
+ * Events (live score updates) pass straight back to the client.
+ */
+
+const API_URL = process.env.API_INTERNAL_URL ?? "http://localhost:3001";
+const SERVICE_TOKEN = process.env.BFF_SERVICE_TOKEN ?? "";
+
+async function handler(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
+ const { path } = await ctx.params;
+ const target = `${API_URL}/${path.join("/")}${req.nextUrl.search}`;
+
+ const headers = new Headers(req.headers);
+ headers.set("x-bff-service-token", SERVICE_TOKEN);
+ headers.delete("host");
+ // TODO (M1): resolve the Auth.js session here and forward a signed user id
+ // header so Nest can authorize on behalf of the logged-in user.
+
+ const hasBody = !(req.method === "GET" || req.method === "HEAD");
+ const res = await fetch(target, {
+ method: req.method,
+ headers,
+ body: hasBody ? req.body : undefined,
+ // @ts-expect-error - duplex is required when streaming a request body
+ duplex: "half",
+ redirect: "manual",
+ });
+
+ return new Response(res.body, {
+ status: res.status,
+ statusText: res.statusText,
+ headers: res.headers,
+ });
+}
+
+export const GET = handler;
+export const POST = handler;
+export const PUT = handler;
+export const PATCH = handler;
+export const DELETE = handler;
+
+export const dynamic = "force-dynamic";
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
new file mode 100644
index 0000000..7e760dd
--- /dev/null
+++ b/apps/web/src/app/globals.css
@@ -0,0 +1,28 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+/*
+ * App design tokens. The bracket renderer reads its own --bracket-* tokens from
+ * here too, so token-based themes restyle the bracket without any raw CSS.
+ */
+:root {
+ --background: 0 0% 100%;
+ --foreground: 222 47% 11%;
+ --muted-foreground: 215 16% 47%;
+
+ --bracket-node-bg: 0 0% 100%;
+ --bracket-connector: 215 16% 70%;
+ --bracket-radius: 0.5rem;
+}
+
+.dark {
+ --background: 222 47% 11%;
+ --foreground: 210 40% 98%;
+ --muted-foreground: 215 20% 65%;
+}
+
+body {
+ background: hsl(var(--background));
+ color: hsl(var(--foreground));
+}
diff --git a/apps/web/src/auth.ts b/apps/web/src/auth.ts
new file mode 100644
index 0000000..8d9b075
--- /dev/null
+++ b/apps/web/src/auth.ts
@@ -0,0 +1,7 @@
+// Auth.js (NextAuth v5) — full wiring lands in M1.
+//
+// Login/provider UX lives here in the frontend. Nest remains the data/identity
+// store, reached privately through the BFF proxy
+// (src/app/api/bff/[...path]/route.ts). On sign-in we will resolve/claim the
+// matching Nest user record and forward the user context on each proxied call.
+export {};
diff --git a/apps/web/src/i18n/request.ts b/apps/web/src/i18n/request.ts
new file mode 100644
index 0000000..b59fbdd
--- /dev/null
+++ b/apps/web/src/i18n/request.ts
@@ -0,0 +1,15 @@
+import { getRequestConfig } from "next-intl/server";
+import { routing } from "./routing";
+
+export default getRequestConfig(async ({ requestLocale }) => {
+ let locale = await requestLocale;
+
+ if (!locale || !(routing.locales as readonly string[]).includes(locale)) {
+ locale = routing.defaultLocale;
+ }
+
+ return {
+ locale,
+ messages: (await import(`../../messages/${locale}.json`)).default,
+ };
+});
diff --git a/apps/web/src/i18n/routing.ts b/apps/web/src/i18n/routing.ts
new file mode 100644
index 0000000..9593c24
--- /dev/null
+++ b/apps/web/src/i18n/routing.ts
@@ -0,0 +1,6 @@
+import { defineRouting } from "next-intl/routing";
+
+export const routing = defineRouting({
+ locales: ["de", "en"],
+ defaultLocale: "de",
+});
diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts
new file mode 100644
index 0000000..bb3194e
--- /dev/null
+++ b/apps/web/src/middleware.ts
@@ -0,0 +1,9 @@
+import createMiddleware from "next-intl/middleware";
+import { routing } from "./i18n/routing";
+
+export default createMiddleware(routing);
+
+export const config = {
+ // Skip API routes (incl. the BFF proxy), Next internals and static files.
+ matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"],
+};
diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts
new file mode 100644
index 0000000..0bb64d5
--- /dev/null
+++ b/apps/web/tailwind.config.ts
@@ -0,0 +1,16 @@
+import type { Config } from "tailwindcss";
+
+export default {
+ darkMode: ["class"],
+ content: ["./src/**/*.{ts,tsx}"],
+ theme: {
+ extend: {
+ colors: {
+ background: "hsl(var(--background))",
+ foreground: "hsl(var(--foreground))",
+ "muted-foreground": "hsl(var(--muted-foreground))",
+ },
+ },
+ },
+ plugins: [],
+} satisfies Config;
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
new file mode 100644
index 0000000..9d4add3
--- /dev/null
+++ b/apps/web/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "jsx": "preserve",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "allowJs": true,
+ "noEmit": true,
+ "incremental": true,
+ "plugins": [{ "name": "next" }],
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/docs/PLAN.md b/docs/PLAN.md
new file mode 100644
index 0000000..842fd58
--- /dev/null
+++ b/docs/PLAN.md
@@ -0,0 +1,122 @@
+# Tournamentify — Projektplan
+
+> Verbindliche Referenz für Architektur- und Produktentscheidungen.
+> Entstanden aus einer ausführlichen Grilling-Session. Änderungen bitte per PR mit Begründung.
+
+## Produkt in einem Satz
+
+Ein Tool, mit dem man **ohne Hürde (als Gast)** sofort einen anpassbaren Turnierbaum
+baut, scored und teilt — mit **Account-Extras** (eigene Designs, Setup-Import/Export,
+History) und langfristig **voller Format-Suite inkl. Swiss**.
+
+## Entscheidungen (Stand: Grilling)
+
+| # | Thema | Entscheidung |
+|----|------------------|--------------|
+| 1 | Custom-Tiefe | **Hybrid** — Standard-Format als Start, danach frei editierbar (Editier-Layer über der Engine) |
+| 2 | Formate (Ziel) | **Volle Suite**: Single/Double-Elim, Round-Robin, Gruppen+KO, **Swiss** |
+| 3 | Auth-Modell | **Server-first + anonymer Owner-Token**, Signup „claimed" Gast-Turniere |
+| 4 | Projektziel | **Offen/Mischung** → monetarisierbar bauen, nicht jetzt monetarisieren (`tier`-Flag) |
+| 5 | Auth-Tech | **Auth.js (NextAuth)** im Frontend |
+| 6 | Auth-Bridge | **BFF-Proxy über Next** (Nest privat, Service-Token + User-Context) |
+| 7 | Repo | **Monorepo: pnpm + Turborepo** (`apps/web`, `apps/api`, `packages/shared`) |
+| 8 | API-Stil | **REST + geteilte Zod-Schemas** |
+| 9 | DB/ORM | **Postgres + Prisma** |
+| 10 | Speichermodell | **Voll normalisiert** |
+| 11 | Bracket-Engine | **brackets-manager-Lib** + erprobtes Schema, Swiss selbst |
+| 12 | Bracket-Render | **Custom SVG/HTML + dnd-kit** |
+| 13 | Live-Updates | **SSE durch den BFF** |
+| 14 | Permissions | **Capability-Links** (View + Score) |
+| 15 | Import/Export | **Nur Setup/Template** (ohne Ergebnisse), versioniert |
+| 16 | Statistiken | **Minimal** (Basis-Zähler pro Turnier) |
+| 17 | Designs | **Token-basierte Themes** (Presets + eigene Tokens) |
+| 18 | Extras auf Radar | **Embed-Widget + PNG/PDF-Export**, **Serien/Ligen** |
+| 19 | FE-State | **TanStack Query + Zustand** |
+| 20 | UI-Kit | **Tailwind + shadcn/ui** |
+| 21 | Hosting | **Eigener VPS + Docker Compose** (+ Caddy/Traefik, pg-Backups) |
+| 22 | Testing | **Breite Test-Pyramide** (Vitest, Testcontainers, Playwright) |
+| 23 | i18n | **DE + EN ab Tag 1** (next-intl) |
+| 24 | Observability | **Minimal** (pino-Logs), Sentry später nachrüstbar |
+| 25 | Reihenfolge | **Plattform/Accounts zuerst** (mit minimalem Erzeugungspfad, s.u.) |
+
+Implizit gesetzt: Next **App Router**, TypeScript strict überall, ESLint + Prettier.
+
+## Architektur
+
+```
+Browser ──cookie/session──► Next (apps/web)
+ │ Auth.js (Login/Provider)
+ │ BFF: Server-Route-Handler (src/app/api/bff/[...path])
+ │ - REST-Calls ──Service-Token + User-Ctx──► Nest
+ │ - SSE-Passthrough ◄──────stream───────────┘
+ ▼
+ Nest (apps/api) [privates Netz]
+ │ REST-Controller (Zod-validiert)
+ │ brackets-manager Engine (Storage-Adapter → Prisma)
+ │ SSE-Emitter (Score-Events)
+ ▼
+ Postgres (normalisiert + JSONB für Design/Overrides)
+
+packages/shared: Zod-Schemas (Domain + Import/Export) → Quelle der Wahrheit für FE, BE & JSON
+```
+
+## Datenmodell (normalisiert, brackets-manager-kompatibel)
+
+Siehe `apps/api/prisma/schema.prisma`. Kern:
+
+```
+User(id, email, authProviderId, tier, …)
+Series(id, ownerUserId, name) ← Ligen-Radar (offen halten)
+Tournament(id, ownerUserId?, anonOwnerToken?, seriesId?, name,
+ status, settings:jsonb, designTokens:jsonb, …)
+Stage(id, tournamentId, type, number, settings:jsonb) ← Gruppen+KO = 2 Stages
+Group(id, stageId, number) ← Bracket-Seiten / RR-Gruppen
+Round(id, groupId, number, nameOverride?, bestOf) ← Custom-Edits leben hier mit
+Match(id, roundId, number, status, opponent1:jsonb, opponent2:jsonb)
+MatchGame(id, matchId, number, scores)
+Participant(id, tournamentId, name, seed) ← ad-hoc; später ParticipantIdentity
+CapabilityLink(id, tournamentId, type[view|score], token, expiresAt?)
+SavedTheme(id, ownerUserId, name, tokens:jsonb)
+```
+
+## Offene Spannungen (bewusst mitführen)
+
+1. **Plattform-zuerst braucht trotzdem früh einen Erzeugungspfad** — sonst verwalten
+ History/Import/Export nichts. → M1 enthält einen minimalen „create-from-template"-Pfad;
+ der reiche Editor kommt in M2.
+2. **Serien/Ligen ↔ Minimal-Stats** — Ligen verlangen persistente Teilnehmer-Identitäten,
+ die bei Stats rausgenommen wurden. → `Series` + spätere `ParticipantIdentity` einplanen,
+ jetzt nicht bauen.
+3. **brackets-manager kann kein echtes Freiform-Editing** — generiert nur Standard-Strukturen.
+ → Spike in M1/M2: wo ist die Lib-Grenze, was übernimmt unser Editier-Layer?
+4. **Auth.js (FE) ↔ Nest-Daten unter BFF** — Token-Mechanik sauber definieren
+ (Session in Next, Service-Token + User-Ctx zum privaten Nest). Sicherheits-kritisch.
+5. **Swiss-Pairing + Tiebreaker** = selbst gebaut, größter Engine-Risikoblock
+ → hohe Test-Priorität, kommt spät (M3).
+
+## Fahrplan
+
+- **M0 — Fundament:** Monorepo (pnpm+Turborepo), `packages/shared` Zod, Nest+Prisma+Postgres,
+ Next+Auth.js+BFF-Verdrahtung, Docker Compose + Caddy, next-intl (DE+EN), pino. *(dieses Gerüst)*
+- **M1 — Plattform/Accounts:** Auth + Claim-Gerüst, `User`+`tier`, komplettes Schema+Migrations,
+ **minimale** Turnier-Erzeugung aus Single/Double-Elim-Template (Engine), Dashboard + History,
+ **JSON Setup Import/Export** (round-trip getestet), Capability-Link-Datenschicht.
+- **M2 — Editor & Live:** Custom-SVG + dnd-kit Editor (Seeding, Scores, Custom-Edits),
+ Token-Theme-Editor + Presets, **SSE** live, Share-/Score-Link-UX, Gast→Account-Claim-Flow.
+- **M3 — Format-Breite:** Round-Robin + Standings, Gruppen+KO (2 Stages),
+ dann **Swiss** + Tiebreaker (stark getestet).
+- **M4 — Extras:** Embed-Widget + PNG/PDF-Export; Serien/Ligen (+ optional Participant-Registry);
+ reichere Stats.
+
+## Tech-Stack (Kurzreferenz)
+
+| Schicht | Wahl |
+|----------------|------|
+| Frontend | Next.js (App Router) · React · Tailwind + shadcn/ui · TanStack Query · Zustand · next-intl |
+| Auth | Auth.js (NextAuth) im FE, Nest als Identitäts-/Datenquelle via BFF |
+| Backend | NestJS · REST · Zod-Validierung · brackets-manager · pino |
+| DB | PostgreSQL · Prisma |
+| Shared | `@tournamentify/shared` — Zod-Schemas (Domain + Import/Export) |
+| Infra | Docker Compose · Caddy (TLS) · eigener VPS |
+| CI | Bewusst keine — Checks lokal vor Commit, Deploy + Betrieb VPS-seitig |
+| Tests | Vitest · Testcontainers · Playwright |
diff --git a/infra/Caddyfile b/infra/Caddyfile
new file mode 100644
index 0000000..348b458
--- /dev/null
+++ b/infra/Caddyfile
@@ -0,0 +1,7 @@
+# Local/dev: serve on :80 and proxy to the web container.
+# Production: replace ":80" with your domain (e.g. "tournamentify.app") and
+# Caddy auto-provisions a Let's Encrypt TLS certificate.
+:80 {
+ encode gzip
+ reverse_proxy web:3000
+}
diff --git a/infra/api.Dockerfile b/infra/api.Dockerfile
new file mode 100644
index 0000000..2302dc6
--- /dev/null
+++ b/infra/api.Dockerfile
@@ -0,0 +1,25 @@
+# syntax=docker/dockerfile:1
+# Starting point — tune layer caching / use Next/Nest standalone output later.
+FROM node:22-alpine AS base
+RUN corepack enable
+WORKDIR /repo
+
+FROM base AS deps
+COPY pnpm-workspace.yaml package.json pnpm-lock.yaml* ./
+COPY packages/shared/package.json packages/shared/
+COPY apps/api/package.json apps/api/
+RUN pnpm install --frozen-lockfile
+
+FROM base AS build
+COPY --from=deps /repo/node_modules ./node_modules
+COPY . .
+RUN pnpm --filter @tournamentify/shared build \
+ && pnpm --filter @tournamentify/api prisma:generate \
+ && pnpm --filter @tournamentify/api build
+
+FROM base AS runner
+ENV NODE_ENV=production
+COPY --from=build /repo ./
+WORKDIR /repo/apps/api
+EXPOSE 3001
+CMD ["node", "dist/main.js"]
diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml
new file mode 100644
index 0000000..abc04ed
--- /dev/null
+++ b/infra/docker-compose.yml
@@ -0,0 +1,58 @@
+# Single-VPS deployment. Only Caddy is published to the host; web + api live on
+# the private compose network. The BFF (web) reaches Nest at http://api:3001.
+services:
+ db:
+ image: postgres:17-alpine
+ restart: unless-stopped
+ environment:
+ POSTGRES_USER: tournamentify
+ POSTGRES_PASSWORD: tournamentify
+ POSTGRES_DB: tournamentify
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U tournamentify"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+ api:
+ build:
+ context: ..
+ dockerfile: infra/api.Dockerfile
+ restart: unless-stopped
+ env_file: ../apps/api/.env
+ depends_on:
+ db:
+ condition: service_healthy
+ expose:
+ - "3001" # private network only — not published to the host
+
+ web:
+ build:
+ context: ..
+ dockerfile: infra/web.Dockerfile
+ restart: unless-stopped
+ env_file: ../apps/web/.env
+ depends_on:
+ - api
+ expose:
+ - "3000"
+
+ caddy:
+ image: caddy:2-alpine
+ restart: unless-stopped
+ depends_on:
+ - web
+ ports:
+ - "80:80"
+ - "443:443"
+ volumes:
+ - ./Caddyfile:/etc/caddy/Caddyfile
+ - caddy_data:/data
+ - caddy_config:/config
+
+volumes:
+ pgdata:
+ caddy_data:
+ caddy_config:
diff --git a/infra/web.Dockerfile b/infra/web.Dockerfile
new file mode 100644
index 0000000..a1a1229
--- /dev/null
+++ b/infra/web.Dockerfile
@@ -0,0 +1,24 @@
+# syntax=docker/dockerfile:1
+# Starting point — switch to Next standalone output for a slimmer image later.
+FROM node:22-alpine AS base
+RUN corepack enable
+WORKDIR /repo
+
+FROM base AS deps
+COPY pnpm-workspace.yaml package.json pnpm-lock.yaml* ./
+COPY packages/shared/package.json packages/shared/
+COPY apps/web/package.json apps/web/
+RUN pnpm install --frozen-lockfile
+
+FROM base AS build
+COPY --from=deps /repo/node_modules ./node_modules
+COPY . .
+RUN pnpm --filter @tournamentify/shared build \
+ && pnpm --filter @tournamentify/web build
+
+FROM base AS runner
+ENV NODE_ENV=production
+COPY --from=build /repo ./
+WORKDIR /repo/apps/web
+EXPOSE 3000
+CMD ["pnpm", "start"]
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..36c8fad
--- /dev/null
+++ b/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "tournamentify",
+ "private": true,
+ "packageManager": "pnpm@11.6.0",
+ "engines": {
+ "node": ">=20"
+ },
+ "scripts": {
+ "dev": "turbo run dev",
+ "build": "turbo run build",
+ "lint": "turbo run lint",
+ "test": "turbo run test",
+ "typecheck": "turbo run typecheck",
+ "format": "prettier --write \"**/*.{ts,tsx,js,mjs,json,md}\""
+ },
+ "devDependencies": {
+ "prettier": "^3.3.3",
+ "turbo": "^2.3.3",
+ "typescript": "^5.7.2"
+ }
+}
diff --git a/packages/shared/package.json b/packages/shared/package.json
new file mode 100644
index 0000000..54a146f
--- /dev/null
+++ b/packages/shared/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "@tournamentify/shared",
+ "version": "0.0.0",
+ "private": true,
+ "type": "commonjs",
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ }
+ },
+ "scripts": {
+ "build": "tsc -p tsconfig.json",
+ "dev": "tsc -p tsconfig.json --watch",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run"
+ },
+ "dependencies": {
+ "zod": "^3.24.1"
+ },
+ "devDependencies": {
+ "typescript": "^5.7.2",
+ "vitest": "^2.1.8"
+ }
+}
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
new file mode 100644
index 0000000..43fa8bd
--- /dev/null
+++ b/packages/shared/src/index.ts
@@ -0,0 +1 @@
+export * from "./schemas/tournament";
diff --git a/packages/shared/src/schemas/tournament.test.ts b/packages/shared/src/schemas/tournament.test.ts
new file mode 100644
index 0000000..2832e8c
--- /dev/null
+++ b/packages/shared/src/schemas/tournament.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from "vitest";
+import { SETUP_SCHEMA_VERSION, tournamentSetupSchema } from "./tournament";
+
+describe("tournamentSetupSchema", () => {
+ it("round-trips a valid setup (no results)", () => {
+ const setup = {
+ schemaVersion: SETUP_SCHEMA_VERSION,
+ name: "Friday Cup",
+ stages: [{ type: "single_elimination", name: "Main", settings: {} }],
+ participants: [{ name: "Alice", seed: 1 }, { name: "Bob", seed: 2 }],
+ };
+
+ const parsed = tournamentSetupSchema.parse(setup);
+ expect(JSON.parse(JSON.stringify(parsed))).toEqual(setup);
+ });
+
+ it("rejects an unknown stage type", () => {
+ const result = tournamentSetupSchema.safeParse({
+ schemaVersion: SETUP_SCHEMA_VERSION,
+ name: "Bad",
+ stages: [{ type: "battle_royale", name: "x", settings: {} }],
+ participants: [],
+ });
+ expect(result.success).toBe(false);
+ });
+});
diff --git a/packages/shared/src/schemas/tournament.ts b/packages/shared/src/schemas/tournament.ts
new file mode 100644
index 0000000..c63b645
--- /dev/null
+++ b/packages/shared/src/schemas/tournament.ts
@@ -0,0 +1,54 @@
+import { z } from "zod";
+
+/**
+ * Single source of truth for the tournament domain — consumed by the Next
+ * frontend, the Nest backend (request validation), and the JSON import/export
+ * feature. If a shape changes here, every layer changes with it.
+ */
+
+/** Bump when the import/export setup format changes in a breaking way. */
+export const SETUP_SCHEMA_VERSION = 1;
+
+export const stageTypeSchema = z.enum([
+ "single_elimination",
+ "double_elimination",
+ "round_robin",
+ "swiss",
+]);
+export type StageType = z.infer;
+
+export const participantSetupSchema = z.object({
+ name: z.string().min(1),
+ seed: z.number().int().positive().optional(),
+});
+export type ParticipantSetup = z.infer;
+
+export const stageSetupSchema = z.object({
+ type: stageTypeSchema,
+ name: z.string().min(1),
+ /** Format-specific options (best-of, group size, swiss rounds, …). */
+ settings: z.record(z.unknown()).default({}),
+});
+export type StageSetup = z.infer;
+
+/** Token-based theme — presets + user overrides, never raw CSS. */
+export const designTokensSchema = z.object({
+ preset: z.string().optional(),
+ nodeBg: z.string().optional(),
+ connector: z.string().optional(),
+ radius: z.string().optional(),
+});
+export type DesignTokens = z.infer;
+
+/**
+ * Import/Export payload = SETUP ONLY (no results), versioned.
+ * The same schema validates incoming JSON at the Nest boundary.
+ */
+export const tournamentSetupSchema = z.object({
+ schemaVersion: z.literal(SETUP_SCHEMA_VERSION),
+ name: z.string().min(1),
+ stages: z.array(stageSetupSchema).min(1),
+ participants: z.array(participantSetupSchema),
+ design: designTokensSchema.optional(),
+});
+export type TournamentSetup = z.infer;
diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json
new file mode 100644
index 0000000..8d3ae98
--- /dev/null
+++ b/packages/shared/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "module": "CommonJS",
+ "moduleResolution": "Node",
+ "target": "ES2022",
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "declaration": true,
+ "sourceMap": true,
+ "composite": false
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
new file mode 100644
index 0000000..3ff5faa
--- /dev/null
+++ b/pnpm-workspace.yaml
@@ -0,0 +1,3 @@
+packages:
+ - "apps/*"
+ - "packages/*"
diff --git a/tsconfig.base.json b/tsconfig.base.json
new file mode 100644
index 0000000..80cf8c8
--- /dev/null
+++ b/tsconfig.base.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": ["ES2022"],
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true
+ }
+}
diff --git a/turbo.json b/turbo.json
new file mode 100644
index 0000000..49ee04a
--- /dev/null
+++ b/turbo.json
@@ -0,0 +1,20 @@
+{
+ "$schema": "https://turbo.build/schema.json",
+ "tasks": {
+ "build": {
+ "dependsOn": ["^build"],
+ "outputs": ["dist/**", ".next/**", "!.next/cache/**"]
+ },
+ "dev": {
+ "cache": false,
+ "persistent": true
+ },
+ "lint": {},
+ "typecheck": {
+ "dependsOn": ["^build"]
+ },
+ "test": {
+ "dependsOn": ["^build"]
+ }
+ }
+}