Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22
6 changes: 6 additions & 0 deletions .prettierrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 100
}
73 changes: 72 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,72 @@
# tournamentify
# 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`).
8 changes: 8 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions apps/api/nest-cli.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
37 changes: 37 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
175 changes: 175 additions & 0 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
@@ -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())
}
18 changes: 18 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
9 changes: 9 additions & 0 deletions apps/api/src/health.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Controller, Get } from "@nestjs/common";

@Controller("health")
export class HealthController {
@Get()
check() {
return { status: "ok", service: "tournamentify-api" };
}
}
16 changes: 16 additions & 0 deletions apps/api/src/main.ts
Original file line number Diff line number Diff line change
@@ -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();
Loading