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"] + } + } +}