Chat room for tournament pick-ups (LFG feature)

This commit is contained in:
Kalle
2026-04-19 14:36:48 +03:00
parent 493f6c01e0
commit a3a03c0c4b
4 changed files with 260 additions and 7 deletions

View File

@@ -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);

View File

@@ -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<DB>,
): Promise<PickupChatTeam | null> {
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),
};
}

View File

@@ -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: {

View File

@@ -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,
}),
});
}