From bea6d2166efc0ce105b119cc46527daad400e8c2 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:23:51 +0300 Subject: [PATCH] SendouQ group suggestions --- app/db/seed/factories/SQGroupFactory.ts | 7 + app/db/tables.ts | 13 ++ .../SQMatchRepository.server.test.ts | 62 ++++++++- .../sendouq-match/SQMatchRepository.server.ts | 10 ++ .../sendouq/SQGroupRepository.server.test.ts | 42 ++++++ .../sendouq/SQGroupRepository.server.ts | 120 ++++++++++++++++-- .../sendouq/actions/q.looking.server.ts | 29 ++++- .../components/GroupCard.browser.test.tsx | 76 +++++++++-- app/features/sendouq/components/GroupCard.tsx | 104 ++++++++++++--- app/features/sendouq/core/groups.ts | 8 ++ .../sendouq/loaders/q.looking.server.ts | 3 + app/features/sendouq/q-action-schemas.ts | 4 + app/features/sendouq/routes/q.looking.tsx | 70 ++++++++-- e2e/helpers/playwright.ts | 11 +- e2e/pages/sendouq/group-card.ts | 15 +++ e2e/pages/sendouq/sendouq-looking-page.ts | 1 + e2e/sendouq.spec.ts | 69 +++++++++- locales/da/q.json | 4 + locales/de/q.json | 4 + locales/en/q.json | 4 + locales/es-ES/q.json | 4 + locales/es-US/q.json | 4 + locales/fr-CA/q.json | 4 + locales/fr-EU/q.json | 4 + locales/he/q.json | 4 + locales/it/q.json | 4 + locales/ja/q.json | 4 + locales/ko/q.json | 4 + locales/nl/q.json | 4 + locales/pl/q.json | 4 + locales/pt-BR/q.json | 4 + locales/ru/q.json | 4 + locales/zh/q.json | 4 + .../20260807134609-group-suggestions.ts | 40 ++++++ scripts/benchmark-db/cases.ts | 5 + 35 files changed, 698 insertions(+), 55 deletions(-) create mode 100644 migrations/20260807134609-group-suggestions.ts diff --git a/app/db/seed/factories/SQGroupFactory.ts b/app/db/seed/factories/SQGroupFactory.ts index 3c8516468..24896403c 100644 --- a/app/db/seed/factories/SQGroupFactory.ts +++ b/app/db/seed/factories/SQGroupFactory.ts @@ -44,9 +44,16 @@ export const { create } = defineFactory({ }, applyOptions: async (group, { isMatchmade, likedByGroupIds }: Options) => { for (const likerGroupId of likedByGroupIds ?? []) { + const liker = await db + .selectFrom("GroupMember") + .select("GroupMember.userId") + .where("GroupMember.groupId", "=", likerGroupId) + .executeTakeFirstOrThrow(); + await SQGroupRepository.insertLike({ likerGroupId, targetGroupId: group.id, + createdByUserId: liker.userId, }); } diff --git a/app/db/tables.ts b/app/db/tables.ts index c44f7bd04..76cd10564 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -312,6 +312,9 @@ export interface GroupLike { likerGroupId: number; targetGroupId: number; isRechallenge: Generated; + // TODO: migrate to not null + /** Member of the liker group who sent the invite. `null` for invites sent before the column existed. */ + createdByUserId: number | null; } export interface GroupMatch { @@ -371,6 +374,15 @@ export interface GroupMember { userId: number; } +/** A group member pointing their own group at another group, without inviting it */ +export interface GroupSuggestion { + createdAt: Generated; + /** Group whose members see the suggestion */ + suggesterGroupId: number; + targetGroupId: number; + createdByUserId: number; +} + export interface PrivateUserNote { authorId: number; targetId: number; @@ -1254,6 +1266,7 @@ export interface DB { GroupMatchContinueVote: GroupMatchContinueVote; GroupMatchMap: GroupMatchMap; GroupMember: GroupMember; + GroupSuggestion: GroupSuggestion; PrivateUserNote: PrivateUserNote; LogInLink: LogInLink; LFGPost: LFGPost; diff --git a/app/features/sendouq-match/SQMatchRepository.server.test.ts b/app/features/sendouq-match/SQMatchRepository.server.test.ts index 4e4a5b954..d13f19500 100644 --- a/app/features/sendouq-match/SQMatchRepository.server.test.ts +++ b/app/features/sendouq-match/SQMatchRepository.server.test.ts @@ -1,10 +1,16 @@ import { add } from "date-fns"; import { beforeEach, describe, expect, test } from "vitest"; +import * as SplatoonFaker from "~/db/seed/core/SplatoonFaker"; +import * as SQGroupFactory from "~/db/seed/factories/SQGroupFactory"; import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; import { db } from "~/db/sql"; import * as Seasons from "~/features/mmr/core/Seasons"; -import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants"; +import { + FULL_GROUP_SIZE, + SENDOUQ_BEST_OF, +} from "~/features/sendouq/q-constants"; +import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; import { withUserId } from "~/utils/Test"; import * as SQMatchRepository from "./SQMatchRepository.server"; @@ -55,6 +61,60 @@ const fetchSkills = async (matchId: number) => { .execute(); }; +describe("insert", () => { + test("deletes the matched groups' pending likes and suggestions", async () => { + const users = await UserFactory.createMany(FULL_GROUP_SIZE * 2 + 1); + const alphaMembers = users.slice(0, FULL_GROUP_SIZE); + const bravoMembers = users.slice(FULL_GROUP_SIZE, FULL_GROUP_SIZE * 2); + const bystander = users[FULL_GROUP_SIZE * 2]; + + const alphaGroup = await SQGroupFactory.create({ + memberUserIds: alphaMembers.map((member) => member.id), + }); + const bravoGroup = await SQGroupFactory.create({ + memberUserIds: bravoMembers.map((member) => member.id), + }); + const bystanderGroup = await SQGroupFactory.create({ + memberUserIds: [bystander.id], + }); + + await SQGroupRepository.insertLike({ + likerGroupId: bystanderGroup.id, + targetGroupId: alphaGroup.id, + createdByUserId: bystander.id, + }); + await SQGroupRepository.insertLike({ + likerGroupId: bravoGroup.id, + targetGroupId: bystanderGroup.id, + createdByUserId: bravoMembers[0].id, + }); + await SQGroupRepository.insertSuggestion({ + suggesterGroupId: alphaGroup.id, + targetGroupId: bystanderGroup.id, + createdByUserId: alphaMembers[0].id, + }); + + await SQMatchRepository.insert({ + alphaGroupId: alphaGroup.id, + bravoGroupId: bravoGroup.id, + mapList: SplatoonFaker.mapList(SENDOUQ_BEST_OF).map((map) => ({ + ...map, + source: "BOTH" as const, + })), + memento: { users: {}, groups: {}, pools: [] }, + }); + + const likes = await db.selectFrom("GroupLike").selectAll().execute(); + const suggestions = await db + .selectFrom("GroupSuggestion") + .selectAll() + .execute(); + + expect(likes).toHaveLength(0); + expect(suggestions).toHaveLength(0); + }); +}); + describe("lockMatchWithoutSkillChange", () => { test("inserts dummy skill to lock match", async () => { const { match } = await setupMatch(); diff --git a/app/features/sendouq-match/SQMatchRepository.server.ts b/app/features/sendouq-match/SQMatchRepository.server.ts index 48bfbb8fc..8026e2d22 100644 --- a/app/features/sendouq-match/SQMatchRepository.server.ts +++ b/app/features/sendouq-match/SQMatchRepository.server.ts @@ -612,6 +612,16 @@ export function insert({ await syncGroupTeamId(alphaGroupId, trx); await syncGroupTeamId(bravoGroupId, trx); + // both groups are locked into this match, so anything pending is moot + await SQGroupRepository.deleteLikesAndSuggestionsByGroupId( + alphaGroupId, + trx, + ); + await SQGroupRepository.deleteLikesAndSuggestionsByGroupId( + bravoGroupId, + trx, + ); + await validateCreatedMatch(trx, alphaGroupId, bravoGroupId); return match; diff --git a/app/features/sendouq/SQGroupRepository.server.test.ts b/app/features/sendouq/SQGroupRepository.server.test.ts index bf088cdaf..5a31dd4e6 100644 --- a/app/features/sendouq/SQGroupRepository.server.test.ts +++ b/app/features/sendouq/SQGroupRepository.server.test.ts @@ -1,8 +1,11 @@ +import { sub } from "date-fns"; import { describe, expect, test } from "vitest"; +import { backdate } from "~/db/seed/core/backdate"; import * as GroupMatchContinueVoteFactory from "~/db/seed/factories/GroupMatchContinueVoteFactory"; import * as SQGroupFactory from "~/db/seed/factories/SQGroupFactory"; import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; +import { db } from "~/db/sql"; import * as GroupMatchContinueVoteRepository from "~/features/sendouq-match/GroupMatchContinueVoteRepository.server"; import { FULL_GROUP_SIZE } from "./q-constants"; import * as SQGroupRepository from "./SQGroupRepository.server"; @@ -128,3 +131,42 @@ describe("insertMember", () => { expect(chatCodeToRevalidate).toBe(matchChatCode); }); }); + +describe("setOldGroupsAsInactive", () => { + test("deletes the expired groups' likes and suggestions", async () => { + const [expiring, other] = await UserFactory.createMany(2); + + const expiringGroup = await SQGroupFactory.create({ + memberUserIds: [expiring.id], + }); + const otherGroup = await SQGroupFactory.create({ + memberUserIds: [other.id], + }); + + await SQGroupRepository.insertLike({ + likerGroupId: otherGroup.id, + targetGroupId: expiringGroup.id, + createdByUserId: other.id, + }); + await SQGroupRepository.insertSuggestion({ + suggesterGroupId: expiringGroup.id, + targetGroupId: otherGroup.id, + createdByUserId: expiring.id, + }); + + await backdate("Group", expiringGroup.id, { + latestActionAt: sub(new Date(), { hours: 2 }), + }); + + await SQGroupRepository.setOldGroupsAsInactive(); + + const likes = await db.selectFrom("GroupLike").selectAll().execute(); + const suggestions = await db + .selectFrom("GroupSuggestion") + .selectAll() + .execute(); + + expect(likes).toHaveLength(0); + expect(suggestions).toHaveLength(0); + }); +}); diff --git a/app/features/sendouq/SQGroupRepository.server.ts b/app/features/sendouq/SQGroupRepository.server.ts index 67e43d5c8..ff32ec5bd 100644 --- a/app/features/sendouq/SQGroupRepository.server.ts +++ b/app/features/sendouq/SQGroupRepository.server.ts @@ -197,6 +197,31 @@ function deleteLikesByGroupId(groupId: number, trx: Transaction) { .execute(); } +function deleteSuggestionsByGroupId(groupId: number, trx: Transaction) { + return trx + .deleteFrom("GroupSuggestion") + .where((eb) => + eb.or([ + eb("GroupSuggestion.suggesterGroupId", "=", groupId), + eb("GroupSuggestion.targetGroupId", "=", groupId), + ]), + ) + .execute(); +} + +/** + * Deletes every like and suggestion where the given group is on either side. + * Called when the group's pending challenges and suggestions stop being + * actionable e.g. its roster changed or it started a match. + */ +export async function deleteLikesAndSuggestionsByGroupId( + groupId: number, + trx: Transaction, +) { + await deleteLikesByGroupId(groupId, trx); + await deleteSuggestionsByGroupId(groupId, trx); +} + export function morphGroups({ survivingGroupId, otherGroupId, @@ -218,7 +243,7 @@ export function morphGroups({ .where("GroupMember.groupId", "=", otherGroupId) .execute(); - await deleteLikesByGroupId(survivingGroupId, trx); + await deleteLikesAndSuggestionsByGroupId(survivingGroupId, trx); await refreshGroup(survivingGroupId, trx); await trx @@ -280,7 +305,7 @@ export async function insertMember( }) .execute(); - await deleteLikesByGroupId(groupId, trx); + await deleteLikesAndSuggestionsByGroupId(groupId, trx); if (!(await isGroupCorrect(groupId, trx))) { throw new SendouQError( @@ -297,10 +322,12 @@ export async function insertMember( export async function findAllLikesByGroupId(groupId: number) { const rows = await db .selectFrom("GroupLike") + .leftJoin("User", "User.id", "GroupLike.createdByUserId") .select([ "GroupLike.likerGroupId", "GroupLike.targetGroupId", "GroupLike.isRechallenge", + "User.username as createdByUsername", ]) .where((eb) => eb.or([ @@ -316,6 +343,7 @@ export async function findAllLikesByGroupId(groupId: number) { .map((row) => ({ groupId: row.targetGroupId, isRechallenge: row.isRechallenge, + createdByUsername: row.createdByUsername, })), received: rows .filter((row) => row.targetGroupId === groupId) @@ -326,6 +354,25 @@ export async function findAllLikesByGroupId(groupId: number) { }; } +/** Suggestions the given group's members have made to each other, newest first. */ +export async function findAllSuggestionsByGroupId(groupId: number) { + const rows = await db + .selectFrom("GroupSuggestion") + .innerJoin("User", "User.id", "GroupSuggestion.createdByUserId") + .select([ + "GroupSuggestion.targetGroupId", + "User.username as createdByUsername", + ]) + .where("GroupSuggestion.suggesterGroupId", "=", groupId) + .orderBy("GroupSuggestion.createdAt", "desc") + .execute(); + + return rows.map((row) => ({ + groupId: row.targetGroupId, + createdByUsername: row.createdByUsername, + })); +} + export function rechallenge({ likerGroupId, targetGroupId, @@ -428,14 +475,32 @@ export async function setOldGroupsAsInactive() { .where("latestActionAt", "<", dateToDatabaseTimestamp(oneHourAgo)) .execute(); + const groupIds = groupsToSetInactive.map((g) => g.id); + + await trx + .deleteFrom("GroupLike") + .where((eb) => + eb.or([ + eb("GroupLike.likerGroupId", "in", groupIds), + eb("GroupLike.targetGroupId", "in", groupIds), + ]), + ) + .execute(); + + await trx + .deleteFrom("GroupSuggestion") + .where((eb) => + eb.or([ + eb("GroupSuggestion.suggesterGroupId", "in", groupIds), + eb("GroupSuggestion.targetGroupId", "in", groupIds), + ]), + ) + .execute(); + return trx .updateTable("Group") .set({ status: "INACTIVE" }) - .where( - "Group.id", - "in", - groupsToSetInactive.map((g) => g.id), - ) + .where("Group.id", "in", groupIds) .executeTakeFirst(); }); } @@ -554,15 +619,17 @@ export async function findRecentlyFinishedMatches() { export function insertLike({ likerGroupId, targetGroupId, + createdByUserId, }: { likerGroupId: number; targetGroupId: number; + createdByUserId: number; }) { return db.transaction().execute(async (trx) => { try { await trx .insertInto("GroupLike") - .values({ likerGroupId, targetGroupId }) + .values({ likerGroupId, targetGroupId, createdByUserId }) .onConflict((oc) => oc.columns(["likerGroupId", "targetGroupId"]).doNothing(), ) @@ -574,10 +641,47 @@ export function insertLike({ throw error; } + // inviting says everything the suggestion was there to say + await trx + .deleteFrom("GroupSuggestion") + .where("suggesterGroupId", "=", likerGroupId) + .where("targetGroupId", "=", targetGroupId) + .execute(); + await refreshGroup(likerGroupId, trx); }); } +/** Marks a group as worth a look for the suggester's own teammates. Suggesting twice is a no-op. */ +export function insertSuggestion({ + suggesterGroupId, + targetGroupId, + createdByUserId, +}: { + suggesterGroupId: number; + targetGroupId: number; + createdByUserId: number; +}) { + return db.transaction().execute(async (trx) => { + try { + await trx + .insertInto("GroupSuggestion") + .values({ suggesterGroupId, targetGroupId, createdByUserId }) + .onConflict((oc) => + oc.columns(["suggesterGroupId", "targetGroupId"]).doNothing(), + ) + .execute(); + } catch (error) { + if (errorIsSqliteForeignKeyConstraintFailure(error)) { + throw new SendouQError(error.message); + } + throw error; + } + + await refreshGroup(suggesterGroupId, trx); + }); +} + export function deleteLike({ likerGroupId, targetGroupId, diff --git a/app/features/sendouq/actions/q.looking.server.ts b/app/features/sendouq/actions/q.looking.server.ts index e7f2a5f85..51e03eb9e 100644 --- a/app/features/sendouq/actions/q.looking.server.ts +++ b/app/features/sendouq/actions/q.looking.server.ts @@ -13,7 +13,7 @@ import { refreshStreamsCache } from "~/features/sendouq-streams/core/streams.ser import { parseFormData } from "~/form/parse.server"; import { assertUnreachable } from "~/utils/types"; import { SENDOUQ_PAGE, sendouQMatchPage } from "~/utils/urls"; -import { groupAfterMorph } from "../core/groups"; +import { canSuggest, groupAfterMorph } from "../core/groups"; import { refreshSendouQInstance, SendouQ } from "../core/SendouQ.server"; import { lookingSchema } from "../q-action-schemas"; import { SENDOUQ_LOOKING_ROOM, sqGroupWebsocketRoom } from "../q-constants"; @@ -68,6 +68,7 @@ export const action: ActionFunction = async ({ request }) => { await SQGroupRepository.insertLike({ likerGroupId: currentGroup.id, targetGroupId: data.targetGroupId, + createdByUserId: user.id, }); notifyLikeReceived(data.targetGroupId); @@ -75,6 +76,32 @@ export const action: ActionFunction = async ({ request }) => { break; } + case "SUGGEST": { + if (!canSuggest(currentGroup)) return null; + + const targetIsInPool = SendouQ.lookingGroups(user.id).some( + (group) => group.id === data.targetGroupId, + ); + if (!targetIsInPool) return null; + + // a group we already invited needs no pointing out + const likes = await SQGroupRepository.findAllLikesByGroupId( + currentGroup.id, + ); + if (likes.given.some((like) => like.groupId === data.targetGroupId)) { + return null; + } + + await SQGroupRepository.insertSuggestion({ + suggesterGroupId: currentGroup.id, + targetGroupId: data.targetGroupId, + createdByUserId: user.id, + }); + + revalidateGroupTopic(currentGroup.id); + + break; + } case "RECHALLENGE": { await SQGroupRepository.rechallenge({ likerGroupId: currentGroup.id, diff --git a/app/features/sendouq/components/GroupCard.browser.test.tsx b/app/features/sendouq/components/GroupCard.browser.test.tsx index 7ac08c41b..bf6af3d6b 100644 --- a/app/features/sendouq/components/GroupCard.browser.test.tsx +++ b/app/features/sendouq/components/GroupCard.browser.test.tsx @@ -61,6 +61,16 @@ function createGroup( }; } +/** A group whose members are censored, as full groups are in the looking page. */ +function createFullGroup( + overrides: Partial> = {}, +): SQGroup { + const group = createGroup(overrides); + group.members = undefined; + + return group; +} + type OwnGroupMember = SQOwnGroup["members"][number]; function createOwnGroupMember( @@ -276,10 +286,9 @@ describe("GroupCard", () => { }; const screen = await renderGroupCard({ - group: createGroup({ + group: createFullGroup({ tierRange, tier: null, - members: undefined, }), }); @@ -316,9 +325,8 @@ describe("GroupCard", () => { }; const screen = await renderGroupCard({ - group: createGroup({ + group: createFullGroup({ skillDifference, - members: undefined, }), }); @@ -345,12 +353,8 @@ describe("GroupCard", () => { test("shows Challenge for LIKE with full group (no visible members)", async () => { const ownGroup = createOwnGroup({ id: 2 }); - // Create a group with members explicitly set to undefined (censored/full group) - const fullGroup = createGroup({}); - fullGroup.members = undefined; - const screen = await renderGroupCard({ - group: fullGroup, + group: createFullGroup(), action: "LIKE", ownGroup, displayOnly: false, @@ -364,7 +368,7 @@ describe("GroupCard", () => { const ownGroup = createOwnGroup({ id: 2 }); const screen = await renderGroupCard({ - group: createGroup({ members: undefined }), + group: createFullGroup(), action: "MATCH_UP", ownGroup, displayOnly: false, @@ -375,6 +379,58 @@ describe("GroupCard", () => { }); }); + describe("trail", () => { + test("shows suggested trail for a partial group", async () => { + const screen = await renderGroupCard({ + group: createGroup({ members: [createMember()] }), + trail: { type: "SUGGESTED", username: "Suggester" }, + }); + + const trail = screen.getByTestId("group-card-trail"); + await expect.element(trail).toHaveTextContent("Suggested by Suggester"); + }); + + test("shows suggested trail for a full group", async () => { + const screen = await renderGroupCard({ + group: createFullGroup(), + trail: { type: "SUGGESTED", username: "Suggester" }, + }); + + const trail = screen.getByTestId("group-card-trail"); + await expect.element(trail).toHaveTextContent("Suggested by Suggester"); + }); + + test("shows invited trail for a partial group", async () => { + const screen = await renderGroupCard({ + group: createGroup({ members: [createMember()] }), + trail: { type: "INVITED", username: "Inviter" }, + }); + + const trail = screen.getByTestId("group-card-trail"); + await expect.element(trail).toHaveTextContent("Invited by Inviter"); + }); + + test("shows challenged trail for a full group", async () => { + const screen = await renderGroupCard({ + group: createFullGroup(), + trail: { type: "INVITED", username: "Challenger" }, + }); + + const trail = screen.getByTestId("group-card-trail"); + await expect.element(trail).toHaveTextContent("Challenged by Challenger"); + }); + + test("renders no trail when not given", async () => { + const screen = await renderGroupCard({ + group: createGroup({ members: [createMember()] }), + }); + + await expect + .element(screen.getByTestId("group-card-trail")) + .not.toBeInTheDocument(); + }); + }); + describe("props", () => { test("hides VC when hideVc=1", async () => { const screen = await renderGroupCard({ diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx index 78607f8ae..060bc1566 100644 --- a/app/features/sendouq/components/GroupCard.tsx +++ b/app/features/sendouq/components/GroupCard.tsx @@ -3,7 +3,7 @@ import type { SqlBool } from "kysely"; import { Mic, Volume2, VolumeX } from "lucide-react"; import * as React from "react"; import { Flipped } from "react-flip-toolkit"; -import { useTranslation } from "react-i18next"; +import { Trans, useTranslation } from "react-i18next"; import { Link } from "react-router"; import { ActionButton } from "~/components/ActionButton"; import { Avatar } from "~/components/Avatar"; @@ -41,9 +41,17 @@ import { updateGroupNoteSchema } from "../q-schemas"; import { resolveFutureMatchModes } from "../q-utils"; import styles from "./GroupCard.module.css"; +/** Who in the viewer's own group acted on the shown group, and how. */ +export type GroupCardTrail = { + type: "INVITED" | "SUGGESTED"; + username: string; +}; + export function GroupCard({ group, action, + suggestable = false, + trail, displayOnly = false, hideVc = false, hideWeapons = false, @@ -53,6 +61,9 @@ export function GroupCard({ }: { group: SQGroup | SQOwnGroup; action?: "LIKE" | "UNLIKE" | "GROUP_UP" | "MATCH_UP" | "MATCH_UP_RECHALLENGE"; + /** Can the viewer point their own teammates at this group? */ + suggestable?: boolean; + trail?: GroupCardTrail; displayOnly?: boolean; hideVc?: SqlBool; hideWeapons?: SqlBool; @@ -194,32 +205,83 @@ export function GroupCard({ {group.skillDifference ? ( ) : null} - {action ? ( - - {action === "MATCH_UP" || action === "MATCH_UP_RECHALLENGE" - ? t("q:looking.groups.actions.startMatch") - : action === "LIKE" && !group.members - ? t("q:looking.groups.actions.challenge") - : action === "LIKE" - ? t("q:looking.groups.actions.invite") - : action === "GROUP_UP" - ? t("q:looking.groups.actions.groupUp") - : t("q:looking.groups.actions.undo")} - + {action || suggestable || trail ? ( +
+
+ {action ? ( + + {action === "MATCH_UP" || action === "MATCH_UP_RECHALLENGE" + ? t("q:looking.groups.actions.startMatch") + : action === "LIKE" && !group.members + ? t("q:looking.groups.actions.challenge") + : action === "LIKE" + ? t("q:looking.groups.actions.invite") + : action === "GROUP_UP" + ? t("q:looking.groups.actions.groupUp") + : t("q:looking.groups.actions.undo")} + + ) : null} + {suggestable ? ( + + {t("q:looking.groups.actions.suggest")} + + ) : null} +
+ {trail ? ( + + ) : null} +
) : null} ); } +function GroupCardTrailText({ + trail, + isFullGroup, +}: { + trail: GroupCardTrail; + isFullGroup: boolean; +}) { + const { t } = useTranslation(["q"]); + + const i18nKey = () => { + if (trail.type === "SUGGESTED") return "q:looking.groups.trail.suggested"; + + return isFullGroup + ? "q:looking.groups.trail.challenged" + : "q:looking.groups.trail.invited"; + }; + + return ( +
+ ]} + /> +
+ ); +} + function GroupCardContainer({ isOwnGroup, groupId, diff --git a/app/features/sendouq/core/groups.ts b/app/features/sendouq/core/groups.ts index 62a6d5c2c..519057fa7 100644 --- a/app/features/sendouq/core/groups.ts +++ b/app/features/sendouq/core/groups.ts @@ -32,6 +32,14 @@ export function groupAfterMorph({ return ourGroup; } +/** + * Whether the group's members can suggest other groups to each other. A suggestion + * is a pointer for teammates, so a solo group has no one to point at anything. + */ +export function canSuggest(group: { members: unknown[] }) { + return group.members.length > 1; +} + export function groupExpiryStatus( latestActionAt: number, ): GroupExpiryStatus | null { diff --git a/app/features/sendouq/loaders/q.looking.server.ts b/app/features/sendouq/loaders/q.looking.server.ts index b8b39e572..e35aa9d9c 100644 --- a/app/features/sendouq/loaders/q.looking.server.ts +++ b/app/features/sendouq/loaders/q.looking.server.ts @@ -52,6 +52,9 @@ export const loader = async ({ url }: LoaderFunctionArgs) => { given: [], received: [], }, + suggestions: ownGroup + ? await SQGroupRepository.findAllSuggestionsByGroupId(ownGroup.id) + : [], lastUpdated: Date.now(), streamsCount: (await cachedStreams()).length, chatCode: diff --git a/app/features/sendouq/q-action-schemas.ts b/app/features/sendouq/q-action-schemas.ts index 9ae4b81f5..8d6ed4038 100644 --- a/app/features/sendouq/q-action-schemas.ts +++ b/app/features/sendouq/q-action-schemas.ts @@ -36,6 +36,10 @@ export const lookingSchema = z.union([ _action: _action("UNLIKE"), targetGroupId: id, }), + z.object({ + _action: _action("SUGGEST"), + targetGroupId: id, + }), z.object({ _action: _action("GROUP_UP"), targetGroupId: id, diff --git a/app/features/sendouq/routes/q.looking.tsx b/app/features/sendouq/routes/q.looking.tsx index 4eb44b85b..6abc48a7e 100644 --- a/app/features/sendouq/routes/q.looking.tsx +++ b/app/features/sendouq/routes/q.looking.tsx @@ -33,10 +33,10 @@ import { SENDOUQ_STREAMS_PAGE, } from "~/utils/urls"; import { action } from "../actions/q.looking.server"; -import { GroupCard } from "../components/GroupCard"; +import { GroupCard, type GroupCardTrail } from "../components/GroupCard"; import { GroupLeaver } from "../components/GroupLeaver"; import { MemberAdder } from "../components/MemberAdder"; -import { groupExpiryStatus } from "../core/groups"; +import { canSuggest, groupExpiryStatus } from "../core/groups"; import { loader } from "../loaders/q.looking.server"; import { lookingSchema } from "../q-action-schemas"; import { @@ -236,7 +236,37 @@ function Groups() { const isFullGroup = data.ownGroup && data.ownGroup.members.length === FULL_GROUP_SIZE; - const groups = sortGroupsByPrivateNoteSentiment(data.groups, data.userCards); + const suggestedByUsernames = new Map( + data.suggestions.map((suggestion) => [ + suggestion.groupId, + suggestion.createdByUsername, + ]), + ); + const invitedByUsernames = new Map( + data.likes.given.map((like) => [like.groupId, like.createdByUsername]), + ); + + const groups = sortGroups(data.groups, { + userCards: data.userCards, + suggestedGroupIds: new Set(suggestedByUsernames.keys()), + }); + + const trailOf = (groupId: number): GroupCardTrail | undefined => { + const invitedBy = invitedByUsernames.get(groupId); + if (invitedBy) return { type: "INVITED", username: invitedBy }; + + const suggestedBy = suggestedByUsernames.get(groupId); + if (suggestedBy) return { type: "SUGGESTED", username: suggestedBy }; + + return undefined; + }; + + const canSuggestGroups = Boolean(data.ownGroup && canSuggest(data.ownGroup)); + // a group already invited or suggested has nothing left to point out + const isSuggestable = (groupId: number) => + canSuggestGroups && + !invitedByUsernames.has(groupId) && + !suggestedByUsernames.has(groupId); const invitedGroupsDesktop = (
@@ -257,6 +287,7 @@ function Groups() { key={group.id} group={group} action="UNLIKE" + trail={trailOf(group.id)} ownGroup={data.ownGroup} layout={layout} /> @@ -358,6 +389,8 @@ function Groups() { ? "UNLIKE" : "LIKE" } + suggestable={isSuggestable(group.id)} + trail={trailOf(group.id)} ownGroup={data.ownGroup} layout={layout} /> @@ -385,6 +418,8 @@ function Groups() { key={group.id} group={group} action={action()} + suggestable={isSuggestable(group.id)} + trail={trailOf(group.id)} ownGroup={data.ownGroup} layout={layout} /> @@ -422,6 +457,8 @@ function Groups() { key={group.id} group={group} action={action()} + suggestable={isSuggestable(group.id)} + trail={trailOf(group.id)} ownGroup={data.ownGroup} layout={layout} /> @@ -435,15 +472,22 @@ function Groups() { } /** - * Floats groups the viewer has a positive private note on up and groups with a - * negative note down, while preserving the server's tier/activity ordering - * within each sentiment bucket and keeping full (censored) groups last. The - * note sentiment is read from the already-loaded `userCards` data so the server - * does not need to attach notes to group members. + * Floats groups a teammate suggested to the very top, then groups the viewer has + * a positive private note on up and groups with a negative note down, while + * preserving the server's tier/activity ordering within each bucket and keeping + * full (censored) groups last. The note sentiment is read from the already-loaded + * `userCards` data so the server does not need to attach notes to group members. */ -function sortGroupsByPrivateNoteSentiment< - T extends { members?: { id: number }[] }, ->(groups: T[], userCards: Map): T[] { +function sortGroups( + groups: T[], + { + userCards, + suggestedGroupIds, + }: { + userCards: Map; + suggestedGroupIds: Set; + }, +): T[] { const sentimentScore = (group: T) => { if (!group.members) return 0; @@ -458,6 +502,10 @@ function sortGroupsByPrivateNoteSentiment< }; return groups.toSorted((a, b) => { + const aIsSuggested = suggestedGroupIds.has(a.id); + const bIsSuggested = suggestedGroupIds.has(b.id); + if (aIsSuggested !== bIsSuggested) return aIsSuggested ? -1 : 1; + const aIsFull = !a.members; const bIsFull = !b.members; if (aIsFull !== bIsFull) return aIsFull ? 1 : -1; diff --git a/e2e/helpers/playwright.ts b/e2e/helpers/playwright.ts index 1a3a829e7..a1f64827c 100644 --- a/e2e/helpers/playwright.ts +++ b/e2e/helpers/playwright.ts @@ -250,9 +250,16 @@ async function retryPost( throw new Error(`${name}: unreachable`); } -export async function submit(page: Page, testId?: string) { +/** Clicks a submit button and waits for the POST it fires. Takes a locator when + * the test id alone is ambiguous, e.g. one button per card on a list page. */ +export async function submit(page: Page, target?: string | Locator) { + const button = + typeof target === "object" + ? target + : page.getByTestId(target ?? "submit-button"); + await waitForPOSTResponse(page, async () => { - await page.getByTestId(testId ?? "submit-button").click(); + await button.click(); }); // Toast flash params are stripped right after via a replace navigation diff --git a/e2e/pages/sendouq/group-card.ts b/e2e/pages/sendouq/group-card.ts index 6879c70ca..5f9d455ae 100644 --- a/e2e/pages/sendouq/group-card.ts +++ b/e2e/pages/sendouq/group-card.ts @@ -1,15 +1,30 @@ import type { Locator } from "@playwright/test"; +import { submit } from "../../helpers/playwright"; import { UserCard } from "../user/user-card"; export class GroupCard { readonly root: Locator; readonly members: Locator; readonly actionButton: Locator; + readonly suggestButton: Locator; + /** Note of who in the own group invited or suggested this group. */ + readonly trail: Locator; constructor(root: Locator) { this.root = root; this.members = root.getByTestId("sendouq-group-card-member"); this.actionButton = root.getByTestId("group-card-action-button"); + this.suggestButton = root.getByTestId("group-card-suggest-button"); + this.trail = root.getByTestId("group-card-trail"); + } + + /** Challenges or invites the group, accepts what it offered, or undoes either. */ + pressAction() { + return submit(this.root.page(), this.actionButton); + } + + pressSuggest() { + return submit(this.root.page(), this.suggestButton); } openMemberCard(name: string) { diff --git a/e2e/pages/sendouq/sendouq-looking-page.ts b/e2e/pages/sendouq/sendouq-looking-page.ts index 348917995..aa3b88ee7 100644 --- a/e2e/pages/sendouq/sendouq-looking-page.ts +++ b/e2e/pages/sendouq/sendouq-looking-page.ts @@ -12,6 +12,7 @@ export class SendouQLookingPage { this.locators = { groupCards: page.getByTestId("sendouq-group-card"), undoButtons: page.getByRole("button", { name: "Undo" }), + suggestButtons: page.getByTestId("group-card-suggest-button"), }; } diff --git a/e2e/sendouq.spec.ts b/e2e/sendouq.spec.ts index cbd94f366..1555a063b 100644 --- a/e2e/sendouq.spec.ts +++ b/e2e/sendouq.spec.ts @@ -1,10 +1,11 @@ +import { sub } from "date-fns"; import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants"; import { SENDOUQ_LOOKING_PAGE, SENDOUQ_PAGE, SENDOUQ_PREPARING_PAGE, } from "~/utils/urls"; -import { expect, impersonate, test } from "./helpers/playwright"; +import { expect, impersonate, isNotVisible, test } from "./helpers/playwright"; import { SendouQLookingPage } from "./pages/sendouq/sendouq-looking-page"; import { SendouQPage } from "./pages/sendouq/sendouq-page"; import { MatchProfilePage } from "./pages/settings/match-profile-page"; @@ -117,6 +118,72 @@ test.describe("SendouQ", () => { await expect(looking.locators.undoButtons).toHaveCount(0); }); + test("Suggesting floats a group to the top and leaves a trail for the group", async ({ + page, + factories, + }) => { + const owner = await factories.UserFactory.create({ + discordName: "Owner", + profile: null, + }); + const teammate = await factories.UserFactory.create({ + discordName: "Teammate", + profile: null, + }); + const target = await factories.UserFactory.create({ + discordName: "Target", + profile: null, + }); + const other = await factories.UserFactory.create({ + discordName: "Other", + profile: null, + }); + + await factories.SQGroupFactory.create({ + memberUserIds: [owner.id, teammate.id], + }); + const suggestedGroup = await factories.SQGroupFactory.create({ + memberUserIds: [target.id], + }); + await factories.SQGroupFactory.create({ memberUserIds: [other.id] }); + + // the least recently active group sorts last, so the suggestion has somewhere to move it from + await factories.backdate("Group", suggestedGroup.id, { + latestActionAt: sub(new Date(), { minutes: 10 }), + }); + + await impersonate(page, owner.id); + + const looking = new SendouQLookingPage(page); + await looking.goto(); + + // the own group's card is always the first one + await expect(looking.groupCard(2).root).toContainText("Target"); + + await looking.groupCard(2).pressSuggest(); + + await expect(looking.groupCard(1).root).toContainText("Target"); + await expect(looking.groupCard(1).trail).toHaveText("Suggested by Owner"); + // a suggestion can't be undone or repeated + await isNotVisible(looking.groupCard(1).suggestButton); + + // the whole group sees the suggestion, and any of them can act on it + await impersonate(page, teammate.id); + await looking.goto(); + await expect(looking.groupCard(1).trail).toHaveText("Suggested by Owner"); + + await looking.groupCard(1).pressAction(); + + // inviting says everything the suggestion said, so it takes the trail over + await expect(looking.groupCard(1).trail).toHaveText("Invited by Teammate"); + await isNotVisible(looking.groupCard(1).suggestButton); + + // a solo queuer has no teammates to suggest anything to + await impersonate(page, other.id); + await looking.goto(); + await isNotVisible(looking.locators.suggestButtons); + }); + test("Joining the queue is blocked when the season's initial powers were never seeded", async ({ page, factories, diff --git a/locales/da/q.json b/locales/da/q.json index 22a67fb9c..e6b7f8007 100644 --- a/locales/da/q.json +++ b/locales/da/q.json @@ -62,6 +62,7 @@ "looking.groups.actions.startMatch": "", "looking.groups.actions.challenge": "", "looking.groups.actions.invite": "", + "looking.groups.actions.suggest": "", "looking.groups.actions.groupUp": "", "looking.groups.actions.undo": "", "looking.groups.actions.giveManager": "", @@ -73,6 +74,9 @@ "looking.groups.actions.organizerRemove.confirm": "", "looking.groups.actions.leaveQ": "", "looking.groups.actions.goBack": "", + "looking.groups.trail.invited": "", + "looking.groups.trail.challenged": "", + "looking.groups.trail.suggested": "", "looking.groups.adder.quickAdd": "", "looking.groups.adder.error": "", "looking.groups.adder.others": "", diff --git a/locales/de/q.json b/locales/de/q.json index 022f51560..c77cc0087 100644 --- a/locales/de/q.json +++ b/locales/de/q.json @@ -62,6 +62,7 @@ "looking.groups.actions.startMatch": "", "looking.groups.actions.challenge": "", "looking.groups.actions.invite": "", + "looking.groups.actions.suggest": "", "looking.groups.actions.groupUp": "", "looking.groups.actions.undo": "", "looking.groups.actions.giveManager": "", @@ -73,6 +74,9 @@ "looking.groups.actions.organizerRemove.confirm": "", "looking.groups.actions.leaveQ": "", "looking.groups.actions.goBack": "", + "looking.groups.trail.invited": "", + "looking.groups.trail.challenged": "", + "looking.groups.trail.suggested": "", "looking.groups.adder.quickAdd": "", "looking.groups.adder.error": "", "looking.groups.adder.others": "", diff --git a/locales/en/q.json b/locales/en/q.json index deb978203..65b6de1e7 100644 --- a/locales/en/q.json +++ b/locales/en/q.json @@ -62,6 +62,7 @@ "looking.groups.actions.startMatch": "Start match", "looking.groups.actions.challenge": "Challenge", "looking.groups.actions.invite": "Invite", + "looking.groups.actions.suggest": "Suggest", "looking.groups.actions.groupUp": "Group up", "looking.groups.actions.undo": "Undo", "looking.groups.actions.giveManager": "Give manager", @@ -73,6 +74,9 @@ "looking.groups.actions.organizerRemove.confirm": "Remove {{name}} from LFG?", "looking.groups.actions.leaveQ": "Leave queue", "looking.groups.actions.goBack": "Go back", + "looking.groups.trail.invited": "Invited by <0>{{username}}", + "looking.groups.trail.challenged": "Challenged by <0>{{username}}", + "looking.groups.trail.suggested": "Suggested by <0>{{username}}", "looking.groups.adder.quickAdd": "Quick add", "looking.groups.adder.error": "That user is already in a group", "looking.groups.adder.others": "Others", diff --git a/locales/es-ES/q.json b/locales/es-ES/q.json index f42858683..23a5e1055 100644 --- a/locales/es-ES/q.json +++ b/locales/es-ES/q.json @@ -62,6 +62,7 @@ "looking.groups.actions.startMatch": "Comenzar partido", "looking.groups.actions.challenge": "Desafiar", "looking.groups.actions.invite": "Invitar", + "looking.groups.actions.suggest": "", "looking.groups.actions.groupUp": "Crear grupo", "looking.groups.actions.undo": "Deshacer", "looking.groups.actions.giveManager": "Hacer mánager", @@ -73,6 +74,9 @@ "looking.groups.actions.organizerRemove.confirm": "¿Eliminar a {{name}} de LFG?", "looking.groups.actions.leaveQ": "Dejar fila", "looking.groups.actions.goBack": "Regresar", + "looking.groups.trail.invited": "", + "looking.groups.trail.challenged": "", + "looking.groups.trail.suggested": "", "looking.groups.adder.quickAdd": "Añadir rápido", "looking.groups.adder.error": "Ese usuario ya está en un grupo", "looking.groups.adder.others": "Otros", diff --git a/locales/es-US/q.json b/locales/es-US/q.json index 2824508fc..e4356fbee 100644 --- a/locales/es-US/q.json +++ b/locales/es-US/q.json @@ -62,6 +62,7 @@ "looking.groups.actions.startMatch": "Comenzar partido", "looking.groups.actions.challenge": "Desafiar", "looking.groups.actions.invite": "Invitar", + "looking.groups.actions.suggest": "", "looking.groups.actions.groupUp": "Crear grupo", "looking.groups.actions.undo": "Deshacer", "looking.groups.actions.giveManager": "Hacer mánager", @@ -73,6 +74,9 @@ "looking.groups.actions.organizerRemove.confirm": "", "looking.groups.actions.leaveQ": "Dejar fila", "looking.groups.actions.goBack": "Regresar", + "looking.groups.trail.invited": "", + "looking.groups.trail.challenged": "", + "looking.groups.trail.suggested": "", "looking.groups.adder.quickAdd": "Añadir rápido", "looking.groups.adder.error": "Ese usuario ya está en un grupo", "looking.groups.adder.others": "", diff --git a/locales/fr-CA/q.json b/locales/fr-CA/q.json index b2704de72..4f74ca379 100644 --- a/locales/fr-CA/q.json +++ b/locales/fr-CA/q.json @@ -62,6 +62,7 @@ "looking.groups.actions.startMatch": "", "looking.groups.actions.challenge": "", "looking.groups.actions.invite": "", + "looking.groups.actions.suggest": "", "looking.groups.actions.groupUp": "", "looking.groups.actions.undo": "", "looking.groups.actions.giveManager": "", @@ -73,6 +74,9 @@ "looking.groups.actions.organizerRemove.confirm": "", "looking.groups.actions.leaveQ": "", "looking.groups.actions.goBack": "", + "looking.groups.trail.invited": "", + "looking.groups.trail.challenged": "", + "looking.groups.trail.suggested": "", "looking.groups.adder.quickAdd": "", "looking.groups.adder.error": "", "looking.groups.adder.others": "", diff --git a/locales/fr-EU/q.json b/locales/fr-EU/q.json index d098f5cf2..965bb1b26 100644 --- a/locales/fr-EU/q.json +++ b/locales/fr-EU/q.json @@ -62,6 +62,7 @@ "looking.groups.actions.startMatch": "Commencé le match", "looking.groups.actions.challenge": "Challenge", "looking.groups.actions.invite": "Invite", + "looking.groups.actions.suggest": "", "looking.groups.actions.groupUp": "Accepté", "looking.groups.actions.undo": "Annulé", "looking.groups.actions.giveManager": "Promouvoir", @@ -73,6 +74,9 @@ "looking.groups.actions.organizerRemove.confirm": "", "looking.groups.actions.leaveQ": "Quitter la queue", "looking.groups.actions.goBack": "Retourner en arrière", + "looking.groups.trail.invited": "", + "looking.groups.trail.challenged": "", + "looking.groups.trail.suggested": "", "looking.groups.adder.quickAdd": "Ajouter rapidement", "looking.groups.adder.error": "Cet utilisateur est déjà dans un groupe", "looking.groups.adder.others": "Autres", diff --git a/locales/he/q.json b/locales/he/q.json index a2e13ec44..d3ea75630 100644 --- a/locales/he/q.json +++ b/locales/he/q.json @@ -62,6 +62,7 @@ "looking.groups.actions.startMatch": "", "looking.groups.actions.challenge": "", "looking.groups.actions.invite": "", + "looking.groups.actions.suggest": "", "looking.groups.actions.groupUp": "", "looking.groups.actions.undo": "", "looking.groups.actions.giveManager": "", @@ -73,6 +74,9 @@ "looking.groups.actions.organizerRemove.confirm": "", "looking.groups.actions.leaveQ": "", "looking.groups.actions.goBack": "", + "looking.groups.trail.invited": "", + "looking.groups.trail.challenged": "", + "looking.groups.trail.suggested": "", "looking.groups.adder.quickAdd": "", "looking.groups.adder.error": "", "looking.groups.adder.others": "", diff --git a/locales/it/q.json b/locales/it/q.json index e71058a12..0c5e64ad4 100644 --- a/locales/it/q.json +++ b/locales/it/q.json @@ -62,6 +62,7 @@ "looking.groups.actions.startMatch": "Inizia match", "looking.groups.actions.challenge": "Sfida", "looking.groups.actions.invite": "Invita", + "looking.groups.actions.suggest": "", "looking.groups.actions.groupUp": "Forma un gruppo", "looking.groups.actions.undo": "Annulla", "looking.groups.actions.giveManager": "Dai manager", @@ -73,6 +74,9 @@ "looking.groups.actions.organizerRemove.confirm": "", "looking.groups.actions.leaveQ": "Lascia coda", "looking.groups.actions.goBack": "Indietro", + "looking.groups.trail.invited": "", + "looking.groups.trail.challenged": "", + "looking.groups.trail.suggested": "", "looking.groups.adder.quickAdd": "Aggiungi direttamente", "looking.groups.adder.error": "L'utente è già in un gruppo", "looking.groups.adder.others": "Altri", diff --git a/locales/ja/q.json b/locales/ja/q.json index ec4cb6838..5e331e172 100644 --- a/locales/ja/q.json +++ b/locales/ja/q.json @@ -62,6 +62,7 @@ "looking.groups.actions.startMatch": "マッチ開始", "looking.groups.actions.challenge": "挑戦", "looking.groups.actions.invite": "招待する", + "looking.groups.actions.suggest": "", "looking.groups.actions.groupUp": "グループに分かれる", "looking.groups.actions.undo": "戻す", "looking.groups.actions.giveManager": "マネージャーにあげる", @@ -73,6 +74,9 @@ "looking.groups.actions.organizerRemove.confirm": "", "looking.groups.actions.leaveQ": "列から出る", "looking.groups.actions.goBack": "戻る", + "looking.groups.trail.invited": "", + "looking.groups.trail.challenged": "", + "looking.groups.trail.suggested": "", "looking.groups.adder.quickAdd": "クイック追加", "looking.groups.adder.error": "そのユーザーはグループに入っています", "looking.groups.adder.others": "その他", diff --git a/locales/ko/q.json b/locales/ko/q.json index 022f51560..c77cc0087 100644 --- a/locales/ko/q.json +++ b/locales/ko/q.json @@ -62,6 +62,7 @@ "looking.groups.actions.startMatch": "", "looking.groups.actions.challenge": "", "looking.groups.actions.invite": "", + "looking.groups.actions.suggest": "", "looking.groups.actions.groupUp": "", "looking.groups.actions.undo": "", "looking.groups.actions.giveManager": "", @@ -73,6 +74,9 @@ "looking.groups.actions.organizerRemove.confirm": "", "looking.groups.actions.leaveQ": "", "looking.groups.actions.goBack": "", + "looking.groups.trail.invited": "", + "looking.groups.trail.challenged": "", + "looking.groups.trail.suggested": "", "looking.groups.adder.quickAdd": "", "looking.groups.adder.error": "", "looking.groups.adder.others": "", diff --git a/locales/nl/q.json b/locales/nl/q.json index 022f51560..c77cc0087 100644 --- a/locales/nl/q.json +++ b/locales/nl/q.json @@ -62,6 +62,7 @@ "looking.groups.actions.startMatch": "", "looking.groups.actions.challenge": "", "looking.groups.actions.invite": "", + "looking.groups.actions.suggest": "", "looking.groups.actions.groupUp": "", "looking.groups.actions.undo": "", "looking.groups.actions.giveManager": "", @@ -73,6 +74,9 @@ "looking.groups.actions.organizerRemove.confirm": "", "looking.groups.actions.leaveQ": "", "looking.groups.actions.goBack": "", + "looking.groups.trail.invited": "", + "looking.groups.trail.challenged": "", + "looking.groups.trail.suggested": "", "looking.groups.adder.quickAdd": "", "looking.groups.adder.error": "", "looking.groups.adder.others": "", diff --git a/locales/pl/q.json b/locales/pl/q.json index 022f51560..c77cc0087 100644 --- a/locales/pl/q.json +++ b/locales/pl/q.json @@ -62,6 +62,7 @@ "looking.groups.actions.startMatch": "", "looking.groups.actions.challenge": "", "looking.groups.actions.invite": "", + "looking.groups.actions.suggest": "", "looking.groups.actions.groupUp": "", "looking.groups.actions.undo": "", "looking.groups.actions.giveManager": "", @@ -73,6 +74,9 @@ "looking.groups.actions.organizerRemove.confirm": "", "looking.groups.actions.leaveQ": "", "looking.groups.actions.goBack": "", + "looking.groups.trail.invited": "", + "looking.groups.trail.challenged": "", + "looking.groups.trail.suggested": "", "looking.groups.adder.quickAdd": "", "looking.groups.adder.error": "", "looking.groups.adder.others": "", diff --git a/locales/pt-BR/q.json b/locales/pt-BR/q.json index e4bfe9eae..6f4f26317 100644 --- a/locales/pt-BR/q.json +++ b/locales/pt-BR/q.json @@ -62,6 +62,7 @@ "looking.groups.actions.startMatch": "Iniciar partida", "looking.groups.actions.challenge": "Desafiar", "looking.groups.actions.invite": "Convidar", + "looking.groups.actions.suggest": "", "looking.groups.actions.groupUp": "Se juntar", "looking.groups.actions.undo": "Desfazer", "looking.groups.actions.giveManager": "Dar gerência", @@ -73,6 +74,9 @@ "looking.groups.actions.organizerRemove.confirm": "", "looking.groups.actions.leaveQ": "Sair da fila", "looking.groups.actions.goBack": "Voltar", + "looking.groups.trail.invited": "", + "looking.groups.trail.challenged": "", + "looking.groups.trail.suggested": "", "looking.groups.adder.quickAdd": "Adicionar rapidamente (quick add)", "looking.groups.adder.error": "Esse usuário já está em um grupo", "looking.groups.adder.others": "", diff --git a/locales/ru/q.json b/locales/ru/q.json index 9f626eb53..5ced74daa 100644 --- a/locales/ru/q.json +++ b/locales/ru/q.json @@ -62,6 +62,7 @@ "looking.groups.actions.startMatch": "Начать матч", "looking.groups.actions.challenge": "Бросить вызов", "looking.groups.actions.invite": "Пригласить", + "looking.groups.actions.suggest": "", "looking.groups.actions.groupUp": "Вступить в группу", "looking.groups.actions.undo": "Отменить", "looking.groups.actions.giveManager": "Дать роль менеджера", @@ -73,6 +74,9 @@ "looking.groups.actions.organizerRemove.confirm": "", "looking.groups.actions.leaveQ": "Покинуть очередь", "looking.groups.actions.goBack": "Вернуться назад", + "looking.groups.trail.invited": "", + "looking.groups.trail.challenged": "", + "looking.groups.trail.suggested": "", "looking.groups.adder.quickAdd": "Быстро добавить", "looking.groups.adder.error": "Этот пользователь сейчас в другой группе", "looking.groups.adder.others": "Другие", diff --git a/locales/zh/q.json b/locales/zh/q.json index e1c4c3fad..c1a68285e 100644 --- a/locales/zh/q.json +++ b/locales/zh/q.json @@ -62,6 +62,7 @@ "looking.groups.actions.startMatch": "开始对战", "looking.groups.actions.challenge": "挑战", "looking.groups.actions.invite": "邀请", + "looking.groups.actions.suggest": "", "looking.groups.actions.groupUp": "组队", "looking.groups.actions.undo": "撤销", "looking.groups.actions.giveManager": "授予管理者权限", @@ -73,6 +74,9 @@ "looking.groups.actions.organizerRemove.confirm": "要将 {{name}} 移出组队吗?", "looking.groups.actions.leaveQ": "停止匹配", "looking.groups.actions.goBack": "返回", + "looking.groups.trail.invited": "", + "looking.groups.trail.challenged": "", + "looking.groups.trail.suggested": "", "looking.groups.adder.quickAdd": "快速添加", "looking.groups.adder.error": "该用户已加入其他小组", "looking.groups.adder.others": "其他", diff --git a/migrations/20260807134609-group-suggestions.ts b/migrations/20260807134609-group-suggestions.ts new file mode 100644 index 000000000..e5bf088bf --- /dev/null +++ b/migrations/20260807134609-group-suggestions.ts @@ -0,0 +1,40 @@ +import { type Kysely, sql } from "kysely"; + +export async function up(db: Kysely): Promise { + await db.transaction().execute(async (trx) => { + await trx.schema + .createTable("GroupSuggestion") + .addColumn("suggesterGroupId", "integer", (col) => + col.notNull().references("Group.id").onDelete("cascade"), + ) + .addColumn("targetGroupId", "integer", (col) => + col.notNull().references("Group.id").onDelete("cascade"), + ) + .addColumn("createdByUserId", "integer", (col) => + col.notNull().references("User.id").onDelete("cascade"), + ) + .addColumn("createdAt", "integer", (col) => + col.notNull().defaultTo(sql`(strftime('%s', 'now'))`), + ) + .addUniqueConstraint( + "group_suggestion_suggester_group_id_target_group_id", + ["suggesterGroupId", "targetGroupId"], + ) + // every table in this schema is strict + .modifyEnd(sql`strict`) + .execute(); + + await trx.schema + .createIndex("group_suggestion_target_group_id") + .on("GroupSuggestion") + .column("targetGroupId") + .execute(); + + await trx.schema + .alterTable("GroupLike") + .addColumn("createdByUserId", "integer", (col) => + col.references("User.id").onDelete("set null"), + ) + .execute(); + }); +} diff --git a/scripts/benchmark-db/cases.ts b/scripts/benchmark-db/cases.ts index f65d016e8..a479e1956 100644 --- a/scripts/benchmark-db/cases.ts +++ b/scripts/benchmark-db/cases.ts @@ -584,6 +584,11 @@ export function buildCases(fx: Fixtures): { add("SQGroupRepository.findAllLikesByGroupId", fx.heavyGroupIds, (groupIds) => SQGroupRepository.findAllLikesByGroupId(groupIds[0]), ); + add( + "SQGroupRepository.findAllSuggestionsByGroupId", + fx.heavyGroupIds, + (groupIds) => SQGroupRepository.findAllSuggestionsByGroupId(groupIds[0]), + ); add("SQGroupRepository.findFriendsAndTeammates", fx.sq, (sq) => SQGroupRepository.findFriendsAndTeammates(sq.userId), );