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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ out/
.turbo/
coverage/
*.log
*.tsbuildinfo
next-env.d.ts

# env files (keep .env.example tracked)
.env
Expand Down
282 changes: 282 additions & 0 deletions apps/api/src/bracket/progression.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
import { StandingsRowDto } from "@tournamentify/shared";

/**
* Pure progression + standings helpers. No DB, no Nest, no I/O — given the same
* inputs they always produce the same output, so they can be exhaustively unit
* tested and reasoned about. The persistence slice feeds these the raw stored
* opponent JSON and applies the returned updates inside its own transaction.
*/

// ---------------------------------------------------------------------------
// Stored opponent-slot JSON (mirrors what M1 persists on Match.opponentN)
// ---------------------------------------------------------------------------

export interface ParticipantSlot {
participantId: string;
score?: number;
}

export interface ByeSlot {
bye: true;
}

export interface SourceSlot {
source: {
type: "winner_of" | "loser_of";
round: number;
match: number;
};
}

export type Slot = ParticipantSlot | ByeSlot | SourceSlot | null;

/**
* A match as the progression code sees it: identity, position within its group
* (1-based round/match), and the raw stored JSON for each opponent slot.
*/
export interface MatchView {
id: string;
roundNumber: number;
matchNumber: number;
opponent1: unknown;
opponent2: unknown;
}

export interface AdvancementUpdate {
matchId: string;
slot: "opponent1" | "opponent2";
participantId: string;
}

export interface ByeAdvancement {
matchId: string;
status: "COMPLETED";
winnerParticipantId: string;
advance: AdvancementUpdate[];
}

// ---------------------------------------------------------------------------
// Slot parsing — narrow `unknown` raw JSON into a discriminated Slot
// ---------------------------------------------------------------------------

export function parseSlot(raw: unknown): Slot {
if (raw === null || raw === undefined || typeof raw !== "object" || Array.isArray(raw)) {
return null;
}
const obj = raw as Record<string, unknown>;
if (typeof obj.participantId === "string") {
const slot: ParticipantSlot = { participantId: obj.participantId };
if (typeof obj.score === "number") {
slot.score = obj.score;
}
return slot;
}
if (obj.bye === true) {
return { bye: true };
}
const source = obj.source;
if (source && typeof source === "object" && !Array.isArray(source)) {
const s = source as Record<string, unknown>;
if (
(s.type === "winner_of" || s.type === "loser_of") &&
typeof s.round === "number" &&
typeof s.match === "number"
) {
return { source: { type: s.type, round: s.round, match: s.match } };
}
}
return null;
}

function isParticipantSlot(slot: Slot): slot is ParticipantSlot {
return slot !== null && "participantId" in slot;
}

function isByeSlot(slot: Slot): slot is ByeSlot {
return slot !== null && "bye" in slot && slot.bye === true;
}

function isWinnerSourceFor(slot: Slot, round: number, match: number): boolean {
if (slot === null || !("source" in slot)) {
return false;
}
const { source } = slot;
return source.type === "winner_of" && source.round === round && source.match === match;
}

// ---------------------------------------------------------------------------
// Advancement
// ---------------------------------------------------------------------------

/**
* Given a just-completed match (by 1-based round/match within the group) and its
* winner, find every downstream slot that sources from `winner_of(round, match)`
* and return one update per matching slot carrying the winner's participantId.
*
* `loser_of` is intentionally not handled here — M2 is single-elimination only —
* but the slot shape is preserved so double-elim can reuse the scan unchanged.
*/
export function winnerAdvancement(
group: { matches: MatchView[] },
completed: { roundNumber: number; matchNumber: number; winnerParticipantId: string },
): AdvancementUpdate[] {
const updates: AdvancementUpdate[] = [];
for (const match of group.matches) {
const slot1 = parseSlot(match.opponent1);
if (isWinnerSourceFor(slot1, completed.roundNumber, completed.matchNumber)) {
updates.push({
matchId: match.id,
slot: "opponent1",
participantId: completed.winnerParticipantId,
});
}
const slot2 = parseSlot(match.opponent2);
if (isWinnerSourceFor(slot2, completed.roundNumber, completed.matchNumber)) {
updates.push({
matchId: match.id,
slot: "opponent2",
participantId: completed.winnerParticipantId,
});
}
}
return updates;
}

/**
* Detect bye matches — exactly one participant slot and one bye slot — and treat
* the lone participant as the automatic winner. For each, also compute the
* downstream advancement so the caller can seat the winner into the next round.
*
* With standard seeding byes only occur in round 1 and never on both sides, so a
* single pass is correct: a downstream slot sourced from a bye match is filled
* here, never itself a bye that would need a further pass.
*/
export function byeAdvancements(group: { matches: MatchView[] }): ByeAdvancement[] {
const result: ByeAdvancement[] = [];
for (const match of group.matches) {
const slot1 = parseSlot(match.opponent1);
const slot2 = parseSlot(match.opponent2);

let winnerParticipantId: string | null = null;
if (isParticipantSlot(slot1) && isByeSlot(slot2)) {
winnerParticipantId = slot1.participantId;
} else if (isByeSlot(slot1) && isParticipantSlot(slot2)) {
winnerParticipantId = slot2.participantId;
}
if (winnerParticipantId === null) {
continue;
}

result.push({
matchId: match.id,
status: "COMPLETED",
winnerParticipantId,
advance: winnerAdvancement(group, {
roundNumber: match.roundNumber,
matchNumber: match.matchNumber,
winnerParticipantId,
}),
});
}
return result;
}

// ---------------------------------------------------------------------------
// Standings (round-robin)
// ---------------------------------------------------------------------------

interface MutableRow {
participantId: string;
name: string;
played: number;
wins: number;
draws: number;
losses: number;
points: number;
}

/**
* Round-robin table built from COMPLETED matches only. A match counts as scored
* when BOTH opponents are participant slots carrying a numeric `score`. Higher
* score wins (3 pts), equal score is a draw (1 pt each), loss scores 0.
*
* Every participant that appears in any match of the group is included, even if
* none of their matches are scored yet (played 0). Sorted by points desc, then
* wins desc, then name asc.
*/
export function computeStandings(
group: { matches: MatchView[] },
participantNames: Map<string, string>,
): StandingsRowDto[] {
const rows = new Map<string, MutableRow>();

const ensure = (participantId: string): MutableRow => {
let row = rows.get(participantId);
if (!row) {
row = {
participantId,
name: participantNames.get(participantId) ?? "",
played: 0,
wins: 0,
draws: 0,
losses: 0,
points: 0,
};
rows.set(participantId, row);
}
return row;
};

for (const match of group.matches) {
const slot1 = parseSlot(match.opponent1);
const slot2 = parseSlot(match.opponent2);

// Register every participant that appears, regardless of scoring.
if (isParticipantSlot(slot1)) {
ensure(slot1.participantId);
}
if (isParticipantSlot(slot2)) {
ensure(slot2.participantId);
}

// Only fully-scored matches between two participants contribute results.
if (
!isParticipantSlot(slot1) ||
!isParticipantSlot(slot2) ||
typeof slot1.score !== "number" ||
typeof slot2.score !== "number"
) {
continue;
}

const rowA = ensure(slot1.participantId);
const rowB = ensure(slot2.participantId);
rowA.played += 1;
rowB.played += 1;

if (slot1.score > slot2.score) {
rowA.wins += 1;
rowA.points += 3;
rowB.losses += 1;
} else if (slot1.score < slot2.score) {
rowB.wins += 1;
rowB.points += 3;
rowA.losses += 1;
} else {
rowA.draws += 1;
rowB.draws += 1;
rowA.points += 1;
rowB.points += 1;
}
}

return Array.from(rows.values()).sort((a, b) => {
if (b.points !== a.points) {
return b.points - a.points;
}
if (b.wins !== a.wins) {
return b.wins - a.wins;
}
return a.name.localeCompare(b.name) || a.participantId.localeCompare(b.participantId);
});
}
10 changes: 7 additions & 3 deletions apps/api/src/common/current-actor.decorator.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
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 {
/** Minimal request shape we read — avoids a direct dependency on express types. */
interface HttpRequest {
headers: Record<string, string | string[] | undefined>;
}

function readHeader(request: HttpRequest, 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<Request>();
const request = ctx.switchToHttp().getRequest<HttpRequest>();
return {
userId: readHeader(request, "x-user-id"),
anonToken: readHeader(request, "x-anon-token"),
Expand Down
8 changes: 6 additions & 2 deletions apps/api/src/common/service-token.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,13 @@ import {
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { timingSafeEqual } from "crypto";
import { Request } from "express";
import { IS_PUBLIC_KEY } from "./public.decorator";

/** Minimal request shape we read — avoids a direct dependency on express types. */
interface HttpRequest {
headers: Record<string, string | string[] | undefined>;
}

/**
* 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.
Expand All @@ -32,7 +36,7 @@ export class ServiceTokenGuard implements CanActivate {
throw new UnauthorizedException();
}

const request = context.switchToHttp().getRequest<Request>();
const request = context.switchToHttp().getRequest<HttpRequest>();
const provided = request.headers["x-bff-service-token"];
const token = Array.isArray(provided) ? provided[0] : provided;

Expand Down
39 changes: 39 additions & 0 deletions apps/api/src/tournaments/events.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Injectable } from "@nestjs/common";
import { MatchUpdateEvent } from "@tournamentify/shared";
import { Observable, Subject } from "rxjs";

/**
* Process-local SSE fan-out. One rxjs Subject per tournament id multiplexes
* change signals to every connected client of that tournament. The payload is a
* deliberately thin "something changed" event — clients refetch the detail on
* receipt rather than trusting an incremental diff over the wire.
*
* NOTE: subjects live in this single instance's memory. That is fine for our
* single-process VPS deployment, but it does NOT fan out across replicas — a
* scorer on instance A would not notify a viewer pinned to instance B. Moving to
* multiple instances would require a shared broker (Redis pub/sub, Postgres
* LISTEN/NOTIFY, …) behind this same interface.
*/
@Injectable()
export class EventsService {
private readonly subjects = new Map<string, Subject<{ data: MatchUpdateEvent }>>();

/** Subscribe to change signals for a tournament, lazily creating its subject. */
stream(tournamentId: string): Observable<{ data: MatchUpdateEvent }> {
return this.subjectFor(tournamentId).asObservable();
}

/** Push a change signal to every current subscriber of this tournament. */
emit(tournamentId: string): void {
this.subjectFor(tournamentId).next({ data: { tournamentId } });
}

private subjectFor(tournamentId: string): Subject<{ data: MatchUpdateEvent }> {
let subject = this.subjects.get(tournamentId);
if (!subject) {
subject = new Subject<{ data: MatchUpdateEvent }>();
this.subjects.set(tournamentId, subject);
}
return subject;
}
}
Loading