sendou.ink/app/features/sendouq/SQGroupRepository.server.ts
Kalle 7b71abfe53
Some checks are pending
E2E Tests / e2e (push) Waiting to run
Tests and checks on push / run-checks-and-tests (push) Waiting to run
Updates translation progress / update-translation-progress-issue (push) Waiting to run
Migrate SQ match queries to Kysely (#2782)
2026-02-21 13:48:18 +02:00

706 lines
18 KiB
TypeScript

import { sub } from "date-fns";
import { type NotNull, sql, type Transaction } from "kysely";
import { jsonArrayFrom, jsonBuildObject } from "kysely/helpers/sqlite";
import { db } from "~/db/sql";
import type { DB, Tables, UserMapModePreferences } from "~/db/tables";
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
import { shortNanoid } from "~/utils/id";
import invariant from "~/utils/invariant";
import {
COMMON_USER_FIELDS,
userChatNameColorForJson,
} from "~/utils/kysely.server";
import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql";
import { userIsBanned } from "../ban/core/banned.server";
import { FULL_GROUP_SIZE } from "./q-constants";
import { SendouQError } from "./q-utils.server";
export function mapModePreferencesByGroupId(groupId: number) {
return db
.selectFrom("GroupMember")
.innerJoin("User", "User.id", "GroupMember.userId")
.select(["User.id as userId", "User.mapModePreferences as preferences"])
.where("GroupMember.groupId", "=", groupId)
.where("User.mapModePreferences", "is not", null)
.$narrowType<{ preferences: NotNull }>()
.execute();
}
export async function findCurrentGroups() {
type SendouQMemberObject = {
id: Tables["User"]["id"];
username: Tables["User"]["username"];
discordId: Tables["User"]["discordId"];
discordAvatar: Tables["User"]["discordAvatar"];
customUrl: Tables["User"]["customUrl"];
pronouns: Tables["User"]["pronouns"] | null;
mapModePreferences: Tables["User"]["mapModePreferences"];
noScreen: Tables["User"]["noScreen"];
languages: Tables["User"]["languages"];
vc: Tables["User"]["vc"];
role: Tables["GroupMember"]["role"];
note: Tables["GroupMember"]["note"];
weapons: Tables["User"]["qWeaponPool"];
chatNameColor: string | null;
plusTier: Tables["PlusTier"]["tier"] | null;
};
return db
.selectFrom("Group")
.innerJoin("GroupMember", "GroupMember.groupId", "Group.id")
.innerJoin("User", "User.id", "GroupMember.userId")
.leftJoin("PlusTier", "PlusTier.userId", "User.id")
.leftJoin("GroupMatch", (join) =>
join.on((eb) =>
eb.or([
eb("GroupMatch.alphaGroupId", "=", eb.ref("Group.id")),
eb("GroupMatch.bravoGroupId", "=", eb.ref("Group.id")),
]),
),
)
.select(({ fn, eb }) => [
"Group.id",
"Group.chatCode",
"Group.inviteCode",
"Group.latestActionAt",
"Group.chatCode",
"Group.inviteCode",
"Group.status",
"GroupMatch.id as matchId",
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"),
mapModePreferences: eb.ref("User.mapModePreferences"),
noScreen: eb.ref("User.noScreen"),
role: eb.ref("GroupMember.role"),
note: eb.ref("GroupMember.note"),
weapons: eb.ref("User.qWeaponPool"),
languages: eb.ref("User.languages"),
plusTier: eb.ref("PlusTier.tier"),
vc: eb.ref("User.vc"),
chatNameColor: userChatNameColorForJson,
}),
])
.$castTo<SendouQMemberObject[]>()
.as("members"),
])
.where((eb) =>
eb.or([
eb("Group.status", "=", "ACTIVE"),
eb("Group.status", "=", "PREPARING"),
]),
)
.groupBy("Group.id")
.execute();
}
export async function findActiveGroupMembers() {
return db
.selectFrom("GroupMember")
.innerJoin("Group", "Group.id", "GroupMember.groupId")
.select("GroupMember.userId")
.where("Group.status", "!=", "INACTIVE")
.execute();
}
type CreateGroupArgs = {
status: Exclude<Tables["Group"]["status"], "INACTIVE">;
userId: number;
};
export function createGroup(args: CreateGroupArgs) {
return db.transaction().execute(async (trx) => {
const createdGroup = await trx
.insertInto("Group")
.values({
inviteCode: shortNanoid(),
chatCode: shortNanoid(),
status: args.status,
})
.returning("id")
.executeTakeFirstOrThrow();
await trx
.insertInto("GroupMember")
.values({
groupId: createdGroup.id,
userId: args.userId,
role: "OWNER",
})
.execute();
if (!(await isGroupCorrect(createdGroup.id, trx))) {
throw new SendouQError("Group has a member in multiple groups");
}
return createdGroup;
});
}
type CreateGroupFromPreviousGroupArgs = {
previousGroupId: number;
members: {
id: number;
role: Tables["GroupMember"]["role"];
}[];
};
export async function createGroupFromPrevious(
args: CreateGroupFromPreviousGroupArgs,
) {
return db.transaction().execute(async (trx) => {
const createdGroup = await trx
.insertInto("Group")
.columns(["teamId", "chatCode", "inviteCode", "status"])
.expression((eb) =>
eb
.selectFrom("Group")
.select((eb) => [
"Group.teamId",
"Group.chatCode",
eb.val(shortNanoid()).as("inviteCode"),
eb.val("PREPARING").as("status"),
])
.where("Group.id", "=", args.previousGroupId),
)
.returning("id")
.executeTakeFirstOrThrow();
await trx
.insertInto("GroupMember")
.values(
args.members.map((member) => ({
groupId: createdGroup.id,
userId: member.id,
role: member.role,
})),
)
.execute();
if (!(await isGroupCorrect(createdGroup.id, trx))) {
throw new SendouQError(
"Group has too many members or member in multiple groups",
);
}
return createdGroup;
});
}
function deleteLikesByGroupId(groupId: number, trx: Transaction<DB>) {
return trx
.deleteFrom("GroupLike")
.where((eb) =>
eb.or([
eb("GroupLike.likerGroupId", "=", groupId),
eb("GroupLike.targetGroupId", "=", groupId),
]),
)
.execute();
}
export function morphGroups({
survivingGroupId,
otherGroupId,
}: {
survivingGroupId: number;
otherGroupId: number;
}) {
return db.transaction().execute(async (trx) => {
// reset chat code so previous messages are not visible
await trx
.updateTable("Group")
.set({ chatCode: shortNanoid() })
.where("Group.id", "=", survivingGroupId)
.execute();
const otherGroupMembers = await trx
.selectFrom("GroupMember")
.select(["GroupMember.userId", "GroupMember.role"])
.where("GroupMember.groupId", "=", otherGroupId)
.execute();
for (const member of otherGroupMembers) {
const oldRole = otherGroupMembers.find(
(m) => m.userId === member.userId,
)?.role;
invariant(oldRole, "Member lacking a role");
await trx
.updateTable("GroupMember")
.set({
role:
oldRole === "OWNER"
? "MANAGER"
: oldRole === "MANAGER"
? "MANAGER"
: "REGULAR",
groupId: survivingGroupId,
})
.where("GroupMember.groupId", "=", otherGroupId)
.where("GroupMember.userId", "=", member.userId)
.execute();
}
await deleteLikesByGroupId(survivingGroupId, trx);
await refreshGroup(survivingGroupId, trx);
await trx
.deleteFrom("Group")
.where("Group.id", "=", otherGroupId)
.execute();
if (!(await isGroupCorrect(survivingGroupId, trx))) {
throw new SendouQError(
"Group has too many members or member in multiple groups",
);
}
});
}
/** Check that the group has at most FULL_GROUP_SIZE members and each member is only in this group */
async function isGroupCorrect(
groupId: number,
trx: Transaction<DB>,
): Promise<boolean> {
const members = await trx
.selectFrom("GroupMember")
.select("GroupMember.userId")
.where("GroupMember.groupId", "=", groupId)
.execute();
if (members.length > FULL_GROUP_SIZE) {
return false;
}
for (const member of members) {
const otherGroup = await trx
.selectFrom("GroupMember")
.innerJoin("Group", "Group.id", "GroupMember.groupId")
.select(["Group.id"])
.where("GroupMember.userId", "=", member.userId)
.where("Group.status", "!=", "INACTIVE")
.where("GroupMember.groupId", "!=", groupId)
.executeTakeFirst();
if (otherGroup) {
return false;
}
}
return true;
}
export function addMember(
groupId: number,
{
userId,
role = "REGULAR",
}: {
userId: number;
role?: Tables["GroupMember"]["role"];
},
) {
return db.transaction().execute(async (trx) => {
await trx
.insertInto("GroupMember")
.values({
groupId,
userId,
role,
})
.execute();
await deleteLikesByGroupId(groupId, trx);
if (!(await isGroupCorrect(groupId, trx))) {
throw new SendouQError(
"Group has too many members or member in multiple groups",
);
}
});
}
export async function allLikesByGroupId(groupId: number) {
const rows = await db
.selectFrom("GroupLike")
.select([
"GroupLike.likerGroupId",
"GroupLike.targetGroupId",
"GroupLike.isRechallenge",
])
.where((eb) =>
eb.or([
eb("GroupLike.likerGroupId", "=", groupId),
eb("GroupLike.targetGroupId", "=", groupId),
]),
)
.execute();
return {
given: rows
.filter((row) => row.likerGroupId === groupId)
.map((row) => ({
groupId: row.targetGroupId,
isRechallenge: row.isRechallenge,
})),
received: rows
.filter((row) => row.targetGroupId === groupId)
.map((row) => ({
groupId: row.likerGroupId,
isRechallenge: row.isRechallenge,
})),
};
}
export function rechallenge({
likerGroupId,
targetGroupId,
}: {
likerGroupId: number;
targetGroupId: number;
}) {
return db
.updateTable("GroupLike")
.set({ isRechallenge: 1 })
.where("likerGroupId", "=", likerGroupId)
.where("targetGroupId", "=", targetGroupId)
.execute();
}
/**
* Retrieves information about users who have trusted the specified user,
* including their associated teams and explicit trust relationships. Banned users are excluded.
*/
export async function usersThatTrusted(userId: number) {
const teams = await db
.selectFrom("TeamMemberWithSecondary")
.innerJoin("Team", "Team.id", "TeamMemberWithSecondary.teamId")
.select(["Team.id", "Team.name", "TeamMemberWithSecondary.isMainTeam"])
.where("userId", "=", userId)
.execute();
const rows = await db
.selectFrom("TeamMemberWithSecondary")
.innerJoin("User", "User.id", "TeamMemberWithSecondary.userId")
.innerJoin("UserFriendCode", "UserFriendCode.userId", "User.id")
.select([
...COMMON_USER_FIELDS,
"User.inGameName",
"TeamMemberWithSecondary.teamId",
])
.where(
"TeamMemberWithSecondary.teamId",
"in",
teams.map((t) => t.id),
)
.union((eb) =>
eb
.selectFrom("TrustRelationship")
.innerJoin("User", "User.id", "TrustRelationship.trustGiverUserId")
.innerJoin("UserFriendCode", "UserFriendCode.userId", "User.id")
.select([
...COMMON_USER_FIELDS,
"User.inGameName",
sql<any>`null`.as("teamId"),
])
.where("TrustRelationship.trustReceiverUserId", "=", userId),
)
.execute();
const rowsWithoutBanned = rows.filter((row) => !userIsBanned(row.id));
const teamMemberIds = rowsWithoutBanned
.filter((row) => row.teamId)
.map((row) => row.id);
// we want user to show twice if member of two different teams
// but we don't want a user from the team to show in teamless section
const deduplicatedRows = rowsWithoutBanned.filter(
(row) => row.teamId || !teamMemberIds.includes(row.id),
);
// done here at not sql just because it was easier to do here ignoring case
deduplicatedRows.sort((a, b) => a.username.localeCompare(b.username));
return {
teams: teams.sort((a, b) => b.isMainTeam - a.isMainTeam),
trusters: deduplicatedRows,
};
}
/** Update the timestamp of the trust relationship, delaying its automatic deletion */
export async function refreshTrust({
trustGiverUserId,
trustReceiverUserId,
}: {
trustGiverUserId: number;
trustReceiverUserId: number;
}) {
return db
.updateTable("TrustRelationship")
.set({ lastUsedAt: databaseTimestampNow() })
.where("trustGiverUserId", "=", trustGiverUserId)
.where("trustReceiverUserId", "=", trustReceiverUserId)
.execute();
}
export async function deleteOldTrust() {
const twoMonthsAgo = sub(new Date(), { months: 2 });
return db
.deleteFrom("TrustRelationship")
.where("lastUsedAt", "<", dateToDatabaseTimestamp(twoMonthsAgo))
.executeTakeFirst();
}
export async function setOldGroupsAsInactive() {
const oneHourAgo = sub(new Date(), { hours: 1 });
return db.transaction().execute(async (trx) => {
const groupsToSetInactive = await trx
.selectFrom("Group")
.leftJoin("GroupMatch", (join) =>
join.on((eb) =>
eb.or([
eb("GroupMatch.alphaGroupId", "=", eb.ref("Group.id")),
eb("GroupMatch.bravoGroupId", "=", eb.ref("Group.id")),
]),
),
)
.select(["Group.id"])
.where("status", "!=", "INACTIVE")
.where("GroupMatch.id", "is", null)
.where("latestActionAt", "<", dateToDatabaseTimestamp(oneHourAgo))
.execute();
return trx
.updateTable("Group")
.set({ status: "INACTIVE" })
.where(
"Group.id",
"in",
groupsToSetInactive.map((g) => g.id),
)
.executeTakeFirst();
});
}
export async function mapModePreferencesBySeasonNth(seasonNth: number) {
return db
.selectFrom("Skill")
.innerJoin("User", "User.id", "Skill.userId")
.select("User.mapModePreferences")
.where("Skill.season", "=", seasonNth)
.where("Skill.userId", "is not", null)
.where("User.mapModePreferences", "is not", null)
.groupBy("Skill.userId")
.$narrowType<{ mapModePreferences: UserMapModePreferences }>()
.execute();
}
export async function findRecentlyFinishedMatches() {
const twoHoursAgo = sub(new Date(), { hours: 2 });
const rows = await db
.selectFrom("GroupMatch")
.select((eb) => [
jsonArrayFrom(
eb
.selectFrom("GroupMember")
.select("GroupMember.userId")
.whereRef("GroupMember.groupId", "=", "GroupMatch.alphaGroupId"),
).as("groupAlphaMemberIds"),
jsonArrayFrom(
eb
.selectFrom("GroupMember")
.select("GroupMember.userId")
.whereRef("GroupMember.groupId", "=", "GroupMatch.bravoGroupId"),
).as("groupBravoMemberIds"),
])
.where("GroupMatch.reportedAt", "is not", null)
.where("GroupMatch.reportedAt", ">", dateToDatabaseTimestamp(twoHoursAgo))
.execute();
return rows.map((row) => ({
groupAlphaMemberIds: row.groupAlphaMemberIds.map((m) => m.userId),
groupBravoMemberIds: row.groupBravoMemberIds.map((m) => m.userId),
}));
}
export function addLike({
likerGroupId,
targetGroupId,
}: {
likerGroupId: number;
targetGroupId: number;
}) {
return db.transaction().execute(async (trx) => {
try {
await trx
.insertInto("GroupLike")
.values({ likerGroupId, targetGroupId })
.onConflict((oc) =>
oc.columns(["likerGroupId", "targetGroupId"]).doNothing(),
)
.execute();
} catch (error) {
if (errorIsSqliteForeignKeyConstraintFailure(error)) {
throw new SendouQError(error.message);
}
throw error;
}
await refreshGroup(likerGroupId, trx);
});
}
export function deleteLike({
likerGroupId,
targetGroupId,
}: {
likerGroupId: number;
targetGroupId: number;
}) {
return db.transaction().execute(async (trx) => {
await trx
.deleteFrom("GroupLike")
.where("likerGroupId", "=", likerGroupId)
.where("targetGroupId", "=", targetGroupId)
.execute();
await refreshGroup(likerGroupId, trx);
});
}
export function leaveGroup(userId: number) {
return db.transaction().execute(async (trx) => {
const userGroup = await trx
.selectFrom("GroupMember")
.innerJoin("Group", "Group.id", "GroupMember.groupId")
.select(["Group.id", "GroupMember.role"])
.where("userId", "=", userId)
.where("Group.status", "!=", "INACTIVE")
.executeTakeFirstOrThrow();
await trx
.deleteFrom("GroupMember")
.where("userId", "=", userId)
.where("GroupMember.groupId", "=", userGroup.id)
.execute();
const remainingMembers = await trx
.selectFrom("GroupMember")
.select(["userId", "role"])
.where("groupId", "=", userGroup.id)
.execute();
if (remainingMembers.length === 0) {
await trx.deleteFrom("Group").where("id", "=", userGroup.id).execute();
return;
}
if (userGroup.role === "OWNER") {
const newOwner =
remainingMembers.find((m) => m.role === "MANAGER") ??
remainingMembers[0];
await trx
.updateTable("GroupMember")
.set({ role: "OWNER" })
.where("userId", "=", newOwner.userId)
.where("groupId", "=", userGroup.id)
.execute();
}
const match = await trx
.selectFrom("GroupMatch")
.select(["GroupMatch.id"])
.where((eb) =>
eb.or([
eb("alphaGroupId", "=", userGroup.id),
eb("bravoGroupId", "=", userGroup.id),
]),
)
.executeTakeFirst();
if (match) {
throw new SendouQError("Can't leave group when already in a match");
}
});
}
export function refreshGroup(groupId: number, trx?: Transaction<DB>) {
return (trx ?? db)
.updateTable("Group")
.set({ latestActionAt: databaseTimestampNow() })
.where("Group.id", "=", groupId)
.execute();
}
export function updateMemberNote({
groupId,
userId,
value,
}: {
groupId: number;
userId: number;
value: string | null;
}) {
return db.transaction().execute(async (trx) => {
await trx
.updateTable("GroupMember")
.set({ note: value })
.where("groupId", "=", groupId)
.where("userId", "=", userId)
.execute();
await refreshGroup(groupId, trx);
});
}
export function updateMemberRole({
userId,
groupId,
role,
}: {
userId: number;
groupId: number;
role: Tables["GroupMember"]["role"];
}) {
if (role === "OWNER") {
throw new Error("Can't set role to OWNER with this function");
}
return db.transaction().execute(async (trx) => {
await trx
.updateTable("GroupMember")
.set({ role })
.where("userId", "=", userId)
.where("groupId", "=", groupId)
.execute();
await refreshGroup(groupId, trx);
});
}
export function setPreparingGroupAsActive(groupId: number) {
return db
.updateTable("Group")
.set({ status: "ACTIVE", latestActionAt: databaseTimestampNow() })
.where("id", "=", groupId)
.where("status", "=", "PREPARING")
.execute();
}
export function setAsInactive(groupId: number, trx?: Transaction<DB>) {
return (trx ?? db)
.updateTable("Group")
.set({ status: "INACTIVE" })
.where("id", "=", groupId)
.execute();
}