mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-14 23:26:15 -05:00
Chat room for tournament pick-ups (LFG feature)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
34
app/features/tournament-lfg/tournament-lfg-utils.server.ts
Normal file
34
app/features/tournament-lfg/tournament-lfg-utils.server.ts
Normal 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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user