mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-27 13:47:56 -05:00
1300 lines
35 KiB
TypeScript
1300 lines
35 KiB
TypeScript
import { addHours, 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 * as ChatRepository from "~/features/chat/ChatRepository.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";
|
|
|
|
const CHAT_ROOM_LIFESPAN_HOURS = 12;
|
|
|
|
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();
|
|
}
|
|
|
|
/** Groups owning the given chat rooms, with their members' user ids. */
|
|
export async function findAllByChatRoomIds(chatRoomIds: number[]) {
|
|
if (chatRoomIds.length === 0) return [];
|
|
|
|
return db
|
|
.selectFrom("Group")
|
|
.select((eb) => [
|
|
"Group.chatRoomId",
|
|
"Group.status",
|
|
jsonArrayFrom(
|
|
eb
|
|
.selectFrom("GroupMember")
|
|
.select("GroupMember.userId")
|
|
.whereRef("GroupMember.groupId", "=", "Group.id"),
|
|
).as("members"),
|
|
])
|
|
.where("Group.chatRoomId", "in", chatRoomIds)
|
|
.$narrowType<{ chatRoomId: 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.chatRoomId",
|
|
"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 chatRoom = await ChatRepository.insertRoom(
|
|
{
|
|
type: "SQ_GROUP",
|
|
expiresAt: addHours(new Date(), CHAT_ROOM_LIFESPAN_HOURS),
|
|
},
|
|
trx,
|
|
);
|
|
|
|
const createdGroup = await trx
|
|
.insertInto("Group")
|
|
.values({
|
|
inviteCode: shortNanoid(),
|
|
chatRoomId: chatRoom.id,
|
|
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 chatRoomIdToRevalidate = await recordImplicitRejoinNoVote(
|
|
args.userId,
|
|
trx,
|
|
);
|
|
|
|
return { id: createdGroup.id, chatRoomIdToRevalidate };
|
|
});
|
|
}
|
|
|
|
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 previousGroup = await trx
|
|
.selectFrom("Group")
|
|
.select(["Group.chatRoomId", "Group.matchmade"])
|
|
.where("Group.id", "=", args.previousGroupId)
|
|
.executeTakeFirstOrThrow();
|
|
|
|
// the successor group carries the previous group's chat over; the room's
|
|
// unique owner index requires the previous group to release it first
|
|
let chatRoomId = previousGroup.chatRoomId;
|
|
if (chatRoomId !== null) {
|
|
await trx
|
|
.updateTable("Group")
|
|
.set({ chatRoomId: null })
|
|
.where("Group.id", "=", args.previousGroupId)
|
|
.execute();
|
|
await ChatRepository.updateRoomExpiresAt(
|
|
{
|
|
roomId: chatRoomId,
|
|
expiresAt: addHours(new Date(), CHAT_ROOM_LIFESPAN_HOURS),
|
|
},
|
|
trx,
|
|
);
|
|
} else {
|
|
chatRoomId = (
|
|
await ChatRepository.insertRoom(
|
|
{
|
|
type: "SQ_GROUP",
|
|
expiresAt: addHours(new Date(), CHAT_ROOM_LIFESPAN_HOURS),
|
|
},
|
|
trx,
|
|
)
|
|
).id;
|
|
}
|
|
|
|
const createdGroup = await trx
|
|
.insertInto("Group")
|
|
.values({
|
|
chatRoomId,
|
|
inviteCode: shortNanoid(),
|
|
status,
|
|
matchmade: previousGroup.matchmade,
|
|
})
|
|
.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 members share one, else clears it. A team's group queues on the team's own map & mode preferences. */
|
|
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 with the group on either side, for when they stop being actionable (roster changed, match started). */
|
|
export async function deleteLikesAndSuggestionsByGroupId(
|
|
groupId: number,
|
|
trx: Transaction<DB>,
|
|
) {
|
|
await deleteLikesByGroupId(groupId, trx);
|
|
await deleteSuggestionsByGroupId(groupId, trx);
|
|
}
|
|
|
|
/** Clears what the departing member is responsible for: every challenge the group received (the roster the other group challenged is gone) plus the challenges and suggestions that member made themselves. */
|
|
async function deleteLikesAndSuggestionsOnLeave(
|
|
{ groupId, userId }: { groupId: number; userId: number },
|
|
trx: Transaction<DB>,
|
|
) {
|
|
await trx
|
|
.deleteFrom("GroupLike")
|
|
.where((eb) =>
|
|
eb.or([
|
|
eb("GroupLike.targetGroupId", "=", groupId),
|
|
eb.and([
|
|
eb("GroupLike.likerGroupId", "=", groupId),
|
|
eb("GroupLike.createdByUserId", "=", userId),
|
|
]),
|
|
]),
|
|
)
|
|
.execute();
|
|
|
|
await trx
|
|
.deleteFrom("GroupSuggestion")
|
|
.where("GroupSuggestion.suggesterGroupId", "=", groupId)
|
|
.where("GroupSuggestion.createdByUserId", "=", userId)
|
|
.execute();
|
|
}
|
|
|
|
export function morphGroups({
|
|
survivingGroupId,
|
|
otherGroupId,
|
|
}: {
|
|
survivingGroupId: number;
|
|
otherGroupId: number;
|
|
}) {
|
|
return db.transaction().execute(async (trx) => {
|
|
const oldChatRooms = await trx
|
|
.selectFrom("Group")
|
|
.select(["Group.chatRoomId"])
|
|
.where("Group.id", "in", [survivingGroupId, otherGroupId])
|
|
.execute();
|
|
|
|
// fresh chat room so neither group's previous messages are visible, and
|
|
// mark as matchmade
|
|
const chatRoom = await ChatRepository.insertRoom(
|
|
{
|
|
type: "SQ_GROUP",
|
|
expiresAt: addHours(new Date(), CHAT_ROOM_LIFESPAN_HOURS),
|
|
},
|
|
trx,
|
|
);
|
|
await trx
|
|
.updateTable("Group")
|
|
.set({ chatRoomId: chatRoom.id, 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 ChatRepository.deleteRoomsByIds(
|
|
oldChatRooms.map((room) => room.chatRoomId),
|
|
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 chatRoomIdToRevalidate = 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 { chatRoomIdToRevalidate };
|
|
}
|
|
|
|
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",
|
|
"TeamMemberWithSecondary.role",
|
|
"TeamMemberWithSecondary.roleType",
|
|
])
|
|
.where(
|
|
"TeamMemberWithSecondary.teamId",
|
|
"in",
|
|
teams.map((t) => t.id),
|
|
)
|
|
.union((eb) =>
|
|
eb
|
|
.selectFrom("Friendship")
|
|
.innerJoin("User", (join) =>
|
|
join.on((joinEb) =>
|
|
joinEb.or([
|
|
joinEb.and([
|
|
joinEb("Friendship.userOneId", "=", userId),
|
|
joinEb("User.id", "=", joinEb.ref("Friendship.userTwoId")),
|
|
]),
|
|
joinEb.and([
|
|
joinEb("Friendship.userTwoId", "=", userId),
|
|
joinEb("User.id", "=", joinEb.ref("Friendship.userOneId")),
|
|
]),
|
|
]),
|
|
),
|
|
)
|
|
.select((friendEb) => [
|
|
...commonUserSelect(friendEb),
|
|
"User.inGameName",
|
|
sql<any>`null`.as("teamId"),
|
|
sql<Tables["TeamMember"]["role"]>`null`.as("role"),
|
|
sql<Tables["TeamMember"]["roleType"]>`null`.as("roleType"),
|
|
]),
|
|
)
|
|
.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.chatRoomId as matchChatRoomId",
|
|
])
|
|
.where("Group.matchmade", "=", 1)
|
|
.where("GroupMatch.confirmedAt", "is not", null)
|
|
.where("GroupMatch.confirmedAt", "<", cutoff)
|
|
.where("GroupMatchContinueVote.id", "is", null)
|
|
.groupBy("Group.id")
|
|
.execute();
|
|
|
|
const chatRoomIdsToRevalidate = eligibleGroups
|
|
.map((group) => group.matchChatRoomId)
|
|
.filter((chatRoomId) => chatRoomId !== 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 {
|
|
chatRoomIdsToRevalidate,
|
|
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, { cause: error });
|
|
}
|
|
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, { cause: error });
|
|
}
|
|
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 it if they were last). A ready check the group was in is called off; returns the ids of the groups that were in it. Challenges the group received and challenges/suggestions the leaver made are cleared. */
|
|
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", "Group.chatRoomId"])
|
|
.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 ChatRepository.deleteRoomsByIds([userGroup.chatRoomId], trx);
|
|
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 deleteLikesAndSuggestionsOnLeave(
|
|
{ groupId: userGroup.id, userId },
|
|
trx,
|
|
);
|
|
|
|
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 and everything they had pending out of the looking pool. The starter 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 groups to the looking pool. `markMissedMembers` marks the unconfirmed as having missed it, which lets the rest of their group kick them. */
|
|
export function deleteReadyCheck(
|
|
{ id, markMissedMembers }: { id: number; markMissedMembers: boolean },
|
|
trx?: Transaction<DB>,
|
|
) {
|
|
const run = (transaction: Transaction<DB>) =>
|
|
deleteReadyCheckInTrx({ id, markMissedMembers }, transaction);
|
|
|
|
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,
|
|
* clearing that group's yes votes: it can no longer continue at the size they were for, so the
|
|
* rest have to vote again. Once the no vote is in, later queue actions leave that group be.
|
|
*/
|
|
async function recordImplicitRejoinNoVote(
|
|
userId: number,
|
|
trx: Transaction<DB>,
|
|
): Promise<number | 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.chatRoomId as matchChatRoomId",
|
|
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.matchChatRoomId;
|
|
}
|
|
|
|
/** 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",
|
|
),
|
|
),
|
|
);
|
|
}
|