diff --git a/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts b/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts index 1e8be51c8..788118eb5 100644 --- a/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts +++ b/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts @@ -107,6 +107,7 @@ export const action = async (args: ActionFunctionArgs) => { type: "participant", userId, }); + await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId); await syncPickupChatMetadata({ teamId: team.id, diff --git a/app/features/api-public/routes/tournament.$id.teams.$teamId.remove-member.ts b/app/features/api-public/routes/tournament.$id.teams.$teamId.remove-member.ts index 4d8c39f9c..e6fb0b033 100644 --- a/app/features/api-public/routes/tournament.$id.teams.$teamId.remove-member.ts +++ b/app/features/api-public/routes/tournament.$id.teams.$teamId.remove-member.ts @@ -72,6 +72,7 @@ export const action = async (args: ActionFunctionArgs) => { type: "participant", userId, }); + await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId); await syncPickupChatMetadata({ teamId: team.id, diff --git a/app/features/calendar/CalendarRepository.server.ts b/app/features/calendar/CalendarRepository.server.ts index 7c53d31bd..5629188f1 100644 --- a/app/features/calendar/CalendarRepository.server.ts +++ b/app/features/calendar/CalendarRepository.server.ts @@ -25,9 +25,9 @@ import { concatUserSubmittedImagePrefix, jsonArrayFrom, jsonObjectFrom, - tournamentCheckedInTeams, tournamentLogoWithDefault, tournamentMembersCount, + tournamentTeamsCount, } from "~/utils/kysely.server"; import { calendarEventPage, tournamentPage } from "~/utils/urls"; import { @@ -140,13 +140,6 @@ const withOrganization = (eb: ExpressionBuilder) => ), ); -const withTeamsCount = ( - eb: ExpressionBuilder, -) => - tournamentCheckedInTeams(eb).select(({ fn }) => [ - fn.countAll().as("count"), - ]); - function findAllBetweenTwoTimestampsQuery({ startTime, endTime, @@ -175,7 +168,7 @@ function findAllBetweenTwoTimestampsQuery({ "normalizedStartsAt", ), withOrganization(eb).as("organization"), - withTeamsCount(eb).as("teamsCount"), + tournamentTeamsCount(eb).as("teamsCount"), tournamentMembersCount(eb).as("membersCount"), tournamentLogoWithDefault(eb).as("logoUrl"), jsonArrayFrom( diff --git a/app/features/front-page/core/ShowcaseTournaments.server.ts b/app/features/front-page/core/ShowcaseTournaments.server.ts index 4d73c7684..d9940c4e3 100644 --- a/app/features/front-page/core/ShowcaseTournaments.server.ts +++ b/app/features/front-page/core/ShowcaseTournaments.server.ts @@ -78,13 +78,10 @@ export function addToCached({ userId, tournamentId, type, - newTeamCount, }: { userId: number; tournamentId: number; type: "participant" | "organizer"; - /** If a new team joined, the new total team count for the tournament including the new one */ - newTeamCount?: number; }) { if (!participationInfoMap) return; @@ -98,13 +95,6 @@ export function addToCached({ } participationInfoMap.set(userId, participation); - - if (typeof newTeamCount === "number") { - updateCachedTournamentTeamCount({ - tournamentId, - newTeamCount, - }); - } } export function removeFromCached({ @@ -130,21 +120,25 @@ export function removeFromCached({ participationInfoMap.set(userId, participation); } -export function updateCachedTournamentTeamCount({ - tournamentId, - newTeamCount, -}: { - tournamentId: number; - newTeamCount: number; -}) { - cachedTournaments().then((tournaments) => { - const tournament = tournaments.upcoming.find( - (tournament) => tournament.id === tournamentId, - ); - if (tournament) { - tournament.teamsCount = newTeamCount; - } - }); +/** + * Re-reads the team & participant counts of one tournament from the database into the cached + * showcase tournaments. No-op if the tournament is not part of the current cache. + */ +export async function refreshCachedTournamentCounts(tournamentId: number) { + if (!cache.has(SHOWCASE_TOURNAMENTS_CACHE_KEY)) return; + + const tournaments = await cachedTournaments(); + const cachedTournament = tournaments.upcoming.find( + (tournament) => tournament.id === tournamentId, + ); + if (!cachedTournament) return; + + const counts = + await TournamentRepository.findShowcaseCountsById(tournamentId); + if (!counts) return; + + cachedTournament.teamsCount = counts.teamsCount; + cachedTournament.membersCount = counts.membersCount; } async function cachedParticipationInfo( diff --git a/app/features/tournament-admin/actions/to.$id.admin.index.server.ts b/app/features/tournament-admin/actions/to.$id.admin.index.server.ts index b5aa1f204..a628143c7 100644 --- a/app/features/tournament-admin/actions/to.$id.admin.index.server.ts +++ b/app/features/tournament-admin/actions/to.$id.admin.index.server.ts @@ -56,6 +56,7 @@ export const action: ActionFunction = async ({ request, params }) => { // no sources = regular check in bracketIdx: bracket.sources ? data.bracketIdx : undefined, }); + await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId); if (!bracket.sources) { await resolveNotifications({ @@ -85,6 +86,7 @@ export const action: ActionFunction = async ({ request, params }) => { // no sources = regular check in bracketIdx: !bracket.sources ? null : data.bracketIdx, }); + await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId); logger.info( `Checked out: tournament team id: ${data.teamId} - user id: ${user.id} - tournament id: ${tournamentId} - bracket idx: ${data.bracketIdx}`, ); @@ -112,12 +114,8 @@ export const action: ActionFunction = async ({ request, params }) => { type: "participant", userId, }); - - ShowcaseTournaments.updateCachedTournamentTeamCount({ - tournamentId, - newTeamCount: tournament.ctx.teams.length - 1, - }); } + await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId); break; } diff --git a/app/features/tournament-admin/actions/to.$id.admin.registration.server.ts b/app/features/tournament-admin/actions/to.$id.admin.registration.server.ts index 2377f8e39..8642cfe99 100644 --- a/app/features/tournament-admin/actions/to.$id.admin.registration.server.ts +++ b/app/features/tournament-admin/actions/to.$id.admin.registration.server.ts @@ -186,12 +186,7 @@ export const upsertRegistrationAction = async ( }); } - if (!team) { - ShowcaseTournaments.updateCachedTournamentTeamCount({ - tournamentId, - newTeamCount: tournament.ctx.teams.length + 1, - }); - } + await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId); clearTournamentDataCache(tournamentId); diff --git a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts index ff3f25534..9ac4c6629 100644 --- a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts +++ b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts @@ -1,6 +1,7 @@ import type { ActionFunction } from "react-router"; import type { PreparedMaps } from "~/db/tables-json"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; +import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; import { notify } from "~/features/notifications/core/notify.server"; import { calculateTournamentTierFromTeams, @@ -176,6 +177,9 @@ export const action: ActionFunction = async ({ params, request }) => { }); } + // starting drops the teams that did not check in and can change the tier + ShowcaseTournaments.clearCachedTournaments(); + // update RunningTournaments await tournamentFromDB({ tournamentId, user }); @@ -281,6 +285,7 @@ export const action: ActionFunction = async ({ params, request }) => { await TournamentTeamRepository.checkIn(teamMemberOf.id, { bracketIdx: data.bracketIdx, }); + await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId); logger.info( `Checking in (bracket success): tournament team id: ${teamMemberOf.id} - user id: ${user.id} - tournament id: ${tournament.ctx.id} - bracket idx: ${data.bracketIdx}`, diff --git a/app/features/tournament-lfg/actions/to.$id.looking.server.ts b/app/features/tournament-lfg/actions/to.$id.looking.server.ts index 07e46b708..83eacf51e 100644 --- a/app/features/tournament-lfg/actions/to.$id.looking.server.ts +++ b/app/features/tournament-lfg/actions/to.$id.looking.server.ts @@ -1,5 +1,6 @@ import type { ActionFunctionArgs } from "react-router"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; +import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; import { notify } from "~/features/notifications/core/notify.server"; import { resolveNotifications } from "~/features/notifications/core/resolve.server"; import { requireNotBannedByOrganization } from "~/features/tournament/tournament-utils.server"; @@ -195,6 +196,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { maxGroupSize: tournament.maxMembersPerTeam, }); + await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId); + if (mergeResult.removedChatCode) { ChatSystemMessage.removeRoom(mergeResult.removedChatCode); } diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index 390f8a067..2f5f28703 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -26,9 +26,9 @@ import { concatUserSubmittedImagePrefix, jsonArrayFrom, jsonObjectFrom, - tournamentCheckedInTeams, tournamentLogoWithDefault, tournamentMembersCount, + tournamentTeamsCount, tournamentUsername, } from "~/utils/kysely.server"; import type { Unwrapped } from "~/utils/types"; @@ -800,11 +800,7 @@ export function findAllForShowcase() { "CalendarEvent.organizationId", "CalendarEventDate.startsAt", "CalendarEvent.hidden", - tournamentCheckedInTeams(eb) - .select(({ fn }) => [ - fn.count("TournamentTeam.id").distinct().as("count"), - ]) - .as("teamsCount"), + tournamentTeamsCount(eb).as("teamsCount"), tournamentMembersCount(eb).as("membersCount"), tournamentLogoWithDefault(eb).as("logoUrl"), jsonObjectFrom( @@ -885,6 +881,22 @@ function databaseTimestampWeekAgo() { return dateToDatabaseTimestamp(now); } +/** + * Resolves the team & participant counts of one tournament exactly like {@link findAllForShowcase} + * does, meant for refreshing those counts of an already cached showcase tournament. + */ +export function findShowcaseCountsById(tournamentId: number) { + return db + .selectFrom("Tournament") + .select((eb) => [ + tournamentTeamsCount(eb).as("teamsCount"), + tournamentMembersCount(eb).as("membersCount"), + ]) + .where("Tournament.id", "=", tournamentId) + .$narrowType<{ teamsCount: NotNull; membersCount: NotNull }>() + .executeTakeFirst(); +} + export function findAllBetweenTwoTimestamps({ startTime, endTime, diff --git a/app/features/tournament/TournamentRepository.showcaseCounts.test.ts b/app/features/tournament/TournamentRepository.showcaseCounts.test.ts new file mode 100644 index 000000000..aca3ec737 --- /dev/null +++ b/app/features/tournament/TournamentRepository.showcaseCounts.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import { actAs } from "~/db/seed/core/actAs"; +import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; +import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory"; +import * as UserFactory from "~/db/seed/factories/UserFactory"; +import * as TournamentRepository from "./TournamentRepository.server"; +import * as TournamentTeamRepository from "./TournamentTeamRepository.server"; + +const MEMBERS_PER_TEAM = 4; +const TEAM_COUNT = 3; + +let users: Array<{ id: number }>; + +const createTournament = () => + TournamentFactory.create({ authorId: users[0].id }); + +const createTeam = ( + tournamentId: number, + { nth, isCheckedIn }: { nth: number; isCheckedIn: boolean }, +) => + TournamentTeamFactory.create( + { + tournamentId, + memberUserIds: users + .slice(nth * MEMBERS_PER_TEAM, (nth + 1) * MEMBERS_PER_TEAM) + .map((user) => user.id), + }, + { isCheckedIn }, + ); + +const showcaseCounts = async (tournamentId: number) => { + const counts = + await TournamentRepository.findShowcaseCountsById(tournamentId); + expect(counts).toBeDefined(); + + return counts!; +}; + +describe("TournamentRepository.findShowcaseCountsById", () => { + beforeEach(async () => { + users = await UserFactory.createMany(MEMBERS_PER_TEAM * TEAM_COUNT); + }); + + test("counts every registered team before a bracket has been started", async () => { + const { id: tournamentId } = await createTournament(); + await createTeam(tournamentId, { nth: 0, isCheckedIn: true }); + await createTeam(tournamentId, { nth: 1, isCheckedIn: true }); + await createTeam(tournamentId, { nth: 2, isCheckedIn: false }); + + const counts = await showcaseCounts(tournamentId); + + expect(counts.teamsCount).toBe(3); + expect(counts.membersCount).toBe(MEMBERS_PER_TEAM * 3); + }); + + test("counts only checked in teams after a bracket has been started", async () => { + const { id: tournamentId } = await createTournament(); + await createTeam(tournamentId, { nth: 0, isCheckedIn: true }); + await createTeam(tournamentId, { nth: 1, isCheckedIn: true }); + await createTeam(tournamentId, { nth: 2, isCheckedIn: false }); + + await TournamentFactory.startBracket(tournamentId); + + const counts = await showcaseCounts(tournamentId); + + expect(counts.teamsCount).toBe(2); + expect(counts.membersCount).toBe(MEMBERS_PER_TEAM * 2); + }); + + test("counts a team having several check in rows once", async () => { + const { id: tournamentId } = await createTournament(); + const team = await createTeam(tournamentId, { nth: 0, isCheckedIn: true }); + await createTeam(tournamentId, { nth: 1, isCheckedIn: true }); + + await TournamentFactory.startBracket(tournamentId); + await actAs(team.ownerUserId, () => + TournamentTeamRepository.checkIn(team.id, { bracketIdx: 1 }), + ); + + const counts = await showcaseCounts(tournamentId); + + expect(counts.teamsCount).toBe(2); + expect(counts.membersCount).toBe(MEMBERS_PER_TEAM * 2); + }); +}); diff --git a/app/features/tournament/actions/to.$id.join.server.ts b/app/features/tournament/actions/to.$id.join.server.ts index 7c0b094f9..26a94be6f 100644 --- a/app/features/tournament/actions/to.$id.join.server.ts +++ b/app/features/tournament/actions/to.$id.join.server.ts @@ -83,6 +83,7 @@ export const action: ActionFunction = async ({ params, url }) => { type: "participant", userId: user.id, }); + await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId); await syncPickupChatMetadata({ teamId: teamToJoin.id, diff --git a/app/features/tournament/actions/to.$id.register.server.ts b/app/features/tournament/actions/to.$id.register.server.ts index 5eade0d7f..1bee9fcec 100644 --- a/app/features/tournament/actions/to.$id.register.server.ts +++ b/app/features/tournament/actions/to.$id.register.server.ts @@ -137,8 +137,8 @@ export const action: ActionFunction = async ({ request, params }) => { tournamentId, type: "participant", userId: user.id, - newTeamCount: tournament.ctx.teams.length + 1, }); + await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId); } break; } @@ -168,6 +168,7 @@ export const action: ActionFunction = async ({ request, params }) => { type: "participant", userId: data.userId, }); + await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId); await syncPickupChatMetadata({ teamId: ownTeam.id, @@ -206,6 +207,7 @@ export const action: ActionFunction = async ({ request, params }) => { type: "participant", userId: user.id, }); + await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId); await syncPickupChatMetadata({ teamId: teamMemberOf.id, @@ -318,6 +320,7 @@ export const action: ActionFunction = async ({ request, params }) => { type: "participant", userId: data.userId, }); + await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId); await syncPickupChatMetadata({ teamId: ownTeam.id, @@ -369,12 +372,8 @@ export const action: ActionFunction = async ({ request, params }) => { type: "participant", userId, }); - - ShowcaseTournaments.updateCachedTournamentTeamCount({ - tournamentId, - newTeamCount: tournament.ctx.teams.length - 1, - }); } + await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId); break; } diff --git a/app/utils/kysely.server.ts b/app/utils/kysely.server.ts index 33b24db61..35d7a2557 100644 --- a/app/utils/kysely.server.ts +++ b/app/utils/kysely.server.ts @@ -19,7 +19,6 @@ import { } from "~/db/json-selections"; import { db } from "~/db/sql"; import type { DB, Tables } from "~/db/tables"; -import { databaseTimestampNow } from "./dates"; import { IS_E2E_TEST_RUN } from "./e2e"; import { safeNumberParse } from "./number"; @@ -290,44 +289,60 @@ export function tournamentTeamCount( .where("TournamentTeam.isPlaceholder", "=", 0); } +/** Expression resolving to whether any of a tournament's brackets has been started. */ +function tournamentHasStarted(eb: ExpressionBuilder) { + return eb.exists( + eb + .selectFrom("TournamentStage") + .select("TournamentStage.id") + .whereRef("TournamentStage.tournamentId", "=", "Tournament.id"), + ); +} + /** - * Subquery resolving to a tournament's non-placeholder teams that are either checked in to the - * tournament itself (not to a specific bracket) or belong to a tournament that has not started yet. - * Correlates on `"Tournament"."id"` and `"CalendarEventDate"."startsAt"`. Has no select of its own, - * so extend it with the aggregate the caller needs, e.g. `.select(({ fn }) => fn.countAll().as("count"))`. + * Subquery resolving to the non-placeholder teams of a tournament that are still relevant to it: + * every registered team as long as no bracket has been started, only the checked in ones after + * that. Mirrors how the tournament page itself resolves its teams, so keep the two in sync. + * Correlates on `"Tournament"."id"`. Has no select of its own, so extend it with the aggregate the + * caller needs. A team can have several check in rows, so aggregate with `.distinct()`, e.g. + * `.select(({ fn }) => fn.count("TournamentTeam.id").distinct().as("count"))`. */ -export function tournamentCheckedInTeams( - eb: ExpressionBuilder, -) { +function tournamentCheckedInTeams(eb: ExpressionBuilder) { return eb .selectFrom("TournamentTeam") - .leftJoin("TournamentTeamCheckIn", (join) => - join - .on("TournamentTeamCheckIn.bracketIdx", "is", null) - .onRef( - "TournamentTeamCheckIn.tournamentTeamId", - "=", - "TournamentTeam.id", - ), + .leftJoin( + "TournamentTeamCheckIn", + "TournamentTeamCheckIn.tournamentTeamId", + "TournamentTeam.id", ) .whereRef("TournamentTeam.tournamentId", "=", "Tournament.id") .where("TournamentTeam.isPlaceholder", "=", 0) .where((eb2) => eb2.or([ eb2("TournamentTeamCheckIn.checkedInAt", "is not", null), - eb2("CalendarEventDate.startsAt", ">", databaseTimestampNow()), + eb2.not(tournamentHasStarted(eb)), ]), ); } +/** + * Subquery counting the teams of {@link tournamentCheckedInTeams}. Correlates on + * `"Tournament"."id"`. Alias it `.as("teamsCount")` when selecting it directly. + */ +export function tournamentTeamsCount(eb: ExpressionBuilder) { + return tournamentCheckedInTeams(eb).select(({ fn }) => [ + fn.count("TournamentTeam.id").distinct().as("count"), + ]); +} + /** * Expression resolving to a tournament's participant count: rostered players of the teams from - * {@link tournamentCheckedInTeams} while the tournament is still to come, players who actually got + * {@link tournamentCheckedInTeams} while the tournament is still ongoing, players who actually got * a result once it has been finalized. Correlates on `"Tournament"."id"`. Alias it * `.as("membersCount")` when selecting it directly. */ export function tournamentMembersCount( - eb: ExpressionBuilder, + eb: ExpressionBuilder, ) { return eb .case() diff --git a/scripts/benchmark-db/cases.ts b/scripts/benchmark-db/cases.ts index c38ef28a3..b9539b6b6 100644 --- a/scripts/benchmark-db/cases.ts +++ b/scripts/benchmark-db/cases.ts @@ -1087,6 +1087,11 @@ export function buildCases(fx: Fixtures): { addStatic("TournamentRepository.findAllForShowcase", () => TournamentRepository.findAllForShowcase(), ); + add( + "TournamentRepository.findShowcaseCountsById", + fx.heavyTournamentId, + (tournamentId) => TournamentRepository.findShowcaseCountsById(tournamentId), + ); add( "TournamentRepository.findAllBetweenTwoTimestamps", fx.calendarWindow,