Remove SendouQ group roles and kicking

This commit is contained in:
Kalle
2026-08-07 16:38:35 +03:00
parent 7d751bb1c8
commit 35412ffe91
37 changed files with 67 additions and 460 deletions

View File

@@ -7,19 +7,19 @@ type InsertArgs = Omit<
Parameters<typeof SQGroupRepository.insert>[0],
"userId"
> & {
/** The group's members, the first of them its owner. */
/** The group's members, the first of them its creator. */
memberUserIds: number[];
};
type Options = {
/** Was the group made in the matchmaking UI? */
isMatchmade?: boolean;
/** Groups that have liked this one, each of them as its own owner. */
/** Groups that have liked this one. */
likedByGroupIds?: number[];
};
/**
* Creates SendouQ groups. The first of `memberUserIds` is the owner, whose
* Creates SendouQ groups. The first of `memberUserIds` is the creator, whose
* membership the repository creates with the group; the rest join it the way they do
* in production. Invite and chat codes are the repository's own.
*/
@@ -28,19 +28,19 @@ export const { create } = defineFactory({
status: "ACTIVE" as const,
}),
insert: async ({ memberUserIds, ...args }: InsertArgs) => {
const [ownerUserId, ...otherMemberUserIds] = memberUserIds;
invariant(ownerUserId, "A group needs at least an owner");
const [creatorUserId, ...otherMemberUserIds] = memberUserIds;
invariant(creatorUserId, "A group needs at least one member");
const group = await SQGroupRepository.insert({
...args,
userId: ownerUserId,
userId: creatorUserId,
});
for (const userId of otherMemberUserIds) {
await SQGroupRepository.insertMember(group.id, { userId });
}
return { id: group.id, memberUserIds, ownerUserId };
return { id: group.id, memberUserIds };
},
applyOptions: async (group, { isMatchmade, likedByGroupIds }: Options) => {
for (const likerGroupId of likedByGroupIds ?? []) {

View File

@@ -139,7 +139,7 @@ async function cancelMatch(
) {
const request = await SQMatchRepository.requestCancelMatch({
matchId: match.id,
requestedByUserId: match.alphaGroup.ownerUserId,
requestedByUserId: match.alphaGroup.memberUserIds[0],
...requested,
});
@@ -150,7 +150,7 @@ async function cancelMatch(
const acceptance = await SQMatchRepository.acceptCancelMatch({
matchId: match.id,
acceptedByUserId: match.bravoGroup.ownerUserId,
acceptedByUserId: match.bravoGroup.memberUserIds[0],
...accepted,
});

View File

@@ -368,7 +368,6 @@ export interface GroupMember {
createdAt: Generated<number>;
groupId: number;
note: string | null;
role: "OWNER" | "MANAGER" | "REGULAR";
userId: number;
}

View File

@@ -154,7 +154,6 @@ function groupWithTeamAndMembers(
)
.select((arrayEb) => [
...commonUserSelect(arrayEb),
"GroupMember.role",
"GroupMember.note",
"User.inGameName",
"User.vc",

View File

@@ -134,10 +134,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
"This group must use the continue vote",
);
const requester = previousGroup.members.find((m) => m.id === user.id);
errorToastIfFalsy(
requester?.role === "OWNER",
"You are not the owner of the group",
previousGroup.members.some((m) => m.id === user.id),
"Not a member of the group",
);
for (const member of previousGroup.members) {
@@ -147,10 +146,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
await SQGroupRepository.insertFromPrevious({
previousGroupId: data.previousGroupId,
members: previousGroup.members.map((m) => ({
id: m.id,
role: m.role,
})),
memberUserIds: previousGroup.members.map((m) => m.id),
status: "ACTIVE",
});
@@ -226,14 +222,14 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
});
if (votingResult?.type === "RESOLVED") {
const survivors = viewerGroup.members
.filter((m) => votingResult.continuingUserIds.includes(m.id))
.map((m) => ({ id: m.id, role: m.role }));
const survivors = viewerGroup.members.filter((m) =>
votingResult.continuingUserIds.includes(m.id),
);
try {
await SQGroupRepository.insertFromPrevious({
previousGroupId: viewerGroup.id,
members: survivors,
memberUserIds: survivors.map((m) => m.id),
status: "ACTIVE",
});
} catch (error) {

View File

@@ -56,39 +56,26 @@ export function MatchmadeRejoinSection({
export function TrustedRejoinSection({
viewerGroup,
viewerUserId,
}: {
viewerGroup: NonNullable<SendouQMatchLoaderData["match"]["groupAlpha"]>;
viewerUserId: number;
}) {
const { t } = useTranslation(["q"]);
const viewerRole = viewerGroup.members.find(
(m) => m.id === viewerUserId,
)?.role;
const lookAgain = useActionSubmit(matchSchema);
if (viewerRole === "OWNER") {
return (
<div className="stack md items-center">
<SendouButton
variant="primary"
isPending={lookAgain.state !== "idle"}
onPress={() => {
lookAgain.submit("LOOK_AGAIN", {
previousGroupId: viewerGroup.id,
});
}}
>
{t("q:match.actions.lookAgain")}
</SendouButton>
</div>
);
}
return (
<p className="text-lighter text-sm text-center">
{t("q:match.rematch.waitingCaptain")}
</p>
<div className="stack md items-center">
<SendouButton
variant="primary"
isPending={lookAgain.state !== "idle"}
onPress={() => {
lookAgain.submit("LOOK_AGAIN", {
previousGroupId: viewerGroup.id,
});
}}
>
{t("q:match.actions.lookAgain")}
</SendouButton>
</div>
);
}

View File

@@ -274,10 +274,7 @@ function RequeueTab({
{!data.isOffSeason &&
!viewerGroup.matchmade &&
(!awaitingConfirmation || isOnReporterTeam) ? (
<TrustedRejoinSection
viewerGroup={viewerGroup}
viewerUserId={user.id}
/>
<TrustedRejoinSection viewerGroup={viewerGroup} />
) : null}
{isOnReporterTeam ? <hr className={styles.divider} /> : null}

View File

@@ -7,7 +7,6 @@ import type { UserMapModePreferences } from "~/db/tables-json";
import { actorId } from "~/features/auth/core/user.server";
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
import { shortNanoid } from "~/utils/id";
import invariant from "~/utils/invariant";
import {
commonUserMembersAgg,
commonUserSelect,
@@ -77,7 +76,6 @@ export async function findCurrentGroups() {
commonUserMembersAgg(eb, {
mapModePreferences: eb.ref("User.mapModePreferences"),
noScreen: eb.ref("User.noScreen"),
role: eb.ref("GroupMember.role"),
note: eb.ref("GroupMember.note"),
weapons: matchProfileWeapons(eb),
languages: eb.ref("User.languages"),
@@ -122,7 +120,6 @@ export async function insert(args: CreateGroupArgs) {
.values({
groupId: createdGroup.id,
userId: args.userId,
role: "OWNER",
})
.execute();
@@ -141,17 +138,13 @@ export async function insert(args: CreateGroupArgs) {
type CreateGroupFromPreviousGroupArgs = {
previousGroupId: number;
members: {
id: number;
role: Tables["GroupMember"]["role"];
}[];
memberUserIds: number[];
status?: Exclude<Tables["Group"]["status"], "INACTIVE">;
};
export async function insertFromPrevious(
args: CreateGroupFromPreviousGroupArgs,
) {
const status = args.status ?? "PREPARING";
const membersWithEnsuredOwner = ensureOwnerRole(args.members);
return db.transaction().execute(async (trx) => {
const createdGroup = await trx
@@ -175,10 +168,9 @@ export async function insertFromPrevious(
await trx
.insertInto("GroupMember")
.values(
membersWithEnsuredOwner.map((member) => ({
args.memberUserIds.map((userId) => ({
groupId: createdGroup.id,
userId: member.id,
role: member.role,
userId,
})),
)
.execute();
@@ -193,19 +185,6 @@ export async function insertFromPrevious(
});
}
function ensureOwnerRole(
members: CreateGroupFromPreviousGroupArgs["members"],
): CreateGroupFromPreviousGroupArgs["members"] {
if (members.some((m) => m.role === "OWNER")) return members;
const promoteeIndex = members.findIndex((m) => m.role === "MANAGER");
const targetIndex = promoteeIndex !== -1 ? promoteeIndex : 0;
return members.map((m, i) =>
i === targetIndex ? { ...m, role: "OWNER" as const } : m,
);
}
function deleteLikesByGroupId(groupId: number, trx: Transaction<DB>) {
return trx
.deleteFrom("GroupLike")
@@ -233,34 +212,12 @@ export function morphGroups({
.where("Group.id", "=", survivingGroupId)
.execute();
const otherGroupMembers = await trx
.selectFrom("GroupMember")
.select(["GroupMember.userId", "GroupMember.role"])
await trx
.updateTable("GroupMember")
.set({ groupId: survivingGroupId })
.where("GroupMember.groupId", "=", otherGroupId)
.execute();
for (const member of otherGroupMembers) {
const oldRole = otherGroupMembers.find(
(m) => m.userId === member.userId,
)?.role;
invariant(oldRole, "Member lacking a role");
await trx
.updateTable("GroupMember")
.set({
role:
oldRole === "OWNER"
? "MANAGER"
: oldRole === "MANAGER"
? "MANAGER"
: "REGULAR",
groupId: survivingGroupId,
})
.where("GroupMember.groupId", "=", otherGroupId)
.where("GroupMember.userId", "=", member.userId)
.execute();
}
await deleteLikesByGroupId(survivingGroupId, trx);
await refreshGroup(survivingGroupId, trx);
@@ -312,13 +269,7 @@ async function isGroupCorrect(
export async function insertMember(
groupId: number,
{
userId,
role = "REGULAR",
}: {
userId: number;
role?: Tables["GroupMember"]["role"];
},
{ userId }: { userId: number },
) {
const chatCodeToRevalidate = await db.transaction().execute(async (trx) => {
await trx
@@ -326,7 +277,6 @@ export async function insertMember(
.values({
groupId,
userId,
role,
})
.execute();
@@ -656,7 +606,7 @@ export function leaveGroup(userId: number) {
const userGroup = await trx
.selectFrom("GroupMember")
.innerJoin("Group", "Group.id", "GroupMember.groupId")
.select(["Group.id", "GroupMember.role"])
.select(["Group.id"])
.where("userId", "=", userId)
.where("Group.status", "!=", "INACTIVE")
.executeTakeFirstOrThrow();
@@ -667,30 +617,17 @@ export function leaveGroup(userId: number) {
.where("GroupMember.groupId", "=", userGroup.id)
.execute();
const remainingMembers = await trx
const remainingMember = await trx
.selectFrom("GroupMember")
.select(["userId", "role"])
.select(["userId"])
.where("groupId", "=", userGroup.id)
.execute();
.executeTakeFirst();
if (remainingMembers.length === 0) {
if (!remainingMember) {
await trx.deleteFrom("Group").where("id", "=", userGroup.id).execute();
return;
}
if (userGroup.role === "OWNER") {
const newOwner =
remainingMembers.find((m) => m.role === "MANAGER") ??
remainingMembers[0];
await trx
.updateTable("GroupMember")
.set({ role: "OWNER" })
.where("userId", "=", newOwner.userId)
.where("groupId", "=", userGroup.id)
.execute();
}
const match = await trx
.selectFrom("GroupMatch")
.select(["GroupMatch.id"])
@@ -735,31 +672,6 @@ export function updateOwnMemberNote({
});
}
export function updateMemberRole({
userId,
groupId,
role,
}: {
userId: number;
groupId: number;
role: Tables["GroupMember"]["role"];
}) {
if (role === "OWNER") {
throw new Error("Can't set role to OWNER with this function");
}
return db.transaction().execute(async (trx) => {
await trx
.updateTable("GroupMember")
.set({ role })
.where("userId", "=", userId)
.where("groupId", "=", groupId)
.execute();
await refreshGroup(groupId, trx);
});
}
export function setPreparingGroupAsActive(groupId: number) {
return db
.updateTable("Group")

View File

@@ -11,7 +11,6 @@ import {
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
import { refreshStreamsCache } from "~/features/sendouq-streams/core/streams.server";
import { parseFormData } from "~/form/parse.server";
import { errorToastIfFalsy } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { SENDOUQ_PAGE, sendouQMatchPage } from "~/utils/urls";
import { groupAfterMorph } from "../core/groups";
@@ -64,17 +63,8 @@ export const action: ActionFunction = async ({ request }) => {
});
try {
// this throws because there should normally be no way user loses ownership by the action of some other user
const validateIsGroupOwner = () =>
errorToastIfFalsy(currentGroup.usersRole === "OWNER", "Not owner");
const isGroupManager = () =>
currentGroup.usersRole === "MANAGER" ||
currentGroup.usersRole === "OWNER";
switch (data._action) {
case "LIKE": {
if (!isGroupManager()) return null;
await SQGroupRepository.insertLike({
likerGroupId: currentGroup.id,
targetGroupId: data.targetGroupId,
@@ -86,8 +76,6 @@ export const action: ActionFunction = async ({ request }) => {
break;
}
case "RECHALLENGE": {
if (!isGroupManager()) return null;
await SQGroupRepository.rechallenge({
likerGroupId: currentGroup.id,
targetGroupId: data.targetGroupId,
@@ -98,8 +86,6 @@ export const action: ActionFunction = async ({ request }) => {
break;
}
case "UNLIKE": {
if (!isGroupManager()) return null;
await SQGroupRepository.deleteLike({
likerGroupId: currentGroup.id,
targetGroupId: data.targetGroupId,
@@ -111,8 +97,6 @@ export const action: ActionFunction = async ({ request }) => {
break;
}
case "GROUP_UP": {
if (!isGroupManager()) return null;
const allLikes = await SQGroupRepository.findAllLikesByGroupId(
data.targetGroupId,
);
@@ -161,8 +145,6 @@ export const action: ActionFunction = async ({ request }) => {
break;
}
case "MATCH_UP": {
if (!isGroupManager()) return null;
const ownGroup = SendouQ.findOwnGroup(user.id);
const theirGroup = SendouQ.findUncensoredGroupById(data.targetGroupId);
if (!ownGroup || !theirGroup) return null;
@@ -264,36 +246,6 @@ export const action: ActionFunction = async ({ request }) => {
throw redirect(sendouQMatchPage(createdMatch.id));
}
case "GIVE_MANAGER": {
validateIsGroupOwner();
await SQGroupRepository.updateMemberRole({
groupId: currentGroup.id,
userId: data.userId,
role: "MANAGER",
});
await refreshSendouQInstance();
revalidateGroupTopic(currentGroup.id);
break;
}
case "REMOVE_MANAGER": {
validateIsGroupOwner();
await SQGroupRepository.updateMemberRole({
groupId: currentGroup.id,
userId: data.userId,
role: "REGULAR",
});
await refreshSendouQInstance();
revalidateGroupTopic(currentGroup.id);
break;
}
case "LEAVE_GROUP": {
await SQGroupRepository.leaveGroup(user.id);
@@ -316,26 +268,6 @@ export const action: ActionFunction = async ({ request }) => {
throw redirect(SENDOUQ_PAGE);
}
case "KICK_FROM_GROUP": {
validateIsGroupOwner();
errorToastIfFalsy(data.userId !== user.id, "Can't kick yourself");
await SQGroupRepository.leaveGroup(data.userId);
await refreshSendouQInstance();
const remainingGroup = SendouQ.findUncensoredGroupById(currentGroup.id);
if (remainingGroup?.chatCode) {
setGroupChatMetadata({
chatCode: remainingGroup.chatCode,
members: remainingGroup.members,
});
}
broadcastLookingUpdate();
break;
}
case "REFRESH_GROUP": {
await SQGroupRepository.refreshGroup(currentGroup.id);

View File

@@ -26,11 +26,6 @@ export const action = async ({ request }: ActionFunctionArgs) => {
const ownGroup = SendouQ.findOwnGroup(user.id);
errorToastIfFalsy(ownGroup, "No group found");
// no perms, possibly just lost them so no more graceful degradation
if (ownGroup.usersRole === "REGULAR") {
return null;
}
const season = Seasons.current();
errorToastIfFalsy(season, "Season is not active");
@@ -67,10 +62,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
const { chatCodeToRevalidate } = await SQGroupRepository.insertMember(
ownGroup.id,
{
userId: data.id,
role: "MANAGER",
},
{ userId: data.id },
);
if (chatCodeToRevalidate) {

View File

@@ -94,10 +94,7 @@ export const action: ActionFunction = async ({ request, url }) => {
const { chatCodeToRevalidate } = await SQGroupRepository.insertMember(
groupInvitedTo.id,
{
userId: user.id,
role: "MANAGER",
},
{ userId: user.id },
);
if (chatCodeToRevalidate) {

View File

@@ -24,7 +24,6 @@ function createMember(overrides: Partial<SQGroupMember> = {}): SQGroupMember {
discordAvatar: null,
customAvatarUrl: null,
customUrl: null,
role: "OWNER",
vc: "NO",
languages: [],
skill: "CALCULATING",
@@ -52,7 +51,6 @@ function createGroup(
tierRange: null,
skillDifference: undefined,
isReplay: false,
usersRole: null,
noScreen: false,
modePreferences: [],
status: "ACTIVE",
@@ -75,7 +73,6 @@ function createOwnGroupMember(
discordAvatar: null,
customAvatarUrl: null,
customUrl: null,
role: "OWNER",
vc: "NO",
languages: [],
skill: "CALCULATING",
@@ -103,7 +100,6 @@ function createOwnGroup(
tierRange: null,
skillDifference: undefined,
isReplay: false,
usersRole: "OWNER",
noScreen: false,
modePreferences: [],
chatCode: null,
@@ -159,8 +155,8 @@ describe("GroupCard", () => {
group: createGroup({
members: [
createMember({ id: 1, username: "Player1" }),
createMember({ id: 2, username: "Player2", role: "MANAGER" }),
createMember({ id: 3, username: "Player3", role: "REGULAR" }),
createMember({ id: 2, username: "Player2" }),
createMember({ id: 3, username: "Player3" }),
],
}),
});
@@ -260,9 +256,9 @@ describe("GroupCard", () => {
tier,
members: [
createMember({ id: 1 }),
createMember({ id: 2, role: "REGULAR" }),
createMember({ id: 3, role: "REGULAR" }),
createMember({ id: 4, role: "REGULAR" }),
createMember({ id: 2 }),
createMember({ id: 3 }),
createMember({ id: 4 }),
],
}),
});
@@ -301,9 +297,9 @@ describe("GroupCard", () => {
isReplay: true,
members: [
createMember({ id: 1 }),
createMember({ id: 2, role: "REGULAR" }),
createMember({ id: 3, role: "REGULAR" }),
createMember({ id: 4, role: "REGULAR" }),
createMember({ id: 2 }),
createMember({ id: 3 }),
createMember({ id: 4 }),
],
}),
});
@@ -377,24 +373,6 @@ describe("GroupCard", () => {
// Actual translated text is "Start match"
await expect.element(screen.getByText("Start match")).toBeVisible();
});
test("hides actions when user is not owner or manager", async () => {
// ownGroup with REGULAR role shouldn't show action buttons
const ownGroup = createOwnGroup({ id: 2, usersRole: "REGULAR" });
const screen = await renderGroupCard({
group: createGroup({ members: [createMember()] }),
action: "LIKE",
ownGroup,
displayOnly: false,
});
// Action button should not be rendered when user is not OWNER or MANAGER
const actionButton = screen.container.querySelector(
'[data-testid="group-card-action-button"]',
);
expect(actionButton).toBeNull();
});
});
describe("props", () => {

View File

@@ -99,20 +99,6 @@
height: 15px;
}
.star {
min-width: 18px;
max-width: 18px;
color: var(--color-text-accent);
}
.starFilled {
fill: var(--color-text-accent);
}
.starInactive {
color: var(--color-text-high);
}
.displayTier {
display: flex;
gap: var(--s-1);

View File

@@ -1,6 +1,6 @@
import clsx from "clsx";
import type { SqlBool } from "kysely";
import { Mic, Star, Volume2, VolumeX } from "lucide-react";
import { Mic, Volume2, VolumeX } from "lucide-react";
import * as React from "react";
import { Flipped } from "react-flip-toolkit";
import { useTranslation } from "react-i18next";
@@ -74,8 +74,6 @@ export function GroupCard({
? resolveFutureMatchModes(ownGroup, group)
: null;
const enableKicking = group.usersRole === "OWNER" && !displayOnly;
return (
<GroupCardContainer
groupId={group.id}
@@ -89,13 +87,10 @@ export function GroupCard({
return (
<GroupMember
member={member}
showActions={group.usersRole === "OWNER"}
key={member.discordId}
displayOnly={displayOnly}
hideVc={hideVc}
hideWeapons={hideWeapons}
hideNote={hideNote}
enableKicking={enableKicking}
/>
);
})}
@@ -199,9 +194,7 @@ export function GroupCard({
{group.skillDifference ? (
<GroupSkillDifference skillDifference={group.skillDifference} />
) : null}
{action &&
(ownGroup?.usersRole === "OWNER" ||
ownGroup?.usersRole === "MANAGER") ? (
{action ? (
<ActionButton
schema={lookingSchema}
action={action === "MATCH_UP_RECHALLENGE" ? "MATCH_UP" : action}
@@ -246,20 +239,14 @@ function GroupCardContainer({
function GroupMember({
member,
showActions,
displayOnly,
hideVc,
hideWeapons,
hideNote,
enableKicking,
}: {
member: SQGroupMember;
showActions: boolean;
displayOnly?: boolean;
hideVc?: SqlBool;
hideWeapons?: SqlBool;
hideNote?: boolean;
enableKicking?: boolean;
}) {
const { t } = useTranslation(["q", "user"]);
const user = useUser();
@@ -298,13 +285,6 @@ function GroupMember({
styles.memberActions,
)}
>
{showActions || displayOnly ? (
<MemberRoleManager
member={member}
displayOnly={displayOnly}
enableKicking={enableKicking}
/>
) : null}
{member.skill ? <TierInfo skill={member.skill} /> : null}
</div>
</div>
@@ -509,83 +489,6 @@ function MemberSkillDifference({
);
}
function MemberRoleManager({
member,
displayOnly,
enableKicking,
}: {
member: Pick<SQGroupMember, "id" | "role">;
displayOnly?: boolean;
enableKicking?: boolean;
}) {
const loggedInUser = useUser();
const { t } = useTranslation(["q"]);
if (displayOnly && member.role !== "OWNER") return null;
return (
<SendouPopover
trigger={
<SendouButton
variant="minimal"
icon={
<Star
className={clsx(styles.star, {
[styles.starFilled]: member.role === "OWNER",
[styles.starInactive]: member.role === "REGULAR",
})}
/>
}
/>
}
>
<div className="stack sm items-center">
<div>{t(`q:roles.${member.role}`)}</div>
{member.role !== "OWNER" && !displayOnly ? (
<div className="stack md items-center">
{member.role === "REGULAR" ? (
<ActionButton
schema={lookingSchema}
action="GIVE_MANAGER"
fields={{ userId: member.id }}
formAction={SENDOUQ_LOOKING_PAGE}
variant="outlined"
size="small"
>
{t("q:looking.groups.actions.giveManager")}
</ActionButton>
) : null}
{member.role === "MANAGER" ? (
<ActionButton
schema={lookingSchema}
action="REMOVE_MANAGER"
fields={{ userId: member.id }}
formAction={SENDOUQ_LOOKING_PAGE}
variant="destructive"
size="small"
>
{t("q:looking.groups.actions.removeManager")}
</ActionButton>
) : null}
{enableKicking && member.id !== loggedInUser?.id ? (
<ActionButton
schema={lookingSchema}
action="KICK_FROM_GROUP"
fields={{ userId: member.id }}
formAction={SENDOUQ_LOOKING_PAGE}
variant="destructive"
size="small"
>
{t("q:looking.groups.actions.kick")}
</ActionButton>
) : null}
</div>
) : null}
</div>
</SendouPopover>
);
}
function TierInfo({ skill }: { skill: TieredSkill | "CALCULATING" }) {
const { t } = useTranslation(["q"]);

View File

@@ -138,28 +138,6 @@ describe("SendouQ", () => {
expect(group).toBeUndefined();
});
test("returns group with correct role when user is OWNER", async () => {
await createGroup([1, 2]);
await refreshSendouQInstance();
const group = SendouQ.findOwnGroup(users.id(1));
expect(group).toBeDefined();
const member = group?.members.find((m) => m.id === users.id(1));
expect(member?.role).toBe("OWNER");
});
test("returns group with correct role when user is REGULAR member", async () => {
await createGroup([1, 2]);
await refreshSendouQInstance();
const group = SendouQ.findOwnGroup(users.id(2));
expect(group).toBeDefined();
const member = group?.members.find((m) => m.id === users.id(2));
expect(member?.role).toBe("REGULAR");
});
test("returns correct group when multiple groups exist", async () => {
await createGroup([1, 2]);
await createGroup([3, 4]);

View File

@@ -1,6 +1,6 @@
import { isWithinInterval, sub } from "date-fns";
import * as R from "remeda";
import type { DBBoolean, Tables } from "~/db/tables";
import type { DBBoolean } from "~/db/tables";
import type { ParsedMemento } from "~/db/tables-json";
import type { AuthenticatedUser } from "~/features/auth/core/user.server";
import * as Seasons from "~/features/mmr/core/Seasons";
@@ -79,7 +79,6 @@ class SendouQClass {
skillDifference:
undefined as ParsedMemento["groups"][number]["skillDifference"],
isReplay: false,
usersRole: null as Tables["GroupMember"]["role"] | null,
members: group.members.map((member) => {
const skill = calculatedUserSkills[String(member.id)];
@@ -116,20 +115,12 @@ class SendouQClass {
/**
* Finds the group that a user belongs to.
* @returns The user's group with their role, or undefined if not in a group
* @returns The user's group, or undefined if not in a group
*/
findOwnGroup(userId: number) {
const result = this.groups.find((group) =>
return this.groups.find((group) =>
group.members.some((member) => member.id === userId),
);
if (!result) return;
const member = result.members.find((m) => m.id === userId)!;
return {
...result,
usersRole: member.role,
};
}
/**

View File

@@ -44,21 +44,9 @@ export const lookingSchema = z.union([
_action: _action("MATCH_UP"),
targetGroupId: id,
}),
z.object({
_action: _action("GIVE_MANAGER"),
userId: id,
}),
z.object({
_action: _action("REMOVE_MANAGER"),
userId: id,
}),
z.object({
_action: _action("LEAVE_GROUP"),
}),
z.object({
_action: _action("KICK_FROM_GROUP"),
userId: id,
}),
z.object({
_action: _action("REFRESH_GROUP"),
}),

View File

@@ -46,9 +46,7 @@ export default function QPreparingPage() {
<div className={styles.cardContainer}>
<GroupCard group={data.group} hideNote ownGroup={data.group} />
</div>
{data.group.members.length < FULL_GROUP_SIZE &&
(data.group.usersRole === "OWNER" ||
data.group.usersRole === "MANAGER") ? (
{data.group.members.length < FULL_GROUP_SIZE ? (
<MemberAdder
inviteCode={data.group.inviteCode}
groupMemberIds={data.group.members.map((m) => m.id)}

View File

@@ -14,7 +14,6 @@ import { Image } from "~/components/Image";
import { LocaleTime } from "~/components/LocaleTime";
import { LocaleTimeRange } from "~/components/LocaleTimeRange";
import { Main } from "~/components/Main";
import type { Tables } from "~/db/tables";
import { useUser } from "~/features/auth/core/user";
import type * as Seasons from "~/features/mmr/core/Seasons";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
@@ -228,7 +227,6 @@ function JoinTeamDialog({
close: () => void;
members: {
username: string;
role: Tables["GroupMember"]["role"];
}[];
}) {
const { t, i18n } = useTranslation(["q"]);

View File

@@ -191,7 +191,8 @@ test.describe("SendouQ match page", () => {
isConcluded: true,
});
await impersonate(page, bravo[0].id);
// any member can re-queue the group, not only the member who created it
await impersonate(page, bravo[1].id);
const match = new SendouQMatchPage(page);
await match.goto(matchId);
await match.lookAgain();

View File

@@ -66,7 +66,6 @@
"looking.groups.actions.undo": "",
"looking.groups.actions.giveManager": "",
"looking.groups.actions.removeManager": "",
"looking.groups.actions.kick": "",
"looking.groups.actions.leaveGroup": "",
"looking.groups.actions.leaveGroup.confirm": "",
"looking.groups.actions.stopLooking": "",
@@ -188,7 +187,6 @@
"match.rematch.vote.noConfirm": "",
"match.rematch.declined": "",
"match.rematch.fizzled": "",
"match.rematch.waitingCaptain": "",
"match.rematch.rejoinQueue": "",
"match.rematch.backToQueue": "",
"match.rematch.offSeason": "",

View File

@@ -66,7 +66,6 @@
"looking.groups.actions.undo": "",
"looking.groups.actions.giveManager": "",
"looking.groups.actions.removeManager": "",
"looking.groups.actions.kick": "",
"looking.groups.actions.leaveGroup": "",
"looking.groups.actions.leaveGroup.confirm": "",
"looking.groups.actions.stopLooking": "",
@@ -188,7 +187,6 @@
"match.rematch.vote.noConfirm": "",
"match.rematch.declined": "",
"match.rematch.fizzled": "",
"match.rematch.waitingCaptain": "",
"match.rematch.rejoinQueue": "",
"match.rematch.backToQueue": "",
"match.rematch.offSeason": "",

View File

@@ -66,7 +66,6 @@
"looking.groups.actions.undo": "Undo",
"looking.groups.actions.giveManager": "Give manager",
"looking.groups.actions.removeManager": "Remove manager",
"looking.groups.actions.kick": "Kick",
"looking.groups.actions.leaveGroup": "Leave group",
"looking.groups.actions.leaveGroup.confirm": "Leave this group?",
"looking.groups.actions.stopLooking": "Stop looking",
@@ -188,7 +187,6 @@
"match.rematch.vote.noConfirm": "Vote no? You can't change your vote afterwards.",
"match.rematch.declined": "You declined to continue",
"match.rematch.fizzled": "Nobody wanted to continue",
"match.rematch.waitingCaptain": "Waiting for the captain to choose whether to re-queue",
"match.rematch.rejoinQueue": "Rejoin queue",
"match.rematch.backToQueue": "Back to queue",
"match.rematch.offSeason": "Season has ended. The queue will reopen when the next season starts.",

View File

@@ -66,7 +66,6 @@
"looking.groups.actions.undo": "Deshacer",
"looking.groups.actions.giveManager": "Hacer mánager",
"looking.groups.actions.removeManager": "Quitar mánager",
"looking.groups.actions.kick": "Expulsar",
"looking.groups.actions.leaveGroup": "Dejar grupo",
"looking.groups.actions.leaveGroup.confirm": "¿Abandonar este grupo?",
"looking.groups.actions.stopLooking": "Dejar de buscar",
@@ -188,7 +187,6 @@
"match.rematch.vote.noConfirm": "¿Votar no? No podrás cambiar tu voto después.",
"match.rematch.declined": "Rechazaste continuar",
"match.rematch.fizzled": "Nadie quiso continuar",
"match.rematch.waitingCaptain": "Esperando a que el capitán decida si volver a la cola",
"match.rematch.rejoinQueue": "Volver a la cola",
"match.rematch.backToQueue": "Volver a la cola",
"match.rematch.offSeason": "La temporada ha terminado. La cola volverá a abrir cuando empiece la siguiente temporada.",

View File

@@ -66,7 +66,6 @@
"looking.groups.actions.undo": "Deshacer",
"looking.groups.actions.giveManager": "Hacer mánager",
"looking.groups.actions.removeManager": "Quitar mánager",
"looking.groups.actions.kick": "Expulsar",
"looking.groups.actions.leaveGroup": "Dejar grupo",
"looking.groups.actions.leaveGroup.confirm": "",
"looking.groups.actions.stopLooking": "",
@@ -188,7 +187,6 @@
"match.rematch.vote.noConfirm": "",
"match.rematch.declined": "",
"match.rematch.fizzled": "",
"match.rematch.waitingCaptain": "",
"match.rematch.rejoinQueue": "",
"match.rematch.backToQueue": "",
"match.rematch.offSeason": "",

View File

@@ -66,7 +66,6 @@
"looking.groups.actions.undo": "",
"looking.groups.actions.giveManager": "",
"looking.groups.actions.removeManager": "",
"looking.groups.actions.kick": "",
"looking.groups.actions.leaveGroup": "",
"looking.groups.actions.leaveGroup.confirm": "",
"looking.groups.actions.stopLooking": "",
@@ -188,7 +187,6 @@
"match.rematch.vote.noConfirm": "",
"match.rematch.declined": "",
"match.rematch.fizzled": "",
"match.rematch.waitingCaptain": "",
"match.rematch.rejoinQueue": "",
"match.rematch.backToQueue": "",
"match.rematch.offSeason": "",

View File

@@ -66,7 +66,6 @@
"looking.groups.actions.undo": "Annulé",
"looking.groups.actions.giveManager": "Promouvoir",
"looking.groups.actions.removeManager": "Rétrograder",
"looking.groups.actions.kick": "Kick",
"looking.groups.actions.leaveGroup": "Quitter le groupe",
"looking.groups.actions.leaveGroup.confirm": "",
"looking.groups.actions.stopLooking": "",
@@ -188,7 +187,6 @@
"match.rematch.vote.noConfirm": "",
"match.rematch.declined": "",
"match.rematch.fizzled": "",
"match.rematch.waitingCaptain": "",
"match.rematch.rejoinQueue": "",
"match.rematch.backToQueue": "",
"match.rematch.offSeason": "",

View File

@@ -66,7 +66,6 @@
"looking.groups.actions.undo": "",
"looking.groups.actions.giveManager": "",
"looking.groups.actions.removeManager": "",
"looking.groups.actions.kick": "",
"looking.groups.actions.leaveGroup": "",
"looking.groups.actions.leaveGroup.confirm": "",
"looking.groups.actions.stopLooking": "",
@@ -188,7 +187,6 @@
"match.rematch.vote.noConfirm": "",
"match.rematch.declined": "",
"match.rematch.fizzled": "",
"match.rematch.waitingCaptain": "",
"match.rematch.rejoinQueue": "",
"match.rematch.backToQueue": "",
"match.rematch.offSeason": "",

View File

@@ -66,7 +66,6 @@
"looking.groups.actions.undo": "Annulla",
"looking.groups.actions.giveManager": "Dai manager",
"looking.groups.actions.removeManager": "Rimuovi manager",
"looking.groups.actions.kick": "Caccia",
"looking.groups.actions.leaveGroup": "Lascia gruppo",
"looking.groups.actions.leaveGroup.confirm": "",
"looking.groups.actions.stopLooking": "",
@@ -188,7 +187,6 @@
"match.rematch.vote.noConfirm": "",
"match.rematch.declined": "",
"match.rematch.fizzled": "",
"match.rematch.waitingCaptain": "",
"match.rematch.rejoinQueue": "",
"match.rematch.backToQueue": "",
"match.rematch.offSeason": "",

View File

@@ -66,7 +66,6 @@
"looking.groups.actions.undo": "戻す",
"looking.groups.actions.giveManager": "マネージャーにあげる",
"looking.groups.actions.removeManager": "マネージャーを外す",
"looking.groups.actions.kick": "キックする",
"looking.groups.actions.leaveGroup": "グループを出る",
"looking.groups.actions.leaveGroup.confirm": "",
"looking.groups.actions.stopLooking": "",
@@ -188,7 +187,6 @@
"match.rematch.vote.noConfirm": "",
"match.rematch.declined": "",
"match.rematch.fizzled": "",
"match.rematch.waitingCaptain": "",
"match.rematch.rejoinQueue": "",
"match.rematch.backToQueue": "",
"match.rematch.offSeason": "",

View File

@@ -66,7 +66,6 @@
"looking.groups.actions.undo": "",
"looking.groups.actions.giveManager": "",
"looking.groups.actions.removeManager": "",
"looking.groups.actions.kick": "",
"looking.groups.actions.leaveGroup": "",
"looking.groups.actions.leaveGroup.confirm": "",
"looking.groups.actions.stopLooking": "",
@@ -188,7 +187,6 @@
"match.rematch.vote.noConfirm": "",
"match.rematch.declined": "",
"match.rematch.fizzled": "",
"match.rematch.waitingCaptain": "",
"match.rematch.rejoinQueue": "",
"match.rematch.backToQueue": "",
"match.rematch.offSeason": "",

View File

@@ -66,7 +66,6 @@
"looking.groups.actions.undo": "",
"looking.groups.actions.giveManager": "",
"looking.groups.actions.removeManager": "",
"looking.groups.actions.kick": "",
"looking.groups.actions.leaveGroup": "",
"looking.groups.actions.leaveGroup.confirm": "",
"looking.groups.actions.stopLooking": "",
@@ -188,7 +187,6 @@
"match.rematch.vote.noConfirm": "",
"match.rematch.declined": "",
"match.rematch.fizzled": "",
"match.rematch.waitingCaptain": "",
"match.rematch.rejoinQueue": "",
"match.rematch.backToQueue": "",
"match.rematch.offSeason": "",

View File

@@ -66,7 +66,6 @@
"looking.groups.actions.undo": "",
"looking.groups.actions.giveManager": "",
"looking.groups.actions.removeManager": "",
"looking.groups.actions.kick": "",
"looking.groups.actions.leaveGroup": "",
"looking.groups.actions.leaveGroup.confirm": "",
"looking.groups.actions.stopLooking": "",
@@ -188,7 +187,6 @@
"match.rematch.vote.noConfirm": "",
"match.rematch.declined": "",
"match.rematch.fizzled": "",
"match.rematch.waitingCaptain": "",
"match.rematch.rejoinQueue": "",
"match.rematch.backToQueue": "",
"match.rematch.offSeason": "",

View File

@@ -66,7 +66,6 @@
"looking.groups.actions.undo": "Desfazer",
"looking.groups.actions.giveManager": "Dar gerência",
"looking.groups.actions.removeManager": "Remover gerência",
"looking.groups.actions.kick": "Chutar (kick)",
"looking.groups.actions.leaveGroup": "Sair do grupo",
"looking.groups.actions.leaveGroup.confirm": "",
"looking.groups.actions.stopLooking": "",
@@ -188,7 +187,6 @@
"match.rematch.vote.noConfirm": "",
"match.rematch.declined": "",
"match.rematch.fizzled": "",
"match.rematch.waitingCaptain": "",
"match.rematch.rejoinQueue": "",
"match.rematch.backToQueue": "",
"match.rematch.offSeason": "",

View File

@@ -66,7 +66,6 @@
"looking.groups.actions.undo": "Отменить",
"looking.groups.actions.giveManager": "Дать роль менеджера",
"looking.groups.actions.removeManager": "Удалить роль менеджера",
"looking.groups.actions.kick": "Выгнать",
"looking.groups.actions.leaveGroup": "Покинуть группу",
"looking.groups.actions.leaveGroup.confirm": "",
"looking.groups.actions.stopLooking": "",
@@ -188,7 +187,6 @@
"match.rematch.vote.noConfirm": "",
"match.rematch.declined": "",
"match.rematch.fizzled": "",
"match.rematch.waitingCaptain": "",
"match.rematch.rejoinQueue": "",
"match.rematch.backToQueue": "",
"match.rematch.offSeason": "",

View File

@@ -66,7 +66,6 @@
"looking.groups.actions.undo": "撤销",
"looking.groups.actions.giveManager": "授予管理者权限",
"looking.groups.actions.removeManager": "移除管理者权限",
"looking.groups.actions.kick": "踢出",
"looking.groups.actions.leaveGroup": "离开小组",
"looking.groups.actions.leaveGroup.confirm": "要离开此小组吗?",
"looking.groups.actions.stopLooking": "停止匹配",
@@ -188,7 +187,6 @@
"match.rematch.vote.noConfirm": "确认投反对票吗?投票后将无法更改。",
"match.rematch.declined": "您已拒绝继续排队",
"match.rematch.fizzled": "无人想要继续排队",
"match.rematch.waitingCaptain": "正在等待队长选择是否重新排队",
"match.rematch.rejoinQueue": "重新加入队列",
"match.rematch.backToQueue": "返回队列",
"match.rematch.offSeason": "赛季已结束。队列将在下个赛季开始时重新开放。",

View File

@@ -0,0 +1,7 @@
import type { Kysely } from "kysely";
export async function up(db: Kysely<any>): Promise<void> {
await db.transaction().execute(async (trx) => {
await trx.schema.alterTable("GroupMember").dropColumn("role").execute();
});
}