From c37b52dfb29d2de3f223d53e29852ae9cd1d651f Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 3 May 2026 11:15:44 +0300 Subject: [PATCH 1/6] Fix new art form double-upload and premature submit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file had name="img", so new FormData(form) already included the original uncompressed file under "img". handleSubmit then appended the compressed version with the same key, and FormData.append doesn't overwrite — every save uploaded two files to S3 (the second clobbering the first under the same generated filename). The submit button also only checked the full-size img state, not smallImg. Both Compressor instances run in parallel, so Save could fire before the thumbnail finished compressing — saving art with no thumbnail, and (under slow MinIO on CI) making the e2e test flaky as two serial S3 uploads pushed past the 5s toHaveURL timeout. --- app/features/art/routes/art.new.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/features/art/routes/art.new.tsx b/app/features/art/routes/art.new.tsx index efff2f512..44f585e6c 100644 --- a/app/features/art/routes/art.new.tsx +++ b/app/features/art/routes/art.new.tsx @@ -65,7 +65,7 @@ export default function NewArtPage() { const submitButtonDisabled = () => { if (fetcher.state !== "idle") return true; - return !img && !data.art; + return (!img || !smallImg) && !data.art; }; if (!isArtist) { @@ -121,7 +121,6 @@ function ImageUpload({ { const uploadedFile = e.target.files?.[0]; From 1bf9802be896486630220ed821ef405b58adb836 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 3 May 2026 11:55:10 +0300 Subject: [PATCH 2/6] Fix tournament team page meta tags not set --- app/features/tournament/routes/to.$id.teams.$tid.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/features/tournament/routes/to.$id.teams.$tid.tsx b/app/features/tournament/routes/to.$id.teams.$tid.tsx index 23cb3d281..684501d96 100644 --- a/app/features/tournament/routes/to.$id.teams.$tid.tsx +++ b/app/features/tournament/routes/to.$id.teams.$tid.tsx @@ -29,7 +29,7 @@ import { useTournament } from "./to.$id"; export { loader }; export const meta: MetaFunction = (args) => { - const tournamentData = (args.matches[1].data as any) + const tournamentData = JSON.parse(args.matches[1].data as any) ?.tournament as TournamentData; if (!args.data || !tournamentData) return []; From 76716bf46457b9253dbcba13c22e9a2e32c44f8d Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 3 May 2026 13:13:31 +0300 Subject: [PATCH 3/6] Add missing order by to TournamentMatchRepository.findByTournamentTeamId Needed so we have the logical order on the team page. --- .../TournamentMatchRepository.server.test.ts | 178 ++++++++++++++++++ .../TournamentMatchRepository.server.ts | 1 + 2 files changed, 179 insertions(+) create mode 100644 app/features/tournament-bracket/TournamentMatchRepository.server.test.ts diff --git a/app/features/tournament-bracket/TournamentMatchRepository.server.test.ts b/app/features/tournament-bracket/TournamentMatchRepository.server.test.ts new file mode 100644 index 000000000..2d90faac6 --- /dev/null +++ b/app/features/tournament-bracket/TournamentMatchRepository.server.test.ts @@ -0,0 +1,178 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { db } from "~/db/sql"; +import { dbInsertUsers, dbReset } from "~/utils/Test"; +import * as TournamentMatchRepository from "./TournamentMatchRepository.server"; + +const createTournament = () => + db + .insertInto("Tournament") + .values({ + mapPickingStyle: "TO", + settings: JSON.stringify({ bracketProgression: [] }), + }) + .returning("id") + .executeTakeFirstOrThrow(); + +const createTeam = (tournamentId: number, name: string) => + db + .insertInto("TournamentTeam") + .values({ + tournamentId, + name, + inviteCode: `inv-${tournamentId}-${name}`, + }) + .returning("id") + .executeTakeFirstOrThrow(); + +const createStage = (tournamentId: number, name: string, number: number) => + db + .insertInto("TournamentStage") + .values({ + tournamentId, + name, + number, + type: "double_elimination", + settings: "{}", + }) + .returning("id") + .executeTakeFirstOrThrow(); + +const createGroup = (stageId: number, number: number) => + db + .insertInto("TournamentGroup") + .values({ stageId, number }) + .returning("id") + .executeTakeFirstOrThrow(); + +const createRound = (stageId: number, groupId: number, number: number) => + db + .insertInto("TournamentRound") + .values({ + stageId, + groupId, + number, + maps: JSON.stringify({ count: 3, type: "BEST_OF" }), + }) + .returning("id") + .executeTakeFirstOrThrow(); + +const createMatch = async (args: { + stageId: number; + groupId: number; + roundId: number; + number: number; + teamOneId: number; + teamTwoId: number; +}) => { + const match = await db + .insertInto("TournamentMatch") + .values({ + stageId: args.stageId, + groupId: args.groupId, + roundId: args.roundId, + number: args.number, + status: 4, + opponentOne: JSON.stringify({ id: args.teamOneId, score: 2 }), + opponentTwo: JSON.stringify({ id: args.teamTwoId, score: 0 }), + }) + .returning("id") + .executeTakeFirstOrThrow(); + + await db + .insertInto("TournamentMatchGameResult") + .values({ + matchId: match.id, + mode: "SZ", + number: 1, + reporterId: 1, + source: "TO", + stageId: 1, + winnerTeamId: args.teamOneId, + }) + .returning("id") + .executeTakeFirstOrThrow() + .then((result) => + db + .insertInto("TournamentMatchGameResultParticipant") + .values([ + { + matchGameResultId: result.id, + userId: 1, + tournamentTeamId: args.teamOneId, + }, + { + matchGameResultId: result.id, + userId: 2, + tournamentTeamId: args.teamTwoId, + }, + ]) + .execute(), + ); + + return match; +}; + +describe("findByTournamentTeamId", () => { + beforeEach(async () => { + await dbInsertUsers(2); + }); + + afterEach(() => { + dbReset(); + }); + + test("preserves stage order: matches from an earlier stage come first even when later stage has lower group numbers", async () => { + // Tournament with two stages. The first stage has a high group number + // (think: round-robin pool 8) and the second stage has group number 1 + // (think: DE bracket winners). The team page should show stage 1's + // matches first, then stage 2's. + const tournament = await createTournament(); + const teamA = await createTeam(tournament.id, "A"); + const teamB = await createTeam(tournament.id, "B"); + + // Insert team members so we have someone to attribute results to + for (const userId of [1, 2]) { + await db + .insertInto("TournamentTeamMember") + .values({ tournamentTeamId: teamA.id, userId, role: "OWNER" }) + .execute(); + await db + .insertInto("TournamentTeamMember") + .values({ tournamentTeamId: teamB.id, userId, role: "OWNER" }) + .execute(); + } + + const stage1 = await createStage(tournament.id, "Stage 1", 1); + const stage1Group = await createGroup(stage1.id, 8); + const stage1Round = await createRound(stage1.id, stage1Group.id, 1); + const stage1Match = await createMatch({ + stageId: stage1.id, + groupId: stage1Group.id, + roundId: stage1Round.id, + number: 1, + teamOneId: teamA.id, + teamTwoId: teamB.id, + }); + + const stage2 = await createStage(tournament.id, "Stage 2", 2); + const stage2Group = await createGroup(stage2.id, 1); + const stage2Round = await createRound(stage2.id, stage2Group.id, 1); + const stage2Match = await createMatch({ + stageId: stage2.id, + groupId: stage2Group.id, + roundId: stage2Round.id, + number: 1, + teamOneId: teamA.id, + teamTwoId: teamB.id, + }); + + const result = await TournamentMatchRepository.findByTournamentTeamId( + teamA.id, + ); + + expect(result.map((s) => s.tournamentMatchId)).toEqual([ + stage1Match.id, + stage2Match.id, + ]); + }); +}); diff --git a/app/features/tournament-bracket/TournamentMatchRepository.server.ts b/app/features/tournament-bracket/TournamentMatchRepository.server.ts index b1928c731..4941582e2 100644 --- a/app/features/tournament-bracket/TournamentMatchRepository.server.ts +++ b/app/features/tournament-bracket/TournamentMatchRepository.server.ts @@ -236,6 +236,7 @@ export function findByTournamentTeamId(tournamentTeamId: number) { ), ), ) + .orderBy("TournamentRound.stageId", "asc") .orderBy("TournamentGroup.number", "asc") .orderBy("TournamentRound.number", "asc") .execute(); From 92c62746be096a5599a148dca904b5c808103c32 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 3 May 2026 14:27:50 +0300 Subject: [PATCH 4/6] Show year on the tournament register page if not current year --- app/features/tournament/routes/to.$id.register.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/features/tournament/routes/to.$id.register.tsx b/app/features/tournament/routes/to.$id.register.tsx index 3179f05f3..b17fcb599 100644 --- a/app/features/tournament/routes/to.$id.register.tsx +++ b/app/features/tournament/routes/to.$id.register.tsx @@ -130,6 +130,11 @@ export default function TournamentRegisterPage() { hour: "numeric", day: "numeric", month: "numeric", + year: + tournament.ctx.startTime.getFullYear() !== + new Date().getFullYear() + ? "2-digit" + : undefined, }} /> ) : null} From 767ca4d7abb7233c8c2fdd5a4f353208349d7952 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 3 May 2026 14:34:03 +0300 Subject: [PATCH 5/6] Fix build delete action 500 error if build does not exist --- app/features/builds/BuildRepository.server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/features/builds/BuildRepository.server.ts b/app/features/builds/BuildRepository.server.ts index 0441ef29a..3c3f7e96e 100644 --- a/app/features/builds/BuildRepository.server.ts +++ b/app/features/builds/BuildRepository.server.ts @@ -257,9 +257,9 @@ export async function ownerIdById(buildId: number) { .selectFrom("Build") .select("ownerId") .where("id", "=", buildId) - .executeTakeFirstOrThrow(); + .executeTakeFirst(); - return result.ownerId; + return result?.ownerId ?? null; } export async function abilityPointAverages(weaponSplId?: MainWeaponId | null) { From b5f8b3667d7dd30f4c7a364301eb0c1d81fc41cc Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 3 May 2026 14:47:20 +0300 Subject: [PATCH 6/6] Fix /suspended 500 when banned user row was deleted via migrate --- app/features/admin/actions/admin.server.ts | 2 ++ app/features/ban/loaders/suspended.server.ts | 9 +++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/app/features/admin/actions/admin.server.ts b/app/features/admin/actions/admin.server.ts index 4f63512f4..32c9ca08c 100644 --- a/app/features/admin/actions/admin.server.ts +++ b/app/features/admin/actions/admin.server.ts @@ -39,6 +39,8 @@ export const action = async ({ request }: ActionFunctionArgs) => { errorToast(`Migration failed. Reason: ${errorMessage}`); } + await refreshBannedCache(); + message = "Account migrated"; break; } catch (err) { diff --git a/app/features/ban/loaders/suspended.server.ts b/app/features/ban/loaders/suspended.server.ts index 2dbed9b32..c8a878cbb 100644 --- a/app/features/ban/loaders/suspended.server.ts +++ b/app/features/ban/loaders/suspended.server.ts @@ -6,14 +6,19 @@ import { } from "~/features/auth/core/authenticator.server"; import { authSessionStorage } from "~/features/auth/core/session.server"; import type { Nullish } from "~/utils/types"; -import { userIsBanned } from "../core/banned.server"; +import { refreshBannedCache, userIsBanned } from "../core/banned.server"; export const loader = async ({ request }: LoaderFunctionArgs) => { const userId = await getUserIdEvenIfBanned(request); if (!userId || !userIsBanned(userId)) return redirect("/"); - const bannedStatus = (await AdminRepository.allBannedUsers()).get(userId)!; + const bannedStatus = (await AdminRepository.allBannedUsers()).get(userId); + + if (!bannedStatus) { + await refreshBannedCache(); + return redirect("/"); + } return { banned: bannedStatus.banned,