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,