From a3a03c0c4b9e79647fbb5b910aa02e0457cbb6a9 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 19 Apr 2026 14:36:48 +0300 Subject: [PATCH] Chat room for tournament pick-ups (LFG feature) --- .../TournamentLFGRepository.server.test.ts | 132 ++++++++++++++++++ .../TournamentLFGRepository.server.ts | 68 ++++++++- .../actions/to.$id.looking.server.ts | 33 ++++- .../tournament-lfg-utils.server.ts | 34 +++++ 4 files changed, 260 insertions(+), 7 deletions(-) create mode 100644 app/features/tournament-lfg/tournament-lfg-utils.server.ts diff --git a/app/features/tournament-lfg/TournamentLFGRepository.server.test.ts b/app/features/tournament-lfg/TournamentLFGRepository.server.test.ts index 1d3fc7331..665ac0e72 100644 --- a/app/features/tournament-lfg/TournamentLFGRepository.server.test.ts +++ b/app/features/tournament-lfg/TournamentLFGRepository.server.test.ts @@ -216,6 +216,94 @@ describe("allLikesByTeamId", () => { }); }); +describe("startLooking", () => { + beforeEach(async () => { + await dbInsertUsers(3); + }); + + afterEach(() => { + dbReset(); + }); + + const createRegisteredTeam = async ( + tournamentId: number, + memberUserIds: number[], + ) => { + const team = await db + .insertInto("TournamentTeam") + .values({ + tournamentId, + name: "Real Team", + inviteCode: `inv-${tournamentId}-${memberUserIds.join("-")}`, + isLooking: 0, + isPlaceholder: 0, + }) + .returning("id") + .executeTakeFirstOrThrow(); + + for (const [idx, userId] of memberUserIds.entries()) { + await db + .insertInto("TournamentTeamMember") + .values({ + tournamentTeamId: team.id, + userId, + role: idx === 0 ? "OWNER" : "REGULAR", + }) + .execute(); + } + + return team; + }; + + test("generates chatCode for a 2+ member team", async () => { + const tournament = await createTournament(); + const team = await createRegisteredTeam(tournament.id, [1, 2]); + + const pickup = await TournamentLFGRepository.startLooking(team.id); + + expect(pickup).not.toBeNull(); + expect(pickup?.chatCode).toMatch(/.+/); + expect(pickup?.memberUserIds.sort()).toEqual([1, 2]); + + const row = await db + .selectFrom("TournamentTeam") + .select("chatCode") + .where("id", "=", team.id) + .executeTakeFirstOrThrow(); + expect(row.chatCode).toBe(pickup?.chatCode); + }); + + test("returns null when team has only 1 member", async () => { + const tournament = await createTournament(); + const team = await createRegisteredTeam(tournament.id, [1]); + + const pickup = await TournamentLFGRepository.startLooking(team.id); + + expect(pickup).toBeNull(); + + const row = await db + .selectFrom("TournamentTeam") + .select("chatCode") + .where("id", "=", team.id) + .executeTakeFirstOrThrow(); + expect(row.chatCode).toBeNull(); + }); + + test("reuses existing chatCode if already set", async () => { + const tournament = await createTournament(); + const team = await createRegisteredTeam(tournament.id, [1, 2]); + await db + .updateTable("TournamentTeam") + .set({ chatCode: "existing-code" }) + .where("id", "=", team.id) + .execute(); + + const pickup = await TournamentLFGRepository.startLooking(team.id); + + expect(pickup?.chatCode).toBe("existing-code"); + }); +}); + describe("mergeTeams", () => { beforeEach(async () => { await dbInsertUsers(5); @@ -298,6 +386,50 @@ describe("mergeTeams", () => { expect(groups).toHaveLength(0); }); + test("survivor gets a chatCode when merged size is 2+", async () => { + const tournament = await createTournament(); + const team1 = await createPlaceholder(tournament.id, 1); + const team2 = await createPlaceholder(tournament.id, 2); + + const result = await TournamentLFGRepository.mergeTeams({ + survivingTeamId: team1.id, + otherTeamId: team2.id, + maxGroupSize: 4, + }); + + expect(result.survivor).not.toBeNull(); + expect(result.survivor?.chatCode).toMatch(/.+/); + expect(result.survivor?.memberUserIds.sort()).toEqual([1, 2]); + expect(result.removedChatCode).toBeNull(); + + const row = await db + .selectFrom("TournamentTeam") + .select("chatCode") + .where("id", "=", team1.id) + .executeTakeFirstOrThrow(); + expect(row.chatCode).toBe(result.survivor?.chatCode); + }); + + test("returns removedChatCode when other team had a chatCode", async () => { + const tournament = await createTournament(); + const team1 = await createPlaceholder(tournament.id, 1); + const team2 = await createPlaceholder(tournament.id, 2); + + await db + .updateTable("TournamentTeam") + .set({ chatCode: "other-code" }) + .where("id", "=", team2.id) + .execute(); + + const result = await TournamentLFGRepository.mergeTeams({ + survivingTeamId: team1.id, + otherTeamId: team2.id, + maxGroupSize: 4, + }); + + expect(result.removedChatCode).toBe("other-code"); + }); + test("clears likes on surviving team after merge", async () => { const tournament = await createTournament(); const team1 = await createPlaceholder(tournament.id, 1); diff --git a/app/features/tournament-lfg/TournamentLFGRepository.server.ts b/app/features/tournament-lfg/TournamentLFGRepository.server.ts index 0cadb1776..26fd51415 100644 --- a/app/features/tournament-lfg/TournamentLFGRepository.server.ts +++ b/app/features/tournament-lfg/TournamentLFGRepository.server.ts @@ -9,11 +9,15 @@ import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql"; import { randomTeamName } from "~/utils/team-name"; export function startLooking(teamId: number) { - return db - .updateTable("TournamentTeam") - .set({ isLooking: 1 }) - .where("id", "=", teamId) - .execute(); + return db.transaction().execute(async (trx) => { + await trx + .updateTable("TournamentTeam") + .set({ isLooking: 1 }) + .where("id", "=", teamId) + .execute(); + + return ensurePickupChatCode(teamId, trx); + }); } type CreatePlaceholderTeamArgs = { @@ -167,6 +171,12 @@ export function mergeTeams({ maxGroupSize: number; }) { return db.transaction().execute(async (trx) => { + const otherTeam = await trx + .selectFrom("TournamentTeam") + .select("chatCode") + .where("id", "=", otherTeamId) + .executeTakeFirst(); + const otherMembers = await trx .selectFrom("TournamentTeamMember") .select(["TournamentTeamMember.userId", "TournamentTeamMember.role"]) @@ -207,6 +217,13 @@ export function mergeTeams({ }) .where("id", "=", survivingTeamId) .execute(); + + const survivor = await ensurePickupChatCode(survivingTeamId, trx); + + return { + survivor, + removedChatCode: otherTeam?.chatCode ?? null, + }; }); } @@ -409,3 +426,44 @@ async function getMemberCount( return members.length; } + +export type PickupChatTeam = { + chatCode: string; + name: string; + memberUserIds: number[]; +}; + +async function ensurePickupChatCode( + teamId: number, + trx: Transaction, +): Promise { + const team = await trx + .selectFrom("TournamentTeam") + .select(["name", "chatCode"]) + .where("id", "=", teamId) + .executeTakeFirstOrThrow(); + + const members = await trx + .selectFrom("TournamentTeamMember") + .select("userId") + .where("tournamentTeamId", "=", teamId) + .execute(); + + if (members.length < 2) return null; + + let chatCode = team.chatCode; + if (!chatCode) { + chatCode = shortNanoid(); + await trx + .updateTable("TournamentTeam") + .set({ chatCode }) + .where("id", "=", teamId) + .execute(); + } + + return { + chatCode, + name: team.name, + memberUserIds: members.map((m) => m.userId), + }; +} 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 5c6b1f9d9..b9b128976 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 { requireUser } from "~/features/auth/core/user.server"; +import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; import { notify } from "~/features/notifications/core/notify.server"; import { requireNotBannedByOrganization } from "~/features/tournament/tournament-utils.server"; import { @@ -16,6 +17,7 @@ import { idObject } from "~/utils/zod"; import * as TournamentLFGRepository from "../TournamentLFGRepository.server"; import { lookingSchema } from "../tournament-lfg-schemas"; import { survivingTeamId } from "../tournament-lfg-utils"; +import { setPickupChatMetadata } from "../tournament-lfg-utils.server"; export const action = async ({ request, params }: ActionFunctionArgs) => { const user = requireUser(); @@ -72,7 +74,18 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { team.members.length < tournament.maxMembersPerTeam, "Team is already at max capacity", ); - await TournamentLFGRepository.startLooking(team.id); + const pickup = await TournamentLFGRepository.startLooking(team.id); + if (pickup) { + setPickupChatMetadata({ + team: pickup, + tournament: { + id: tournamentId, + name: tournament.ctx.name, + logoUrl: tournament.ctx.logoUrl, + startTime: tournament.ctx.startTime, + }, + }); + } } else { await TournamentLFGRepository.createPlaceholderTeam({ tournamentId, @@ -181,12 +194,28 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { user, }); - await TournamentLFGRepository.mergeTeams({ + const mergeResult = await TournamentLFGRepository.mergeTeams({ survivingTeamId: surviving, otherTeamId: otherGroup.id, maxGroupSize: tournament.maxMembersPerTeam, }); + if (mergeResult.removedChatCode) { + ChatSystemMessage.removeRoom(mergeResult.removedChatCode); + } + + if (mergeResult.survivor) { + setPickupChatMetadata({ + team: mergeResult.survivor, + tournament: { + id: tournamentId, + name: tournament.ctx.name, + logoUrl: tournament.ctx.logoUrl, + startTime: tournament.ctx.startTime, + }, + }); + } + notify({ userIds: theirGroup.members.map((m) => m.id), notification: { diff --git a/app/features/tournament-lfg/tournament-lfg-utils.server.ts b/app/features/tournament-lfg/tournament-lfg-utils.server.ts new file mode 100644 index 000000000..902676a02 --- /dev/null +++ b/app/features/tournament-lfg/tournament-lfg-utils.server.ts @@ -0,0 +1,34 @@ +import { add } from "date-fns"; +import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; +import { tournamentSubsPage } from "~/utils/urls"; + +const PICKUP_CHAT_EXPIRES_AFTER_DAYS = 7; + +export function setPickupChatMetadata({ + team, + tournament, +}: { + team: { + chatCode: string; + name: string; + memberUserIds: number[]; + }; + tournament: { + id: number; + name: string; + logoUrl: string | null; + startTime: Date; + }; +}) { + return ChatSystemMessage.setMetadata({ + chatCode: team.chatCode, + header: team.name, + subtitle: tournament.name, + url: tournamentSubsPage(tournament.id), + imageUrl: tournament.logoUrl ?? undefined, + participantUserIds: team.memberUserIds, + expiresAt: add(tournament.startTime, { + days: PICKUP_CHAT_EXPIRES_AFTER_DAYS, + }), + }); +}