import type { Transaction } from "kysely"; import { jsonBuildObject } from "kysely/helpers/sqlite"; import { db } from "~/db/sql"; import type { DB, Tables } from "~/db/tables"; import { shortNanoid } from "~/utils/id"; import invariant from "~/utils/invariant"; import { concatUserSubmittedImagePrefix } from "~/utils/kysely.server"; import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql"; import { randomTeamName } from "~/utils/team-name"; export function startLooking(teamId: number) { return db.transaction().execute(async (trx) => { await trx .updateTable("TournamentTeam") .set({ isLooking: 1 }) .where("id", "=", teamId) .execute(); return ensurePickupChatCode(teamId, trx); }); } type CreatePlaceholderTeamArgs = { tournamentId: number; userId: number; isStayAsSub?: boolean; lfgNote?: string; }; export function createPlaceholderTeam(args: CreatePlaceholderTeamArgs) { return db.transaction().execute(async (trx) => { const createdTeam = await trx .insertInto("TournamentTeam") .values({ tournamentId: args.tournamentId, name: randomTeamName(), inviteCode: shortNanoid(), isPlaceholder: 1, isLooking: 1, lfgNote: args.lfgNote ?? null, }) .returning("id") .executeTakeFirstOrThrow(); await trx .insertInto("TournamentTeamMember") .values({ tournamentTeamId: createdTeam.id, userId: args.userId, role: "OWNER", isStayAsSub: args.isStayAsSub ? 1 : 0, }) .execute(); return createdTeam; }); } type TournamentLFGMemberObject = { id: Tables["User"]["id"]; username: Tables["User"]["username"]; discordId: Tables["User"]["discordId"]; discordAvatar: Tables["User"]["discordAvatar"]; customUrl: Tables["User"]["customUrl"]; languages: Tables["User"]["languages"]; vc: Tables["User"]["vc"]; pronouns: Tables["User"]["pronouns"]; role: Tables["TournamentTeamMember"]["role"]; isStayAsSub: Tables["TournamentTeamMember"]["isStayAsSub"]; weapons: Tables["User"]["qWeaponPool"]; plusTier: Tables["PlusTier"]["tier"] | null; }; export async function findLookingTeamsByTournamentId(tournamentId: number) { return db .selectFrom("TournamentTeam") .innerJoin( "TournamentTeamMember", "TournamentTeamMember.tournamentTeamId", "TournamentTeam.id", ) .innerJoin("User", "User.id", "TournamentTeamMember.userId") .leftJoin("PlusTier", "PlusTier.userId", "User.id") .leftJoin( "UserSubmittedImage", "UserSubmittedImage.id", "TournamentTeam.avatarImgId", ) .select(({ fn, eb }) => [ "TournamentTeam.id", "TournamentTeam.isPlaceholder", "TournamentTeam.lfgNote as note", "TournamentTeam.name as teamName", concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as( "teamAvatarUrl", ), fn .agg("json_group_array", [ jsonBuildObject({ id: eb.ref("User.id"), username: eb.ref("User.username"), discordId: eb.ref("User.discordId"), discordAvatar: eb.ref("User.discordAvatar"), customUrl: eb.ref("User.customUrl"), languages: eb.ref("User.languages"), vc: eb.ref("User.vc"), pronouns: eb.ref("User.pronouns"), role: eb.ref("TournamentTeamMember.role"), isStayAsSub: eb.ref("TournamentTeamMember.isStayAsSub"), weapons: eb.ref("User.qWeaponPool"), plusTier: eb.ref("PlusTier.tier"), }), ]) .$castTo() .as("members"), ]) .where("TournamentTeam.tournamentId", "=", tournamentId) .where("TournamentTeam.isLooking", "=", 1) .groupBy("TournamentTeam.id") .execute(); } export async function findSubGroups(tournamentId: number) { const rows = await db .selectFrom("TournamentTeam") .innerJoin( "TournamentTeamMember", "TournamentTeamMember.tournamentTeamId", "TournamentTeam.id", ) .innerJoin("User", "User.id", "TournamentTeamMember.userId") .leftJoin("PlusTier", "PlusTier.userId", "User.id") .select(({ fn, eb }) => [ "TournamentTeam.id", "TournamentTeam.lfgNote as message", fn .agg("json_group_array", [ jsonBuildObject({ id: eb.ref("User.id"), username: eb.ref("User.username"), discordId: eb.ref("User.discordId"), discordAvatar: eb.ref("User.discordAvatar"), customUrl: eb.ref("User.customUrl"), languages: eb.ref("User.languages"), vc: eb.ref("User.vc"), pronouns: eb.ref("User.pronouns"), role: eb.ref("TournamentTeamMember.role"), isStayAsSub: eb.ref("TournamentTeamMember.isStayAsSub"), weapons: eb.ref("User.qWeaponPool"), plusTier: eb.ref("PlusTier.tier"), }), ]) .$castTo() .as("members"), ]) .where("TournamentTeam.tournamentId", "=", tournamentId) .where("TournamentTeam.isPlaceholder", "=", 1) .where("TournamentTeamMember.isStayAsSub", "=", 1) .groupBy("TournamentTeam.id") .execute(); return rows; } export function mergeTeams({ survivingTeamId, otherTeamId, maxGroupSize, }: { survivingTeamId: number; otherTeamId: number; 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"]) .where("TournamentTeamMember.tournamentTeamId", "=", otherTeamId) .execute(); for (const member of otherMembers) { await trx .updateTable("TournamentTeamMember") .set({ role: member.role === "OWNER" ? "MANAGER" : member.role, tournamentTeamId: survivingTeamId, }) .where("TournamentTeamMember.tournamentTeamId", "=", otherTeamId) .where("TournamentTeamMember.userId", "=", member.userId) .execute(); } await deleteLikesByTeamId(survivingTeamId, trx); await trx .deleteFrom("TournamentTeam") .where("TournamentTeam.id", "=", otherTeamId) .execute(); const memberCount = await getMemberCount(survivingTeamId, trx); invariant( memberCount <= maxGroupSize, "Group has too many members after merge", ); await trx .updateTable("TournamentTeam") .set({ isLooking: memberCount >= maxGroupSize ? 0 : undefined, isPlaceholder: 0, }) .where("id", "=", survivingTeamId) .execute(); const survivor = await ensurePickupChatCode(survivingTeamId, trx); return { survivor, removedChatCode: otherTeam?.chatCode ?? null, }; }); } export async function addLike({ likerTeamId, targetTeamId, }: { likerTeamId: number; targetTeamId: number; }) { try { await db .insertInto("TournamentLFGLike") .values({ likerTeamId, targetTeamId }) .onConflict((oc) => oc.columns(["likerTeamId", "targetTeamId"]).doNothing(), ) .execute(); } catch (error) { if (errorIsSqliteForeignKeyConstraintFailure(error)) { return; } throw error; } } export function deleteLike({ likerTeamId, targetTeamId, }: { likerTeamId: number; targetTeamId: number; }) { return db .deleteFrom("TournamentLFGLike") .where("likerTeamId", "=", likerTeamId) .where("targetTeamId", "=", targetTeamId) .execute(); } export async function allLikesByTeamId(teamId: number) { const rows = await db .selectFrom("TournamentLFGLike") .select(["TournamentLFGLike.likerTeamId", "TournamentLFGLike.targetTeamId"]) .where((eb) => eb.or([ eb("TournamentLFGLike.likerTeamId", "=", teamId), eb("TournamentLFGLike.targetTeamId", "=", teamId), ]), ) .execute(); return { given: rows .filter((row) => row.likerTeamId === teamId) .map((row) => ({ teamId: row.targetTeamId })), received: rows .filter((row) => row.targetTeamId === teamId) .map((row) => ({ teamId: row.likerTeamId })), }; } export function updateTeamNote({ teamId, value, }: { teamId: number; value: string | null; }) { return db .updateTable("TournamentTeam") .set({ lfgNote: value }) .where("id", "=", teamId) .execute(); } export function updateMemberRole({ userId, teamId, role, }: { userId: number; teamId: number; role: Tables["TournamentTeamMember"]["role"]; }) { if (role === "OWNER") { throw new Error("Can't set role to OWNER with this function"); } return db .updateTable("TournamentTeamMember") .set({ role }) .where("userId", "=", userId) .where("tournamentTeamId", "=", teamId) .execute(); } export function updateStayAsSub({ teamId, userId, value, }: { teamId: number; userId: number; value: boolean; }) { return db .updateTable("TournamentTeamMember") .set({ isStayAsSub: value ? 1 : 0 }) .where("tournamentTeamId", "=", teamId) .where("userId", "=", userId) .execute(); } export function leaveLfg({ userId, tournamentId, }: { userId: number; tournamentId: number; }) { return db.transaction().execute(async (trx) => { const userTeam = await trx .selectFrom("TournamentTeamMember") .innerJoin( "TournamentTeam", "TournamentTeam.id", "TournamentTeamMember.tournamentTeamId", ) .select([ "TournamentTeamMember.tournamentTeamId", "TournamentTeam.isPlaceholder", ]) .where("TournamentTeamMember.userId", "=", userId) .where("TournamentTeam.tournamentId", "=", tournamentId) .where("TournamentTeam.isLooking", "=", 1) .executeTakeFirst(); if (!userTeam) return; if (!userTeam.isPlaceholder) { await trx .updateTable("TournamentTeam") .set({ isLooking: 0 }) .where("id", "=", userTeam.tournamentTeamId) .execute(); await trx .updateTable("TournamentTeamMember") .set({ isStayAsSub: 0 }) .where("tournamentTeamId", "=", userTeam.tournamentTeamId) .execute(); await deleteLikesByTeamId(userTeam.tournamentTeamId, trx); return; } await trx .deleteFrom("TournamentTeam") .where("id", "=", userTeam.tournamentTeamId) .execute(); }); } export async function getSubsForTournament(tournamentId: number) { const rows = await db .selectFrom("TournamentTeamMember") .innerJoin( "TournamentTeam", "TournamentTeam.id", "TournamentTeamMember.tournamentTeamId", ) .select("TournamentTeamMember.userId") .where("TournamentTeam.tournamentId", "=", tournamentId) .where("TournamentTeamMember.isStayAsSub", "=", 1) .execute(); return rows.map((row) => row.userId); } function deleteLikesByTeamId(teamId: number, trx: Transaction) { return trx .deleteFrom("TournamentLFGLike") .where((eb) => eb.or([ eb("TournamentLFGLike.likerTeamId", "=", teamId), eb("TournamentLFGLike.targetTeamId", "=", teamId), ]), ) .execute(); } async function getMemberCount( teamId: number, trx: Transaction, ): Promise { const members = await trx .selectFrom("TournamentTeamMember") .select("TournamentTeamMember.userId") .where("TournamentTeamMember.tournamentTeamId", "=", teamId) .execute(); 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), }; }