mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 23:58:36 -05:00
SendouQ group suggestions
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -312,6 +312,9 @@ export interface GroupLike {
|
||||
likerGroupId: number;
|
||||
targetGroupId: number;
|
||||
isRechallenge: Generated<DBBoolean>;
|
||||
// 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<number>;
|
||||
/** 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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -197,6 +197,31 @@ function deleteLikesByGroupId(groupId: number, trx: Transaction<DB>) {
|
||||
.execute();
|
||||
}
|
||||
|
||||
function deleteSuggestionsByGroupId(groupId: number, trx: Transaction<DB>) {
|
||||
return trx
|
||||
.deleteFrom("GroupSuggestion")
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb("GroupSuggestion.suggesterGroupId", "=", groupId),
|
||||
eb("GroupSuggestion.targetGroupId", "=", groupId),
|
||||
]),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes every like and suggestion where the given group is on either side.
|
||||
* Called when the group's pending challenges and suggestions stop being
|
||||
* actionable e.g. its roster changed or it started a match.
|
||||
*/
|
||||
export async function deleteLikesAndSuggestionsByGroupId(
|
||||
groupId: number,
|
||||
trx: Transaction<DB>,
|
||||
) {
|
||||
await deleteLikesByGroupId(groupId, trx);
|
||||
await deleteSuggestionsByGroupId(groupId, trx);
|
||||
}
|
||||
|
||||
export function morphGroups({
|
||||
survivingGroupId,
|
||||
otherGroupId,
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -61,6 +61,16 @@ function createGroup(
|
||||
};
|
||||
}
|
||||
|
||||
/** A group whose members are censored, as full groups are in the looking page. */
|
||||
function createFullGroup(
|
||||
overrides: Partial<Omit<SQGroup, "members">> = {},
|
||||
): 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({
|
||||
|
||||
@@ -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 ? (
|
||||
<GroupSkillDifference skillDifference={group.skillDifference} />
|
||||
) : null}
|
||||
{action ? (
|
||||
<ActionButton
|
||||
schema={lookingSchema}
|
||||
action={action === "MATCH_UP_RECHALLENGE" ? "MATCH_UP" : action}
|
||||
fields={{ targetGroupId: group.id }}
|
||||
formClassName="stack items-center"
|
||||
size="small"
|
||||
variant={action === "UNLIKE" ? "destructive" : "outlined"}
|
||||
testId="group-card-action-button"
|
||||
>
|
||||
{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")}
|
||||
</ActionButton>
|
||||
{action || suggestable || trail ? (
|
||||
<div className="stack xs items-center">
|
||||
<div className="stack sm horizontal items-center justify-center">
|
||||
{action ? (
|
||||
<ActionButton
|
||||
schema={lookingSchema}
|
||||
action={
|
||||
action === "MATCH_UP_RECHALLENGE" ? "MATCH_UP" : action
|
||||
}
|
||||
fields={{ targetGroupId: group.id }}
|
||||
size="small"
|
||||
variant={action === "UNLIKE" ? "destructive" : undefined}
|
||||
testId="group-card-action-button"
|
||||
>
|
||||
{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")}
|
||||
</ActionButton>
|
||||
) : null}
|
||||
{suggestable ? (
|
||||
<ActionButton
|
||||
schema={lookingSchema}
|
||||
action="SUGGEST"
|
||||
fields={{ targetGroupId: group.id }}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
testId="group-card-suggest-button"
|
||||
>
|
||||
{t("q:looking.groups.actions.suggest")}
|
||||
</ActionButton>
|
||||
) : null}
|
||||
</div>
|
||||
{trail ? (
|
||||
<GroupCardTrailText trail={trail} isFullGroup={!group.members} />
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</GroupCardContainer>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="text-xxs text-lighter mt-1" data-testid="group-card-trail">
|
||||
<Trans
|
||||
t={t}
|
||||
i18nKey={i18nKey()}
|
||||
values={{ username: trail.username }}
|
||||
components={[<span key="username" className="font-bold" />]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupCardContainer({
|
||||
isOwnGroup,
|
||||
groupId,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = (
|
||||
<div className="stack sm">
|
||||
@@ -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<number, UserCardData>): T[] {
|
||||
function sortGroups<T extends { id: number; members?: { id: number }[] }>(
|
||||
groups: T[],
|
||||
{
|
||||
userCards,
|
||||
suggestedGroupIds,
|
||||
}: {
|
||||
userCards: Map<number, UserCardData>;
|
||||
suggestedGroupIds: Set<number>;
|
||||
},
|
||||
): 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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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}}</0>",
|
||||
"looking.groups.trail.challenged": "Challenged by <0>{{username}}</0>",
|
||||
"looking.groups.trail.suggested": "Suggested by <0>{{username}}</0>",
|
||||
"looking.groups.adder.quickAdd": "Quick add",
|
||||
"looking.groups.adder.error": "That user is already in a group",
|
||||
"looking.groups.adder.others": "Others",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "その他",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "Другие",
|
||||
|
||||
@@ -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": "其他",
|
||||
|
||||
40
migrations/20260807134609-group-suggestions.ts
Normal file
40
migrations/20260807134609-group-suggestions.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { type Kysely, sql } from "kysely";
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
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();
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user