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 apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { LoggerModule } from "nestjs-pino";
import { ServiceTokenGuard } from "./common/service-token.guard";
import { HealthController } from "./health.controller";
import { PrismaModule } from "./prisma/prisma.module";
import { ThemesModule } from "./themes/themes.module";
import { TournamentsModule } from "./tournaments/tournaments.module";
import { UsersModule } from "./users/users.module";

Expand All @@ -29,6 +30,7 @@ import { UsersModule } from "./users/users.module";
PrismaModule,
UsersModule,
TournamentsModule,
ThemesModule,
],
controllers: [HealthController],
providers: [
Expand Down
52 changes: 52 additions & 0 deletions apps/api/src/themes/themes.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
Post,
UnauthorizedException,
UsePipes,
} from "@nestjs/common";
import {
CreateSavedThemeInput,
SavedThemeDto,
createSavedThemeInputSchema,
} from "@tournamentify/shared";
import { Actor, CurrentActor } from "../common/current-actor.decorator";
import { ZodValidationPipe } from "../common/zod-validation.pipe";
import { ThemesService } from "./themes.service";

@Controller("themes")
export class ThemesController {
constructor(private readonly themes: ThemesService) {}

@Get()
list(@CurrentActor() actor: Actor): Promise<SavedThemeDto[]> {
return this.themes.list(requireUser(actor));
}

@Post()
@UsePipes(new ZodValidationPipe(createSavedThemeInputSchema))
create(
@CurrentActor() actor: Actor,
@Body() input: CreateSavedThemeInput,
): Promise<SavedThemeDto> {
return this.themes.create(requireUser(actor), input);
}

@Delete(":themeId")
@HttpCode(204)
remove(@CurrentActor() actor: Actor, @Param("themeId") themeId: string): Promise<void> {
return this.themes.remove(requireUser(actor), themeId);
}
}

/** Saved themes are tied to an account — guests (anon token only) get 401. */
function requireUser(actor: Actor): string {
if (!actor.userId) {
throw new UnauthorizedException("Saved themes require a logged-in account");
}
return actor.userId;
}
9 changes: 9 additions & 0 deletions apps/api/src/themes/themes.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { ThemesController } from "./themes.controller";
import { ThemesService } from "./themes.service";

@Module({
controllers: [ThemesController],
providers: [ThemesService],
})
export class ThemesModule {}
54 changes: 54 additions & 0 deletions apps/api/src/themes/themes.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { Prisma, SavedTheme } from "@prisma/client";
import { CreateSavedThemeInput, SavedThemeDto, designTokensSchema } from "@tournamentify/shared";
import { PrismaService } from "../prisma/prisma.service";

/**
* Saved themes are an account feature: every operation is scoped to a single
* owning user. Stored tokens are validated through the shared schema on the way
* out so a malformed row never leaks an unexpected shape to the client.
*/
@Injectable()
export class ThemesService {
constructor(private readonly prisma: PrismaService) {}

async list(userId: string): Promise<SavedThemeDto[]> {
const themes = await this.prisma.savedTheme.findMany({
where: { ownerUserId: userId },
orderBy: { createdAt: "desc" },
});
return themes.map(toSavedTheme);
}

async create(userId: string, input: CreateSavedThemeInput): Promise<SavedThemeDto> {
const theme = await this.prisma.savedTheme.create({
data: {
ownerUserId: userId,
name: input.name,
tokens: input.tokens as Prisma.InputJsonValue,
},
});
return toSavedTheme(theme);
}

async remove(userId: string, themeId: string): Promise<void> {
// deleteMany scoped to the owner so another user's theme is never touched;
// a zero count means it either does not exist or is not ours -> 404.
const result = await this.prisma.savedTheme.deleteMany({
where: { id: themeId, ownerUserId: userId },
});
if (result.count === 0) {
throw new NotFoundException("Theme not found");
}
}
}

function toSavedTheme(theme: SavedTheme): SavedThemeDto {
const parsed = designTokensSchema.safeParse(theme.tokens);
return {
id: theme.id,
name: theme.name,
tokens: parsed.success ? parsed.data : {},
createdAt: theme.createdAt.toISOString(),
};
}
25 changes: 23 additions & 2 deletions apps/api/src/tournaments/links.service.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,46 @@
import { randomUUID } from "crypto";
import { Injectable } from "@nestjs/common";
import { Injectable, NotFoundException } 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";

const MS_PER_HOUR = 60 * 60 * 1000;

@Injectable()
export class LinksService {
constructor(private readonly prisma: PrismaService) {}

async create(tournamentId: string, type: CapabilityType): Promise<CapabilityLinkDto> {
async create(
tournamentId: string,
type: CapabilityType,
expiresInHours?: number,
): Promise<CapabilityLinkDto> {
const expiresAt =
expiresInHours !== undefined
? new Date(Date.now() + expiresInHours * MS_PER_HOUR)
: null;
const link = await this.prisma.capabilityLink.create({
data: {
tournamentId,
type,
token: randomUUID(),
expiresAt,
},
});
return toCapabilityLink(link);
}

/** Revoke (delete) a link, but only if it belongs to the given tournament. */
async revoke(tournamentId: string, linkId: string): Promise<void> {
const result = await this.prisma.capabilityLink.deleteMany({
where: { id: linkId, tournamentId },
});
if (result.count === 0) {
throw new NotFoundException("Link not found");
}
}

async list(tournamentId: string): Promise<CapabilityLinkDto[]> {
const links = await this.prisma.capabilityLink.findMany({
where: { tournamentId },
Expand Down
24 changes: 23 additions & 1 deletion apps/api/src/tournaments/tournaments.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
Param,
Patch,
Post,
Put,
Query,
Sse,
UsePipes,
Expand All @@ -17,6 +18,7 @@ import {
CreateCapabilityLinkInput,
CreateTournamentInput,
MatchUpdateEvent,
ReseedInput,
ScoreInput,
TournamentDetailDto,
TournamentSetup,
Expand All @@ -25,6 +27,7 @@ import {
createCapabilityLinkInputSchema,
createTournamentInputSchema,
importSetupInputSchema,
reseedInputSchema,
scoreInputSchema,
updateDesignInputSchema,
} from "@tournamentify/shared";
Expand Down Expand Up @@ -107,14 +110,33 @@ export class TournamentsController {
@CurrentActor() actor: Actor,
@Body(new ZodValidationPipe(createCapabilityLinkInputSchema)) body: CreateCapabilityLinkInput,
): Promise<CapabilityLinkDto> {
return this.tournaments.createLink(id, actor, body.type);
return this.tournaments.createLink(id, actor, body.type, body.expiresInHours);
}

@Get(":id/links")
listLinks(@Param("id") id: string, @CurrentActor() actor: Actor): Promise<CapabilityLinkDto[]> {
return this.tournaments.listLinks(id, actor);
}

@Delete(":id/links/:linkId")
@HttpCode(204)
revokeLink(
@Param("id") id: string,
@Param("linkId") linkId: string,
@CurrentActor() actor: Actor,
): Promise<void> {
return this.tournaments.revokeLink(id, actor, linkId);
}

@Put(":id/seeding")
reseed(
@Param("id") id: string,
@CurrentActor() actor: Actor,
@Body(new ZodValidationPipe(reseedInputSchema)) body: ReseedInput,
): Promise<TournamentDetailDto> {
return this.tournaments.reseed(actor, id, body.participantIds);
}

@Post(":id/matches/:matchId/score")
score(
@Param("id") id: string,
Expand Down
Loading