diff --git a/app/features/tournament/SavedCalendarEventRepository.server.ts b/app/features/tournament/SavedCalendarEventRepository.server.ts index 7fe818811..a0cc6a89d 100644 --- a/app/features/tournament/SavedCalendarEventRepository.server.ts +++ b/app/features/tournament/SavedCalendarEventRepository.server.ts @@ -2,6 +2,7 @@ import { db } from "~/db/sql"; import { actorId } from "~/features/auth/core/user.server"; import type { ShowcaseCalendarEvent } from "~/features/calendar/calendar-types"; import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; +import { databaseTimestampNow } from "~/utils/dates"; export function saveOwn(tournamentId: number) { return db @@ -58,11 +59,18 @@ export async function isSaved({ return Boolean(row); } -export async function countByUserId(userId: number): Promise { +/** Counts the saved tournaments of the user that haven't started yet. */ +export async function countUpcomingByUserId(userId: number): Promise { const result = await db .selectFrom("SavedCalendarEvent") .select((eb) => eb.fn.countAll().as("count")) - .where("userId", "=", userId) + .where("SavedCalendarEvent.userId", "=", userId) + .where("SavedCalendarEvent.calendarEventId", "in", (eb) => + eb + .selectFrom("CalendarEventDate") + .select("CalendarEventDate.eventId") + .where("CalendarEventDate.startsAt", ">", databaseTimestampNow()), + ) .executeTakeFirstOrThrow(); return result.count; diff --git a/app/features/tournament/actions/to.$id.info.server.test.ts b/app/features/tournament/actions/to.$id.info.server.test.ts new file mode 100644 index 000000000..2c1a948db --- /dev/null +++ b/app/features/tournament/actions/to.$id.info.server.test.ts @@ -0,0 +1,83 @@ +import { addDays, subDays } from "date-fns"; +import { beforeEach, describe, expect, test } from "vitest"; +import * as SavedCalendarEventFactory from "~/db/seed/factories/SavedCalendarEventFactory"; +import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; +import * as UserFactory from "~/db/seed/factories/UserFactory"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; +import { wrappedAction } from "~/utils/Test"; +import * as SavedCalendarEventRepository from "../SavedCalendarEventRepository.server"; +import { TOURNAMENT } from "../tournament-constants"; +import type { saveTournamentSchema } from "../tournament-schemas"; +import { action as infoAction } from "./to.$id.info.server"; + +const info = wrappedAction({ action: infoAction }); + +const users = UserFactory.pool(); +const saverId = () => users.id(1); + +describe("SAVE_TOURNAMENT", () => { + beforeEach(async () => { + await users.create(1); + }); + + test("tournaments saved in the past don't count towards the saved limit", async () => { + for (let i = 0; i < TOURNAMENT.MAX_SAVED_COUNT; i++) { + await saveTournamentStartingAt(subDays(new Date(), 30 + i)); + } + const upcoming = await createTournamentStartingAt(addDays(new Date(), 7)); + + const response = await save(upcoming.id); + + expect(errorToastOf(response)).toBeNull(); + expect(await isSaved(upcoming.id)).toBe(true); + }); + + test("rejects saving once the limit of upcoming saved tournaments is reached", async () => { + for (let i = 0; i < TOURNAMENT.MAX_SAVED_COUNT; i++) { + await saveTournamentStartingAt(addDays(new Date(), 1 + i)); + } + const upcoming = await createTournamentStartingAt(addDays(new Date(), 30)); + + const response = await save(upcoming.id); + + expect(errorToastOf(response)).toBe("Maximum saved tournaments reached"); + expect(await isSaved(upcoming.id)).toBe(false); + }); +}); + +function createTournamentStartingAt(startTime: Date) { + return TournamentFactory.create({ + authorId: saverId(), + startTimes: [dateToDatabaseTimestamp(startTime)], + }); +} + +async function saveTournamentStartingAt(startTime: Date) { + const tournament = await createTournamentStartingAt(startTime); + await SavedCalendarEventFactory.create({ + userId: saverId(), + tournamentId: tournament.id, + }); +} + +function save(tournamentId: number) { + return info( + { _action: "SAVE_TOURNAMENT" }, + { user: saverId(), params: { id: String(tournamentId) } }, + ); +} + +function isSaved(tournamentId: number) { + return SavedCalendarEventRepository.isSaved({ + userId: saverId(), + tournamentId, + }); +} + +function errorToastOf(response: Response | null | undefined) { + if (!response) return null; + + return new URLSearchParams( + response.headers.get("location")?.split("?")[1], + ).get("__error"); +} diff --git a/app/features/tournament/actions/to.$id.info.server.ts b/app/features/tournament/actions/to.$id.info.server.ts index 368f1c584..1d57772ac 100644 --- a/app/features/tournament/actions/to.$id.info.server.ts +++ b/app/features/tournament/actions/to.$id.info.server.ts @@ -14,9 +14,10 @@ export const action = defineAction( switch (body._action) { case "SAVE_TOURNAMENT": { - const count = await SavedCalendarEventRepository.countByUserId(user.id); + const upcomingCount = + await SavedCalendarEventRepository.countUpcomingByUserId(user.id); errorToastIfFalsy( - count < TOURNAMENT.MAX_SAVED_COUNT, + upcomingCount < TOURNAMENT.MAX_SAVED_COUNT, "Maximum saved tournaments reached", ); diff --git a/changelog/2026-11-08-saved-tournaments-limit.md b/changelog/2026-11-08-saved-tournaments-limit.md new file mode 100644 index 000000000..b50f02125 --- /dev/null +++ b/changelog/2026-11-08-saved-tournaments-limit.md @@ -0,0 +1,5 @@ +--- +navItem: medal +type: bug +--- +Fixed saving tournaments failing with "Maximum saved tournaments reached" once 20 of your saved tournaments had already happened diff --git a/scripts/benchmark-db/cases.ts b/scripts/benchmark-db/cases.ts index 3a692f585..92f7587f4 100644 --- a/scripts/benchmark-db/cases.ts +++ b/scripts/benchmark-db/cases.ts @@ -2013,8 +2013,10 @@ export function buildCases(fx: Fixtures): { tournamentId, }), ); - add("SavedCalendarEventRepository.countByUserId", fx.heavyUser, (user) => - SavedCalendarEventRepository.countByUserId(user.id), + add( + "SavedCalendarEventRepository.countUpcomingByUserId", + fx.heavyUser, + (user) => SavedCalendarEventRepository.countUpcomingByUserId(user.id), ); add( "SavedCalendarEventRepository.findAllUpcomingByUserId",