mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 23:58:36 -05:00
1205 lines
32 KiB
TypeScript
1205 lines
32 KiB
TypeScript
import { sub } from "date-fns";
|
|
import {
|
|
type ExpressionBuilder,
|
|
type NotNull,
|
|
sql,
|
|
type Transaction,
|
|
} from "kysely";
|
|
import { db } from "~/db/sql";
|
|
import type { DB, Tables } from "~/db/tables";
|
|
import type { UserMapModePreferences } from "~/db/tables-json";
|
|
import { actorId } from "~/features/auth/core/user.server";
|
|
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
|
|
import { shortNanoid } from "~/utils/id";
|
|
import {
|
|
commonUserMembersAgg,
|
|
commonUserSelect,
|
|
jsonArrayFrom,
|
|
matchProfileWeapons,
|
|
} 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 async function findMapModePreferencesByGroupId(groupId: number) {
|
|
const group = await db
|
|
.selectFrom("Group")
|
|
.leftJoin("AllTeam", "AllTeam.id", "Group.teamId")
|
|
.select([
|
|
"AllTeam.mapModePreferences as teamMapModePreferences",
|
|
"AllTeam.name as teamName",
|
|
])
|
|
.where("Group.id", "=", groupId)
|
|
.executeTakeFirst();
|
|
|
|
if (group?.teamMapModePreferences) {
|
|
const members = await db
|
|
.selectFrom("GroupMember")
|
|
.select("GroupMember.userId")
|
|
.where("GroupMember.groupId", "=", groupId)
|
|
.execute();
|
|
|
|
return members.map((m) => ({
|
|
userId: m.userId,
|
|
preferences: group.teamMapModePreferences as UserMapModePreferences,
|
|
teamName: group.teamName,
|
|
}));
|
|
}
|
|
|
|
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() {
|
|
return (
|
|
db
|
|
.selectFrom("Group")
|
|
.innerJoin("GroupMember", "GroupMember.groupId", "Group.id")
|
|
.innerJoin("User", "User.id", "GroupMember.userId")
|
|
.leftJoin("AllTeam", "AllTeam.id", "Group.teamId")
|
|
.leftJoin("GroupMatch", (join) =>
|
|
join.on((eb) =>
|
|
eb.or([
|
|
eb("GroupMatch.alphaGroupId", "=", eb.ref("Group.id")),
|
|
eb("GroupMatch.bravoGroupId", "=", eb.ref("Group.id")),
|
|
]),
|
|
),
|
|
)
|
|
.select(({ eb }) => [
|
|
"Group.id",
|
|
"Group.chatCode",
|
|
"Group.inviteCode",
|
|
"Group.latestActionAt",
|
|
"Group.status",
|
|
"AllTeam.mapModePreferences as teamMapModePreferences",
|
|
"GroupMatch.id as matchId",
|
|
commonUserMembersAgg(eb, {
|
|
mapModePreferences: eb.ref("User.mapModePreferences"),
|
|
noScreen: eb.ref("User.noScreen"),
|
|
note: eb.ref("GroupMember.note"),
|
|
weapons: matchProfileWeapons(eb),
|
|
languages: eb.ref("User.languages"),
|
|
vc: eb.ref("User.vc"),
|
|
}).as("members"),
|
|
])
|
|
// != INACTIVE (same set as ACTIVE or PREPARING) so the partial
|
|
// group_status_active index applies
|
|
.where("Group.status", "!=", "INACTIVE")
|
|
.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 async function insert(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,
|
|
})
|
|
.execute();
|
|
|
|
if (!(await isGroupCorrect(createdGroup.id, trx))) {
|
|
throw new SendouQError("Group has a member in multiple groups");
|
|
}
|
|
|
|
const chatCodeToRevalidate = await recordImplicitRejoinNoVote(
|
|
args.userId,
|
|
trx,
|
|
);
|
|
|
|
return { id: createdGroup.id, chatCodeToRevalidate };
|
|
});
|
|
}
|
|
|
|
type CreateGroupFromPreviousGroupArgs = {
|
|
previousGroupId: number;
|
|
memberUserIds: number[];
|
|
status?: Exclude<Tables["Group"]["status"], "INACTIVE">;
|
|
};
|
|
export async function insertFromPrevious(
|
|
args: CreateGroupFromPreviousGroupArgs,
|
|
) {
|
|
const status = args.status ?? "PREPARING";
|
|
|
|
return db.transaction().execute(async (trx) => {
|
|
const createdGroup = await trx
|
|
.insertInto("Group")
|
|
.columns(["chatCode", "inviteCode", "status", "matchmade"])
|
|
.expression((eb) =>
|
|
eb
|
|
.selectFrom("Group")
|
|
.select((eb) => [
|
|
"Group.chatCode",
|
|
eb.val(shortNanoid()).as("inviteCode"),
|
|
eb.val(status).as("status"),
|
|
"Group.matchmade",
|
|
])
|
|
.where("Group.id", "=", args.previousGroupId),
|
|
)
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow();
|
|
|
|
await trx
|
|
.insertInto("GroupMember")
|
|
.values(
|
|
args.memberUserIds.map((userId) => ({
|
|
groupId: createdGroup.id,
|
|
userId,
|
|
})),
|
|
)
|
|
.execute();
|
|
|
|
await syncTeamId(createdGroup.id, trx);
|
|
|
|
if (!(await isGroupCorrect(createdGroup.id, trx))) {
|
|
throw new SendouQError(
|
|
"Group has too many members or member in multiple groups",
|
|
);
|
|
}
|
|
|
|
return createdGroup;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Stamps the group as a team's, when a full group's worth of its members share one,
|
|
* and clears the stamp otherwise. A team's group queues on the team's own map & mode
|
|
* preferences rather than on those of its members.
|
|
*/
|
|
export async function syncTeamId(groupId: number, trx: Transaction<DB>) {
|
|
// the tables are joined directly instead of through `TeamMemberWithSecondary`,
|
|
// which SQLite materializes in full before the group filter narrows it down
|
|
const members = await trx
|
|
.selectFrom("GroupMember")
|
|
.innerJoin("AllTeamMember", (join) =>
|
|
join
|
|
.onRef("AllTeamMember.userId", "=", "GroupMember.userId")
|
|
.on("AllTeamMember.leftAt", "is", null),
|
|
)
|
|
.innerJoin("Team", "Team.id", "AllTeamMember.teamId")
|
|
.select(["AllTeamMember.teamId"])
|
|
.where("GroupMember.groupId", "=", groupId)
|
|
.execute();
|
|
|
|
const counts = new Map<number, number>();
|
|
|
|
for (const member of members) {
|
|
const newCount = (counts.get(member.teamId) ?? 0) + 1;
|
|
if (newCount === FULL_GROUP_SIZE) {
|
|
await trx
|
|
.updateTable("Group")
|
|
.set({ teamId: member.teamId })
|
|
.where("id", "=", groupId)
|
|
.execute();
|
|
return;
|
|
}
|
|
|
|
counts.set(member.teamId, newCount);
|
|
}
|
|
|
|
await trx
|
|
.updateTable("Group")
|
|
.set({ teamId: null })
|
|
.where("id", "=", groupId)
|
|
.execute();
|
|
}
|
|
|
|
function deleteLikesByGroupId(groupId: number, trx: Transaction<DB>) {
|
|
return trx
|
|
.deleteFrom("GroupLike")
|
|
.where((eb) =>
|
|
eb.or([
|
|
eb("GroupLike.likerGroupId", "=", groupId),
|
|
eb("GroupLike.targetGroupId", "=", groupId),
|
|
]),
|
|
)
|
|
.execute();
|
|
}
|
|
|
|
function deleteSuggestionsByGroupId(groupId: number, trx: Transaction<DB>) {
|
|
return trx
|
|
.deleteFrom("GroupSuggestion")
|
|
.where((eb) =>
|
|
eb.or([
|
|
eb("GroupSuggestion.suggesterGroupId", "=", groupId),
|
|
eb("GroupSuggestion.targetGroupId", "=", groupId),
|
|
]),
|
|
)
|
|
.execute();
|
|
}
|
|
|
|
/**
|
|
* Deletes every like and suggestion where the given group is on either side.
|
|
* Called when the group's pending challenges and suggestions stop being
|
|
* actionable e.g. its roster changed or it started a match.
|
|
*/
|
|
export async function deleteLikesAndSuggestionsByGroupId(
|
|
groupId: number,
|
|
trx: Transaction<DB>,
|
|
) {
|
|
await deleteLikesByGroupId(groupId, trx);
|
|
await deleteSuggestionsByGroupId(groupId, trx);
|
|
}
|
|
|
|
export function morphGroups({
|
|
survivingGroupId,
|
|
otherGroupId,
|
|
}: {
|
|
survivingGroupId: number;
|
|
otherGroupId: number;
|
|
}) {
|
|
return db.transaction().execute(async (trx) => {
|
|
// reset chat code so previous messages are not visible, and mark as matchmade
|
|
await trx
|
|
.updateTable("Group")
|
|
.set({ chatCode: shortNanoid(), matchmade: 1 })
|
|
.where("Group.id", "=", survivingGroupId)
|
|
.execute();
|
|
|
|
await trx
|
|
.updateTable("GroupMember")
|
|
.set({ groupId: survivingGroupId })
|
|
.where("GroupMember.groupId", "=", otherGroupId)
|
|
.execute();
|
|
|
|
await syncTeamId(survivingGroupId, trx);
|
|
await deleteLikesAndSuggestionsByGroupId(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 async function insertMember(
|
|
groupId: number,
|
|
{ userId }: { userId: number },
|
|
) {
|
|
const chatCodeToRevalidate = await db.transaction().execute(async (trx) => {
|
|
await trx
|
|
.insertInto("GroupMember")
|
|
.values({
|
|
groupId,
|
|
userId,
|
|
})
|
|
.execute();
|
|
|
|
await syncTeamId(groupId, trx);
|
|
await deleteLikesAndSuggestionsByGroupId(groupId, trx);
|
|
|
|
if (!(await isGroupCorrect(groupId, trx))) {
|
|
throw new SendouQError(
|
|
"Group has too many members or member in multiple groups",
|
|
);
|
|
}
|
|
|
|
return recordImplicitRejoinNoVote(userId, trx);
|
|
});
|
|
|
|
return { chatCodeToRevalidate };
|
|
}
|
|
|
|
export async function findAllLikesByGroupId(groupId: number) {
|
|
const rows = await db
|
|
.selectFrom("GroupLike")
|
|
.leftJoin("User", "User.id", "GroupLike.createdByUserId")
|
|
.select([
|
|
"GroupLike.likerGroupId",
|
|
"GroupLike.targetGroupId",
|
|
"GroupLike.isRechallenge",
|
|
"User.username as createdByUsername",
|
|
])
|
|
.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,
|
|
createdByUsername: row.createdByUsername,
|
|
})),
|
|
received: rows
|
|
.filter((row) => row.targetGroupId === groupId)
|
|
.map((row) => ({
|
|
groupId: row.likerGroupId,
|
|
isRechallenge: row.isRechallenge,
|
|
})),
|
|
};
|
|
}
|
|
|
|
/** Suggestions the given group's members have made to each other, newest first. */
|
|
export async function findAllSuggestionsByGroupId(groupId: number) {
|
|
const rows = await db
|
|
.selectFrom("GroupSuggestion")
|
|
.innerJoin("User", "User.id", "GroupSuggestion.createdByUserId")
|
|
.select([
|
|
"GroupSuggestion.targetGroupId",
|
|
"User.username as createdByUsername",
|
|
])
|
|
.where("GroupSuggestion.suggesterGroupId", "=", groupId)
|
|
.orderBy("GroupSuggestion.createdAt", "desc")
|
|
.execute();
|
|
|
|
return rows.map((row) => ({
|
|
groupId: row.targetGroupId,
|
|
createdByUsername: row.createdByUsername,
|
|
}));
|
|
}
|
|
|
|
export function rechallenge({
|
|
likerGroupId,
|
|
targetGroupId,
|
|
}: {
|
|
likerGroupId: number;
|
|
targetGroupId: number;
|
|
}) {
|
|
return db
|
|
.updateTable("GroupLike")
|
|
.set({ isRechallenge: 1 })
|
|
.where("likerGroupId", "=", likerGroupId)
|
|
.where("targetGroupId", "=", targetGroupId)
|
|
.execute();
|
|
}
|
|
|
|
export async function findFriendsAndTeammates(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")
|
|
.select((eb) => [
|
|
...commonUserSelect(eb),
|
|
"User.inGameName",
|
|
"TeamMemberWithSecondary.teamId",
|
|
])
|
|
.where(
|
|
"TeamMemberWithSecondary.teamId",
|
|
"in",
|
|
teams.map((t) => t.id),
|
|
)
|
|
.union((eb) =>
|
|
eb
|
|
.selectFrom("Friendship")
|
|
.innerJoin("User", (join) =>
|
|
join.on((eb) =>
|
|
eb.or([
|
|
eb.and([
|
|
eb("Friendship.userOneId", "=", userId),
|
|
eb("User.id", "=", eb.ref("Friendship.userTwoId")),
|
|
]),
|
|
eb.and([
|
|
eb("Friendship.userTwoId", "=", userId),
|
|
eb("User.id", "=", eb.ref("Friendship.userOneId")),
|
|
]),
|
|
]),
|
|
),
|
|
)
|
|
.select((eb) => [
|
|
...commonUserSelect(eb),
|
|
"User.inGameName",
|
|
sql<any>`null`.as("teamId"),
|
|
]),
|
|
)
|
|
.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),
|
|
friends: deduplicatedRows,
|
|
};
|
|
}
|
|
|
|
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();
|
|
|
|
const groupIds = groupsToSetInactive.map((g) => g.id);
|
|
|
|
await trx
|
|
.deleteFrom("GroupLike")
|
|
.where((eb) =>
|
|
eb.or([
|
|
eb("GroupLike.likerGroupId", "in", groupIds),
|
|
eb("GroupLike.targetGroupId", "in", groupIds),
|
|
]),
|
|
)
|
|
.execute();
|
|
|
|
await trx
|
|
.deleteFrom("GroupSuggestion")
|
|
.where((eb) =>
|
|
eb.or([
|
|
eb("GroupSuggestion.suggesterGroupId", "in", groupIds),
|
|
eb("GroupSuggestion.targetGroupId", "in", groupIds),
|
|
]),
|
|
)
|
|
.execute();
|
|
|
|
return trx
|
|
.updateTable("Group")
|
|
.set({ status: "INACTIVE" })
|
|
.where("Group.id", "in", groupIds)
|
|
.executeTakeFirst();
|
|
});
|
|
}
|
|
export async function closeExpiredContinueVotes() {
|
|
const cutoff = dateToDatabaseTimestamp(sub(new Date(), { hours: 1 }));
|
|
|
|
return db.transaction().execute(async (trx) => {
|
|
const eligibleGroups = await trx
|
|
.selectFrom("Group")
|
|
.innerJoin("GroupMatch", (join) =>
|
|
join.on((eb) =>
|
|
eb.or([
|
|
eb("GroupMatch.alphaGroupId", "=", eb.ref("Group.id")),
|
|
eb("GroupMatch.bravoGroupId", "=", eb.ref("Group.id")),
|
|
]),
|
|
),
|
|
)
|
|
.innerJoin("GroupMember", "GroupMember.groupId", "Group.id")
|
|
.leftJoin("GroupMatchContinueVote", (join) =>
|
|
join
|
|
.onRef("GroupMatchContinueVote.groupId", "=", "Group.id")
|
|
.onRef("GroupMatchContinueVote.userId", "=", "GroupMember.userId"),
|
|
)
|
|
.select(["Group.id as groupId", "GroupMatch.chatCode as matchChatCode"])
|
|
.where("Group.matchmade", "=", 1)
|
|
.where("GroupMatch.confirmedAt", "is not", null)
|
|
.where("GroupMatch.confirmedAt", "<", cutoff)
|
|
.where("GroupMatchContinueVote.id", "is", null)
|
|
.groupBy("Group.id")
|
|
.execute();
|
|
|
|
const chatCodesToRevalidate = eligibleGroups
|
|
.map((group) => group.matchChatCode)
|
|
.filter((chatCode) => chatCode !== null);
|
|
|
|
if (eligibleGroups.length > 0) {
|
|
const members = await trx
|
|
.selectFrom("GroupMember")
|
|
.select(["GroupMember.groupId", "GroupMember.userId"])
|
|
.where(
|
|
"GroupMember.groupId",
|
|
"in",
|
|
eligibleGroups.map((group) => group.groupId),
|
|
)
|
|
.execute();
|
|
|
|
await trx
|
|
.insertInto("GroupMatchContinueVote")
|
|
.values(
|
|
members.map((member) => ({
|
|
groupId: member.groupId,
|
|
userId: member.userId,
|
|
isContinuing: 0 as const,
|
|
})),
|
|
)
|
|
.onConflict((oc) =>
|
|
oc.columns(["groupId", "userId"]).doUpdateSet({ isContinuing: 0 }),
|
|
)
|
|
.execute();
|
|
}
|
|
|
|
return {
|
|
chatCodesToRevalidate,
|
|
numAffectedGroups: eligibleGroups.length,
|
|
};
|
|
});
|
|
}
|
|
|
|
export async function findAllMapModePreferencesBySeasonNth(seasonNth: number) {
|
|
return db
|
|
.selectFrom("User")
|
|
.select("User.mapModePreferences")
|
|
.where("User.mapModePreferences", "is not", null)
|
|
.where(({ eb, exists }) =>
|
|
exists(
|
|
eb
|
|
.selectFrom("Skill")
|
|
.select("Skill.id")
|
|
.whereRef("Skill.userId", "=", "User.id")
|
|
.where("Skill.season", "=", seasonNth),
|
|
),
|
|
)
|
|
.$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.confirmedAt", "is not", null)
|
|
.where("GroupMatch.confirmedAt", ">", dateToDatabaseTimestamp(twoHoursAgo))
|
|
.execute();
|
|
|
|
return rows.map((row) => ({
|
|
groupAlphaMemberIds: row.groupAlphaMemberIds.map((m) => m.userId),
|
|
groupBravoMemberIds: row.groupBravoMemberIds.map((m) => m.userId),
|
|
}));
|
|
}
|
|
|
|
export function insertLike({
|
|
likerGroupId,
|
|
targetGroupId,
|
|
createdByUserId,
|
|
}: {
|
|
likerGroupId: number;
|
|
targetGroupId: number;
|
|
createdByUserId: number;
|
|
}) {
|
|
return db.transaction().execute(async (trx) => {
|
|
try {
|
|
await trx
|
|
.insertInto("GroupLike")
|
|
.values({ likerGroupId, targetGroupId, createdByUserId })
|
|
.onConflict((oc) =>
|
|
oc.columns(["likerGroupId", "targetGroupId"]).doNothing(),
|
|
)
|
|
.execute();
|
|
} catch (error) {
|
|
if (errorIsSqliteForeignKeyConstraintFailure(error)) {
|
|
throw new SendouQError(error.message);
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
// inviting says everything the suggestion was there to say
|
|
await trx
|
|
.deleteFrom("GroupSuggestion")
|
|
.where("suggesterGroupId", "=", likerGroupId)
|
|
.where("targetGroupId", "=", targetGroupId)
|
|
.execute();
|
|
|
|
await refreshGroup(likerGroupId, trx);
|
|
});
|
|
}
|
|
|
|
/** Marks a group as worth a look for the suggester's own teammates. Suggesting twice is a no-op. */
|
|
export function insertSuggestion({
|
|
suggesterGroupId,
|
|
targetGroupId,
|
|
createdByUserId,
|
|
}: {
|
|
suggesterGroupId: number;
|
|
targetGroupId: number;
|
|
createdByUserId: number;
|
|
}) {
|
|
return db.transaction().execute(async (trx) => {
|
|
try {
|
|
await trx
|
|
.insertInto("GroupSuggestion")
|
|
.values({ suggesterGroupId, targetGroupId, createdByUserId })
|
|
.onConflict((oc) =>
|
|
oc.columns(["suggesterGroupId", "targetGroupId"]).doNothing(),
|
|
)
|
|
.execute();
|
|
} catch (error) {
|
|
if (errorIsSqliteForeignKeyConstraintFailure(error)) {
|
|
throw new SendouQError(error.message);
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
await refreshGroup(suggesterGroupId, 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);
|
|
});
|
|
}
|
|
|
|
/** Deletes every like where the given group is the liker or the target. */
|
|
export function deleteAllLikesByGroupId(groupId: number) {
|
|
return db.transaction().execute((trx) => deleteLikesByGroupId(groupId, trx));
|
|
}
|
|
|
|
/**
|
|
* Removes the user from their group, deleting the group if they were its last
|
|
* member. A ready check the group was in is called off, its groups returning to
|
|
* the looking pool. Returns the ids of the groups that were in that check.
|
|
*/
|
|
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"])
|
|
.where("userId", "=", userId)
|
|
.where("Group.status", "!=", "INACTIVE")
|
|
.executeTakeFirstOrThrow();
|
|
|
|
// the group can no longer field the match it was about to play, so the
|
|
// other group is freed to look again as well
|
|
const readyCheck = await trx
|
|
.selectFrom("GroupReadyCheck")
|
|
.select([
|
|
"GroupReadyCheck.id",
|
|
"GroupReadyCheck.alphaGroupId",
|
|
"GroupReadyCheck.bravoGroupId",
|
|
])
|
|
.where((eb) =>
|
|
eb.or([
|
|
eb("GroupReadyCheck.alphaGroupId", "=", userGroup.id),
|
|
eb("GroupReadyCheck.bravoGroupId", "=", userGroup.id),
|
|
]),
|
|
)
|
|
.executeTakeFirst();
|
|
|
|
if (readyCheck) {
|
|
await deleteReadyCheckInTrx(
|
|
{ id: readyCheck.id, markMissedMembers: false },
|
|
trx,
|
|
);
|
|
}
|
|
|
|
const abortedReadyCheckGroupIds = readyCheck
|
|
? [readyCheck.alphaGroupId, readyCheck.bravoGroupId]
|
|
: [];
|
|
|
|
await trx
|
|
.deleteFrom("GroupMember")
|
|
.where("userId", "=", userId)
|
|
.where("GroupMember.groupId", "=", userGroup.id)
|
|
.execute();
|
|
|
|
const remainingMember = await trx
|
|
.selectFrom("GroupMember")
|
|
.select(["userId"])
|
|
.where("groupId", "=", userGroup.id)
|
|
.executeTakeFirst();
|
|
|
|
if (!remainingMember) {
|
|
await trx.deleteFrom("Group").where("id", "=", userGroup.id).execute();
|
|
return { abortedReadyCheckGroupIds };
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
await syncTeamId(userGroup.id, trx);
|
|
|
|
return { abortedReadyCheckGroupIds };
|
|
});
|
|
}
|
|
|
|
export function refreshGroup(groupId: number, trx?: Transaction<DB>) {
|
|
return (trx ?? db)
|
|
.updateTable("Group")
|
|
.set({ latestActionAt: databaseTimestampNow() })
|
|
.where("Group.id", "=", groupId)
|
|
.execute();
|
|
}
|
|
|
|
export function updateOwnMemberNote({
|
|
groupId,
|
|
value,
|
|
}: {
|
|
groupId: number;
|
|
value: string | null;
|
|
}) {
|
|
return db.transaction().execute(async (trx) => {
|
|
await trx
|
|
.updateTable("GroupMember")
|
|
.set({ note: value })
|
|
.where("groupId", "=", groupId)
|
|
.where("userId", "=", actorId())
|
|
.execute();
|
|
|
|
await refreshGroup(groupId, trx);
|
|
});
|
|
}
|
|
|
|
/** User ids of the group's members who let a ready check expire without confirming, and can thus be kicked by the rest of the group. */
|
|
export async function findAllMissedReadyCheckUserIdsByGroupId(groupId: number) {
|
|
const rows = await db
|
|
.selectFrom("GroupMember")
|
|
.select("GroupMember.userId")
|
|
.where("GroupMember.groupId", "=", groupId)
|
|
.where("GroupMember.missedReadyCheckAt", "is not", null)
|
|
.execute();
|
|
|
|
return rows.map((row) => row.userId);
|
|
}
|
|
|
|
/** The ready check the group is in, with each member of both groups and when (if at all) they confirmed being ready. */
|
|
export async function findReadyCheckByGroupId(groupId: number) {
|
|
const readyCheck = await db
|
|
.selectFrom("GroupReadyCheck")
|
|
.select([
|
|
"GroupReadyCheck.id",
|
|
"GroupReadyCheck.alphaGroupId",
|
|
"GroupReadyCheck.bravoGroupId",
|
|
"GroupReadyCheck.createdAt",
|
|
])
|
|
.where((eb) =>
|
|
eb.or([
|
|
eb("GroupReadyCheck.alphaGroupId", "=", groupId),
|
|
eb("GroupReadyCheck.bravoGroupId", "=", groupId),
|
|
]),
|
|
)
|
|
.executeTakeFirst();
|
|
|
|
if (!readyCheck) return;
|
|
|
|
const members = await db
|
|
.selectFrom("GroupMember")
|
|
.leftJoin("GroupReadyCheckConfirmation", (join) =>
|
|
join
|
|
.onRef("GroupReadyCheckConfirmation.userId", "=", "GroupMember.userId")
|
|
.on("GroupReadyCheckConfirmation.readyCheckId", "=", readyCheck.id),
|
|
)
|
|
.select([
|
|
"GroupMember.userId",
|
|
"GroupMember.groupId",
|
|
"GroupReadyCheckConfirmation.createdAt as confirmedAt",
|
|
])
|
|
.where("GroupMember.groupId", "in", [
|
|
readyCheck.alphaGroupId,
|
|
readyCheck.bravoGroupId,
|
|
])
|
|
.execute();
|
|
|
|
return { ...readyCheck, members };
|
|
}
|
|
|
|
/** Ready checks that were started before the given time. */
|
|
export function findAllReadyChecksStartedBefore(date: Date) {
|
|
return db
|
|
.selectFrom("GroupReadyCheck")
|
|
.select((eb) => [
|
|
"GroupReadyCheck.id",
|
|
"GroupReadyCheck.alphaGroupId",
|
|
"GroupReadyCheck.bravoGroupId",
|
|
jsonArrayFrom(
|
|
eb
|
|
.selectFrom("GroupMember")
|
|
.select("GroupMember.userId")
|
|
.where((innerEb) =>
|
|
innerEb.or([
|
|
innerEb(
|
|
"GroupMember.groupId",
|
|
"=",
|
|
innerEb.ref("GroupReadyCheck.alphaGroupId"),
|
|
),
|
|
innerEb(
|
|
"GroupMember.groupId",
|
|
"=",
|
|
innerEb.ref("GroupReadyCheck.bravoGroupId"),
|
|
),
|
|
]),
|
|
),
|
|
).as("members"),
|
|
])
|
|
.where("GroupReadyCheck.createdAt", "<", dateToDatabaseTimestamp(date))
|
|
.execute();
|
|
}
|
|
|
|
/**
|
|
* Starts a ready check between two groups, taking both out of the looking pool
|
|
* along with everything they had pending. The user starting it counts as ready
|
|
* right away.
|
|
*/
|
|
export function insertReadyCheck({
|
|
alphaGroupId,
|
|
bravoGroupId,
|
|
confirmedByUserId,
|
|
}: {
|
|
alphaGroupId: number;
|
|
bravoGroupId: number;
|
|
confirmedByUserId: number;
|
|
}) {
|
|
return db.transaction().execute(async (trx) => {
|
|
// the status doubles as the lock that keeps a group out of two ready checks at once
|
|
const { numUpdatedRows } = await trx
|
|
.updateTable("Group")
|
|
.set({ status: "READY_CHECK", latestActionAt: databaseTimestampNow() })
|
|
.where("Group.id", "in", [alphaGroupId, bravoGroupId])
|
|
.where("Group.status", "=", "ACTIVE")
|
|
.executeTakeFirstOrThrow();
|
|
|
|
if (Number(numUpdatedRows) !== 2) {
|
|
throw new SendouQError("Both groups are not available for a ready check");
|
|
}
|
|
|
|
await deleteLikesAndSuggestionsByGroupId(alphaGroupId, trx);
|
|
await deleteLikesAndSuggestionsByGroupId(bravoGroupId, trx);
|
|
|
|
// a new ready check is a fresh chance to show up for everyone
|
|
await trx
|
|
.updateTable("GroupMember")
|
|
.set({ missedReadyCheckAt: null })
|
|
.where("GroupMember.groupId", "in", [alphaGroupId, bravoGroupId])
|
|
.execute();
|
|
|
|
const readyCheck = await trx
|
|
.insertInto("GroupReadyCheck")
|
|
.values({ alphaGroupId, bravoGroupId })
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow();
|
|
|
|
await trx
|
|
.insertInto("GroupReadyCheckConfirmation")
|
|
.values({ readyCheckId: readyCheck.id, userId: confirmedByUserId })
|
|
.execute();
|
|
|
|
return readyCheck;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Records the user as ready to play. Confirming twice is a no-op.
|
|
*
|
|
* @returns Whether every member of both groups has now confirmed, or `null` if the ready check no longer exists
|
|
*/
|
|
export function insertReadyCheckConfirmation({
|
|
readyCheckId,
|
|
userId,
|
|
}: {
|
|
readyCheckId: number;
|
|
userId: number;
|
|
}) {
|
|
return db.transaction().execute(async (trx) => {
|
|
const readyCheck = await trx
|
|
.selectFrom("GroupReadyCheck")
|
|
.select(["GroupReadyCheck.alphaGroupId", "GroupReadyCheck.bravoGroupId"])
|
|
.where("GroupReadyCheck.id", "=", readyCheckId)
|
|
.executeTakeFirst();
|
|
|
|
// someone else's request resolved the ready check while this one was in flight
|
|
if (!readyCheck) return null;
|
|
|
|
await trx
|
|
.insertInto("GroupReadyCheckConfirmation")
|
|
.values({ readyCheckId, userId })
|
|
.onConflict((oc) => oc.columns(["readyCheckId", "userId"]).doNothing())
|
|
.execute();
|
|
|
|
// read back rather than trusting the caller's view of who had confirmed, so
|
|
// that two last confirmations at once can't both conclude someone is missing
|
|
const unconfirmedMember = await trx
|
|
.selectFrom("GroupMember")
|
|
.select("GroupMember.userId")
|
|
.where("GroupMember.groupId", "in", [
|
|
readyCheck.alphaGroupId,
|
|
readyCheck.bravoGroupId,
|
|
])
|
|
.where((eb) => didNotConfirmReadyCheck(eb, readyCheckId))
|
|
.executeTakeFirst();
|
|
|
|
return { everyoneIsReady: !unconfirmedMember };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Ends a ready check, returning both of its groups to the looking pool. With
|
|
* `markMissedMembers` the members who never confirmed are marked as having
|
|
* missed it, which is what lets the rest of their group kick them.
|
|
*/
|
|
export function deleteReadyCheck(
|
|
{ id, markMissedMembers }: { id: number; markMissedMembers: boolean },
|
|
trx?: Transaction<DB>,
|
|
) {
|
|
const run = (trx: Transaction<DB>) =>
|
|
deleteReadyCheckInTrx({ id, markMissedMembers }, trx);
|
|
|
|
return trx ? run(trx) : db.transaction().execute(run);
|
|
}
|
|
|
|
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();
|
|
}
|
|
async function deleteReadyCheckInTrx(
|
|
{ id, markMissedMembers }: { id: number; markMissedMembers: boolean },
|
|
trx: Transaction<DB>,
|
|
) {
|
|
const readyCheck = await trx
|
|
.selectFrom("GroupReadyCheck")
|
|
.select(["GroupReadyCheck.alphaGroupId", "GroupReadyCheck.bravoGroupId"])
|
|
.where("GroupReadyCheck.id", "=", id)
|
|
.executeTakeFirst();
|
|
|
|
if (!readyCheck) return;
|
|
|
|
const groupIds = [readyCheck.alphaGroupId, readyCheck.bravoGroupId];
|
|
|
|
if (markMissedMembers) {
|
|
await trx
|
|
.updateTable("GroupMember")
|
|
.set({ missedReadyCheckAt: databaseTimestampNow() })
|
|
.where("GroupMember.groupId", "in", groupIds)
|
|
.where((eb) => didNotConfirmReadyCheck(eb, id))
|
|
.execute();
|
|
}
|
|
|
|
await trx
|
|
.deleteFrom("GroupReadyCheck")
|
|
.where("GroupReadyCheck.id", "=", id)
|
|
.execute();
|
|
|
|
await trx
|
|
.updateTable("Group")
|
|
.set({ status: "ACTIVE", latestActionAt: databaseTimestampNow() })
|
|
.where("Group.id", "in", groupIds)
|
|
.where("Group.status", "=", "READY_CHECK")
|
|
.execute();
|
|
}
|
|
|
|
/**
|
|
* Records the user as not continuing with the group they last played a matchmade
|
|
* match with, taking that group's votes in favour down with it. Getting a group
|
|
* elsewhere overrides a vote already cast in favour: the group can no longer
|
|
* continue at the size that vote was for, so the rest have to vote again. Once
|
|
* their no vote is in, that group is settled and later queue actions leave it be.
|
|
*/
|
|
async function recordImplicitRejoinNoVote(
|
|
userId: number,
|
|
trx: Transaction<DB>,
|
|
): Promise<string | null> {
|
|
const candidate = await trx
|
|
.selectFrom("GroupMember")
|
|
.innerJoin("Group", "Group.id", "GroupMember.groupId")
|
|
.innerJoin("GroupMatch", (join) =>
|
|
join.on((eb) =>
|
|
eb.or([
|
|
eb("GroupMatch.alphaGroupId", "=", eb.ref("Group.id")),
|
|
eb("GroupMatch.bravoGroupId", "=", eb.ref("Group.id")),
|
|
]),
|
|
),
|
|
)
|
|
.select((eb) => [
|
|
"Group.id as groupId",
|
|
"GroupMatch.chatCode as matchChatCode",
|
|
hasVotedNo(eb, userId).as("alreadySettled"),
|
|
])
|
|
.where("GroupMember.userId", "=", userId)
|
|
.where("Group.matchmade", "=", 1)
|
|
// only the group they came from is still voting, older ones are long settled
|
|
.orderBy("Group.id", "desc")
|
|
.limit(1)
|
|
.executeTakeFirst();
|
|
|
|
if (!candidate || candidate.alreadySettled) return null;
|
|
|
|
await trx
|
|
.deleteFrom("GroupMatchContinueVote")
|
|
.where("GroupMatchContinueVote.groupId", "=", candidate.groupId)
|
|
.where("GroupMatchContinueVote.isContinuing", "=", 1)
|
|
.execute();
|
|
|
|
await trx
|
|
.insertInto("GroupMatchContinueVote")
|
|
.values({
|
|
groupId: candidate.groupId,
|
|
userId,
|
|
isContinuing: 0,
|
|
})
|
|
.onConflict((oc) =>
|
|
oc.columns(["groupId", "userId"]).doUpdateSet({ isContinuing: 0 }),
|
|
)
|
|
.execute();
|
|
|
|
return candidate.matchChatCode;
|
|
}
|
|
|
|
/** Matches the `Group` rows the given user has already voted against continuing with. */
|
|
function hasVotedNo(eb: ExpressionBuilder<DB, "Group">, userId: number) {
|
|
return eb.exists(
|
|
eb
|
|
.selectFrom("GroupMatchContinueVote")
|
|
.select("GroupMatchContinueVote.id")
|
|
.whereRef("GroupMatchContinueVote.groupId", "=", "Group.id")
|
|
.where("GroupMatchContinueVote.userId", "=", userId)
|
|
.where("GroupMatchContinueVote.isContinuing", "=", 0),
|
|
);
|
|
}
|
|
|
|
/** Matches the `GroupMember` rows that have no confirmation for the given ready check. */
|
|
function didNotConfirmReadyCheck(
|
|
eb: ExpressionBuilder<DB, "GroupMember">,
|
|
readyCheckId: number,
|
|
) {
|
|
return eb.not(
|
|
eb.exists(
|
|
eb
|
|
.selectFrom("GroupReadyCheckConfirmation")
|
|
.select("GroupReadyCheckConfirmation.userId")
|
|
.where("GroupReadyCheckConfirmation.readyCheckId", "=", readyCheckId)
|
|
.whereRef(
|
|
"GroupReadyCheckConfirmation.userId",
|
|
"=",
|
|
"GroupMember.userId",
|
|
),
|
|
),
|
|
);
|
|
}
|