diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 4198800..1a4d4f9 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,7 +1,11 @@ import { Module } from "@nestjs/common"; +import { APP_GUARD } from "@nestjs/core"; import { LoggerModule } from "nestjs-pino"; +import { ServiceTokenGuard } from "./common/service-token.guard"; import { HealthController } from "./health.controller"; -import { PrismaService } from "./prisma/prisma.service"; +import { PrismaModule } from "./prisma/prisma.module"; +import { TournamentsModule } from "./tournaments/tournaments.module"; +import { UsersModule } from "./users/users.module"; @Module({ imports: [ @@ -9,10 +13,29 @@ import { PrismaService } from "./prisma/prisma.service"; pinoHttp: { transport: process.env.NODE_ENV !== "production" ? { target: "pino-pretty" } : undefined, + // Never log the BFF shared secret, the forwarded actor headers, or cookies. + redact: { + paths: [ + 'req.headers["x-bff-service-token"]', + 'req.headers["x-user-id"]', + 'req.headers["x-anon-token"]', + "req.headers.authorization", + "req.headers.cookie", + ], + censor: "[redacted]", + }, }, }), + PrismaModule, + UsersModule, + TournamentsModule, ], controllers: [HealthController], - providers: [PrismaService], + providers: [ + { + provide: APP_GUARD, + useClass: ServiceTokenGuard, + }, + ], }) export class AppModule {} diff --git a/apps/api/src/bracket/bracket-generator.service.ts b/apps/api/src/bracket/bracket-generator.service.ts new file mode 100644 index 0000000..9017000 --- /dev/null +++ b/apps/api/src/bracket/bracket-generator.service.ts @@ -0,0 +1,171 @@ +import { Injectable, NotImplementedException } from "@nestjs/common"; +import { StageSetup } from "@tournamentify/shared"; +import { GeneratedGroup, GeneratedMatch, GeneratedRound, GeneratedSlot, GeneratedStage } from "./types"; + +/** + * Pure, deterministic bracket generator. No DB, no randomness, no external + * libraries — given the same inputs it always produces the same structure. + * Output uses 0-based participantIndex (seed s -> participantIndex s-1). + */ +@Injectable() +export class BracketGenerator { + generateStage(stage: StageSetup, participantCount: number): GeneratedStage { + switch (stage.type) { + case "single_elimination": + return this.generateSingleElimination(stage, participantCount); + case "round_robin": + return this.generateRoundRobin(stage, participantCount); + case "double_elimination": + case "swiss": + throw new NotImplementedException( + "Format wird ab M2/M3 unterstuetzt: " + stage.type, + ); + default: { + const _exhaustive: never = stage.type; + throw new NotImplementedException( + "Format wird ab M2/M3 unterstuetzt: " + String(_exhaustive), + ); + } + } + } + + /** + * Standard single-elimination seeding. The bracket size is the smallest power + * of two >= max(count, 2); short fields are padded with byes. Slot order is + * built by the classic mirror expansion so that top seeds meet only late: + * starting from [1, 2], each pass doubles the list, appending for every seed + * its complement (sum - seed) where sum = currentLength*2 + 1. + */ + private generateSingleElimination(stage: StageSetup, participantCount: number): GeneratedStage { + const count = Math.max(participantCount, 2); + let size = 2; + while (size < count) { + size *= 2; + } + + // Build the 1-based seed order for the bracket slots. + let slots: number[] = [1, 2]; + while (slots.length < size) { + const sum = slots.length * 2 + 1; + const next: number[] = []; + for (const s of slots) { + next.push(s); + next.push(sum - s); + } + slots = next; + } + + const rounds = Math.log2(size); + const generatedRounds: GeneratedRound[] = []; + + // Round 1: seat the seeded participants (or byes) into mirror-ordered slots. + const firstRoundMatches: GeneratedMatch[] = []; + const firstRoundMatchCount = size / 2; + for (let i = 0; i < firstRoundMatchCount; i++) { + const seedA = slots[2 * i]; + const seedB = slots[2 * i + 1]; + firstRoundMatches.push({ + number: i + 1, + opponent1: this.seedSlot(seedA, participantCount), + opponent2: this.seedSlot(seedB, participantCount), + }); + } + generatedRounds.push({ + number: 1, + name: this.roundName(1, rounds), + matches: firstRoundMatches, + }); + + // Subsequent rounds: each match feeds from the two winners below it. + for (let r = 2; r <= rounds; r++) { + const matchCount = size / Math.pow(2, r); + const matches: GeneratedMatch[] = []; + for (let m = 1; m <= matchCount; m++) { + matches.push({ + number: m, + opponent1: this.winnerSource(r - 1, 2 * m - 1), + opponent2: this.winnerSource(r - 1, 2 * m), + }); + } + generatedRounds.push({ + number: r, + name: this.roundName(r, rounds), + matches, + }); + } + + const group: GeneratedGroup = { number: 1, rounds: generatedRounds }; + return { type: stage.type, name: stage.name, groups: [group] }; + } + + /** + * Round-robin via the circle method. With an odd field a virtual bye player + * (-1) is added to make the count even; pairings touching -1 are dropped so a + * player simply sits out that day. The first array slot stays fixed while the + * rest rotate, yielding n-1 distinct rounds (Spieltage). + */ + private generateRoundRobin(stage: StageSetup, participantCount: number): GeneratedStage { + const players: number[] = []; + for (let i = 0; i < participantCount; i++) { + players.push(i); + } + if (players.length % 2 === 1) { + players.push(-1); // bye marker + } + + const n = players.length; + const rounds = Math.max(n - 1, 0); + const half = n / 2; + const generatedRounds: GeneratedRound[] = []; + + const arr = players.slice(); + for (let r = 0; r < rounds; r++) { + const matches: GeneratedMatch[] = []; + let matchNumber = 1; + for (let i = 0; i < half; i++) { + const a = arr[i]; + const b = arr[n - 1 - i]; + if (a !== -1 && b !== -1) { + matches.push({ + number: matchNumber++, + opponent1: { kind: "participant", participantIndex: a }, + opponent2: { kind: "participant", participantIndex: b }, + }); + } + } + generatedRounds.push({ + number: r + 1, + name: "Spieltag " + (r + 1), + matches, + }); + + // Rotate everything except the fixed first slot. + arr.splice(0, arr.length, arr[0], arr[n - 1], ...arr.slice(1, n - 1)); + } + + const group: GeneratedGroup = { number: 1, rounds: generatedRounds }; + return { type: stage.type, name: stage.name, groups: [group] }; + } + + /** A real entrant becomes a participant slot; an absent seed becomes a bye. */ + private seedSlot(seed: number, participantCount: number): GeneratedSlot { + if (seed <= participantCount) { + return { kind: "participant", participantIndex: seed - 1 }; + } + return { kind: "bye" }; + } + + private winnerSource(round: number, match: number): GeneratedSlot { + return { kind: "source", source: { type: "winner_of", round, match } }; + } + + private roundName(round: number, totalRounds: number): string { + if (round === totalRounds) { + return "Finale"; + } + if (round === totalRounds - 1) { + return "Halbfinale"; + } + return "Runde " + round; + } +} diff --git a/apps/api/src/bracket/stage-type.ts b/apps/api/src/bracket/stage-type.ts new file mode 100644 index 0000000..b6d7f2a --- /dev/null +++ b/apps/api/src/bracket/stage-type.ts @@ -0,0 +1,42 @@ +import { StageType as PrismaStageType } from "@prisma/client"; +import { StageType } from "@tournamentify/shared"; + +/** + * Bridges the two enum dialects: the shared/domain layer uses lower_snake + * (zod enum), while Prisma generates UPPER_SNAKE constants. Both switches are + * exhaustive so adding a new format breaks the build until it is mapped here. + */ + +export function toPrismaStageType(s: StageType): PrismaStageType { + switch (s) { + case "single_elimination": + return PrismaStageType.SINGLE_ELIMINATION; + case "double_elimination": + return PrismaStageType.DOUBLE_ELIMINATION; + case "round_robin": + return PrismaStageType.ROUND_ROBIN; + case "swiss": + return PrismaStageType.SWISS; + default: { + const _exhaustive: never = s; + return _exhaustive; + } + } +} + +export function toDomainStageType(p: PrismaStageType): StageType { + switch (p) { + case PrismaStageType.SINGLE_ELIMINATION: + return "single_elimination"; + case PrismaStageType.DOUBLE_ELIMINATION: + return "double_elimination"; + case PrismaStageType.ROUND_ROBIN: + return "round_robin"; + case PrismaStageType.SWISS: + return "swiss"; + default: { + const _exhaustive: never = p; + return _exhaustive; + } + } +} diff --git a/apps/api/src/bracket/types.ts b/apps/api/src/bracket/types.ts new file mode 100644 index 0000000..a6d4428 --- /dev/null +++ b/apps/api/src/bracket/types.ts @@ -0,0 +1,41 @@ +import { StageType } from "@tournamentify/shared"; + +/** + * Pure, DB-free description of a generated bracket. The persistence slice maps + * these shapes onto Prisma Stage/Group/Round/Match rows; the renderer reads the + * same shapes. `participantIndex` is the 0-based index into the input + * participants array (seed s maps to participantIndex s-1). + */ + +export interface GeneratedSlot { + kind: "participant" | "bye" | "source" | "empty"; + participantIndex?: number; + source?: { + type: "winner_of" | "loser_of"; + round: number; + match: number; + }; +} + +export interface GeneratedMatch { + number: number; + opponent1: GeneratedSlot; + opponent2: GeneratedSlot; +} + +export interface GeneratedRound { + number: number; + name: string; + matches: GeneratedMatch[]; +} + +export interface GeneratedGroup { + number: number; + rounds: GeneratedRound[]; +} + +export interface GeneratedStage { + type: StageType; + name: string; + groups: GeneratedGroup[]; +} diff --git a/apps/api/src/common/current-actor.decorator.ts b/apps/api/src/common/current-actor.decorator.ts new file mode 100644 index 0000000..eda0ec4 --- /dev/null +++ b/apps/api/src/common/current-actor.decorator.ts @@ -0,0 +1,21 @@ +import { createParamDecorator, ExecutionContext } from "@nestjs/common"; +import { Request } from "express"; + +export interface Actor { + userId?: string; + anonToken?: string; +} + +function readHeader(request: Request, name: string): string | undefined { + const value = request.headers[name]; + const header = Array.isArray(value) ? value[0] : value; + return typeof header === "string" && header.length > 0 ? header : undefined; +} + +export const CurrentActor = createParamDecorator((_data: unknown, ctx: ExecutionContext): Actor => { + const request = ctx.switchToHttp().getRequest(); + return { + userId: readHeader(request, "x-user-id"), + anonToken: readHeader(request, "x-anon-token"), + }; +}); diff --git a/apps/api/src/common/public.decorator.ts b/apps/api/src/common/public.decorator.ts new file mode 100644 index 0000000..c33de53 --- /dev/null +++ b/apps/api/src/common/public.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from "@nestjs/common"; + +export const IS_PUBLIC_KEY = "isPublic"; + +/** Exempt a route from the global ServiceTokenGuard (e.g. health checks). */ +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/apps/api/src/common/service-token.guard.ts b/apps/api/src/common/service-token.guard.ts new file mode 100644 index 0000000..951a699 --- /dev/null +++ b/apps/api/src/common/service-token.guard.ts @@ -0,0 +1,54 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { timingSafeEqual } from "crypto"; +import { Request } from "express"; +import { IS_PUBLIC_KEY } from "./public.decorator"; + +/** + * Nest is private behind the Next BFF. Every request must carry the shared + * x-bff-service-token. Routes marked @Public() (e.g. health) are exempt. + */ +@Injectable() +export class ServiceTokenGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) { + return true; + } + + const expected = process.env.BFF_SERVICE_TOKEN; + if (!expected) { + // Fail closed. main.ts validates this at boot, so we should never get here. + throw new UnauthorizedException(); + } + + const request = context.switchToHttp().getRequest(); + const provided = request.headers["x-bff-service-token"]; + const token = Array.isArray(provided) ? provided[0] : provided; + + if (typeof token !== "string" || !this.safeEqual(token, expected)) { + throw new UnauthorizedException(); + } + + return true; + } + + private safeEqual(a: string, b: string): boolean { + const bufferA = Buffer.from(a); + const bufferB = Buffer.from(b); + if (bufferA.length !== bufferB.length) { + return false; + } + return timingSafeEqual(bufferA, bufferB); + } +} diff --git a/apps/api/src/common/zod-validation.pipe.ts b/apps/api/src/common/zod-validation.pipe.ts new file mode 100644 index 0000000..805d626 --- /dev/null +++ b/apps/api/src/common/zod-validation.pipe.ts @@ -0,0 +1,20 @@ +import { BadRequestException, PipeTransform } from "@nestjs/common"; +import { ZodError, ZodSchema } from "zod"; + +export class ZodValidationPipe implements PipeTransform { + constructor(private readonly schema: ZodSchema) {} + + transform(value: unknown): T { + try { + return this.schema.parse(value); + } catch (error) { + if (error instanceof ZodError) { + throw new BadRequestException({ + message: "Validation failed", + issues: error.flatten(), + }); + } + throw error; + } + } +} diff --git a/apps/api/src/health.controller.ts b/apps/api/src/health.controller.ts index b538970..0d5b2b1 100644 --- a/apps/api/src/health.controller.ts +++ b/apps/api/src/health.controller.ts @@ -1,7 +1,9 @@ import { Controller, Get } from "@nestjs/common"; +import { Public } from "./common/public.decorator"; @Controller("health") export class HealthController { + @Public() @Get() check() { return { status: "ok", service: "tournamentify-api" }; diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 3388726..d518045 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -4,8 +4,15 @@ import { Logger } from "nestjs-pino"; import { AppModule } from "./app.module"; async function bootstrap() { + if (!process.env.BFF_SERVICE_TOKEN) { + // All API authorization depends on this shared secret — refuse to boot + // without it rather than failing open or only failing per-request. + throw new Error("BFF_SERVICE_TOKEN is not set — refusing to start."); + } + const app = await NestFactory.create(AppModule, { bufferLogs: true }); app.useLogger(app.get(Logger)); + app.enableShutdownHooks(); const port = process.env.PORT ?? 3001; // Listen on all interfaces so the container is reachable on the private diff --git a/apps/api/src/prisma/prisma.module.ts b/apps/api/src/prisma/prisma.module.ts new file mode 100644 index 0000000..1edbf95 --- /dev/null +++ b/apps/api/src/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from "@nestjs/common"; +import { PrismaService } from "./prisma.service"; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/apps/api/src/tournaments/links.service.ts b/apps/api/src/tournaments/links.service.ts new file mode 100644 index 0000000..c9e69db --- /dev/null +++ b/apps/api/src/tournaments/links.service.ts @@ -0,0 +1,42 @@ +import { randomUUID } from "crypto"; +import { Injectable } from "@nestjs/common"; +import { CapabilityLink, CapabilityType } from "@prisma/client"; +import { CapabilityLinkDto } from "@tournamentify/shared"; +import { PrismaService } from "../prisma/prisma.service"; +import { toCapabilityLink } from "./tournament.mapper"; + +@Injectable() +export class LinksService { + constructor(private readonly prisma: PrismaService) {} + + async create(tournamentId: string, type: CapabilityType): Promise { + const link = await this.prisma.capabilityLink.create({ + data: { + tournamentId, + type, + token: randomUUID(), + }, + }); + return toCapabilityLink(link); + } + + async list(tournamentId: string): Promise { + const links = await this.prisma.capabilityLink.findMany({ + where: { tournamentId }, + orderBy: { createdAt: "desc" }, + }); + return links.map(toCapabilityLink); + } + + /** Returns the link only when it belongs to the tournament and is unexpired. */ + async resolve(tournamentId: string, token: string): Promise { + const link = await this.prisma.capabilityLink.findUnique({ where: { token } }); + if (!link || link.tournamentId !== tournamentId) { + return null; + } + if (link.expiresAt && link.expiresAt.getTime() <= Date.now()) { + return null; + } + return link; + } +} diff --git a/apps/api/src/tournaments/tournament.mapper.ts b/apps/api/src/tournaments/tournament.mapper.ts new file mode 100644 index 0000000..79dab1d --- /dev/null +++ b/apps/api/src/tournaments/tournament.mapper.ts @@ -0,0 +1,171 @@ +import { + CapabilityLink, + Group, + Match, + Participant, + Round, + Stage, + Tournament, +} from "@prisma/client"; +import { + designTokensSchema, + MatchDto, + MatchSlotDto, + StageType, + TournamentDetailDto, + TournamentSummaryDto, +} from "@tournamentify/shared"; +import { toDomainStageType } from "../bracket/stage-type"; + +/** + * Pure Prisma-row -> shared-DTO mapping. No DB access, no Nest decorators — so + * the service can compose these freely and they stay trivially testable. + */ + +// --------------------------------------------------------------------------- +// Stored opponent-slot JSON (the shape the service persists on Match.opponentN) +// --------------------------------------------------------------------------- + +type StoredSlot = + | { participantId: string } + | { bye: true } + | { source: { type: "winner_of" | "loser_of"; round: number; match: number } } + | null; + +type ParticipantNameLookup = Map; + +function sourceLabel(source: { type: "winner_of" | "loser_of"; round: number; match: number }): string { + const prefix = source.type === "loser_of" ? "Verlierer" : "Sieger"; + return `${prefix} R${source.round} M${source.match}`; +} + +function resolveSlot(raw: unknown, names: ParticipantNameLookup): MatchSlotDto | null { + if (raw === null || raw === undefined) { + return null; + } + const slot = raw as StoredSlot; + if (slot === null) { + return null; + } + if ("participantId" in slot) { + return { + participantId: slot.participantId, + label: names.get(slot.participantId) ?? "", + score: null, + }; + } + if ("bye" in slot && slot.bye) { + return { participantId: null, label: "BYE", score: null }; + } + if ("source" in slot && slot.source) { + return { participantId: null, label: sourceLabel(slot.source), score: null }; + } + return null; +} + +// --------------------------------------------------------------------------- +// Prisma include shapes +// --------------------------------------------------------------------------- + +type MatchRow = Match; +type RoundRow = Round & { matches: MatchRow[] }; +type GroupRow = Group & { rounds: RoundRow[] }; +type StageRow = Stage & { groups: GroupRow[] }; + +export type TournamentSummaryRow = Tournament & { + participants: Pick[]; + stages: Pick[]; +}; + +export type TournamentDetailRow = Tournament & { + participants: Participant[]; + stages: StageRow[]; +}; + +// --------------------------------------------------------------------------- +// Mappers +// --------------------------------------------------------------------------- + +export function toSummary(t: TournamentSummaryRow): TournamentSummaryDto { + const stageTypes: StageType[] = t.stages.map((s) => toDomainStageType(s.type)); + return { + id: t.id, + name: t.name, + status: t.status, + participantCount: t.participants.length, + stageTypes, + createdAt: t.createdAt.toISOString(), + updatedAt: t.updatedAt.toISOString(), + }; +} + +export function toDetail(t: TournamentDetailRow): TournamentDetailDto { + const names: ParticipantNameLookup = new Map(t.participants.map((p) => [p.id, p.name])); + const parsedDesign = designTokensSchema.safeParse(t.designTokens); + + return { + id: t.id, + name: t.name, + status: t.status, + design: parsedDesign.success ? parsedDesign.data : null, + participants: t.participants.map((p) => ({ + id: p.id, + name: p.name, + seed: p.seed ?? null, + })), + stages: t.stages.map((stage) => ({ + id: stage.id, + type: toDomainStageType(stage.type), + number: stage.number, + name: stageName(stage), + groups: stage.groups.map((group) => ({ + id: group.id, + number: group.number, + rounds: group.rounds.map((round) => ({ + id: round.id, + number: round.number, + name: round.nameOverride ?? `Runde ${round.number}`, + matches: round.matches.map((match) => toMatch(match, names)), + })), + })), + })), + createdAt: t.createdAt.toISOString(), + updatedAt: t.updatedAt.toISOString(), + }; +} + +function toMatch(match: MatchRow, names: ParticipantNameLookup): MatchDto { + return { + id: match.id, + number: match.number, + status: match.status, + opponent1: resolveSlot(match.opponent1, names), + opponent2: resolveSlot(match.opponent2, names), + }; +} + +/** Stage has no name column — the display name lives in Stage.settings.name. */ +function stageName(stage: Pick): string { + const settings = stage.settings; + if (settings && typeof settings === "object" && !Array.isArray(settings)) { + const name = (settings as Record).name; + if (typeof name === "string" && name.length > 0) { + return name; + } + } + return "Hauptrunde"; +} + +export function toCapabilityLink(link: CapabilityLink): { + id: string; + type: "VIEW" | "SCORE"; + token: string; + expiresAt: string | null; +} { + return { + id: link.id, + type: link.type, + token: link.token, + expiresAt: link.expiresAt ? link.expiresAt.toISOString() : null, + }; +} diff --git a/apps/api/src/tournaments/tournaments.controller.ts b/apps/api/src/tournaments/tournaments.controller.ts new file mode 100644 index 0000000..f270b5c --- /dev/null +++ b/apps/api/src/tournaments/tournaments.controller.ts @@ -0,0 +1,104 @@ +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + HttpCode, + Param, + Post, + Query, + UsePipes, +} from "@nestjs/common"; +import { + CapabilityLinkDto, + CreateCapabilityLinkInput, + CreateTournamentInput, + TournamentDetailDto, + TournamentSetup, + TournamentSummaryDto, + createCapabilityLinkInputSchema, + createTournamentInputSchema, + importSetupInputSchema, +} from "@tournamentify/shared"; +import { Actor, CurrentActor } from "../common/current-actor.decorator"; +import { ZodValidationPipe } from "../common/zod-validation.pipe"; +import { TournamentsService } from "./tournaments.service"; + +@Controller("tournaments") +export class TournamentsController { + constructor(private readonly tournaments: TournamentsService) {} + + @Post() + @UsePipes(new ZodValidationPipe(createTournamentInputSchema)) + create( + @CurrentActor() actor: Actor, + @Body() input: CreateTournamentInput, + ): Promise { + return this.tournaments.create(actor, input); + } + + @Get() + list(@CurrentActor() actor: Actor): Promise { + return this.tournaments.list(actor); + } + + // Static segments before the parametric `:id` routes so they are not swallowed. + + @Post("import") + @UsePipes(new ZodValidationPipe(importSetupInputSchema)) + import( + @CurrentActor() actor: Actor, + @Body() setup: TournamentSetup, + ): Promise { + return this.tournaments.importSetup(actor, setup); + } + + @Post("claim") + claim(@CurrentActor() actor: Actor): Promise<{ claimed: number }> { + // The anon token comes from the BFF-injected x-anon-token header (sourced + // from the caller's own httpOnly cookie), never from client-supplied input — + // otherwise any logged-in user could claim another guest's tournaments. + if (!actor.userId) { + throw new BadRequestException("A logged-in user is required to claim tournaments"); + } + if (!actor.anonToken) { + return Promise.resolve({ claimed: 0 }); + } + return this.tournaments.claim(actor.userId, actor.anonToken); + } + + @Get(":id") + getById( + @Param("id") id: string, + @CurrentActor() actor: Actor, + @Query("token") token?: string, + ): Promise { + return this.tournaments.getById(id, actor, token); + } + + @Delete(":id") + @HttpCode(204) + remove(@Param("id") id: string, @CurrentActor() actor: Actor): Promise { + return this.tournaments.remove(id, actor); + } + + @Get(":id/export") + export(@Param("id") id: string, @CurrentActor() actor: Actor): Promise { + return this.tournaments.exportSetup(id, actor); + } + + @Post(":id/links") + createLink( + @Param("id") id: string, + @CurrentActor() actor: Actor, + @Body(new ZodValidationPipe(createCapabilityLinkInputSchema)) body: CreateCapabilityLinkInput, + ): Promise { + return this.tournaments.createLink(id, actor, body.type); + } + + @Get(":id/links") + listLinks(@Param("id") id: string, @CurrentActor() actor: Actor): Promise { + return this.tournaments.listLinks(id, actor); + } +} diff --git a/apps/api/src/tournaments/tournaments.module.ts b/apps/api/src/tournaments/tournaments.module.ts new file mode 100644 index 0000000..4844fb1 --- /dev/null +++ b/apps/api/src/tournaments/tournaments.module.ts @@ -0,0 +1,12 @@ +import { Module } from "@nestjs/common"; +import { BracketGenerator } from "../bracket/bracket-generator.service"; +import { LinksService } from "./links.service"; +import { TournamentsController } from "./tournaments.controller"; +import { TournamentsService } from "./tournaments.service"; + +@Module({ + controllers: [TournamentsController], + providers: [TournamentsService, LinksService, BracketGenerator], +}) +export class TournamentsModule {} + diff --git a/apps/api/src/tournaments/tournaments.service.ts b/apps/api/src/tournaments/tournaments.service.ts new file mode 100644 index 0000000..daeb011 --- /dev/null +++ b/apps/api/src/tournaments/tournaments.service.ts @@ -0,0 +1,326 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { Prisma } from "@prisma/client"; +import { + CapabilityLinkDto, + CreateTournamentInput, + SETUP_SCHEMA_VERSION, + TournamentDetailDto, + TournamentSetup, + TournamentSummaryDto, + designTokensSchema, +} from "@tournamentify/shared"; +import { BracketGenerator } from "../bracket/bracket-generator.service"; +import { GeneratedSlot } from "../bracket/types"; +import { toDomainStageType, toPrismaStageType } from "../bracket/stage-type"; +import { Actor } from "../common/current-actor.decorator"; +import { PrismaService } from "../prisma/prisma.service"; +import { LinksService } from "./links.service"; +import { toDetail, toSummary, TournamentDetailRow } from "./tournament.mapper"; + +/** Full include tree needed to build a TournamentDetailDto. */ +const detailInclude = { + participants: { orderBy: { seed: "asc" } }, + stages: { + orderBy: { number: "asc" }, + include: { + groups: { + orderBy: { number: "asc" }, + include: { + rounds: { + orderBy: { number: "asc" }, + include: { + matches: { orderBy: { number: "asc" } }, + }, + }, + }, + }, + }, + }, +} satisfies Prisma.TournamentInclude; + +@Injectable() +export class TournamentsService { + constructor( + private readonly prisma: PrismaService, + private readonly generator: BracketGenerator, + private readonly links: LinksService, + ) {} + + async create(actor: Actor, input: CreateTournamentInput): Promise { + if (!actor.userId && !actor.anonToken) { + throw new BadRequestException("An owner (user or anonymous token) is required"); + } + + const id = await this.prisma.$transaction(async (tx) => { + const tournament = await tx.tournament.create({ + data: { + name: input.name, + status: "DRAFT", + ownerUserId: actor.userId ?? null, + anonOwnerToken: actor.userId ? null : (actor.anonToken ?? null), + designTokens: (input.design ?? {}) as Prisma.InputJsonValue, + }, + }); + + // Persist participants in input order, remembering each row id by index so + // generated slots (which reference 0-based participantIndex) can resolve. + const idByIndex: string[] = []; + for (const [index, participant] of input.participants.entries()) { + const created = await tx.participant.create({ + data: { + tournamentId: tournament.id, + name: participant.name, + seed: participant.seed ?? index + 1, + }, + }); + idByIndex[index] = created.id; + } + + for (const [stageIndex, stageSetup] of input.stages.entries()) { + const generated = this.generator.generateStage(stageSetup, input.participants.length); + const stage = await tx.stage.create({ + data: { + tournamentId: tournament.id, + type: toPrismaStageType(stageSetup.type), + number: stageIndex + 1, + // Carry the display name inside settings — Stage has no name column. + settings: { + ...stageSetup.settings, + name: stageSetup.name, + } as Prisma.InputJsonValue, + }, + }); + + for (const group of generated.groups) { + const groupRow = await tx.group.create({ + data: { stageId: stage.id, number: group.number }, + }); + + for (const round of group.rounds) { + const roundRow = await tx.round.create({ + data: { + groupId: groupRow.id, + number: round.number, + nameOverride: round.name, + bestOf: 1, + }, + }); + + for (const match of round.matches) { + await tx.match.create({ + data: { + roundId: roundRow.id, + number: match.number, + status: "PENDING", + opponent1: slotToJson(match.opponent1, idByIndex), + opponent2: slotToJson(match.opponent2, idByIndex), + }, + }); + } + } + } + } + + return tournament.id; + }); + + return this.loadDetail(id); + } + + async list(actor: Actor): Promise { + if (!actor.userId && !actor.anonToken) { + throw new BadRequestException("An actor (user or anonymous token) is required"); + } + + const where: Prisma.TournamentWhereInput = actor.userId + ? { ownerUserId: actor.userId } + : { anonOwnerToken: actor.anonToken }; + + const tournaments = await this.prisma.tournament.findMany({ + where, + include: { + participants: { select: { id: true } }, + stages: { select: { type: true } }, + }, + orderBy: { updatedAt: "desc" }, + }); + + return tournaments.map(toSummary); + } + + async getById(id: string, actor: Actor, token?: string): Promise { + const tournament = await this.prisma.tournament.findUnique({ + where: { id }, + include: detailInclude, + }); + if (!tournament) { + throw new NotFoundException("Tournament not found"); + } + + if (!isOwner(tournament, actor)) { + const link = token ? await this.links.resolve(id, token) : null; + if (!link) { + // Do not reveal that the tournament exists to non-owners without a token. + throw new NotFoundException("Tournament not found"); + } + } + + return toDetail(tournament as TournamentDetailRow); + } + + async remove(id: string, actor: Actor): Promise { + const tournament = await this.prisma.tournament.findUnique({ + where: { id }, + select: { id: true, ownerUserId: true, anonOwnerToken: true }, + }); + if (!tournament) { + throw new NotFoundException("Tournament not found"); + } + if (!isOwner(tournament, actor)) { + throw new ForbiddenException("Not the owner of this tournament"); + } + await this.prisma.tournament.delete({ where: { id } }); + } + + async exportSetup(id: string, actor: Actor): Promise { + const tournament = await this.prisma.tournament.findUnique({ + where: { id }, + include: { + participants: { orderBy: { seed: "asc" } }, + stages: { orderBy: { number: "asc" } }, + }, + }); + if (!tournament) { + throw new NotFoundException("Tournament not found"); + } + if (!isOwner(tournament, actor)) { + throw new ForbiddenException("Not the owner of this tournament"); + } + + const parsedDesign = designTokensSchema.safeParse(tournament.designTokens); + + return { + schemaVersion: SETUP_SCHEMA_VERSION, + name: tournament.name, + stages: tournament.stages.map((stage) => ({ + type: toDomainStageType(stage.type), + name: settingsName(stage.settings), + settings: stripStageName(stage.settings), + })), + participants: tournament.participants.map((participant) => ({ + name: participant.name, + seed: participant.seed ?? undefined, + })), + design: parsedDesign.success ? parsedDesign.data : undefined, + }; + } + + async importSetup(actor: Actor, setup: TournamentSetup): Promise { + const { schemaVersion: _schemaVersion, ...input } = setup; + return this.create(actor, input); + } + + async claim(userId: string, anonToken: string): Promise<{ claimed: number }> { + const result = await this.prisma.tournament.updateMany({ + where: { anonOwnerToken: anonToken }, + data: { ownerUserId: userId, anonOwnerToken: null }, + }); + return { claimed: result.count }; + } + + async createLink(id: string, actor: Actor, type: "VIEW" | "SCORE"): Promise { + await this.assertOwner(id, actor); + return this.links.create(id, type); + } + + async listLinks(id: string, actor: Actor): Promise { + await this.assertOwner(id, actor); + return this.links.list(id); + } + + private async assertOwner(id: string, actor: Actor): Promise { + const tournament = await this.prisma.tournament.findUnique({ + where: { id }, + select: { id: true, ownerUserId: true, anonOwnerToken: true }, + }); + if (!tournament) { + throw new NotFoundException("Tournament not found"); + } + if (!isOwner(tournament, actor)) { + throw new ForbiddenException("Not the owner of this tournament"); + } + } + + private async loadDetail(id: string): Promise { + const tournament = await this.prisma.tournament.findUniqueOrThrow({ + where: { id }, + include: detailInclude, + }); + return toDetail(tournament as TournamentDetailRow); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function isOwner( + tournament: { ownerUserId: string | null; anonOwnerToken: string | null }, + actor: Actor, +): boolean { + if (actor.userId && tournament.ownerUserId === actor.userId) { + return true; + } + if (actor.anonToken && tournament.anonOwnerToken === actor.anonToken) { + return true; + } + return false; +} + +/** Map a generated slot onto the JSON shape the mapper reads back. */ +function slotToJson(slot: GeneratedSlot, idByIndex: string[]): Prisma.InputJsonValue | typeof Prisma.JsonNull { + switch (slot.kind) { + case "participant": { + const participantId = + slot.participantIndex !== undefined ? idByIndex[slot.participantIndex] : undefined; + if (!participantId) { + return Prisma.JsonNull; + } + return { participantId }; + } + case "bye": + return { bye: true }; + case "source": + return slot.source ? { source: { ...slot.source } } : Prisma.JsonNull; + case "empty": + return Prisma.JsonNull; + default: { + const _exhaustive: never = slot.kind; + return _exhaustive; + } + } +} + +function settingsName(settings: Prisma.JsonValue): string { + if (settings && typeof settings === "object" && !Array.isArray(settings)) { + const name = (settings as Record).name; + if (typeof name === "string" && name.length > 0) { + return name; + } + } + return "Hauptrunde"; +} + +/** Strip the synthetic `name` key so exported settings round-trip cleanly. */ +function stripStageName(settings: Prisma.JsonValue): Record { + if (settings && typeof settings === "object" && !Array.isArray(settings)) { + const { name: _name, ...rest } = settings as Record; + return rest; + } + return {}; +} diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts new file mode 100644 index 0000000..cf7eed9 --- /dev/null +++ b/apps/api/src/users/users.controller.ts @@ -0,0 +1,23 @@ +import { Body, Controller, Get, Post, UnauthorizedException } from "@nestjs/common"; +import { UserSyncInput, userSyncInputSchema } from "@tournamentify/shared"; +import { Actor, CurrentActor } from "../common/current-actor.decorator"; +import { ZodValidationPipe } from "../common/zod-validation.pipe"; +import { UsersService } from "./users.service"; + +@Controller("users") +export class UsersController { + constructor(private readonly usersService: UsersService) {} + + @Post("sync") + sync(@Body(new ZodValidationPipe(userSyncInputSchema)) body: UserSyncInput) { + return this.usersService.sync(body); + } + + @Get("me") + me(@CurrentActor() actor: Actor) { + if (!actor.userId) { + throw new UnauthorizedException(); + } + return this.usersService.me(actor.userId); + } +} diff --git a/apps/api/src/users/users.module.ts b/apps/api/src/users/users.module.ts new file mode 100644 index 0000000..1a5cff0 --- /dev/null +++ b/apps/api/src/users/users.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { UsersController } from "./users.controller"; +import { UsersService } from "./users.service"; + +@Module({ + controllers: [UsersController], + providers: [UsersService], + exports: [UsersService], +}) +export class UsersModule {} diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts new file mode 100644 index 0000000..1eeeef2 --- /dev/null +++ b/apps/api/src/users/users.service.ts @@ -0,0 +1,43 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { UserDto, UserSyncInput } from "@tournamentify/shared"; +import { PrismaService } from "../prisma/prisma.service"; + +@Injectable() +export class UsersService { + constructor(private readonly prisma: PrismaService) {} + + async sync(input: UserSyncInput): Promise { + const authProviderId = `${input.provider}:${input.providerId}`; + + const byProvider = await this.prisma.user.findUnique({ where: { authProviderId } }); + if (byProvider) { + return this.toDto(byProvider); + } + + const byEmail = await this.prisma.user.findUnique({ where: { email: input.email } }); + if (byEmail) { + const updated = await this.prisma.user.update({ + where: { id: byEmail.id }, + data: { authProviderId }, + }); + return this.toDto(updated); + } + + const created = await this.prisma.user.create({ + data: { email: input.email, authProviderId, tier: "FREE" }, + }); + return this.toDto(created); + } + + async me(userId: string): Promise { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + throw new NotFoundException("User not found"); + } + return this.toDto(user); + } + + private toDto(user: { id: string; email: string; tier: UserDto["tier"] }): UserDto { + return { id: user.id, email: user.email, tier: user.tier }; + } +} diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index 871b39c..1066ad2 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -1,6 +1,68 @@ { "home": { "title": "Tournamentify", - "subtitle": "Bau deinen eigenen Turnierbaum – schnell und ganz nach deinen Vorstellungen." + "subtitle": "Bau deinen eigenen Turnierbaum – schnell und ganz nach deinen Vorstellungen.", + "ctaCreate": "Turnier erstellen", + "ctaDashboard": "Meine Turniere" + }, + "dashboard": { + "title": "Meine Turniere", + "create": "Neues Turnier", + "loading": "Turniere werden geladen …", + "loadError": "Turniere konnten nicht geladen werden", + "emptyTitle": "Du hast noch keine Turniere erstellt.", + "emptyCta": "Erstelle dein erstes Turnier", + "participants": "Teilnehmer", + "format": "Format", + "updated": "Aktualisiert", + "status": { + "DRAFT": "Entwurf", + "RUNNING": "Läuft", + "COMPLETED": "Abgeschlossen" + }, + "formatSingleElimination": "Single Elimination", + "formatDoubleElimination": "Double Elimination", + "formatRoundRobin": "Round Robin", + "formatSwiss": "Schweizer System" + }, + "create": { + "title": "Neues Turnier", + "subtitle": "Lege Name, Format und Teilnehmer fest.", + "nameLabel": "Name", + "namePlaceholder": "z. B. Sommer-Cup 2026", + "formatLabel": "Format", + "formatHelp": "Double Elimination und Schweizer System folgen in M2/M3.", + "participantsLabel": "Teilnehmer", + "participantsPlaceholder": "Ein Name pro Zeile", + "participantsHelp": "Ein Name pro Zeile. Die Reihenfolge bestimmt die Setzliste.", + "submit": "Turnier erstellen", + "submitting": "Wird erstellt …", + "submitError": "Turnier konnte nicht erstellt werden", + "formatSingleElimination": "Single Elimination", + "formatDoubleElimination": "Double Elimination", + "formatRoundRobin": "Round Robin", + "formatSwiss": "Schweizer System" + }, + "detail": { + "loading": "Turnier wird geladen …", + "loadError": "Turnier konnte nicht geladen werden", + "tbd": "Offen", + "group": "Gruppe {number}", + "export": "Exportieren", + "exportError": "Export fehlgeschlagen", + "delete": "Löschen", + "deleting": "Wird gelöscht …", + "deleteConfirm": "Dieses Turnier wirklich löschen?", + "deleteError": "Turnier konnte nicht gelöscht werden", + "editorComingSoon": "Der visuelle Bracket-Editor kommt in M2.", + "status": { + "DRAFT": "Entwurf", + "RUNNING": "Läuft", + "COMPLETED": "Abgeschlossen" + }, + "formatSingleElimination": "Single Elimination", + "formatDoubleElimination": "Double Elimination", + "formatRoundRobin": "Round Robin", + "formatSwiss": "Schweizer System" } } diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 60bd5c7..33cce71 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1,6 +1,68 @@ { "home": { "title": "Tournamentify", - "subtitle": "Build your own tournament bracket – fast and exactly your way." + "subtitle": "Build your own tournament bracket – fast and exactly your way.", + "ctaCreate": "Create tournament", + "ctaDashboard": "My tournaments" + }, + "dashboard": { + "title": "My tournaments", + "create": "New tournament", + "loading": "Loading tournaments …", + "loadError": "Could not load tournaments", + "emptyTitle": "You haven't created any tournaments yet.", + "emptyCta": "Create your first tournament", + "participants": "Participants", + "format": "Format", + "updated": "Updated", + "status": { + "DRAFT": "Draft", + "RUNNING": "Running", + "COMPLETED": "Completed" + }, + "formatSingleElimination": "Single elimination", + "formatDoubleElimination": "Double elimination", + "formatRoundRobin": "Round robin", + "formatSwiss": "Swiss" + }, + "create": { + "title": "New tournament", + "subtitle": "Set the name, format and participants.", + "nameLabel": "Name", + "namePlaceholder": "e.g. Summer Cup 2026", + "formatLabel": "Format", + "formatHelp": "Double elimination and Swiss arrive in M2/M3.", + "participantsLabel": "Participants", + "participantsPlaceholder": "One name per line", + "participantsHelp": "One name per line. The order sets the seeding.", + "submit": "Create tournament", + "submitting": "Creating …", + "submitError": "Could not create tournament", + "formatSingleElimination": "Single elimination", + "formatDoubleElimination": "Double elimination", + "formatRoundRobin": "Round robin", + "formatSwiss": "Swiss" + }, + "detail": { + "loading": "Loading tournament …", + "loadError": "Could not load tournament", + "tbd": "TBD", + "group": "Group {number}", + "export": "Export", + "exportError": "Export failed", + "delete": "Delete", + "deleting": "Deleting …", + "deleteConfirm": "Really delete this tournament?", + "deleteError": "Could not delete tournament", + "editorComingSoon": "The visual bracket editor arrives in M2.", + "status": { + "DRAFT": "Draft", + "RUNNING": "Running", + "COMPLETED": "Completed" + }, + "formatSingleElimination": "Single elimination", + "formatDoubleElimination": "Double elimination", + "formatRoundRobin": "Round robin", + "formatSwiss": "Swiss" } } diff --git a/apps/web/src/app/[locale]/dashboard/page.tsx b/apps/web/src/app/[locale]/dashboard/page.tsx new file mode 100644 index 0000000..67f5dd4 --- /dev/null +++ b/apps/web/src/app/[locale]/dashboard/page.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { useTranslations, useLocale } from "next-intl"; +import Link from "next/link"; +import { useEffect, useRef } from "react"; +import type { TournamentSummaryDto } from "@tournamentify/shared"; +import * as api from "@/lib/api"; +import { useTournaments } from "@/lib/queries"; +import { Button, Card } from "@/components/ui"; + +const stageTypeLabelKey: Record = { + single_elimination: "formatSingleElimination", + double_elimination: "formatDoubleElimination", + round_robin: "formatRoundRobin", + swiss: "formatSwiss", +}; + +function TournamentCard({ + tournament, + locale, +}: { + tournament: TournamentSummaryDto; + locale: string; +}) { + const t = useTranslations("dashboard"); + const formats = tournament.stageTypes + .map((type) => t(stageTypeLabelKey[type] ?? type)) + .join(", "); + const updated = new Date(tournament.updatedAt).toLocaleString(locale); + + return ( + + +
+

{tournament.name}

+ + {t(`status.${tournament.status}`)} + +
+
+
+
{t("participants")}
+
{tournament.participantCount}
+
+ {formats ? ( +
+
{t("format")}
+
{formats}
+
+ ) : null} +
+
{t("updated")}
+
{updated}
+
+
+
+ + ); +} + +function DashboardContent() { + const t = useTranslations("dashboard"); + const locale = useLocale(); + const { data, isLoading, isError, error } = useTournaments(); + + // Best-effort: after login, claim any tournaments created while a guest. + // Fire once per mount; failures are intentionally swallowed. + const claimed = useRef(false); + useEffect(() => { + if (claimed.current) return; + claimed.current = true; + api.claim().catch(() => {}); + }, []); + + return ( +
+
+

{t("title")}

+ + + +
+ + {isLoading ?

{t("loading")}

: null} + + {isError ? ( +

+ {t("loadError")} + {error instanceof Error ? `: ${error.message}` : ""} +

+ ) : null} + + {!isLoading && !isError && data && data.length === 0 ? ( + +

{t("emptyTitle")}

+
+ + + +
+
+ ) : null} + + {!isLoading && !isError && data && data.length > 0 ? ( +
+ {data.map((tournament) => ( + + ))} +
+ ) : null} +
+ ); +} + +export default function DashboardPage() { + return ; +} diff --git a/apps/web/src/app/[locale]/layout.tsx b/apps/web/src/app/[locale]/layout.tsx index 9e3615f..68f5706 100644 --- a/apps/web/src/app/[locale]/layout.tsx +++ b/apps/web/src/app/[locale]/layout.tsx @@ -2,6 +2,7 @@ import { NextIntlClientProvider } from "next-intl"; import { getMessages } from "next-intl/server"; import { notFound } from "next/navigation"; import { routing } from "@/i18n/routing"; +import { Providers } from "@/app/providers"; import "../globals.css"; export default async function LocaleLayout({ @@ -19,7 +20,9 @@ export default async function LocaleLayout({ return ( - {children} + + {children} + ); diff --git a/apps/web/src/app/[locale]/page.tsx b/apps/web/src/app/[locale]/page.tsx index 0f51490..416f0a8 100644 --- a/apps/web/src/app/[locale]/page.tsx +++ b/apps/web/src/app/[locale]/page.tsx @@ -1,12 +1,24 @@ -import { useTranslations } from "next-intl"; +import { getTranslations } from "next-intl/server"; +import Link from "next/link"; +import { Button } from "@/components/ui"; -export default function Home() { - const t = useTranslations("home"); +export default async function Home({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + const t = await getTranslations("home"); return (

{t("title")}

{t("subtitle")}

+ +
+ + + + + + +
); } diff --git a/apps/web/src/app/[locale]/tournaments/[id]/page.tsx b/apps/web/src/app/[locale]/tournaments/[id]/page.tsx new file mode 100644 index 0000000..951ca8d --- /dev/null +++ b/apps/web/src/app/[locale]/tournaments/[id]/page.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { useTranslations, useLocale } from "next-intl"; +import { useParams, useRouter } from "next/navigation"; +import { useState } from "react"; +import type { MatchDto, MatchSlotDto, StageDto } from "@tournamentify/shared"; +import * as api from "@/lib/api"; +import { useDeleteTournament, useTournament } from "@/lib/queries"; +import { Button, Card } from "@/components/ui"; + +const stageTypeLabelKey: Record = { + single_elimination: "formatSingleElimination", + double_elimination: "formatDoubleElimination", + round_robin: "formatRoundRobin", + swiss: "formatSwiss", +}; + +function slotLabel(slot: MatchSlotDto | null, fallback: string): string { + if (!slot) return fallback; + return slot.label; +} + +function MatchCard({ match }: { match: MatchDto }) { + const t = useTranslations("detail"); + return ( + +
+
+ {slotLabel(match.opponent1, t("tbd"))} + + {match.opponent1?.score ?? ""} + +
+
+
+ {slotLabel(match.opponent2, t("tbd"))} + + {match.opponent2?.score ?? ""} + +
+
+ + ); +} + +function StageView({ stage }: { stage: StageDto }) { + const t = useTranslations("detail"); + return ( +
+

+ {stage.name} + + {t(stageTypeLabelKey[stage.type] ?? stage.type)} + +

+ + {stage.groups.map((group) => ( +
+ {stage.groups.length > 1 ? ( +

+ {t("group", { number: group.number })} +

+ ) : null} + +
+ {group.rounds.map((round) => ( +
+

+ {round.name} +

+ {round.matches.map((match) => ( + + ))} +
+ ))} +
+
+ ))} +
+ ); +} + +function DetailContent() { + const t = useTranslations("detail"); + const locale = useLocale(); + const router = useRouter(); + const params = useParams<{ id: string }>(); + const id = params.id; + + const { data, isLoading, isError, error } = useTournament(id); + const deleteTournament = useDeleteTournament(); + const [exportError, setExportError] = useState(null); + + async function onExport() { + setExportError(null); + try { + const setup = await api.exportSetup(id); + const blob = new Blob([JSON.stringify(setup, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + const safeName = (data?.name ?? "tournament").replace(/[^a-z0-9-_]+/gi, "-").toLowerCase(); + anchor.download = `${safeName || "tournament"}.json`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); + } catch (err) { + setExportError(err instanceof Error ? err.message : t("exportError")); + } + } + + function onDelete() { + if (!window.confirm(t("deleteConfirm"))) return; + deleteTournament.mutate(id, { + onSuccess: () => { + router.push(`/${locale}/dashboard`); + }, + }); + } + + if (isLoading) { + return ( +
+

{t("loading")}

+
+ ); + } + + if (isError || !data) { + return ( +
+

+ {t("loadError")} + {error instanceof Error ? `: ${error.message}` : ""} +

+
+ ); + } + + return ( +
+
+
+

{data.name}

+ + {t(`status.${data.status}`)} + +
+
+ + +
+
+ + {exportError ?

{exportError}

: null} + {deleteTournament.isError ? ( +

+ {deleteTournament.error instanceof Error + ? deleteTournament.error.message + : t("deleteError")} +

+ ) : null} + +

+ {t("editorComingSoon")} +

+ + {data.stages.map((stage) => ( + + ))} +
+ ); +} + +export default function TournamentDetailPage() { + return ; +} diff --git a/apps/web/src/app/[locale]/tournaments/new/page.tsx b/apps/web/src/app/[locale]/tournaments/new/page.tsx new file mode 100644 index 0000000..d9f453d --- /dev/null +++ b/apps/web/src/app/[locale]/tournaments/new/page.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { useTranslations, useLocale } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useState, type FormEvent } from "react"; +import type { CreateTournamentInput, StageType } from "@tournamentify/shared"; +import { useCreateTournament } from "@/lib/queries"; +import { Button, Card, Input, Label, Select, Textarea } from "@/components/ui"; + +const stageTypeOptions: Array<{ value: StageType; labelKey: string }> = [ + { value: "single_elimination", labelKey: "formatSingleElimination" }, + { value: "double_elimination", labelKey: "formatDoubleElimination" }, + { value: "round_robin", labelKey: "formatRoundRobin" }, + { value: "swiss", labelKey: "formatSwiss" }, +]; + +function CreateContent() { + const t = useTranslations("create"); + const locale = useLocale(); + const router = useRouter(); + const createTournament = useCreateTournament(); + + const [name, setName] = useState(""); + const [format, setFormat] = useState("single_elimination"); + const [participantsText, setParticipantsText] = useState(""); + + function onSubmit(event: FormEvent) { + event.preventDefault(); + + const participants = participantsText + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((participantName, i) => ({ name: participantName, seed: i + 1 })); + + const input: CreateTournamentInput = { + name: name.trim(), + stages: [{ type: format, name: "Hauptrunde", settings: {} }], + participants, + }; + + createTournament.mutate(input, { + onSuccess: (created) => { + router.push(`/${locale}/tournaments/${created.id}`); + }, + }); + } + + const error = createTournament.error; + + return ( +
+

{t("title")}

+

{t("subtitle")}

+ + +
+
+ + setName(e.target.value)} + placeholder={t("namePlaceholder")} + required + /> +
+ +
+ + +

{t("formatHelp")}

+
+ +
+ +