+
{streamedMatch.stream.viewerCount}
diff --git a/app/features/sendouq/PrivateUserNoteRepository.server.ts b/app/features/sendouq/PrivateUserNoteRepository.server.ts
new file mode 100644
index 000000000..86349a1ae
--- /dev/null
+++ b/app/features/sendouq/PrivateUserNoteRepository.server.ts
@@ -0,0 +1,61 @@
+import { db } from "~/db/sql";
+import type { TablesInsertable } from "~/db/tables";
+import { databaseTimestampNow } from "~/utils/dates";
+
+export function byAuthorUserId(
+ authorId: number,
+ /** Which users to get notes for, if omitted all notes for author are returned */
+ targetUserIds: number[] = [],
+) {
+ let query = db
+ .selectFrom("PrivateUserNote")
+ .select([
+ "PrivateUserNote.sentiment",
+ "PrivateUserNote.targetId as targetUserId",
+ "PrivateUserNote.text",
+ "PrivateUserNote.updatedAt",
+ ])
+ .where("authorId", "=", authorId);
+
+ const targetUsersWithoutAuthor = targetUserIds.filter(
+ (id) => id !== authorId,
+ );
+ if (targetUsersWithoutAuthor.length > 0) {
+ query = query.where("targetId", "in", targetUsersWithoutAuthor);
+ }
+
+ return query.execute();
+}
+
+export function upsert(args: TablesInsertable["PrivateUserNote"]) {
+ return db
+ .insertInto("PrivateUserNote")
+ .values({
+ authorId: args.authorId,
+ targetId: args.targetId,
+ sentiment: args.sentiment,
+ text: args.text,
+ })
+ .onConflict((oc) =>
+ oc.columns(["authorId", "targetId"]).doUpdateSet({
+ sentiment: args.sentiment,
+ text: args.text,
+ updatedAt: databaseTimestampNow(),
+ }),
+ )
+ .execute();
+}
+
+export function del({
+ authorId,
+ targetId,
+}: {
+ authorId: number;
+ targetId: number;
+}) {
+ return db
+ .deleteFrom("PrivateUserNote")
+ .where("authorId", "=", authorId)
+ .where("targetId", "=", targetId)
+ .execute();
+}
diff --git a/app/features/sendouq/QRepository.server.ts b/app/features/sendouq/QRepository.server.ts
deleted file mode 100644
index ea0ccfa56..000000000
--- a/app/features/sendouq/QRepository.server.ts
+++ /dev/null
@@ -1,409 +0,0 @@
-import { sub } from "date-fns";
-import { type NotNull, sql } from "kysely";
-import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
-import { db } from "~/db/sql";
-import type {
- Tables,
- TablesInsertable,
- UserMapModePreferences,
-} from "~/db/tables";
-import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
-import { IS_E2E_TEST_RUN } from "~/utils/e2e";
-import { shortNanoid } from "~/utils/id";
-import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
-import { userIsBanned } from "../ban/core/banned.server";
-import type { LookingGroupWithInviteCode } from "./q-types";
-
-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();
-}
-
-// groups visible for longer to make development easier
-const SECONDS_TILL_STALE =
- process.env.NODE_ENV === "development" || IS_E2E_TEST_RUN ? 1_000_000 : 1_800;
-
-export async function findLookingGroups({
- minGroupSize,
- maxGroupSize,
- ownGroupId,
- includeChatCode = false,
- includeMapModePreferences = false,
- loggedInUserId,
-}: {
- minGroupSize?: number;
- maxGroupSize?: number;
- ownGroupId?: number;
- includeChatCode?: boolean;
- includeMapModePreferences?: boolean;
- loggedInUserId?: number;
-}): Promise
{
- const rows = await db
- .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((eb) => [
- "Group.id",
- "Group.createdAt",
- "Group.chatCode",
- "Group.inviteCode",
- jsonArrayFrom(
- eb
- .selectFrom("GroupMember")
- .innerJoin("User", "User.id", "GroupMember.userId")
- .leftJoin("PlusTier", "PlusTier.userId", "GroupMember.userId")
- .select((arrayEb) => [
- ...COMMON_USER_FIELDS,
- "User.qWeaponPool as weapons",
- "PlusTier.tier as plusTier",
- "GroupMember.note",
- "GroupMember.role",
- "User.languages",
- "User.vc",
- "User.noScreen",
- jsonObjectFrom(
- eb
- .selectFrom("PrivateUserNote")
- .select([
- "PrivateUserNote.sentiment",
- "PrivateUserNote.text",
- "PrivateUserNote.updatedAt",
- ])
- .where("authorId", "=", loggedInUserId ?? -1)
- .where("targetId", "=", arrayEb.ref("User.id")),
- ).as("privateNote"),
- sql<
- string | null
- >`IIF(COALESCE("User"."patronTier", 0) >= 2, "User"."css" ->> 'chat', null)`.as(
- "chatNameColor",
- ),
- ])
- .where("GroupMember.groupId", "=", eb.ref("Group.id"))
- .groupBy("GroupMember.userId"),
- ).as("members"),
- ])
- .$if(includeMapModePreferences, (qb) =>
- qb.select((eb) =>
- jsonArrayFrom(
- eb
- .selectFrom("GroupMember")
- .innerJoin("User", "User.id", "GroupMember.userId")
- .select("User.mapModePreferences")
- .where("GroupMember.groupId", "=", eb.ref("Group.id"))
- .where("User.mapModePreferences", "is not", null),
- ).as("mapModePreferences"),
- ),
- )
- .where("Group.status", "=", "ACTIVE")
- .where("GroupMatch.id", "is", null)
- .where((eb) =>
- eb.or([
- eb(
- "Group.latestActionAt",
- ">",
- sql`(unixepoch() - ${SECONDS_TILL_STALE})`,
- ),
- eb("Group.id", "=", ownGroupId ?? -1),
- ]),
- )
- .execute();
-
- // TODO: a bit weird we filter chatCode here but not inviteCode and do some logic about filtering
- return rows
- .map((row) => {
- return {
- ...row,
- chatCode: includeChatCode ? row.chatCode : undefined,
- mapModePreferences: row.mapModePreferences?.map(
- (c) => c.mapModePreferences,
- ) as NonNullable[],
- members: row.members.map((member) => {
- return {
- ...member,
- languages: member.languages ? member.languages.split(",") : [],
- } as LookingGroupWithInviteCode["members"][number];
- }),
- };
- })
- .filter((group) => {
- if (group.id === ownGroupId) return true;
- if (maxGroupSize && group.members.length > maxGroupSize) return false;
- if (minGroupSize && group.members.length < minGroupSize) return false;
-
- return true;
- });
-}
-
-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;
- 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();
-
- 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();
-
- return createdGroup;
- });
-}
-
-export function rechallenge({
- likerGroupId,
- targetGroupId,
-}: {
- likerGroupId: number;
- targetGroupId: number;
-}) {
- return db
- .updateTable("GroupLike")
- .set({ isRechallenge: 1 })
- .where("likerGroupId", "=", likerGroupId)
- .where("targetGroupId", "=", targetGroupId)
- .execute();
-}
-
-export function upsertPrivateUserNote(
- args: TablesInsertable["PrivateUserNote"],
-) {
- return db
- .insertInto("PrivateUserNote")
- .values({
- authorId: args.authorId,
- targetId: args.targetId,
- sentiment: args.sentiment,
- text: args.text,
- })
- .onConflict((oc) =>
- oc.columns(["authorId", "targetId"]).doUpdateSet({
- sentiment: args.sentiment,
- text: args.text,
- updatedAt: dateToDatabaseTimestamp(new Date()),
- }),
- )
- .execute();
-}
-
-export function deletePrivateUserNote({
- authorId,
- targetId,
-}: {
- authorId: number;
- targetId: number;
-}) {
- return db
- .deleteFrom("PrivateUserNote")
- .where("authorId", "=", authorId)
- .where("targetId", "=", targetId)
- .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`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();
-}
diff --git a/app/features/sendouq/SQGroupRepository.server.ts b/app/features/sendouq/SQGroupRepository.server.ts
new file mode 100644
index 000000000..60a44777f
--- /dev/null
+++ b/app/features/sendouq/SQGroupRepository.server.ts
@@ -0,0 +1,694 @@
+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"];
+ mapModePreferences: Tables["User"]["mapModePreferences"];
+ noScreen: Tables["User"]["noScreen"];
+ languages: Tables["User"]["languages"];
+ vc: Tables["User"]["vc"];
+ role: Tables["GroupMember"]["role"];
+ 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"),
+ weapons: eb.ref("User.qWeaponPool"),
+ languages: eb.ref("User.languages"),
+ plusTier: eb.ref("PlusTier.tier"),
+ vc: eb.ref("User.vc"),
+ chatNameColor: userChatNameColorForJson,
+ }),
+ ])
+ .$castTo()
+ .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;
+ 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) {
+ 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,
+): Promise {
+ 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`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) {
+ 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();
+}
diff --git a/app/features/sendouq/actions/q.looking.server.ts b/app/features/sendouq/actions/q.looking.server.ts
index e99ee7940..e9e9eee3f 100644
--- a/app/features/sendouq/actions/q.looking.server.ts
+++ b/app/features/sendouq/actions/q.looking.server.ts
@@ -3,41 +3,20 @@ import { redirect } from "@remix-run/node";
import { requireUser } from "~/features/auth/core/user.server";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import { notify } from "~/features/notifications/core/notify.server";
-import * as QRepository from "~/features/sendouq/QRepository.server";
-import type { LookingGroupWithInviteCode } from "~/features/sendouq/q-types";
+import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import {
createMatchMemento,
matchMapList,
} from "~/features/sendouq-match/core/match.server";
-import invariant from "~/utils/invariant";
-import { logger } from "~/utils/logger";
-import {
- errorToast,
- errorToastIfFalsy,
- parseRequestPayload,
-} from "~/utils/remix.server";
-import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql";
+import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
+import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { SENDOUQ_PAGE, sendouQMatchPage } from "~/utils/urls";
import { groupAfterMorph } from "../core/groups";
-import { membersNeededForFull } from "../core/groups.server";
-import { FULL_GROUP_SIZE } from "../q-constants";
+import { refreshSendouQInstance, SendouQ } from "../core/SendouQ.server";
+import * as PrivateUserNoteRepository from "../PrivateUserNoteRepository.server";
import { lookingSchema } from "../q-schemas.server";
-import { addLike } from "../queries/addLike.server";
-import { addManagerRole } from "../queries/addManagerRole.server";
-import { chatCodeByGroupId } from "../queries/chatCodeByGroupId.server";
-import { createMatch } from "../queries/createMatch.server";
-import { deleteLike } from "../queries/deleteLike.server";
-import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
-import { groupHasMatch } from "../queries/groupHasMatch.server";
-import { groupSize } from "../queries/groupSize.server";
-import { groupSuccessorOwner } from "../queries/groupSuccessorOwner";
-import { leaveGroup } from "../queries/leaveGroup.server";
-import { likeExists } from "../queries/likeExists.server";
-import { morphGroups } from "../queries/morphGroups.server";
-import { refreshGroup } from "../queries/refreshGroup.server";
-import { removeManagerRole } from "../queries/removeManagerRole.server";
-import { updateNote } from "../queries/updateNote.server";
+import { SendouQError } from "../q-utils.server";
// this function doesn't throw normally because we are assuming
// if there is a validation error the user saw stale data
@@ -48,362 +27,278 @@ export const action: ActionFunction = async ({ request }) => {
request,
schema: lookingSchema,
});
- const currentGroup = findCurrentGroupByUserId(user.id);
+ const currentGroup = SendouQ.findOwnGroup(user.id);
if (!currentGroup) return null;
- // this throws because there should normally be no way user loses ownership by the action of some other user
- const validateIsGroupOwner = () =>
- errorToastIfFalsy(currentGroup.role === "OWNER", "Not owner");
- const isGroupManager = () =>
- currentGroup.role === "MANAGER" || currentGroup.role === "OWNER";
+ try {
+ // this throws because there should normally be no way user loses ownership by the action of some other user
+ const validateIsGroupOwner = () =>
+ errorToastIfFalsy(currentGroup.usersRole === "OWNER", "Not owner");
+ const isGroupManager = () =>
+ currentGroup.usersRole === "MANAGER" ||
+ currentGroup.usersRole === "OWNER";
- switch (data._action) {
- case "LIKE": {
- if (!isGroupManager()) return null;
+ switch (data._action) {
+ case "LIKE": {
+ if (!isGroupManager()) return null;
- try {
- addLike({
+ await SQGroupRepository.addLike({
likerGroupId: currentGroup.id,
targetGroupId: data.targetGroupId,
});
- } catch (e) {
- // the group disbanded before we could like it
- if (errorIsSqliteForeignKeyConstraintFailure(e)) return null;
- throw e;
- }
- refreshGroup(currentGroup.id);
-
- const targetChatCode = chatCodeByGroupId(data.targetGroupId);
- if (targetChatCode) {
- ChatSystemMessage.send({
- room: targetChatCode,
- type: "LIKE_RECEIVED",
- revalidateOnly: true,
- });
- }
-
- break;
- }
- case "RECHALLENGE": {
- if (!isGroupManager()) return null;
-
- await QRepository.rechallenge({
- likerGroupId: currentGroup.id,
- targetGroupId: data.targetGroupId,
- });
-
- const targetChatCode = chatCodeByGroupId(data.targetGroupId);
- if (targetChatCode) {
- ChatSystemMessage.send({
- room: targetChatCode,
- type: "LIKE_RECEIVED",
- revalidateOnly: true,
- });
- }
- break;
- }
- case "UNLIKE": {
- if (!isGroupManager()) return null;
-
- deleteLike({
- likerGroupId: currentGroup.id,
- targetGroupId: data.targetGroupId,
- });
- refreshGroup(currentGroup.id);
-
- break;
- }
- case "GROUP_UP": {
- if (!isGroupManager()) return null;
- if (
- !likeExists({
- targetGroupId: currentGroup.id,
- likerGroupId: data.targetGroupId,
- })
- ) {
- return null;
- }
-
- const lookingGroups = await QRepository.findLookingGroups({
- maxGroupSize: membersNeededForFull(groupSize(currentGroup.id)),
- ownGroupId: currentGroup.id,
- includeChatCode: true,
- });
-
- const ourGroup = lookingGroups.find(
- (group) => group.id === currentGroup.id,
- );
- if (!ourGroup) return null;
- const theirGroup = lookingGroups.find(
- (group) => group.id === data.targetGroupId,
- );
- if (!theirGroup) return null;
-
- const { id: survivingGroupId } = groupAfterMorph({
- liker: "THEM",
- ourGroup,
- theirGroup,
- });
-
- const otherGroup =
- ourGroup.id === survivingGroupId ? theirGroup : ourGroup;
-
- invariant(ourGroup.members, "our group has no members");
- invariant(otherGroup.members, "other group has no members");
-
- morphGroups({
- survivingGroupId,
- otherGroupId: otherGroup.id,
- newMembers: otherGroup.members.map((m) => m.id),
- });
- refreshGroup(survivingGroupId);
-
- if (ourGroup.chatCode && theirGroup.chatCode) {
- ChatSystemMessage.send([
- {
- room: ourGroup.chatCode,
- type: "NEW_GROUP",
+ const targetChatCode = SendouQ.findUncensoredGroupById(
+ data.targetGroupId,
+ )?.chatCode;
+ if (targetChatCode) {
+ ChatSystemMessage.send({
+ room: targetChatCode,
+ type: "LIKE_RECEIVED",
revalidateOnly: true,
- },
- {
- room: theirGroup.chatCode,
- type: "NEW_GROUP",
- revalidateOnly: true,
- },
- ]);
+ });
+ }
+
+ break;
}
+ case "RECHALLENGE": {
+ if (!isGroupManager()) return null;
- break;
- }
- case "MATCH_UP_RECHALLENGE":
- case "MATCH_UP": {
- if (!isGroupManager()) return null;
- if (
- !likeExists({
- targetGroupId: currentGroup.id,
- likerGroupId: data.targetGroupId,
- })
- ) {
- return null;
- }
-
- const lookingGroups = await QRepository.findLookingGroups({
- minGroupSize: FULL_GROUP_SIZE,
- ownGroupId: currentGroup.id,
- includeChatCode: true,
- });
-
- const ourGroup = lookingGroups.find(
- (group) => group.id === currentGroup.id,
- );
- if (!ourGroup) return null;
- const theirGroup = lookingGroups.find(
- (group) => group.id === data.targetGroupId,
- );
- if (!theirGroup) return null;
-
- errorToastIfFalsy(
- ourGroup.members.length === FULL_GROUP_SIZE,
- "Our group is not full",
- );
- errorToastIfFalsy(
- theirGroup.members.length === FULL_GROUP_SIZE,
- "Their group is not full",
- );
-
- errorToastIfFalsy(
- !groupHasMatch(ourGroup.id),
- "Our group already has a match",
- );
- errorToastIfFalsy(
- !groupHasMatch(theirGroup.id),
- "Their group already has a match",
- );
-
- const ourGroupPreferences = await QRepository.mapModePreferencesByGroupId(
- ourGroup.id,
- );
- const theirGroupPreferences =
- await QRepository.mapModePreferencesByGroupId(theirGroup.id);
- const mapList = await matchMapList(
- {
- id: ourGroup.id,
- preferences: ourGroupPreferences,
- },
- {
- id: theirGroup.id,
- preferences: theirGroupPreferences,
- ignoreModePreferences: data._action === "MATCH_UP_RECHALLENGE",
- },
- );
-
- const memberInManyGroups = verifyNoMemberInTwoGroups(
- [...ourGroup.members, ...theirGroup.members],
- lookingGroups,
- );
- if (memberInManyGroups) {
- logger.error("User in two groups preventing match creation", {
- userId: memberInManyGroups.id,
+ await SQGroupRepository.rechallenge({
+ likerGroupId: currentGroup.id,
+ targetGroupId: data.targetGroupId,
});
- errorToast(
- `${memberInManyGroups.username} is in two groups so match can't be started`,
+ const targetChatCode = SendouQ.findUncensoredGroupById(
+ data.targetGroupId,
+ )?.chatCode;
+ if (targetChatCode) {
+ ChatSystemMessage.send({
+ room: targetChatCode,
+ type: "LIKE_RECEIVED",
+ revalidateOnly: true,
+ });
+ }
+ break;
+ }
+ case "UNLIKE": {
+ if (!isGroupManager()) return null;
+
+ await SQGroupRepository.deleteLike({
+ likerGroupId: currentGroup.id,
+ targetGroupId: data.targetGroupId,
+ });
+
+ break;
+ }
+ case "GROUP_UP": {
+ if (!isGroupManager()) return null;
+
+ const allLikes = await SQGroupRepository.allLikesByGroupId(
+ data.targetGroupId,
);
- }
+ if (!allLikes.given.some((like) => like.groupId === currentGroup.id)) {
+ return null;
+ }
- const createdMatch = createMatch({
- alphaGroupId: ourGroup.id,
- bravoGroupId: theirGroup.id,
- mapList,
- memento: createMatchMemento({
- own: { group: ourGroup, preferences: ourGroupPreferences },
- their: { group: theirGroup, preferences: theirGroupPreferences },
- mapList,
- }),
- });
+ const ourGroup = SendouQ.findOwnGroup(user.id);
+ const theirGroup = SendouQ.findUncensoredGroupById(data.targetGroupId);
+ if (!ourGroup || !theirGroup) return null;
- if (ourGroup.chatCode && theirGroup.chatCode) {
- ChatSystemMessage.send([
- {
- room: ourGroup.chatCode,
- type: "MATCH_STARTED",
- revalidateOnly: true,
- },
- {
- room: theirGroup.chatCode,
- type: "MATCH_STARTED",
- revalidateOnly: true,
- },
- ]);
- }
-
- notify({
- userIds: [
- ...ourGroup.members.map((m) => m.id),
- ...theirGroup.members.map((m) => m.id),
- ],
- defaultSeenUserIds: [user.id],
- notification: {
- type: "SQ_NEW_MATCH",
- meta: {
- matchId: createdMatch.id,
- },
- },
- });
-
- throw redirect(sendouQMatchPage(createdMatch.id));
- }
- case "GIVE_MANAGER": {
- validateIsGroupOwner();
-
- addManagerRole({
- groupId: currentGroup.id,
- userId: data.userId,
- });
- refreshGroup(currentGroup.id);
-
- break;
- }
- case "REMOVE_MANAGER": {
- validateIsGroupOwner();
-
- removeManagerRole({
- groupId: currentGroup.id,
- userId: data.userId,
- });
- refreshGroup(currentGroup.id);
-
- break;
- }
- case "LEAVE_GROUP": {
- errorToastIfFalsy(
- !currentGroup.matchId,
- "Can't leave group while in a match",
- );
- let newOwnerId: number | null = null;
- if (currentGroup.role === "OWNER") {
- newOwnerId = groupSuccessorOwner(currentGroup.id);
- }
-
- leaveGroup({
- groupId: currentGroup.id,
- userId: user.id,
- newOwnerId,
- wasOwner: currentGroup.role === "OWNER",
- });
-
- const targetChatCode = chatCodeByGroupId(currentGroup.id);
- if (targetChatCode) {
- ChatSystemMessage.send({
- room: targetChatCode,
- type: "USER_LEFT",
- context: { name: user.username },
+ const { id: survivingGroupId } = groupAfterMorph({
+ liker: "THEM",
+ ourGroup,
+ theirGroup,
});
+
+ const otherGroup =
+ ourGroup.id === survivingGroupId ? theirGroup : ourGroup;
+
+ await SQGroupRepository.morphGroups({
+ survivingGroupId,
+ otherGroupId: otherGroup.id,
+ });
+
+ await refreshSendouQInstance();
+
+ if (ourGroup.chatCode && theirGroup.chatCode) {
+ ChatSystemMessage.send([
+ {
+ room: ourGroup.chatCode,
+ type: "NEW_GROUP",
+ revalidateOnly: true,
+ },
+ {
+ room: theirGroup.chatCode,
+ type: "NEW_GROUP",
+ revalidateOnly: true,
+ },
+ ]);
+ }
+
+ break;
}
+ case "MATCH_UP_RECHALLENGE":
+ case "MATCH_UP": {
+ if (!isGroupManager()) return null;
- throw redirect(SENDOUQ_PAGE);
- }
- case "KICK_FROM_GROUP": {
- validateIsGroupOwner();
- errorToastIfFalsy(data.userId !== user.id, "Can't kick yourself");
+ const ourGroup = SendouQ.findOwnGroup(user.id);
+ const theirGroup = SendouQ.findUncensoredGroupById(data.targetGroupId);
+ if (!ourGroup || !theirGroup) return null;
- leaveGroup({
- groupId: currentGroup.id,
- userId: data.userId,
- newOwnerId: null,
- wasOwner: false,
- });
+ const ourGroupPreferences =
+ await SQGroupRepository.mapModePreferencesByGroupId(ourGroup.id);
+ const theirGroupPreferences =
+ await SQGroupRepository.mapModePreferencesByGroupId(theirGroup.id);
+ const mapList = await matchMapList(
+ {
+ id: ourGroup.id,
+ preferences: ourGroupPreferences,
+ },
+ {
+ id: theirGroup.id,
+ preferences: theirGroupPreferences,
+ ignoreModePreferences: data._action === "MATCH_UP_RECHALLENGE",
+ },
+ );
- break;
- }
- case "REFRESH_GROUP": {
- refreshGroup(currentGroup.id);
+ const createdMatch = await SQMatchRepository.create({
+ alphaGroupId: ourGroup.id,
+ bravoGroupId: theirGroup.id,
+ mapList,
+ memento: createMatchMemento({
+ own: { group: ourGroup, preferences: ourGroupPreferences },
+ their: { group: theirGroup, preferences: theirGroupPreferences },
+ mapList,
+ }),
+ });
- break;
- }
- case "UPDATE_NOTE": {
- updateNote({
- note: data.value,
- groupId: currentGroup.id,
- userId: user.id,
- });
- refreshGroup(currentGroup.id);
+ await refreshSendouQInstance();
- break;
- }
- case "DELETE_PRIVATE_USER_NOTE": {
- await QRepository.deletePrivateUserNote({
- authorId: user.id,
- targetId: data.targetId,
- });
+ if (ourGroup.chatCode && theirGroup.chatCode) {
+ ChatSystemMessage.send([
+ {
+ room: ourGroup.chatCode,
+ type: "MATCH_STARTED",
+ revalidateOnly: true,
+ },
+ {
+ room: theirGroup.chatCode,
+ type: "MATCH_STARTED",
+ revalidateOnly: true,
+ },
+ ]);
+ }
- break;
+ notify({
+ userIds: [
+ ...ourGroup.members.map((m) => m.id),
+ ...theirGroup.members.map((m) => m.id),
+ ],
+ defaultSeenUserIds: [user.id],
+ notification: {
+ type: "SQ_NEW_MATCH",
+ meta: {
+ matchId: createdMatch.id,
+ },
+ },
+ });
+
+ throw redirect(sendouQMatchPage(createdMatch.id));
+ }
+ case "GIVE_MANAGER": {
+ validateIsGroupOwner();
+
+ await SQGroupRepository.updateMemberRole({
+ groupId: currentGroup.id,
+ userId: data.userId,
+ role: "MANAGER",
+ });
+
+ await refreshSendouQInstance();
+
+ break;
+ }
+ case "REMOVE_MANAGER": {
+ validateIsGroupOwner();
+
+ await SQGroupRepository.updateMemberRole({
+ groupId: currentGroup.id,
+ userId: data.userId,
+ role: "REGULAR",
+ });
+
+ await refreshSendouQInstance();
+
+ break;
+ }
+ case "LEAVE_GROUP": {
+ await SQGroupRepository.leaveGroup(user.id);
+
+ await refreshSendouQInstance();
+
+ const targetChatCode = SendouQ.findUncensoredGroupById(
+ currentGroup.id,
+ )?.chatCode;
+ if (targetChatCode) {
+ ChatSystemMessage.send({
+ room: targetChatCode,
+ type: "USER_LEFT",
+ context: { name: user.username },
+ });
+ }
+
+ throw redirect(SENDOUQ_PAGE);
+ }
+ case "KICK_FROM_GROUP": {
+ validateIsGroupOwner();
+ errorToastIfFalsy(data.userId !== user.id, "Can't kick yourself");
+
+ await SQGroupRepository.leaveGroup(data.userId);
+
+ await refreshSendouQInstance();
+
+ break;
+ }
+ case "REFRESH_GROUP": {
+ await SQGroupRepository.refreshGroup(currentGroup.id);
+
+ await refreshSendouQInstance();
+
+ break;
+ }
+ case "UPDATE_NOTE": {
+ await SQGroupRepository.updateMemberNote({
+ groupId: currentGroup.id,
+ userId: user.id,
+ value: data.value,
+ });
+
+ await refreshSendouQInstance();
+
+ break;
+ }
+ case "DELETE_PRIVATE_USER_NOTE": {
+ await PrivateUserNoteRepository.del({
+ authorId: user.id,
+ targetId: data.targetId,
+ });
+
+ break;
+ }
+ default: {
+ assertUnreachable(data);
+ }
}
- default: {
- assertUnreachable(data);
+
+ return null;
+ } catch (error) {
+ // some errors are expected to happen, for example they might request two groups at the same time
+ // then after morphing one group the other request fails because the group no longer exists
+ // return null causes loaders to run and they see the fresh state again instead of error page
+ if (error instanceof SendouQError) {
+ return null;
}
+
+ throw error;
}
-
- return null;
};
-
-/** Sanity check that no member is in two groups due to a bug or race condition.
- *
- * @returns null if no member is in two groups, otherwise return the problematic member
- */
-function verifyNoMemberInTwoGroups(
- members: LookingGroupWithInviteCode["members"],
- allGroups: LookingGroupWithInviteCode[],
-) {
- for (const member of members) {
- if (
- allGroups.filter((group) => group.members.some((m) => m.id === member.id))
- .length > 1
- ) {
- return member;
- }
- }
-
- return null;
-}
diff --git a/app/features/sendouq/actions/q.preparing.server.ts b/app/features/sendouq/actions/q.preparing.server.ts
index 8146c624e..37a7e2ec7 100644
--- a/app/features/sendouq/actions/q.preparing.server.ts
+++ b/app/features/sendouq/actions/q.preparing.server.ts
@@ -3,19 +3,12 @@ import { redirect } from "@remix-run/node";
import { requireUser } from "~/features/auth/core/user.server";
import * as Seasons from "~/features/mmr/core/Seasons";
import { notify } from "~/features/notifications/core/notify.server";
-import * as QRepository from "~/features/sendouq/QRepository.server";
-import * as QMatchRepository from "~/features/sendouq-match/QMatchRepository.server";
-import invariant from "~/utils/invariant";
+import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { SENDOUQ_LOOKING_PAGE } from "~/utils/urls";
-import { hasGroupManagerPerms } from "../core/groups";
-import { FULL_GROUP_SIZE } from "../q-constants";
+import { refreshSendouQInstance, SendouQ } from "../core/SendouQ.server";
import { preparingSchema } from "../q-schemas.server";
-import { addMember } from "../queries/addMember.server";
-import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
-import { refreshGroup } from "../queries/refreshGroup.server";
-import { setGroupAsActive } from "../queries/setGroupAsActive.server";
export type SendouQPreparingAction = typeof action;
@@ -26,10 +19,11 @@ export const action = async ({ request }: ActionFunctionArgs) => {
schema: preparingSchema,
});
- const currentGroup = findCurrentGroupByUserId(user.id);
- errorToastIfFalsy(currentGroup, "No group found");
+ const ownGroup = SendouQ.findOwnGroup(user.id);
+ errorToastIfFalsy(ownGroup, "No group found");
- if (!hasGroupManagerPerms(currentGroup.role)) {
+ // no perms, possibly just lost them so no more graceful degradation
+ if (ownGroup.usersRole === "REGULAR") {
return null;
}
@@ -38,48 +32,37 @@ export const action = async ({ request }: ActionFunctionArgs) => {
switch (data._action) {
case "JOIN_QUEUE": {
- if (currentGroup.status !== "PREPARING") {
- return null;
- }
+ await SQGroupRepository.setPreparingGroupAsActive(ownGroup.id);
- setGroupAsActive(currentGroup.id);
- refreshGroup(currentGroup.id);
+ await refreshSendouQInstance();
return redirect(SENDOUQ_LOOKING_PAGE);
}
case "ADD_TRUSTED": {
- const available = await QRepository.findActiveGroupMembers();
+ const available = await SQGroupRepository.findActiveGroupMembers();
if (available.some(({ userId }) => userId === data.id)) {
return { error: "taken" } as const;
}
errorToastIfFalsy(
- (await QRepository.usersThatTrusted(user.id)).trusters.some(
+ (await SQGroupRepository.usersThatTrusted(user.id)).trusters.some(
(trusterUser) => trusterUser.id === data.id,
),
"Not trusted",
);
- const ownGroupWithMembers = await QMatchRepository.findGroupById({
- groupId: currentGroup.id,
- });
- invariant(ownGroupWithMembers, "No own group found");
- errorToastIfFalsy(
- ownGroupWithMembers.members.length < FULL_GROUP_SIZE,
- "Group is full",
- );
-
- addMember({
- groupId: currentGroup.id,
+ await SQGroupRepository.addMember(ownGroup.id, {
userId: data.id,
role: "MANAGER",
});
- await QRepository.refreshTrust({
+ await SQGroupRepository.refreshTrust({
trustGiverUserId: data.id,
trustReceiverUserId: user.id,
});
+ await refreshSendouQInstance();
+
notify({
userIds: [data.id],
notification: {
diff --git a/app/features/sendouq/actions/q.server.ts b/app/features/sendouq/actions/q.server.ts
index 3b089c6f4..b2f9fcc79 100644
--- a/app/features/sendouq/actions/q.server.ts
+++ b/app/features/sendouq/actions/q.server.ts
@@ -1,11 +1,10 @@
import type { ActionFunction } from "@remix-run/node";
import { redirect } from "@remix-run/node";
-import { sql } from "~/db/sql";
import * as AdminRepository from "~/features/admin/AdminRepository.server";
import { requireUser } from "~/features/auth/core/user.server";
import { refreshBannedCache } from "~/features/ban/core/banned.server";
import * as Seasons from "~/features/mmr/core/Seasons";
-import * as QRepository from "~/features/sendouq/QRepository.server";
+import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import { giveTrust } from "~/features/tournament/queries/giveTrust.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import invariant from "~/utils/invariant";
@@ -16,13 +15,10 @@ import {
SENDOUQ_PREPARING_PAGE,
SUSPENDED_PAGE,
} from "~/utils/urls";
-import { FULL_GROUP_SIZE, JOIN_CODE_SEARCH_PARAM_KEY } from "../q-constants";
+import { refreshSendouQInstance, SendouQ } from "../core/SendouQ.server";
+import { JOIN_CODE_SEARCH_PARAM_KEY } from "../q-constants";
import { frontPageSchema } from "../q-schemas.server";
import { userCanJoinQueueAt } from "../q-utils";
-import { addMember } from "../queries/addMember.server";
-import { deleteLikesByGroupId } from "../queries/deleteLikesByGroupId.server";
-import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
-import { findGroupByInviteCode } from "../queries/findGroupByInviteCode.server";
export const action: ActionFunction = async ({ request }) => {
const user = await requireUser(request);
@@ -35,11 +31,13 @@ export const action: ActionFunction = async ({ request }) => {
case "JOIN_QUEUE": {
await validateCanJoinQ(user);
- await QRepository.createGroup({
+ await SQGroupRepository.createGroup({
status: data.direct === "true" ? "ACTIVE" : "PREPARING",
userId: user.id,
});
+ await refreshSendouQInstance();
+
return redirect(
data.direct === "true" ? SENDOUQ_LOOKING_PAGE : SENDOUQ_PREPARING_PAGE,
);
@@ -53,34 +51,28 @@ export const action: ActionFunction = async ({ request }) => {
);
const groupInvitedTo =
- code && user ? findGroupByInviteCode(code) : undefined;
+ code && user ? SendouQ.findGroupByInviteCode(code) : undefined;
errorToastIfFalsy(
groupInvitedTo,
"Invite code doesn't match any active team",
);
- errorToastIfFalsy(
- groupInvitedTo.members.length < FULL_GROUP_SIZE,
- "Team is full",
- );
- sql.transaction(() => {
- addMember({
- groupId: groupInvitedTo.id,
- userId: user.id,
- role: "MANAGER",
+ await SQGroupRepository.addMember(groupInvitedTo.id, {
+ userId: user.id,
+ role: "MANAGER",
+ });
+
+ if (data._action === "JOIN_TEAM_WITH_TRUST") {
+ const owner = groupInvitedTo.members.find((m) => m.role === "OWNER");
+ invariant(owner, "Owner not found");
+
+ giveTrust({
+ trustGiverUserId: user.id,
+ trustReceiverUserId: owner.id,
});
- deleteLikesByGroupId(groupInvitedTo.id);
+ }
- if (data._action === "JOIN_TEAM_WITH_TRUST") {
- const owner = groupInvitedTo.members.find((m) => m.role === "OWNER");
- invariant(owner, "Owner not found");
-
- giveTrust({
- trustGiverUserId: user.id,
- trustReceiverUserId: owner.id,
- });
- }
- })();
+ await refreshSendouQInstance();
return redirect(
groupInvitedTo.status === "PREPARING"
@@ -132,6 +124,5 @@ async function validateCanJoinQ(user: { id: number; discordId: string }) {
const canJoinQueue = userCanJoinQueueAt(user, friendCode) === "NOW";
errorToastIfFalsy(Seasons.current(), "Season is not active");
- errorToastIfFalsy(!findCurrentGroupByUserId(user.id), "Already in a group");
errorToastIfFalsy(canJoinQueue, "Can't join queue right now");
}
diff --git a/app/features/sendouq/components/GroupCard.module.css b/app/features/sendouq/components/GroupCard.module.css
new file mode 100644
index 000000000..69b54673c
--- /dev/null
+++ b/app/features/sendouq/components/GroupCard.module.css
@@ -0,0 +1,174 @@
+.group {
+ background-color: var(--bg-lighter-solid);
+ width: 100%;
+ border-radius: var(--rounded);
+ padding: var(--s-2-5);
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-4);
+ position: relative;
+ color: var(--text);
+}
+
+.noScreen {
+ background-color: var(--theme-error-transparent);
+ border-radius: 100%;
+ padding: var(--s-1);
+ width: 30px;
+ height: 30px;
+ display: grid;
+ place-items: center;
+}
+
+.displayOnly {
+ height: 100%;
+ padding-block-end: var(--s-10);
+}
+
+.member {
+ display: flex;
+ gap: var(--s-2);
+ align-items: center;
+ background-color: var(--bg-darker);
+ border-radius: var(--rounded);
+ font-size: var(--fonts-xsm);
+ font-weight: var(--semi-bold);
+ padding-inline-end: var(--s-2-5);
+}
+
+.name {
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ max-width: 7.5rem;
+ font-size: var(--fonts-xs);
+ color: var(--text);
+}
+
+.avatar {
+ min-width: 36px;
+}
+
+.avatarPositive {
+ outline: 2px solid var(--theme-success-transparent);
+}
+
+.avatarNeutral {
+ outline: 2px solid var(--theme-warning-transparent);
+}
+
+.avatarNegative {
+ outline: 2px solid var(--theme-error-transparent);
+}
+
+.tier {
+ margin-inline-start: auto;
+}
+
+.tierPlaceholder {
+ min-width: 26.58px;
+}
+
+.extraInfo {
+ font-size: var(--fonts-xs);
+ background-color: var(--bg-darker);
+ border-radius: var(--rounded);
+ padding: var(--s-0-5) var(--s-1-5);
+ width: max-content;
+ display: flex;
+ align-items: center;
+ gap: var(--s-1);
+ font-weight: var(--semi-bold);
+ min-height: 24px;
+}
+
+.extraInfoButton {
+ font-size: var(--fonts-xs);
+ background-color: var(--bg-darker);
+ border-radius: var(--rounded);
+ padding: var(--s-0-5) var(--s-1-5);
+ width: max-content;
+ display: flex;
+ align-items: center;
+ gap: var(--s-1);
+ font-weight: var(--semi-bold);
+ color: var(--text);
+ border: none;
+ min-height: 24px;
+}
+
+.addNoteButton {
+ border: none;
+ padding: 0 var(--s-1-5);
+ color: var(--body);
+ font-size: var(--fonts-xxs);
+ font-weight: var(--semi-bold);
+ background-color: var(--bg-darker);
+ white-space: nowrap;
+}
+
+.addNoteButtonEdit > svg {
+ color: var(--theme);
+}
+
+.addNoteButton > svg {
+ width: 14px;
+ margin-inline-end: var(--s-1);
+}
+
+.noteTextarea {
+ height: 4rem !important;
+}
+
+.futureMatchMode {
+ border-radius: 100%;
+ background-color: var(--bg-lightest);
+ height: 30px;
+ width: 30px;
+ display: grid;
+ place-items: center;
+ padding: var(--s-1-5);
+}
+
+.vcIcon {
+ height: 15px;
+ stroke-width: 2;
+}
+
+.star {
+ min-width: 18px;
+ max-width: 18px;
+ color: var(--theme-secondary);
+ stroke-width: 2;
+}
+
+.starInactive {
+ color: var(--text-lighter);
+}
+
+.displayTier {
+ display: flex;
+ gap: var(--s-1);
+ align-items: center;
+ position: absolute;
+ border-radius: var(--rounded);
+ background-color: var(--bg-darker);
+ padding: var(--s-0-5) var(--s-2-5);
+ font-size: var(--fonts-xs);
+ font-weight: var(--semi-bold);
+ bottom: -36px;
+ left: 50%;
+ transform: translate(-50%, -50%);
+}
+
+.popoverButton {
+ background-color: transparent;
+ color: var(--text-lighter);
+ font-size: var(--fonts-xs);
+ padding: 0;
+ border: none;
+ text-decoration: underline;
+ text-decoration-style: dotted;
+ font-weight: var(--bold);
+ height: 19.8281px;
+}
diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx
index dc54782f0..7a8b194b7 100644
--- a/app/features/sendouq/components/GroupCard.tsx
+++ b/app/features/sendouq/components/GroupCard.tsx
@@ -17,14 +17,13 @@ import { StarIcon } from "~/components/icons/Star";
import { StarFilledIcon } from "~/components/icons/StarFilled";
import { TrashIcon } from "~/components/icons/Trash";
import { SubmitButton } from "~/components/SubmitButton";
-import type { ParsedMemento, Tables } from "~/db/tables";
+import type { ParsedMemento } from "~/db/tables";
import { useUser } from "~/features/auth/core/user";
import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants";
import { ordinalToRoundedSp } from "~/features/mmr/mmr-utils";
import type { TieredSkill } from "~/features/mmr/tiered.server";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { languagesUnified } from "~/modules/i18n/config";
-import type { ModeShort } from "~/modules/in-game-lists/types";
import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids";
import { databaseTimestampToDate } from "~/utils/dates";
import { inGameNameWithoutDiscriminator } from "~/utils/strings";
@@ -36,35 +35,37 @@ import {
tierImageUrl,
userPage,
} from "~/utils/urls";
+import type {
+ SQGroup,
+ SQGroupMember,
+ SQMatchGroup,
+ SQMatchGroupMember,
+ SQOwnGroup,
+} from "../core/SendouQ.server";
import { FULL_GROUP_SIZE, SENDOUQ } from "../q-constants";
-import type { LookingGroup } from "../q-types";
+import { resolveFutureMatchModes } from "../q-utils";
+import styles from "./GroupCard.module.css";
export function GroupCard({
group,
action,
- ownRole,
- ownGroup = false,
- isExpired = false,
displayOnly = false,
hideVc = false,
hideWeapons = false,
hideNote: _hidenote = false,
- enableKicking,
showAddNote,
showNote = false,
+ ownGroup,
}: {
- group: Omit;
+ group: SQGroup | SQOwnGroup | SQMatchGroup;
action?: "LIKE" | "UNLIKE" | "GROUP_UP" | "MATCH_UP" | "MATCH_UP_RECHALLENGE";
- ownRole?: Tables["GroupMember"]["role"] | "PREVIEWER";
- ownGroup?: boolean;
- isExpired?: boolean;
displayOnly?: boolean;
hideVc?: SqlBool;
hideWeapons?: SqlBool;
hideNote?: boolean;
- enableKicking?: boolean;
showAddNote?: SqlBool;
showNote?: boolean;
+ ownGroup?: SQOwnGroup;
}) {
const { t } = useTranslation(["q"]);
const user = useUser();
@@ -76,10 +77,22 @@ export function GroupCard({
group.members.length === FULL_GROUP_SIZE ||
_hidenote;
+ const isOwnGroup = group.id === ownGroup?.id;
+
+ const futureMatchModes = ownGroup
+ ? resolveFutureMatchModes({
+ ownGroup,
+ theirGroup: group,
+ })
+ : null;
+
+ const enableKicking = group.usersRole === "OWNER" && !displayOnly;
+
return (
-
+
{group.members ? (
@@ -87,7 +100,7 @@ export function GroupCard({
return (
) : null}
- {group.futureMatchModes && !group.members ? (
+ {futureMatchModes && !group.members ? (
- {group.futureMatchModes.map((mode) => {
+ {futureMatchModes.map((mode) => {
return (
-
- {group.isNoScreen ? (
-
+ {group.noScreen ? (
+
) : null}
- {group.tier && !displayOnly ? (
+ {group.tier && !displayOnly && !group.members ? (
@@ -152,26 +159,26 @@ export function GroupCard({
) : null}
- {group.tier && displayOnly ? (
-
+ {group.tier && displayOnly && !group.members ? (
+
{group.tier.name}
{group.tier.isPlus ? "+" : ""}
) : null}
- {group.tierRange?.range ? (
+ {group.tierRange ? (
- (-{group.tierRange.diff})
+ ({group.tierRange.diff[0]})
+
{t("q:looking.range.or")}
}
@@ -181,7 +188,7 @@ export function GroupCard({
- (+{group.tierRange.diff})
+ (+{group.tierRange.diff[1]})
@@ -196,8 +203,8 @@ export function GroupCard({
) : null}
{action &&
- (ownRole === "OWNER" || ownRole === "MANAGER") &&
- !isExpired ? (
+ (ownGroup?.usersRole === "OWNER" ||
+ ownGroup?.usersRole === "MANAGER") ? (
) : null}
- {!group.isRechallenge &&
- group.rechallengeMatchModes &&
- (ownRole === "OWNER" || ownRole === "MANAGER") &&
- !isExpired ? (
-
- ) : null}
);
}
function GroupCardContainer({
- ownGroup,
+ isOwnGroup,
groupId,
children,
}: {
- ownGroup: boolean;
+ isOwnGroup: boolean;
groupId: number;
children: React.ReactNode;
}) {
// we don't want it to animate
- if (ownGroup) return <>{children}>;
+ if (isOwnGroup) return <>{children}>;
return
{children};
}
@@ -258,7 +256,7 @@ function GroupMember({
showAddNote,
showNote,
}: {
- member: NonNullable
[number];
+ member: SQGroupMember | SQMatchGroupMember;
showActions: boolean;
displayOnly?: boolean;
hideVc?: SqlBool;
@@ -273,8 +271,8 @@ function GroupMember({
const { formatDateTime } = useTimeFormat();
return (
-
-
+
+
{showNote && member.privateNote ? (
@@ -319,7 +319,7 @@ function GroupMember({
) : (
)}
-
+
{member.inGameName ? (
<>
@@ -346,12 +346,12 @@ function GroupMember({
{member.vc && !hideVc ? (
-
+
) : null}
{member.plusTier ? (
-
+
{member.plusTier}
@@ -359,7 +359,7 @@ function GroupMember({
{member.friendCode ? (
+
FC
}
@@ -371,8 +371,8 @@ function GroupMember({
}
- className={clsx("q__group-member__add-note-button", {
- "q__group-member__add-note-button__edit": member.privateNote,
+ className={clsx(styles.addNoteButton, {
+ [styles.addNoteButtonEdit]: member.privateNote,
})}
>
{member.privateNote
@@ -382,7 +382,7 @@ function GroupMember({
) : null}
{member.weapons && member.weapons.length > 0 && !hideWeapons ? (
-
+
{member.weapons?.map((weapon) => {
return (
{!hideNote ? (
-
+
) : null}
);
@@ -485,7 +488,7 @@ function AddPrivateNoteForm({
value={value}
onChange={(e) => setValue(e.target.value)}
rows={2}
- className="q__group-member__note-textarea mt-1"
+ className={`${styles.noteTextarea} mt-1`}
name="value"
ref={textareaRef}
/>
@@ -515,36 +518,6 @@ function AddPrivateNoteForm({
);
}
-function RechallengeForm({
- modes,
- targetGroupId,
-}: {
- modes: ModeShort[];
- targetGroupId: number;
-}) {
- const { t } = useTranslation(["q"]);
- const fetcher = useFetcher();
-
- return (
-
-
-
- {t("q:looking.groups.actions.rechallenge")}
-
- {modes.map((mode) => (
-
- ))}
-
-
-
- );
-}
-
function DeletePrivateNoteForm({
targetId,
name,
@@ -622,7 +595,7 @@ function MemberSkillDifference({
▼
);
return (
-
+
{symbol}
{Math.abs(skillDifference.spDiff)}SP
@@ -631,7 +604,7 @@ function MemberSkillDifference({
if (skillDifference.matchesCount === skillDifference.matchesCountNeeded) {
return (
-
+
{t("q:looking.sp.calculated")}:{" "}
{skillDifference.newSp ? <>{skillDifference.newSp}SP> : null}
@@ -639,7 +612,7 @@ function MemberSkillDifference({
}
return (
-
+
{t("q:looking.sp.calculating")} (
{skillDifference.matchesCount}/{skillDifference.matchesCountNeeded})
@@ -651,7 +624,7 @@ function MemberRoleManager({
displayOnly,
enableKicking,
}: {
- member: NonNullable
[number];
+ member: Pick;
displayOnly?: boolean;
enableKicking?: boolean;
}) {
@@ -669,8 +642,8 @@ function MemberRoleManager({
variant="minimal"
icon={
}
@@ -728,7 +701,7 @@ function TierInfo({ skill }: { skill: TieredSkill | "CALCULATING" }) {
if (skill === "CALCULATING") {
return (
-
+
@@ -736,7 +709,7 @@ function TierInfo({ skill }: { skill: TieredSkill | "CALCULATING" }) {
path={tierImageUrl("CALCULATING")}
alt=""
height={32.965}
- className="q__group-member__tier__placeholder"
+ className={styles.tierPlaceholder}
/>
}
@@ -750,7 +723,7 @@ function TierInfo({ skill }: { skill: TieredSkill | "CALCULATING" }) {
}
return (
-
+
@@ -785,7 +758,7 @@ function TierInfo({ skill }: { skill: TieredSkill | "CALCULATING" }) {
function VoiceChatInfo({
member,
}: {
- member: NonNullable[number];
+ member: Pick;
}) {
const user = useUser();
const { t } = useTranslation(["q"]);
@@ -830,7 +803,7 @@ function VoiceChatInfo({
}
+ icon={}
/>
}
>
diff --git a/app/features/sendouq/components/MemberAdder.module.css b/app/features/sendouq/components/MemberAdder.module.css
new file mode 100644
index 000000000..7223ee1f4
--- /dev/null
+++ b/app/features/sendouq/components/MemberAdder.module.css
@@ -0,0 +1,4 @@
+.input {
+ --input-width: 11rem;
+ width: 11rem;
+}
diff --git a/app/features/sendouq/components/MemberAdder.tsx b/app/features/sendouq/components/MemberAdder.tsx
index f511043e3..64e58df95 100644
--- a/app/features/sendouq/components/MemberAdder.tsx
+++ b/app/features/sendouq/components/MemberAdder.tsx
@@ -14,6 +14,7 @@ import {
sendouQInviteLink,
} from "~/utils/urls";
import type { SendouQPreparingAction } from "../actions/q.preparing.server";
+import styles from "./MemberAdder.module.css";
export function MemberAdder({
inviteCode,
@@ -56,7 +57,7 @@ export function MemberAdder({
value={inviteLink}
readOnly
id="invite"
- className="q__member-adder__input"
+ className={styles.input}
/>
- );
+ return ;
}
const trustersNotInGroup = trusters.filter(
@@ -132,7 +126,7 @@ function TrusterDropdown({
onChange={(e) =>
setTruster(e.target.value ? Number(e.target.value) : undefined)
}
- className="q__member-adder__input"
+ className={styles.input}
>
{teams?.map((team) => {
diff --git a/app/features/sendouq/core/SendouQ.server.test.ts b/app/features/sendouq/core/SendouQ.server.test.ts
new file mode 100644
index 000000000..110ad7871
--- /dev/null
+++ b/app/features/sendouq/core/SendouQ.server.test.ts
@@ -0,0 +1,821 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { db } from "~/db/sql";
+import * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server";
+import { dbInsertUsers, dbReset } from "~/utils/Test";
+import * as SQGroupRepository from "../SQGroupRepository.server";
+import { refreshSendouQInstance, SendouQ } from "./SendouQ.server";
+
+const { mockSeasonCurrentOrPrevious } = vi.hoisted(() => ({
+ mockSeasonCurrentOrPrevious: vi.fn(() => ({
+ nth: 1,
+ starts: new Date("2023-01-01"),
+ ends: new Date("2030-12-31"),
+ })),
+}));
+
+vi.mock("~/features/mmr/core/Seasons", () => ({
+ currentOrPrevious: mockSeasonCurrentOrPrevious,
+}));
+
+const createGroup = async (
+ userIds: number[],
+ options: {
+ status?: "PREPARING" | "ACTIVE";
+ inviteCode?: string;
+ } = {},
+) => {
+ const { status = "ACTIVE", inviteCode } = options;
+
+ const groupResult = await SQGroupRepository.createGroup({
+ status,
+ userId: userIds[0],
+ });
+
+ if (inviteCode) {
+ await db
+ .updateTable("Group")
+ .set({ inviteCode })
+ .where("id", "=", groupResult.id)
+ .execute();
+ }
+
+ for (let i = 1; i < userIds.length; i++) {
+ await SQGroupRepository.addMember(groupResult.id, {
+ userId: userIds[i],
+ role: "REGULAR",
+ });
+ }
+
+ return groupResult.id;
+};
+
+const createMatch = async (
+ alphaGroupId: number,
+ bravoGroupId: number,
+ options: { reportedAt?: number } = {},
+) => {
+ const { reportedAt = Date.now() } = options;
+
+ await db
+ .insertInto("GroupMatch")
+ .values({
+ alphaGroupId,
+ bravoGroupId,
+ reportedAt,
+ })
+ .execute();
+};
+
+const createPrivateNote = async (
+ authorId: number,
+ targetId: number,
+ sentiment: "POSITIVE" | "NEUTRAL" | "NEGATIVE",
+ text = "test note",
+) => {
+ await PrivateUserNoteRepository.upsert({
+ authorId,
+ targetId,
+ sentiment,
+ text,
+ });
+};
+
+const insertSkill = async (userId: number, ordinal: number, season = 1) => {
+ await db
+ .insertInto("Skill")
+ .values({
+ userId,
+ season,
+ mu: 25,
+ sigma: 8.333,
+ ordinal,
+ matchesCount: 10,
+ })
+ .execute();
+};
+
+describe("SendouQ", () => {
+ describe("currentViewByUserId", () => {
+ beforeEach(async () => {
+ await dbInsertUsers(4);
+ });
+
+ afterEach(() => {
+ dbReset();
+ });
+
+ test("returns 'default' when user not in any group", async () => {
+ await refreshSendouQInstance();
+
+ const view = SendouQ.currentViewByUserId(1);
+
+ expect(view).toBe("default");
+ });
+
+ test("returns 'preparing' when user in PREPARING group", async () => {
+ await createGroup([1], { status: "PREPARING" });
+ await refreshSendouQInstance();
+
+ const view = SendouQ.currentViewByUserId(1);
+
+ expect(view).toBe("preparing");
+ });
+
+ test("returns 'match' when user in ACTIVE group with matchId", async () => {
+ const groupId1 = await createGroup([1]);
+ const groupId2 = await createGroup([2]);
+
+ await createMatch(groupId1, groupId2);
+
+ await refreshSendouQInstance();
+
+ const view = SendouQ.currentViewByUserId(1);
+
+ expect(view).toBe("match");
+ });
+
+ test("returns 'looking' when user in ACTIVE group without matchId", async () => {
+ await createGroup([1], { status: "ACTIVE" });
+ await refreshSendouQInstance();
+
+ const view = SendouQ.currentViewByUserId(1);
+
+ expect(view).toBe("looking");
+ });
+ });
+
+ describe("findOwnGroup", () => {
+ beforeEach(async () => {
+ await dbInsertUsers(8);
+ });
+
+ afterEach(() => {
+ dbReset();
+ });
+
+ test("returns group when user is a member", async () => {
+ await createGroup([1, 2, 3]);
+ await refreshSendouQInstance();
+
+ const group = SendouQ.findOwnGroup(1);
+
+ expect(group).toBeDefined();
+ expect(group?.members.some((m) => m.id === 1)).toBe(true);
+ });
+
+ test("returns undefined when user not in any group", async () => {
+ await createGroup([1, 2, 3]);
+ await refreshSendouQInstance();
+
+ const group = SendouQ.findOwnGroup(4);
+
+ expect(group).toBeUndefined();
+ });
+
+ test("returns group with correct role when user is OWNER", async () => {
+ await createGroup([1, 2]);
+ await refreshSendouQInstance();
+
+ const group = SendouQ.findOwnGroup(1);
+
+ expect(group).toBeDefined();
+ const member = group?.members.find((m) => m.id === 1);
+ expect(member?.role).toBe("OWNER");
+ });
+
+ test("returns group with correct role when user is REGULAR member", async () => {
+ await createGroup([1, 2]);
+ await refreshSendouQInstance();
+
+ const group = SendouQ.findOwnGroup(2);
+
+ expect(group).toBeDefined();
+ const member = group?.members.find((m) => m.id === 2);
+ expect(member?.role).toBe("REGULAR");
+ });
+
+ test("returns correct group when multiple groups exist", async () => {
+ await createGroup([1, 2]);
+ await createGroup([3, 4]);
+ await createGroup([5, 6]);
+ await refreshSendouQInstance();
+
+ const group = SendouQ.findOwnGroup(5);
+
+ expect(group).toBeDefined();
+ expect(group?.members.some((m) => m.id === 5)).toBe(true);
+ expect(group?.members.some((m) => m.id === 1)).toBe(false);
+ });
+ });
+
+ describe("findGroupByInviteCode", () => {
+ beforeEach(async () => {
+ await dbInsertUsers(4);
+ });
+
+ afterEach(() => {
+ dbReset();
+ });
+
+ test("returns group when invite code is valid", async () => {
+ await createGroup([1], { inviteCode: "ABC123" });
+ await refreshSendouQInstance();
+
+ const group = SendouQ.findGroupByInviteCode("ABC123");
+
+ expect(group).toBeDefined();
+ expect(group?.inviteCode).toBe("ABC123");
+ });
+
+ test("returns undefined when invite code is invalid", async () => {
+ await createGroup([1], { inviteCode: "ABC123" });
+ await refreshSendouQInstance();
+
+ const group = SendouQ.findGroupByInviteCode("INVALID");
+
+ expect(group).toBeUndefined();
+ });
+
+ test("returns correct group when multiple groups exist", async () => {
+ await createGroup([1], { inviteCode: "CODE1" });
+ await createGroup([2], { inviteCode: "CODE2" });
+ await createGroup([3], { inviteCode: "CODE3" });
+ await refreshSendouQInstance();
+
+ const group = SendouQ.findGroupByInviteCode("CODE2");
+
+ expect(group).toBeDefined();
+ expect(group?.members[0].id).toBe(2);
+ });
+ });
+
+ describe("previewGroups", () => {
+ beforeEach(async () => {
+ await dbInsertUsers(12);
+ });
+
+ afterEach(() => {
+ dbReset();
+ });
+
+ test("returns empty array when no groups exist", async () => {
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.previewGroups(notes);
+
+ expect(groups).toEqual([]);
+ });
+
+ test("censors members for full groups", async () => {
+ await createGroup([1, 2, 3, 4]);
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.previewGroups(notes);
+
+ expect(groups).toHaveLength(1);
+ expect(groups[0].members).toBeUndefined();
+ });
+
+ test("shows members for partial groups", async () => {
+ await createGroup([1, 2]);
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.previewGroups(notes);
+
+ expect(groups).toHaveLength(1);
+ expect(groups[0].members).toBeDefined();
+ expect(groups[0].members).toHaveLength(2);
+ });
+
+ test("attaches private notes to members", async () => {
+ await createGroup([1, 2]);
+ await createPrivateNote(3, 2, "POSITIVE", "Great player");
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(3);
+ const groups = SendouQ.previewGroups(notes);
+
+ expect(groups).toHaveLength(1);
+ const member = groups[0].members?.find((m) => m.id === 2);
+ expect(member?.privateNote).toBeDefined();
+ expect(member?.privateNote?.sentiment).toBe("POSITIVE");
+ });
+
+ test("removes inviteCode and chatCode from all groups", async () => {
+ await createGroup([1, 2], { inviteCode: "CODE1" });
+ await createGroup([3, 4, 5, 6], { inviteCode: "CODE2" });
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.previewGroups(notes);
+
+ expect(groups).toHaveLength(2);
+ for (const group of groups) {
+ expect(group).not.toHaveProperty("inviteCode");
+ expect(group).not.toHaveProperty("chatCode");
+ }
+ });
+
+ test("applies correct censoring for mix of full and partial groups", async () => {
+ await createGroup([1, 2]);
+ await createGroup([3, 4, 5, 6]);
+ await createGroup([7, 8, 9]);
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.previewGroups(notes);
+
+ expect(groups).toHaveLength(3);
+
+ const partialGroups = groups.filter((g) => g.members !== undefined);
+ const fullGroups = groups.filter((g) => g.members === undefined);
+
+ expect(partialGroups).toHaveLength(2);
+ expect(fullGroups).toHaveLength(1);
+ });
+
+ test("censors tier and sets tier range for full groups", async () => {
+ await createGroup([1, 2, 3, 4]);
+ await createGroup([5, 6]);
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.previewGroups(notes);
+
+ const fullGroup = groups.find((g) => g.members === undefined);
+ const partialGroup = groups.find((g) => g.members !== undefined);
+
+ expect(fullGroup?.tier).toBeNull();
+ expect(fullGroup?.tierRange).toBeDefined();
+
+ expect(fullGroup?.tierRange?.range[0].name).toBe("IRON");
+ expect(fullGroup?.tierRange?.range[1].name).toBe("LEVIATHAN");
+
+ expect(partialGroup?.tier).toBeDefined();
+ expect(partialGroup?.tierRange).toBeNull();
+ });
+ });
+
+ describe("lookingGroups", () => {
+ describe("filtering", () => {
+ beforeEach(async () => {
+ await dbInsertUsers(20);
+ });
+
+ afterEach(() => {
+ dbReset();
+ });
+
+ test("returns empty array when user not in a group", async () => {
+ await createGroup([1, 2, 3, 4]);
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(5);
+ const groups = SendouQ.lookingGroups(5, notes);
+
+ expect(groups).toEqual([]);
+ });
+
+ test("only returns ACTIVE groups", async () => {
+ await createGroup([1]);
+ await createGroup([2], { status: "PREPARING" });
+ const group3 = await createGroup([3]);
+ await db
+ .updateTable("Group")
+ .set({ status: "INACTIVE" })
+ .where("id", "=", group3)
+ .execute();
+ await createGroup([4], { status: "ACTIVE" });
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ expect(groups).toHaveLength(1);
+ expect(groups[0].members![0].id).toBe(4);
+ });
+
+ test("only returns groups without matchId", async () => {
+ await createGroup([1]);
+ await createGroup([2]);
+ await createGroup([3]);
+
+ await createMatch(1, 2);
+
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ expect(groups).toHaveLength(1);
+ expect(groups[0].members![0].id).toBe(3);
+ });
+
+ test("excludes own group from results", async () => {
+ await createGroup([1, 2]);
+ await createGroup([3, 4]);
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ expect(groups).toHaveLength(1);
+ expect(groups[0].members?.some((m) => m.id === 1)).toBe(false);
+ });
+
+ test("own group size 4 only shows size 4 groups", async () => {
+ await createGroup([1, 2, 3, 4]);
+ await createGroup([5]);
+ await createGroup([6, 7]);
+ await createGroup([8, 9, 10]);
+ await createGroup([11, 12, 13, 14]);
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ expect(groups).toHaveLength(1);
+ expect(groups[0].members).toBeUndefined();
+ });
+
+ test("own group size 3 only shows size 1 groups", async () => {
+ await createGroup([1, 2, 3]);
+ await createGroup([4]);
+ await createGroup([5, 6]);
+ await createGroup([7, 8, 9]);
+ await createGroup([10, 11, 12, 13]);
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ expect(groups).toHaveLength(1);
+ expect(groups[0].members).toHaveLength(1);
+ expect(groups[0].members![0].id).toBe(4);
+ });
+
+ test("own group size 2 shows size 1 and 2 groups", async () => {
+ await createGroup([1, 2]);
+ await createGroup([3]);
+ await createGroup([4, 5]);
+ await createGroup([6, 7, 8]);
+ await createGroup([9, 10, 11, 12]);
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ expect(groups).toHaveLength(2);
+ const groupSizes = groups.map((g) => g.members!.length);
+ expect(groupSizes).toContain(1);
+ expect(groupSizes).toContain(2);
+ });
+
+ test("own group size 1 shows size 1, 2, and 3 groups", async () => {
+ await createGroup([1]);
+ await createGroup([2]);
+ await createGroup([3, 4]);
+ await createGroup([5, 6, 7]);
+ await createGroup([8, 9, 10, 11]);
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ expect(groups).toHaveLength(3);
+ const groupSizes = groups.map((g) => g.members!.length);
+ expect(groupSizes).toContain(1);
+ expect(groupSizes).toContain(2);
+ expect(groupSizes).toContain(3);
+ });
+ });
+
+ describe("replay detection", () => {
+ beforeEach(async () => {
+ await dbInsertUsers(12);
+ });
+
+ afterEach(() => {
+ dbReset();
+ });
+
+ test("marks group as replay when 3+ members overlap", async () => {
+ const group1 = await createGroup([1, 2, 3, 4]);
+ const group2 = await createGroup([5, 6, 7, 8]);
+
+ await createMatch(group1, group2);
+
+ await db
+ .updateTable("Group")
+ .set({ status: "INACTIVE" })
+ .where("id", "in", [group1, group2])
+ .execute();
+
+ await createGroup([1, 2, 3, 4]);
+ await createGroup([5, 6, 7, 8]);
+ await createGroup([9, 10, 11, 12]);
+
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ const replayGroup = groups.find((g) => g.members === undefined);
+ expect(replayGroup?.isReplay).toBe(true);
+ });
+
+ test("does not mark as replay when less than 3 members overlap", async () => {
+ const group1 = await createGroup([1, 2, 3, 4]);
+ const group2 = await createGroup([5, 6, 7, 8]);
+
+ await createMatch(group1, group2);
+
+ await db
+ .updateTable("Group")
+ .set({ status: "INACTIVE" })
+ .where("id", "in", [group1, group2])
+ .execute();
+
+ await createGroup([1, 2, 3, 4]);
+ await createGroup([5, 6, 9, 10]);
+
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ for (const group of groups) {
+ expect(group.isReplay).toBe(false);
+ }
+ });
+
+ test("all groups have isReplay false when no recent matches", async () => {
+ await createGroup([1]);
+ await createGroup([2]);
+ await createGroup([3]);
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ for (const group of groups) {
+ expect(group.isReplay).toBe(false);
+ }
+ });
+
+ test("non-full groups do not have isReplay even with 3+ overlapping members", async () => {
+ const group1 = await createGroup([1, 2, 3, 4]);
+ const group2 = await createGroup([5, 6, 7, 8]);
+
+ await createMatch(group1, group2);
+
+ await db
+ .updateTable("Group")
+ .set({ status: "INACTIVE" })
+ .where("id", "in", [group1, group2])
+ .execute();
+
+ await createGroup([1]);
+ await createGroup([5, 6, 7]);
+
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ const partialGroup = groups.find((g) =>
+ g.members?.some((m) => m.id === 5),
+ );
+ expect(partialGroup?.isReplay).toBe(false);
+ });
+ });
+
+ describe("censoring", () => {
+ beforeEach(async () => {
+ await dbInsertUsers(12);
+ });
+
+ afterEach(() => {
+ dbReset();
+ });
+
+ test("full groups have members undefined", async () => {
+ await createGroup([1, 2, 3, 4]);
+ await createGroup([5, 6, 7, 8]);
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ const fullGroup = groups.find((g) => g.members === undefined);
+ expect(fullGroup).toBeDefined();
+ });
+
+ test("partial groups have members visible", async () => {
+ await createGroup([1]);
+ await createGroup([2, 3]);
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ const partialGroup = groups.find((g) => g.members?.length === 2);
+ expect(partialGroup).toBeDefined();
+ expect(partialGroup?.members).toHaveLength(2);
+ });
+
+ test("inviteCode and chatCode removed from all groups", async () => {
+ await createGroup([1]);
+ await createGroup([2]);
+ await createGroup([3, 4, 5, 6]);
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ for (const group of groups) {
+ expect(group).not.toHaveProperty("inviteCode");
+ expect(group).not.toHaveProperty("chatCode");
+ }
+ });
+ });
+
+ describe("private note sorting", () => {
+ beforeEach(async () => {
+ await dbInsertUsers(8);
+ });
+
+ afterEach(() => {
+ dbReset();
+ });
+
+ test("users with positive note sorted first", async () => {
+ await createGroup([1]);
+ await createGroup([2]);
+ await createGroup([3]);
+ await createGroup([4]);
+ await createGroup([5]);
+ await createGroup([6, 7]);
+ await createGroup([8]);
+
+ await createPrivateNote(1, 5, "POSITIVE");
+
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ expect(groups[0].members![0].id).toBe(5);
+ });
+
+ test("users with negative note sorted last", async () => {
+ await createGroup([1]);
+ await createGroup([2]);
+ await createGroup([3]);
+ await createGroup([4]);
+ await createGroup([5]);
+ await createGroup([6, 7]);
+ await createGroup([8]);
+
+ await createPrivateNote(1, 5, "NEGATIVE");
+
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ expect(groups[groups.length - 1].members![0].id).toBe(5);
+ });
+
+ test("group with both negative and positive sentiment sorted last", async () => {
+ await createGroup([1]);
+ await createGroup([2]);
+ await createGroup([3]);
+ await createGroup([4]);
+ await createGroup([5]);
+ await createGroup([6, 7]);
+ await createGroup([8]);
+
+ await createPrivateNote(1, 6, "POSITIVE");
+ await createPrivateNote(1, 7, "NEGATIVE");
+
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ expect(groups[groups.length - 1].members?.some((m) => m.id === 6)).toBe(
+ true,
+ );
+ });
+ });
+
+ describe("skill-based sorting", () => {
+ beforeEach(async () => {
+ await dbInsertUsers(10);
+ });
+
+ afterEach(() => {
+ dbReset();
+ });
+
+ test("sentiment still takes priority over skill", async () => {
+ await insertSkill(1, 1000);
+ await insertSkill(2, 500);
+ await insertSkill(4, 2000);
+
+ await createGroup([1]);
+ await createGroup([2]);
+ await createGroup([4]);
+
+ await createPrivateNote(1, 4, "POSITIVE");
+
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ expect(groups[0].members![0].id).toBe(4);
+ });
+
+ test("groups with closer skill sorted first within same sentiment", async () => {
+ await insertSkill(1, 1000);
+ await insertSkill(2, 1050);
+ await insertSkill(3, 500);
+ await insertSkill(4, 2000);
+
+ await createGroup([1]);
+ await createGroup([2]);
+ await createGroup([3]);
+ await createGroup([4]);
+
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ expect(groups[0].members![0].id).toBe(2);
+ });
+
+ test("full groups sorted by average skill", async () => {
+ await insertSkill(1, 1000);
+ await insertSkill(2, 1000);
+ await insertSkill(3, 1000);
+ await insertSkill(4, 1000);
+ await insertSkill(5, 1100);
+ await insertSkill(6, 1100);
+ await insertSkill(7, 1100);
+ await insertSkill(8, 1100);
+ await insertSkill(9, 500);
+ await insertSkill(10, 500);
+
+ await createGroup([1, 2, 3, 4]);
+ const closerGroup = await createGroup([5, 6, 7, 8]);
+ await createGroup([9, 10]);
+
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ expect(groups.length).toBeGreaterThan(0);
+ expect(groups[0].id).toBe(closerGroup);
+ });
+
+ test("newer groups sorted first when skill is equal", async () => {
+ await insertSkill(1, 1000);
+ await insertSkill(2, 1000);
+ await insertSkill(3, 1000);
+
+ const group1Id = await createGroup([2]);
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ const group2Id = await createGroup([3]);
+
+ const currentTimeInSeconds = Math.floor(Date.now() / 1000);
+ await db
+ .updateTable("Group")
+ .set({ latestActionAt: currentTimeInSeconds - 100 })
+ .where("id", "=", group1Id)
+ .execute();
+
+ await db
+ .updateTable("Group")
+ .set({ latestActionAt: currentTimeInSeconds - 50 })
+ .where("id", "=", group2Id)
+ .execute();
+
+ await createGroup([1]);
+ await refreshSendouQInstance();
+
+ const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
+ const groups = SendouQ.lookingGroups(1, notes);
+
+ expect(groups[0].members![0].id).toBe(3);
+ expect(groups[1].members![0].id).toBe(2);
+ });
+ });
+ });
+});
diff --git a/app/features/sendouq/core/SendouQ.server.ts b/app/features/sendouq/core/SendouQ.server.ts
new file mode 100644
index 000000000..388be5353
--- /dev/null
+++ b/app/features/sendouq/core/SendouQ.server.ts
@@ -0,0 +1,547 @@
+import { isWithinInterval, sub } from "date-fns";
+import * as R from "remeda";
+import type { DBBoolean, ParsedMemento, Tables } from "~/db/tables";
+import type { AuthenticatedUser } from "~/features/auth/core/user.server";
+import * as Seasons from "~/features/mmr/core/Seasons";
+import { defaultOrdinal } from "~/features/mmr/mmr-utils";
+import {
+ type SkillTierInterval,
+ type TieredSkill,
+ userSkills,
+} from "~/features/mmr/tiered.server";
+import type * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server";
+import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
+import type * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
+import { modesShort } from "~/modules/in-game-lists/modes";
+import type { ModeShort } from "~/modules/in-game-lists/types";
+import { databaseTimestampToDate } from "~/utils/dates";
+import { IS_E2E_TEST_RUN } from "~/utils/e2e";
+import type { SerializeFrom } from "~/utils/remix";
+import { FULL_GROUP_SIZE } from "../q-constants";
+import type { TierRange } from "../q-types";
+import { getTierIndex } from "../q-utils.server";
+import { tierDifferenceToRangeOrExact } from "./groups.server";
+
+type DBGroupRow = Awaited<
+ ReturnType
+>[number];
+type DBPrivateNoteRow = Awaited<
+ ReturnType
+>[number];
+type DBRecentlyFinishedMatchRow = Awaited<
+ ReturnType
+>[number];
+type DBMatch = NonNullable<
+ Awaited>
+>;
+
+export type SQUncensoredGroup = SerializeFrom<
+ (typeof SendouQClass.prototype.groups)[number]
+>;
+export type SQGroup = SerializeFrom<
+ ReturnType[number]
+>;
+export type SQOwnGroup = SerializeFrom<
+ NonNullable>
+>;
+export type SQMatch = SerializeFrom>;
+export type SQMatchGroup = SQMatch["groupAlpha"] | SQMatch["groupBravo"];
+export type SQGroupMember = NonNullable[number];
+export type SQMatchGroupMember = SQMatchGroup["members"][number];
+
+const FALLBACK_TIER = { isPlus: false, name: "IRON" } as const;
+const SECONDS_TILL_STALE =
+ process.env.NODE_ENV === "development" || IS_E2E_TEST_RUN ? 1_000_000 : 1_800;
+
+class SendouQClass {
+ groups;
+ #recentMatches;
+ #isAccurateTiers;
+ /** Array of user IDs currently in the queue */
+ usersInQueue;
+
+ constructor(
+ groups: DBGroupRow[],
+ recentMatches: DBRecentlyFinishedMatchRow[],
+ ) {
+ const season = Seasons.currentOrPrevious();
+ const {
+ intervals,
+ userSkills: calculatedUserSkills,
+ isAccurateTiers,
+ } = userSkills(season!.nth);
+
+ this.#recentMatches = recentMatches;
+ this.#isAccurateTiers = isAccurateTiers;
+ this.usersInQueue = groups.flatMap((group) =>
+ group.members.map((member) => member.id),
+ );
+ this.groups = groups.map((group) => ({
+ ...group,
+ noScreen: this.#groupNoScreen(group),
+ modePreferences: this.#groupModePreferences(group),
+ tier: this.#groupTier({
+ group,
+ userSkills: calculatedUserSkills,
+ intervals,
+ }) as TieredSkill["tier"] | null,
+ tierRange: null as TierRange | null,
+ skillDifference:
+ undefined as ParsedMemento["groups"][number]["skillDifference"],
+ isReplay: false,
+ usersRole: null as Tables["GroupMember"]["role"] | null,
+ members: group.members.map((member) => {
+ const skill = calculatedUserSkills[String(member.id)];
+
+ return {
+ ...member,
+ privateNote: null as DBPrivateNoteRow | null,
+ languages: member.languages?.split(",") || [],
+ skill: !skill || skill.approximate ? ("CALCULATING" as const) : skill,
+ mapModePreferences: undefined,
+ noScreen: undefined,
+ friendCode: null as string | null,
+ inGameName: null as string | null,
+ skillDifference:
+ undefined as ParsedMemento["users"][number]["skillDifference"],
+ };
+ }),
+ }));
+ }
+
+ /**
+ * Determines the current view state for a user based on their group status.
+ */
+ currentViewByUserId(
+ /** The ID of the logged in user */
+ userId: number,
+ ) {
+ const ownGroup = this.findOwnGroup(userId);
+
+ if (!ownGroup) return "default";
+ if (ownGroup.status === "PREPARING") return "preparing";
+ if (ownGroup.matchId) return "match";
+
+ return "looking";
+ }
+
+ /**
+ * Finds the group that a user belongs to.
+ * @returns The user's group with their role, or undefined if not in a group
+ */
+ findOwnGroup(userId: number) {
+ const result = this.groups.find((group) =>
+ group.members.some((member) => member.id === userId),
+ );
+ if (!result) return;
+
+ const member = result.members.find((m) => m.id === userId)!;
+
+ return {
+ ...result,
+ usersRole: member.role,
+ };
+ }
+
+ /**
+ * Finds a group by its ID without censoring sensitive data.
+ * @returns The uncensored group, or undefined if not found
+ */
+ findUncensoredGroupById(groupId: number) {
+ return this.groups.find((group) => group.id === groupId);
+ }
+
+ /**
+ * Finds a group by its invite code.
+ * @returns The group with matching invite code, or undefined if not found
+ */
+ findGroupByInviteCode(inviteCode: string) {
+ return this.groups.find((group) => group.inviteCode === inviteCode);
+ }
+
+ /**
+ * Maps a database match to a format with appropriate censoring based on user permissions.
+ * Includes private notes for team members and censors sensitive data for non-participants.
+ * @returns The mapped match with censored data based on user permissions
+ */
+ mapMatch(
+ /** The database match object to map */
+ match: DBMatch,
+ /** The authenticated user viewing the match (if any) */
+ user?: AuthenticatedUser,
+ /** Array of private user notes to include */
+ notes: DBPrivateNoteRow[] = [],
+ ) {
+ const isTeamAlphaMember = match.groupAlpha.members.some(
+ (m) => m.id === user?.id,
+ );
+ const isTeamBravoMember = match.groupBravo.members.some(
+ (m) => m.id === user?.id,
+ );
+ const isMatchInsider =
+ isTeamAlphaMember || isTeamBravoMember || user?.roles.includes("STAFF");
+ const happenedInLastMonth = isWithinInterval(
+ databaseTimestampToDate(match.createdAt),
+ {
+ start: sub(new Date(), { months: 1 }),
+ end: new Date(),
+ },
+ );
+
+ const matchGroupCensorer = (
+ group: DBMatch["groupAlpha"] | DBMatch["groupBravo"],
+ isTeamMember: boolean,
+ ) => {
+ return {
+ ...group,
+ isReplay: false,
+ tierRange: null as TierRange | null,
+ chatCode: isTeamMember ? group.chatCode : undefined,
+ noScreen: this.#groupNoScreen(group),
+ tier: match.memento?.groups[group.id]?.tier,
+ skillDifference: match.memento?.groups[group.id]?.skillDifference,
+ modePreferences: this.#groupModePreferences(group),
+ usersRole: null as Tables["GroupMember"]["role"] | null,
+ members: group.members.map((member) => {
+ return {
+ ...member,
+ skill: match.memento?.users[member.id]?.skill,
+ privateNote: null as DBPrivateNoteRow | null,
+ skillDifference: match.memento?.users[member.id]?.skillDifference,
+ noScreen: undefined,
+ languages: member.languages?.split(",") || [],
+ friendCode:
+ isMatchInsider && happenedInLastMonth
+ ? member.friendCode
+ : undefined,
+ };
+ }),
+ };
+ };
+
+ return {
+ ...match,
+ chatCode: isMatchInsider ? match.chatCode : undefined,
+ groupAlpha: this.#getAddMemberPrivateNoteMapper(notes)(
+ matchGroupCensorer(match.groupAlpha, isTeamAlphaMember),
+ ),
+ groupBravo: this.#getAddMemberPrivateNoteMapper(notes)(
+ matchGroupCensorer(match.groupBravo, isTeamBravoMember),
+ ),
+ };
+ }
+
+ /**
+ * Returns all groups with wide tier ranges for preview purposes. Full groups being preview always show the full range (IRON-LEVIATHAN)
+ * @returns Array of censored groups with preview tier ranges
+ */
+ previewGroups(
+ /** Array of private user notes to include */
+ notes: DBPrivateNoteRow[],
+ ) {
+ return this.groups
+ .map((group) => this.#addPreviewTierRange(group))
+ .map(this.#getAddMemberPrivateNoteMapper(notes))
+ .map((group) => this.#censorGroup(group));
+ }
+
+ /**
+ * Returns groups that are available for matchmaking for a specific user based on their current group size.
+ * Filters groups based on member count compatibility, activity status, and excludes stale groups.
+ * Results are sorted by sentiment (notes), tier difference, and activity.
+ * @returns Array of compatible groups sorted by relevance, or empty array if user has no group
+ */
+ lookingGroups(
+ /** The ID of the user looking for groups */
+ userId: number,
+ /** Array of private user notes to include */
+ notes: DBPrivateNoteRow[] = [],
+ ) {
+ const ownGroup = this.findOwnGroup(userId);
+ if (!ownGroup) return [];
+
+ const currentMemberCountOptions =
+ ownGroup.members.length === 4
+ ? [4]
+ : ownGroup.members.length === 3
+ ? [1]
+ : ownGroup.members.length === 2
+ ? [1, 2]
+ : [1, 2, 3];
+
+ const staleThreshold = sub(new Date(), { seconds: SECONDS_TILL_STALE });
+ return this.groups
+ .filter((group) => {
+ const groupLastAction = databaseTimestampToDate(group.latestActionAt);
+ return (
+ group.status === "ACTIVE" &&
+ !group.matchId &&
+ group.id !== ownGroup.id &&
+ currentMemberCountOptions.includes(group.members.length) &&
+ groupLastAction >= staleThreshold
+ );
+ })
+ .map(this.#getGroupReplayMapper(userId))
+ .map(this.#getAddTierRangeMapper(ownGroup.tier))
+ .map(this.#getAddMemberPrivateNoteMapper(notes))
+ .sort(this.#getSkillAndNoteSortComparator(ownGroup.tier))
+ .map((group) => this.#censorGroup(group));
+ }
+
+ #getGroupReplayMapper(userId: number) {
+ const recentOpponents = this.#recentMatches.flatMap((match) => {
+ if (match.groupAlphaMemberIds.includes(userId)) {
+ return [match.groupBravoMemberIds];
+ }
+
+ if (match.groupBravoMemberIds.includes(userId)) {
+ return [match.groupAlphaMemberIds];
+ }
+
+ return [];
+ });
+
+ return (group: T) => {
+ if (recentOpponents.length === 0) return group;
+ if (!this.#groupIsFull(group)) return group;
+
+ const isReplay = recentOpponents.some((opponentIds) => {
+ const duplicateCount =
+ R.countBy(opponentIds, (id) =>
+ group.members.some((m) => m.id === id) ? "match" : "no-match",
+ ).match ?? 0;
+
+ return duplicateCount >= 3;
+ });
+
+ return {
+ ...group,
+ isReplay,
+ };
+ };
+ }
+
+ #getAddTierRangeMapper(ownTier?: TieredSkill["tier"] | null) {
+ return (group: T) => {
+ if (!this.#groupIsFull(group)) {
+ return group;
+ }
+
+ const tierRangeOrExact = tierDifferenceToRangeOrExact({
+ ourTier: ownTier ?? FALLBACK_TIER,
+ theirTier: group.tier ?? FALLBACK_TIER,
+ hasLeviathan: this.#isAccurateTiers,
+ });
+
+ if (tierRangeOrExact.type === "exact") {
+ return group;
+ }
+
+ return {
+ ...group,
+ tierRange: R.omit(tierRangeOrExact, ["type"]),
+ tier: null,
+ };
+ };
+ }
+
+ #addPreviewTierRange(group: T) {
+ if (!this.#groupIsFull(group)) {
+ return group;
+ }
+
+ return {
+ ...group,
+ tierRange: {
+ type: "range" as const,
+ range: [
+ { name: "IRON", isPlus: false } as TieredSkill["tier"],
+ { name: "LEVIATHAN", isPlus: true } as TieredSkill["tier"],
+ ],
+ diff: 0,
+ },
+ tier: null,
+ };
+ }
+
+ #censorGroup(
+ group: T,
+ ): Omit & {
+ members: T["members"] | undefined;
+ } {
+ const baseGroup = R.omit(group, ["inviteCode", "chatCode", "members"]);
+
+ if (this.#groupIsFull(group)) {
+ return {
+ ...baseGroup,
+ members: undefined,
+ };
+ }
+
+ return {
+ ...baseGroup,
+ members: group.members,
+ };
+ }
+
+ #getAddMemberPrivateNoteMapper(notes: DBPrivateNoteRow[]) {
+ return (group: T) => {
+ const membersWithNotes = group.members.map((member) => {
+ const note = notes.find((n) => n.targetUserId === member.id);
+ return {
+ ...member,
+ privateNote: note ?? null,
+ };
+ });
+
+ return {
+ ...group,
+ members: membersWithNotes,
+ };
+ };
+ }
+
+ #getSkillAndNoteSortComparator(ownTier?: TieredSkill["tier"] | null) {
+ return <
+ T extends {
+ members: { privateNote: DBPrivateNoteRow | null }[];
+ tierRange: TierRange | null;
+ tier: TieredSkill["tier"] | null;
+ latestActionAt: number;
+ },
+ >(
+ a: T,
+ b: T,
+ ) => {
+ const getGroupSentimentScore = (group: T) => {
+ const hasNegative = group.members.some(
+ (m) => m.privateNote?.sentiment === "NEGATIVE",
+ );
+ const hasPositive = group.members.some(
+ (m) => m.privateNote?.sentiment === "POSITIVE",
+ );
+
+ if (hasNegative) return -1;
+ if (hasPositive) return 1;
+ return 0;
+ };
+
+ const scoreA = getGroupSentimentScore(a);
+ const scoreB = getGroupSentimentScore(b);
+
+ if (scoreA !== scoreB) {
+ return scoreB - scoreA;
+ }
+
+ if (a.tierRange && b.tierRange) {
+ if (a.tierRange.diff[1] !== b.tierRange.diff[1]) {
+ return a.tierRange.diff[1] - b.tierRange.diff[1];
+ }
+ }
+
+ const ownTierIndex = getTierIndex(ownTier, this.#isAccurateTiers);
+ if (typeof ownTierIndex === "number") {
+ const diffA = Math.abs(
+ ownTierIndex - (getTierIndex(a.tier, this.#isAccurateTiers) ?? 999),
+ );
+ const diffB = Math.abs(
+ ownTierIndex - (getTierIndex(b.tier, this.#isAccurateTiers) ?? 999),
+ );
+ if (diffA !== diffB) {
+ return diffA - diffB;
+ }
+ }
+
+ return b.latestActionAt - a.latestActionAt;
+ };
+ }
+
+ #groupNoScreen(group: { members: { noScreen: DBBoolean }[] }) {
+ return this.#groupIsFull(group)
+ ? group.members.some((member) => member.noScreen)
+ : null;
+ }
+
+ #groupModePreferences(
+ group: DBGroupRow | DBMatch["groupAlpha"] | DBMatch["groupBravo"],
+ ): ModeShort[] {
+ const modePreferences: ModeShort[] = [];
+
+ for (const mode of modesShort) {
+ let score = 0;
+ for (const member of group.members) {
+ const userModePreferences = member.mapModePreferences?.modes;
+ if (!userModePreferences) continue;
+
+ if (
+ userModePreferences.some(
+ (p) => p.mode === mode && p.preference === "PREFER",
+ )
+ ) {
+ score += 1;
+ } else if (
+ userModePreferences.some(
+ (p) => p.mode === mode && p.preference === "AVOID",
+ )
+ ) {
+ score -= 1;
+ }
+ }
+
+ if (score > 0) {
+ modePreferences.push(mode);
+ }
+ }
+
+ // reasonable default
+ if (modePreferences.length === 0) {
+ return ["SZ"];
+ }
+
+ return modePreferences;
+ }
+
+ #groupIsFull(group: { members: unknown[] }) {
+ return group.members.length === FULL_GROUP_SIZE;
+ }
+
+ #groupTier({
+ group,
+ userSkills,
+ intervals,
+ }: {
+ group: DBGroupRow | DBMatch["groupAlpha"] | DBMatch["groupBravo"];
+ userSkills: Record;
+ intervals: SkillTierInterval[];
+ }): TieredSkill["tier"] | undefined {
+ if (!group.members) return;
+
+ const skills = group.members.map(
+ (m) => userSkills[String(m.id)] ?? { ordinal: defaultOrdinal() },
+ );
+
+ const averageOrdinal =
+ skills.reduce((acc, s) => acc + s.ordinal, 0) / skills.length;
+
+ return (
+ intervals.find(
+ (i) => i.neededOrdinal && averageOrdinal > i.neededOrdinal,
+ ) ?? { isPlus: false, name: "IRON" }
+ );
+ }
+}
+
+const groups = await SQGroupRepository.findCurrentGroups();
+const recentMatches = await SQGroupRepository.findRecentlyFinishedMatches();
+/** Global instance of the SendouQ manager. Manages all active groups and matchmaking state. */
+export let SendouQ = new SendouQClass(groups, recentMatches);
+
+/**
+ * Refreshes the global SendouQ instance with the latest data from the database.
+ * Should be called after any database changes that affect groups or matches.
+ */
+export async function refreshSendouQInstance() {
+ const groups = await SQGroupRepository.findCurrentGroups();
+ const recentMatches = await SQGroupRepository.findRecentlyFinishedMatches();
+ SendouQ = new SendouQClass(groups, recentMatches);
+}
diff --git a/app/features/sendouq/core/default-maps.server.ts b/app/features/sendouq/core/default-maps.server.ts
index 1b0f9501e..de14686ae 100644
--- a/app/features/sendouq/core/default-maps.server.ts
+++ b/app/features/sendouq/core/default-maps.server.ts
@@ -3,8 +3,8 @@ import * as MapList from "~/features/map-list-generator/core/MapList";
import * as Seasons from "~/features/mmr/core/Seasons";
import { modesShort } from "~/modules/in-game-lists/modes";
import { logger } from "~/utils/logger";
-import * as QRepository from "../QRepository.server";
import { SENDOUQ_BEST_OF } from "../q-constants";
+import * as SQGroupRepository from "../SQGroupRepository.server";
let cachedDefaults: Map | null = null;
@@ -59,7 +59,7 @@ async function calculateSeasonDefaultMaps(
seasonNth: number,
): Promise