Move app to apps/web-react in pnpm workspace layout

This commit is contained in:
Kalle
2026-08-16 09:20:33 +03:00
parent 7e365ccfcf
commit bbc8ea57af
2913 changed files with 227 additions and 250 deletions

View File

@@ -0,0 +1,458 @@
import { beforeEach, describe, expect, test } from "vitest";
import * as BuildFactory from "~/db/seed/factories/BuildFactory";
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
import * as TournamentOrganizationFactory from "~/db/seed/factories/TournamentOrganizationFactory";
import * as TrophyFactory from "~/db/seed/factories/TrophyFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import { db } from "~/db/sql";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { databaseTimestampNow } from "~/utils/dates";
import * as AdminRepository from "./AdminRepository.server";
const users = UserFactory.pool();
// the ban log records who banned by their Discord id, so the tests give the users
// one they can name
const createUsers = (count: number) =>
users.create(count, (index) => ({ discordId: String(index) }));
describe("migrate", () => {
let oldUserId: number;
let newUserId: number;
beforeEach(async () => {
oldUserId = (await UserFactory.create()).id;
newUserId = (await UserFactory.create()).id;
});
const insertOwnership = async (args: {
trophyId: number;
tournamentId: number;
userId: number;
}) => {
// biome-ignore lint/plugin: awarding a trophy requires playing out a whole tournament; migrate only cares that the rows exist
await db.insertInto("TrophyOwner").values(args).execute();
// biome-ignore lint/plugin: special trophies are synced from X rank placements; migrate only cares that the rows exist
await db
.insertInto("SpecialTrophyOwner")
.values({
trophyId: args.trophyId,
userId: args.userId,
createdAt: databaseTimestampNow(),
})
.execute();
};
const createPendingWithApprovals = async (args: {
submitterUserId: number;
managerId: number;
approverUserIds: number[];
}) => {
const organization = await TournamentOrganizationFactory.create({
ownerId: oldUserId,
});
return TrophyFactory.createPending(
{
organizationId: organization.id,
submitterUserId: args.submitterUserId,
managerId: args.managerId,
},
{ approverUserIds: args.approverUserIds },
);
};
test("re-points trophy data to the remaining user", async () => {
const trophy = await TrophyFactory.create({
creatorId: newUserId,
managerId: newUserId,
});
const tournament = await TournamentFactory.create({ authorId: oldUserId });
await insertOwnership({
trophyId: trophy.id,
tournamentId: tournament.id,
userId: newUserId,
});
await createPendingWithApprovals({
submitterUserId: newUserId,
managerId: newUserId,
approverUserIds: [newUserId],
});
expect(await AdminRepository.migrate({ newUserId, oldUserId })).toBe(null);
const migrated = await db
.selectFrom("Trophy")
.select(["creatorId", "managerId"])
.where("id", "=", trophy.id)
.executeTakeFirstOrThrow();
expect(migrated).toEqual({
creatorId: oldUserId,
managerId: oldUserId,
});
expect(
await db.selectFrom("TrophyOwner").select("userId").execute(),
).toEqual([{ userId: oldUserId }]);
expect(
await db.selectFrom("SpecialTrophyOwner").select("userId").execute(),
).toEqual([{ userId: oldUserId }]);
const pending = await db
.selectFrom("PendingTrophy")
.select(["submitterUserId", "managerId"])
.executeTakeFirstOrThrow();
expect(pending).toEqual({
submitterUserId: oldUserId,
managerId: oldUserId,
});
expect(
await db.selectFrom("PendingTrophyApproval").select("userId").execute(),
).toEqual([{ userId: oldUserId }]);
});
test("drops the migrated account's duplicate rows when both accounts own the same trophy", async () => {
const trophy = await TrophyFactory.create({
creatorId: oldUserId,
managerId: oldUserId,
});
const tournament = await TournamentFactory.create({ authorId: oldUserId });
for (const userId of [oldUserId, newUserId]) {
await insertOwnership({
trophyId: trophy.id,
tournamentId: tournament.id,
userId,
});
}
await createPendingWithApprovals({
submitterUserId: oldUserId,
managerId: oldUserId,
approverUserIds: [oldUserId, newUserId],
});
expect(await AdminRepository.migrate({ newUserId, oldUserId })).toBe(null);
expect(
await db.selectFrom("TrophyOwner").select("userId").execute(),
).toEqual([{ userId: oldUserId }]);
expect(
await db.selectFrom("SpecialTrophyOwner").select("userId").execute(),
).toEqual([{ userId: oldUserId }]);
expect(
await db.selectFrom("PendingTrophyApproval").select("userId").execute(),
).toEqual([{ userId: oldUserId }]);
});
});
describe("findAllBannedUsers", () => {
let admin: { id: number };
const createBannedUser = (bannedReason: string, banned: 1 | Date = 1) =>
UserFactory.create(null, {
ban: { banned, bannedReason, bannedByUserId: admin.id },
});
beforeEach(async () => {
admin = await UserFactory.create();
});
test("returns empty Map when no users are banned", async () => {
await UserFactory.create();
const result = await AdminRepository.findAllBannedUsers();
expect(result.size).toBe(0);
});
test("returns Map with single banned user", async () => {
const banned = await createBannedUser("Test ban");
const result = await AdminRepository.findAllBannedUsers();
expect(result.size).toBe(1);
expect(result.get(banned.id)).toBeDefined();
expect(result.get(banned.id)?.userId).toBe(banned.id);
expect(result.get(banned.id)?.banned).toBe(1);
expect(result.get(banned.id)?.bannedReason).toBe("Test ban");
});
test("returns Map with multiple banned users", async () => {
const first = await createBannedUser("Reason 1");
const second = await createBannedUser("Reason 2");
const result = await AdminRepository.findAllBannedUsers();
expect(result.size).toBe(2);
expect(result.get(first.id)?.userId).toBe(first.id);
expect(result.get(second.id)?.userId).toBe(second.id);
});
test("excludes non-banned users from results", async () => {
const banned = await createBannedUser("Test ban");
const [other, another] = await UserFactory.createMany(2);
const result = await AdminRepository.findAllBannedUsers();
expect(result.size).toBe(1);
expect(result.get(banned.id)).toBeDefined();
expect(result.get(other.id)).toBeUndefined();
expect(result.get(another.id)).toBeUndefined();
});
test("includes both permanently and temporarily banned users", async () => {
const futureDate = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7);
const permanent = await createBannedUser("Permanent ban");
const temporary = await createBannedUser("Temporary ban", futureDate);
const result = await AdminRepository.findAllBannedUsers();
expect(result.size).toBe(2);
expect(result.get(permanent.id)?.banned).toBe(1);
expect(result.get(temporary.id)?.banned).toBeGreaterThan(1);
});
});
describe("banUser", () => {
beforeEach(async () => {
await createUsers(3);
});
test("permanently bans user (banned = 1)", async () => {
await AdminRepository.banUser({
userId: users.id(1),
banned: 1,
bannedReason: "Test permanent ban",
bannedByUserId: users.id(2),
});
const result = await AdminRepository.findAllBannedUsers();
expect(result.get(users.id(1))?.banned).toBe(1);
expect(result.get(users.id(1))?.bannedReason).toBe("Test permanent ban");
});
test("temporarily bans user (banned = Date)", async () => {
const futureDate = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7);
await AdminRepository.banUser({
userId: users.id(1),
banned: futureDate,
bannedReason: "Test temporary ban",
bannedByUserId: users.id(2),
});
const result = await AdminRepository.findAllBannedUsers();
expect(result.get(users.id(1))?.banned).toBeGreaterThan(1);
expect(result.get(users.id(1))?.bannedReason).toBe("Test temporary ban");
});
test("sets bannedReason correctly", async () => {
const reason = "Violating terms of service";
await AdminRepository.banUser({
userId: users.id(1),
banned: 1,
bannedReason: reason,
bannedByUserId: users.id(2),
});
const result = await AdminRepository.findAllBannedUsers();
expect(result.get(users.id(1))?.bannedReason).toBe(reason);
});
test("creates BanLog entry when bannedByUserId is provided", async () => {
await AdminRepository.banUser({
userId: users.id(1),
banned: 1,
bannedReason: "Test ban",
bannedByUserId: users.id(2),
});
const modInfo = await UserRepository.findModInfoById(users.id(1));
expect(modInfo?.banLogs).toHaveLength(1);
expect(modInfo?.banLogs[0].banned).toBe(1);
expect(modInfo?.banLogs[0].bannedReason).toBe("Test ban");
expect(modInfo?.banLogs[0].id).toBe(users.id(2));
});
test("does not create BanLog when bannedByUserId is null (automatic ban)", async () => {
await AdminRepository.banUser({
userId: users.id(1),
banned: 1,
bannedReason: "Automatic ban",
bannedByUserId: null,
});
const modInfo = await UserRepository.findModInfoById(users.id(1));
expect(modInfo?.banLogs).toHaveLength(0);
});
test("updates existing user correctly", async () => {
const bannedUsers = await AdminRepository.findAllBannedUsers();
expect(bannedUsers.size).toBe(0);
await AdminRepository.banUser({
userId: users.id(1),
banned: 1,
bannedReason: "First ban",
bannedByUserId: users.id(2),
});
let result = await AdminRepository.findAllBannedUsers();
expect(result.size).toBe(1);
await AdminRepository.banUser({
userId: users.id(1),
banned: 1,
bannedReason: "Updated ban reason",
bannedByUserId: users.id(2),
});
result = await AdminRepository.findAllBannedUsers();
expect(result.size).toBe(1);
expect(result.get(users.id(1))?.bannedReason).toBe("Updated ban reason");
const modInfo = await UserRepository.findModInfoById(users.id(1));
expect(modInfo?.banLogs).toHaveLength(2);
expect(modInfo?.banLogs[0].bannedReason).toBe("First ban");
expect(modInfo?.banLogs[1].bannedReason).toBe("Updated ban reason");
});
});
describe("unbanUser", () => {
let banner: { id: number };
let unbanner: { id: number };
const createBannedUser = (bannedReason: string, banned: 1 | Date = 1) =>
UserFactory.create(null, {
ban: { banned, bannedReason, bannedByUserId: banner.id },
});
beforeEach(async () => {
// the unban log records who unbanned by their Discord id
banner = await UserFactory.create({ discordId: "1" });
unbanner = await UserFactory.create({ discordId: "2" });
});
test("unbans a previously banned user", async () => {
const banned = await createBannedUser("Test ban");
let result = await AdminRepository.findAllBannedUsers();
expect(result.size).toBe(1);
await AdminRepository.unbanUser({
userId: banned.id,
unbannedByUserId: unbanner.id,
});
result = await AdminRepository.findAllBannedUsers();
expect(result.size).toBe(0);
});
test("creates BanLog entry with correct unbannedByUserId", async () => {
const banned = await createBannedUser("Test ban");
await AdminRepository.unbanUser({
userId: banned.id,
unbannedByUserId: unbanner.id,
});
const modInfo = await UserRepository.findModInfoById(banned.id);
expect(modInfo?.banLogs).toHaveLength(2);
const unbanLog = modInfo?.banLogs.find((log) => log.banned === 0);
expect(unbanLog).toBeDefined();
expect(unbanLog?.bannedReason).toBeNull();
expect(unbanLog?.id).toBe(unbanner.id);
});
test("can unban permanently banned user", async () => {
const banned = await createBannedUser("Permanent ban");
await AdminRepository.unbanUser({
userId: banned.id,
unbannedByUserId: unbanner.id,
});
const result = await AdminRepository.findAllBannedUsers();
expect(result.size).toBe(0);
});
test("can unban temporarily banned user", async () => {
const futureDate = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7);
const banned = await createBannedUser("Temporary ban", futureDate);
await AdminRepository.unbanUser({
userId: banned.id,
unbannedByUserId: unbanner.id,
});
const result = await AdminRepository.findAllBannedUsers();
expect(result.size).toBe(0);
});
});
describe("replacePlusTiers", () => {
// tier * 2 + 1, so tier 1 is 3 and the tier 4 users default to is 9
const TIER_1_SORT_VALUE = 3;
const NO_TIER_SORT_VALUE = 9;
const SPLATTERSHOT = 40;
const createBuild = (ownerId: number) =>
BuildFactory.create({
ownerId,
weaponSplIds: [SPLATTERSHOT],
isPrivate: 0,
});
const sortValueByBuildId = async (buildId: number) => {
const buildWeapon = await db
.selectFrom("BuildWeapon")
.select("sortValue")
.where("buildId", "=", buildId)
.executeTakeFirstOrThrow();
return buildWeapon.sortValue;
};
beforeEach(async () => {
await createUsers(2);
});
test("refreshes the sort values of the builds a new tier applies to", async () => {
const build = await createBuild(users.id(1));
expect(await sortValueByBuildId(build.id)).toBe(NO_TIER_SORT_VALUE);
await AdminRepository.replacePlusTiers([
{ userId: users.id(1), plusTier: 1 },
]);
expect(await sortValueByBuildId(build.id)).toBe(TIER_1_SORT_VALUE);
});
test("refreshes the sort values of the builds of a user left out of the new tiers", async () => {
await AdminRepository.replacePlusTiers([
{ userId: users.id(1), plusTier: 1 },
]);
const build = await createBuild(users.id(1));
expect(await sortValueByBuildId(build.id)).toBe(TIER_1_SORT_VALUE);
await AdminRepository.replacePlusTiers([
{ userId: users.id(2), plusTier: 1 },
]);
expect(await sortValueByBuildId(build.id)).toBe(NO_TIER_SORT_VALUE);
});
});

View File

@@ -0,0 +1,507 @@
import type { Transaction } from "kysely";
import { db } from "~/db/sql";
import type { DB, Tables, TablesInsertable } from "~/db/tables";
import { actorId } from "~/features/auth/core/user.server";
import * as BadgeRepository from "~/features/badges/BadgeRepository.server";
import * as BuildRepository from "~/features/builds/BuildRepository.server";
import * as XRankPlacementRepository from "~/features/top-search/XRankPlacementRepository.server";
import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import invariant from "~/utils/invariant";
/**
* Migrates user-related data. Takes data from the "old user" and remaps it to the Discord ID of the "new user". Used when user switches their Discord accounts.
*
* @param args - An object containing:
* - `newUserId`: The ID of the user whose data will be migrated and then deleted.
* - `oldUserId`: The ID of the user who will receive the migrated data.
* @returns A promise that resolves to `null` if the migration succeeds, or an error message if validation fails.
*/
export function migrate(args: { newUserId: number; oldUserId: number }) {
return db.transaction().execute(async (trx) => {
const error = await validateMigration(trx, args);
if (error) {
return error;
}
// delete some limited data from the target user
// idea is to make the migration a bit more smooth
// since it won't fail if some small thing has been added
// but for bigger things (e.g. has played tournaments)
// it will still fail
await trx
.deleteFrom("UserWeapon")
.where("userId", "=", args.newUserId)
.execute();
await trx
.deleteFrom("Build")
.where("ownerId", "=", args.newUserId)
.execute();
await trx
.deleteFrom("UserFriendCode")
.where("userId", "=", args.newUserId)
.execute();
await trx
.deleteFrom("LFGPost")
.where("authorId", "=", args.newUserId)
.execute();
await trx
.deleteFrom("BanLog")
.where("userId", "=", args.newUserId)
.execute();
await trx
.updateTable("GroupMember")
.where("userId", "=", args.newUserId)
.set({ userId: args.oldUserId })
.execute();
await trx
.updateTable("UnvalidatedUserSubmittedImage")
.where("submitterUserId", "=", args.newUserId)
.set({ submitterUserId: args.oldUserId })
.execute();
await trx
.updateTable("ModNote")
.where("userId", "=", args.newUserId)
.set({ userId: args.oldUserId })
.execute();
// reports between the two merged accounts would become self-reports
await trx
.deleteFrom("UserReport")
.where((eb) =>
eb.or([
eb.and([
eb("reporterUserId", "=", args.newUserId),
eb("reportedUserId", "=", args.oldUserId),
]),
eb.and([
eb("reporterUserId", "=", args.oldUserId),
eb("reportedUserId", "=", args.newUserId),
]),
]),
)
.execute();
await deleteOlderCollidingUserReports(trx, args, "reporterUserId");
await deleteOlderCollidingUserReports(trx, args, "reportedUserId");
await trx
.updateTable("UserReport")
.where("reporterUserId", "=", args.newUserId)
.set({ reporterUserId: args.oldUserId })
.execute();
await trx
.updateTable("UserReport")
.where("reportedUserId", "=", args.newUserId)
.set({ reportedUserId: args.oldUserId })
.execute();
// special case: delete same team membership to avoid unique constraint violation
await trx
.deleteFrom("AllTeamMember")
.where("userId", "=", args.oldUserId)
.where("leftAt", "is not", null)
.where((eb) =>
eb(
"AllTeamMember.teamId",
"in",
eb
.selectFrom("AllTeamMember")
.select("teamId")
.where("userId", "=", args.newUserId)
.where("leftAt", "is", null),
),
)
.execute();
// delete past team membership data (not user visible)
await trx
.deleteFrom("AllTeamMember")
.where("userId", "=", args.newUserId)
.where("leftAt", "is not", null)
.execute();
// existing team membership will stay
await trx
.updateTable("AllTeamMember")
.where("userId", "=", args.newUserId)
.set({ userId: args.oldUserId })
.execute();
// If both accounts own the same trophy, drop the
// migrated account's duplicate rows
await trx
.deleteFrom("TrophyOwner")
.where("userId", "=", args.newUserId)
.where((eb) =>
eb.exists(
eb
.selectFrom("TrophyOwner as existing")
.select("existing.trophyId")
.where("existing.userId", "=", args.oldUserId)
.whereRef("existing.trophyId", "=", "TrophyOwner.trophyId")
.whereRef("existing.tournamentId", "=", "TrophyOwner.tournamentId"),
),
)
.execute();
await trx
.deleteFrom("SpecialTrophyOwner")
.where("userId", "=", args.newUserId)
.where((eb) =>
eb(
"SpecialTrophyOwner.trophyId",
"in",
eb
.selectFrom("SpecialTrophyOwner")
.select("trophyId")
.where("userId", "=", args.oldUserId),
),
)
.execute();
await trx
.deleteFrom("PendingTrophyApproval")
.where("userId", "=", args.newUserId)
.where((eb) =>
eb(
"PendingTrophyApproval.pendingTrophyId",
"in",
eb
.selectFrom("PendingTrophyApproval")
.select("pendingTrophyId")
.where("userId", "=", args.oldUserId),
),
)
.execute();
await trx
.updateTable("TrophyOwner")
.where("userId", "=", args.newUserId)
.set({ userId: args.oldUserId })
.execute();
await trx
.updateTable("SpecialTrophyOwner")
.where("userId", "=", args.newUserId)
.set({ userId: args.oldUserId })
.execute();
await trx
.updateTable("Trophy")
.where("creatorId", "=", args.newUserId)
.set({ creatorId: args.oldUserId })
.execute();
await trx
.updateTable("Trophy")
.where("managerId", "=", args.newUserId)
.set({ managerId: args.oldUserId })
.execute();
await trx
.updateTable("PendingTrophy")
.where("submitterUserId", "=", args.newUserId)
.set({ submitterUserId: args.oldUserId })
.execute();
await trx
.updateTable("PendingTrophy")
.where("managerId", "=", args.newUserId)
.set({ managerId: args.oldUserId })
.execute();
await trx
.updateTable("PendingTrophy")
.where("declinedByUserId", "=", args.newUserId)
.set({ declinedByUserId: args.oldUserId })
.execute();
await trx
.updateTable("PendingTrophyApproval")
.where("userId", "=", args.newUserId)
.set({ userId: args.oldUserId })
.execute();
const deletedUser = await trx
.deleteFrom("User")
.where("User.id", "=", args.newUserId)
.returning("discordId")
.executeTakeFirstOrThrow();
await trx
.updateTable("User")
.set({ discordId: deletedUser.discordId })
.where("User.id", "=", args.oldUserId)
.execute();
return null;
});
}
/**
* Merging accounts can collide on the one-report-per-pair unique index; the newer
* report (by `createdAt`, id as tie-breaker) wins and the other row is dropped.
*/
function deleteOlderCollidingUserReports(
trx: Transaction<DB>,
args: { newUserId: number; oldUserId: number },
column: "reporterUserId" | "reportedUserId",
) {
const otherColumn =
column === "reporterUserId" ? "reportedUserId" : "reporterUserId";
return trx
.deleteFrom("UserReport")
.where(column, "in", [args.newUserId, args.oldUserId])
.where((eb) =>
eb.exists(
eb
.selectFrom("UserReport as newer")
.select("newer.id")
.where(`newer.${column}`, "in", [args.newUserId, args.oldUserId])
.whereRef(`newer.${otherColumn}`, "=", `UserReport.${otherColumn}`)
.whereRef("newer.id", "!=", "UserReport.id")
.where((inner) =>
inner.or([
inner("newer.createdAt", ">", inner.ref("UserReport.createdAt")),
inner.and([
inner(
"newer.createdAt",
"=",
inner.ref("UserReport.createdAt"),
),
inner("newer.id", ">", inner.ref("UserReport.id")),
]),
]),
),
),
)
.execute();
}
async function validateMigration(
trx: Transaction<DB>,
args: { newUserId: number; oldUserId: number },
) {
const newUserTeam = await trx
.selectFrom("TournamentTeamMember")
.select(["tournamentTeamId"])
.where("userId", "=", args.newUserId)
.executeTakeFirst();
if (newUserTeam) {
return "new user is in a tournament team";
}
const oldUserCurrentTeam = await trx
.selectFrom("TeamMember")
.select(["teamId"])
.where("userId", "=", args.oldUserId)
.executeTakeFirst();
const newUserCurrentTeam = await trx
.selectFrom("TeamMember")
.select(["teamId"])
.where("userId", "=", args.newUserId)
.executeTakeFirst();
if (oldUserCurrentTeam && newUserCurrentTeam) {
return "both old and new user are in teams";
}
return null;
}
/** Replaces every `PlusTier` row, also refreshing the build sort values derived from them. */
export function replacePlusTiers(
plusTiers: Array<{ userId: number; plusTier: number }>,
) {
invariant(plusTiers.length > 0, "plusTiers must not be empty");
return db.transaction().execute(async (trx) => {
await trx.deleteFrom("PlusTier").execute();
await trx
.insertInto("PlusTier")
.values(
plusTiers.map(({ plusTier, userId }) => ({ userId, tier: plusTier })),
)
.execute();
await BuildRepository.recalculateAllSortValues(undefined, trx);
});
}
export function makeVideoAdderByUserId(userId: number) {
return db
.updateTable("User")
.set({ isVideoAdder: 1 })
.where("User.id", "=", userId)
.execute();
}
export function makeArtistByUserId(userId: number) {
return db
.updateTable("User")
.set({ isArtist: 1 })
.where("User.id", "=", userId)
.execute();
}
export function makeTournamentOrganizerByUserId(userId: number) {
return db
.updateTable("User")
.set({ isTournamentOrganizer: 1 })
.where("User.id", "=", userId)
.execute();
}
export function makeApiAccesserByUserId(userId: number) {
return db
.updateTable("User")
.set({ isApiAccesser: 1 })
.where("User.id", "=", userId)
.execute();
}
export async function linkUserAndPlayer({
userId,
playerId,
}: {
userId: number;
playerId: number;
}) {
await db
.updateTable("SplatoonPlayer")
.set({ userId: null })
.where("SplatoonPlayer.userId", "=", userId)
.execute();
await db
.updateTable("SplatoonPlayer")
.set({ userId })
.where("SplatoonPlayer.id", "=", playerId)
.execute();
await BadgeRepository.syncXPBadges();
await TrophyRepository.syncSpecialTrophies();
await BuildRepository.recalculateAllSortValues(userId);
await XRankPlacementRepository.refreshTenStarWeapons(userId);
}
export async function forcePatron(args: {
id: number;
patronTier: Tables["User"]["patronTier"];
patronStartedAt: Date;
patronExpiresAt: Date;
}) {
await db
.updateTable("User")
.set({
patronTier: args.patronTier,
patronStartedAt: dateToDatabaseTimestamp(args.patronStartedAt),
patronExpiresAt: dateToDatabaseTimestamp(args.patronExpiresAt),
})
.where("User.id", "=", args.id)
.execute();
await TrophyRepository.syncSpecialTrophies();
}
export async function findAllBannedUsers() {
const rows = await db
.selectFrom("User")
.select(["User.id as userId", "User.banned", "User.bannedReason"])
.where("User.banned", "!=", 0)
.execute();
const result: Map<number, (typeof rows)[number]> = new Map();
for (const row of rows) {
result.set(row.userId, row);
}
return result;
}
export function banUser({
userId,
banned,
bannedReason,
bannedByUserId,
}: {
userId: number;
banned: 1 | Date;
bannedReason: string | null;
/** Which user banned the user? If null then it means it was an automatic ban. */
bannedByUserId: number | null;
}) {
return db.transaction().execute(async (trx) => {
const banArgs = {
banned: banned === 1 ? banned : dateToDatabaseTimestamp(banned),
bannedReason,
};
await trx
.updateTable("User")
.set(banArgs)
.where("User.id", "=", userId)
.execute();
if (typeof bannedByUserId === "number") {
await trx
.insertInto("BanLog")
.values({
...banArgs,
userId,
bannedByUserId,
})
.execute();
}
});
}
export function unbanUser({
userId,
unbannedByUserId,
}: {
userId: number;
unbannedByUserId: number;
}) {
return db.transaction().execute(async (trx) => {
const banArgs = {
banned: 0,
bannedReason: null,
};
await trx
.updateTable("User")
.set(banArgs)
.where("User.id", "=", userId)
.execute();
await trx
.insertInto("BanLog")
.values({
...banArgs,
userId,
bannedByUserId: unbannedByUserId,
})
.execute();
});
}
export function addModNote(
args: Omit<TablesInsertable["ModNote"], "authorId">,
) {
return db
.insertInto("ModNote")
.values({ ...args, authorId: actorId() })
.execute();
}
export function findModNoteById(id: number) {
return db
.selectFrom("ModNote")
.selectAll()
.where("ModNote.id", "=", id)
.executeTakeFirst();
}
export function deleteModNote(id: number) {
return db
.updateTable("ModNote")
.set({ isDeleted: 1 })
.where("ModNote.id", "=", id)
.execute();
}

View File

@@ -0,0 +1,80 @@
import { db } from "~/db/sql";
import type { TablesInsertable } from "~/db/tables";
import { databaseTimestampNow } from "~/utils/dates";
import { concatUserSubmittedImagePrefix } from "~/utils/kysely.server";
/** Number of seconds an external stream keeps showing in the sidebar after its start time. */
const SIDEBAR_VISIBLE_SECONDS = 6 * 60 * 60;
/** Number of seconds after the start time before an external stream row is deleted. */
const RETENTION_SECONDS = 24 * 60 * 60;
/** Inserts a new admin-curated external stream. */
export function insert(
args: Pick<
TablesInsertable["ExternalStream"],
"name" | "url" | "avatarImgId" | "startsAt"
>,
) {
return db.insertInto("ExternalStream").values(args).execute();
}
/** Deletes an external stream by its id. */
export function deleteById(id: number) {
return db.deleteFrom("ExternalStream").where("id", "=", id).execute();
}
/** Lists all external streams (for the admin management page), soonest start time first. */
export function findAll() {
return db
.selectFrom("ExternalStream")
.leftJoin(
"UserSubmittedImage",
"UserSubmittedImage.id",
"ExternalStream.avatarImgId",
)
.select((eb) => [
"ExternalStream.id",
"ExternalStream.name",
"ExternalStream.url",
"ExternalStream.startsAt",
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"avatarUrl",
),
])
.orderBy("ExternalStream.startsAt", "asc")
.execute();
}
/** External streams that should currently show in the sidebar (started under 6h ago or upcoming). */
export function findAllForSidebar() {
return db
.selectFrom("ExternalStream")
.leftJoin(
"UserSubmittedImage",
"UserSubmittedImage.id",
"ExternalStream.avatarImgId",
)
.select((eb) => [
"ExternalStream.id",
"ExternalStream.name",
"ExternalStream.url",
"ExternalStream.startsAt",
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"avatarUrl",
),
])
.where(
"ExternalStream.startsAt",
">=",
databaseTimestampNow() - SIDEBAR_VISIBLE_SECONDS,
)
.execute();
}
/** Deletes external streams whose start time is more than 24h in the past. */
export function deleteOld() {
return db
.deleteFrom("ExternalStream")
.where("startsAt", "<", databaseTimestampNow() - RETENTION_SECONDS)
.executeTakeFirst();
}

View File

@@ -0,0 +1,198 @@
import type { ActionFunctionArgs } from "react-router";
import * as AdminRepository from "~/features/admin/AdminRepository.server";
import { requireUser } from "~/features/auth/core/user.server";
import { refreshBannedCache } from "~/features/ban/core/banned.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { parseFormData } from "~/form/parse.server";
import { requireRole } from "~/modules/permissions/guards.server";
import {
errorToast,
notFoundIfNullish,
successToast,
} from "~/utils/remix.server";
import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql";
import { assertUnreachable } from "~/utils/types";
import { normalizeFriendCode } from "~/utils/zod";
import { adminActionSchema } from "../admin-schemas";
import {
sendUserBannedWebhook,
sendUserUnbannedWebhook,
} from "../core/discord-webhook.server";
import { plusTiersFromVotingAndLeaderboard } from "../core/plus-tier.server";
export const action = async ({ request }: ActionFunctionArgs) => {
const result = await parseFormData({
request,
schema: adminActionSchema,
});
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
const data = result.data;
const user = requireUser();
let message: string;
switch (data._action) {
case "MIGRATE": {
requireRole("STAFF");
try {
const errorMessage = await AdminRepository.migrate({
oldUserId: data.oldUser,
newUserId: data.newUser,
});
if (errorMessage) {
errorToast(`Migration failed. Reason: ${errorMessage}`);
}
await refreshBannedCache();
message = "Account migrated";
break;
} catch (err) {
if (errorIsSqliteForeignKeyConstraintFailure(err)) {
errorToast(
"New user has data preventing the migration (e.g. member of tournament teams or SendouQ played)",
);
}
throw err;
}
}
case "REFRESH": {
requireRole("ADMIN");
await AdminRepository.replacePlusTiers(
await plusTiersFromVotingAndLeaderboard(),
);
message = "Plus tiers refreshed";
break;
}
case "FORCE_PATRON": {
requireRole("ADMIN");
await AdminRepository.forcePatron({
id: data.user,
patronStartedAt: new Date(),
patronTier: Number(data.patronTier),
patronExpiresAt: data.patronExpiresAt,
});
message = "Patron status updated";
break;
}
case "ARTIST": {
requireRole("STAFF");
await AdminRepository.makeArtistByUserId(data.user);
message = "Artist permissions given";
break;
}
case "VIDEO_ADDER": {
requireRole("STAFF");
await AdminRepository.makeVideoAdderByUserId(data.user);
message = "VoD adder permissions given";
break;
}
case "TOURNAMENT_ORGANIZER": {
requireRole("ADMIN");
await AdminRepository.makeTournamentOrganizerByUserId(data.user);
message = "Tournament permissions given";
break;
}
case "LINK_PLAYER": {
requireRole("STAFF");
await AdminRepository.linkUserAndPlayer({
userId: data.user,
playerId: data.playerId,
});
message = "Linked user and player";
break;
}
case "BAN_USER": {
requireRole("STAFF");
const bannedUser = notFoundIfNullish(
await UserRepository.findLeanById(data.user),
);
const banExpiresAt = data.expiresAt ?? null;
await AdminRepository.banUser({
bannedReason: data.reason ?? null,
userId: data.user,
banned: banExpiresAt ?? 1,
bannedByUserId: user.id,
});
await refreshBannedCache();
sendUserBannedWebhook({
bannedUser,
bannedBy: user,
reason: data.reason ?? null,
expiresAt: banExpiresAt,
});
message = "User banned";
break;
}
case "UNBAN_USER": {
requireRole("STAFF");
const unbannedUser = notFoundIfNullish(
await UserRepository.findLeanById(data.user),
);
await AdminRepository.unbanUser({
userId: data.user,
unbannedByUserId: user.id,
});
await refreshBannedCache();
sendUserUnbannedWebhook({
unbannedUser,
unbannedBy: user,
});
message = "User unbanned";
break;
}
case "UPDATE_FRIEND_CODE": {
requireRole("STAFF");
await UserRepository.insertFriendCode({
friendCode: normalizeFriendCode(data.friendCode),
submitterUserId: user.id,
userId: data.user,
});
message = "Friend code updated";
break;
}
case "API_ACCESS": {
requireRole("ADMIN");
await AdminRepository.makeApiAccesserByUserId(data.user);
message = "API access granted";
break;
}
default: {
assertUnreachable(data);
}
}
return successToast(message);
};

View File

@@ -0,0 +1,46 @@
import type { ActionFunctionArgs } from "react-router";
import { clearCombinedStreamsCache } from "~/features/core/streams/streams.server";
import { parseFormDataWithImages } from "~/form/parse.server";
import { requireRole } from "~/modules/permissions/guards.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { assertUnreachable } from "~/utils/types";
import { externalStreamActionSchema } from "../admin-schemas";
import * as ExternalStreamRepository from "../ExternalStreamRepository.server";
export const action = async ({ request }: ActionFunctionArgs) => {
requireRole("ADMIN");
const result = await parseFormDataWithImages({
request,
schema: externalStreamActionSchema,
});
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
const data = result.data;
switch (data._action) {
case "CREATE": {
await ExternalStreamRepository.insert({
name: data.name,
url: data.url,
avatarImgId: data.avatar,
startsAt: dateToDatabaseTimestamp(data.startTime),
});
break;
}
case "DELETE": {
await ExternalStreamRepository.deleteById(data.id);
break;
}
default: {
assertUnreachable(data);
}
}
clearCombinedStreamsCache();
return null;
};

View File

@@ -0,0 +1,22 @@
export const BAN_REASON_MAX_LENGTH = 200;
export const ADMIN_DISCORD_ID = "79237403620945920";
export const ADMIN_ID = process.env.NODE_ENV === "test" ? 1 : 274;
// Panda Scep Acing Baja Michi
export const STAFF_IDS = [11329, 9719, 9342, 20774, 23094];
// hfcRed
export const DEV_IDS = [27883];
// hfcRed Dreamy Cafy Acing
export const QA_IDS: number[] = [27883, 38176, 10654, 9342];
// xi
/** Users who can access the scanner while it is not yet enabled for everyone */
export const SCANNER_TESTER_IDS: number[] = [30228];
export const STAFF_DISCORD_IDS = [
"138757634500067328",
"184478601171828737",
"342369454719631361",
"785678909555474462",
"725416982681223259",
];

View File

@@ -0,0 +1,136 @@
import { z } from "zod";
import { friendCodeField } from "~/features/sendouq/q-schemas";
import {
datetime,
datetimeOptional,
image,
numberField,
select,
stringConstant,
textField,
textFieldOptional,
userSearch,
} from "~/form/fields";
import { id } from "~/utils/zod";
import { BAN_REASON_MAX_LENGTH } from "./admin-constants";
const userField = userSearch({ label: "labels.user" });
export const friendCodeSearchSchema = z.object({
friendCode: friendCodeField,
});
export const migrateUserSchema = z.object({
_action: stringConstant("MIGRATE"),
oldUser: userSearch({ label: "labels.adminOldUser" }),
newUser: userSearch({
label: "labels.adminNewUser",
bottomText: "bottomTexts.adminMigrateNewUser",
}),
});
export const linkPlayerSchema = z.object({
_action: stringConstant("LINK_PLAYER"),
user: userField,
playerId: numberField({ label: "labels.adminPlayerId", min: 1 }),
});
export const giveArtistSchema = z.object({
_action: stringConstant("ARTIST"),
user: userField,
});
export const giveVideoAdderSchema = z.object({
_action: stringConstant("VIDEO_ADDER"),
user: userField,
});
export const giveTournamentOrganizerSchema = z.object({
_action: stringConstant("TOURNAMENT_ORGANIZER"),
user: userField,
});
export const giveApiAccessSchema = z.object({
_action: stringConstant("API_ACCESS"),
user: userField,
});
export const updateFriendCodeSchema = z.object({
_action: stringConstant("UPDATE_FRIEND_CODE"),
user: userField,
friendCode: friendCodeField,
});
export const forcePatronSchema = z.object({
_action: stringConstant("FORCE_PATRON"),
user: userField,
patronTier: select({
label: "labels.patronTier",
items: [
{ value: "1", label: "options.patronTier.1" },
{ value: "2", label: "options.patronTier.2" },
{ value: "3", label: "options.patronTier.3" },
],
}),
patronExpiresAt: datetime({ label: "labels.patronExpiresAt" }),
});
export const banUserSchema = z.object({
_action: stringConstant("BAN_USER"),
user: userField,
expiresAt: datetimeOptional({
label: "labels.banUserExpiresAt",
bottomText: "bottomTexts.banUserExpiresAtHelp",
min: () => new Date(),
minMessage: "errors.dateInPast",
}),
reason: textFieldOptional({
label: "labels.reason",
maxLength: BAN_REASON_MAX_LENGTH,
}),
});
export const unbanUserSchema = z.object({
_action: stringConstant("UNBAN_USER"),
user: userField,
});
export const refreshPlusTiersSchema = z.object({
_action: stringConstant("REFRESH"),
});
export const adminActionSchema = z.union([
migrateUserSchema,
linkPlayerSchema,
giveArtistSchema,
giveVideoAdderSchema,
giveTournamentOrganizerSchema,
giveApiAccessSchema,
updateFriendCodeSchema,
forcePatronSchema,
banUserSchema,
unbanUserSchema,
refreshPlusTiersSchema,
]);
export const createExternalStreamSchema = z.object({
_action: stringConstant("CREATE"),
name: textField({ label: "labels.name", maxLength: 64 }),
url: textField({
label: "labels.link",
maxLength: 200,
validate: "url",
}),
avatar: image({ label: "labels.logo", autoValidate: true }),
startTime: datetime({ label: "labels.startTime" }),
});
const deleteExternalStreamSchema = z.object({
_action: stringConstant("DELETE"),
id,
});
export const externalStreamActionSchema = z.union([
createExternalStreamSchema,
deleteExternalStreamSchema,
]);

View File

@@ -0,0 +1,21 @@
import { describe, test } from "vitest";
import {
assertDecodesToDefault,
assertRoundTrips,
} from "~/modules/search-params/search-params-test-utils";
import { adminSearchParams } from "./admin-search-params";
describe("adminSearchParams", () => {
test("round-trips", () => {
assertRoundTrips(adminSearchParams, {
friendCode: ["1234-5678-9012", "SW-1234-5678-9012", "123456789012"],
});
});
test("garbage decodes to default", () => {
assertDecodesToDefault(adminSearchParams, "friendCode", [
["not-a-friend-code"],
["1234-5678"],
]);
});
});

View File

@@ -0,0 +1,10 @@
import { z } from "zod";
import { FRIEND_CODE_REGEXP } from "~/features/sendouq/q-constants";
import * as SearchParams from "~/modules/search-params/search-params";
import { SP } from "~/modules/search-params/search-params";
export const adminSearchParams = SearchParams.define({
friendCode: SP.param(z.string().regex(FRIEND_CODE_REGEXP).nullable(), {
loader: true,
}),
});

View File

@@ -0,0 +1,5 @@
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
/** Should the user be able to access dev controls? Seeding & user impersonation without auth. */
export const DANGEROUS_CAN_ACCESS_DEV_CONTROLS =
process.env.NODE_ENV === "development" || IS_E2E_TEST_RUN;

View File

@@ -0,0 +1,70 @@
import {
sendModDiscordWebhook,
truncateEmbedValue,
userAdminPageLink,
userPageLink,
type WebhookUser,
} from "~/modules/discord-webhook.server";
/**
* Posts a rich embed about a user getting banned to the mod channel Discord webhook.
* Fire-and-forget (see `sendModDiscordWebhook`).
*/
export function sendUserBannedWebhook(args: {
bannedUser: WebhookUser;
bannedBy: WebhookUser;
reason: string | null;
/** When the ban ends, null when the ban has no end date */
expiresAt: Date | null;
}) {
sendModDiscordWebhook({
title: "User banned",
fields: [
{
name: "Banned user",
value: userAdminPageLink(args.bannedUser),
},
{
name: "Banned by",
value: userPageLink(args.bannedBy),
},
{
name: "Expires",
value: args.expiresAt
? `<t:${Math.floor(args.expiresAt.getTime() / 1000)}:f>`
: "No end date",
},
...(args.reason
? [
{
name: "Reason",
value: truncateEmbedValue(args.reason),
},
]
: []),
],
});
}
/**
* Posts a rich embed about a user getting unbanned to the mod channel Discord webhook.
* Fire-and-forget (see `sendModDiscordWebhook`).
*/
export function sendUserUnbannedWebhook(args: {
unbannedUser: WebhookUser;
unbannedBy: WebhookUser;
}) {
sendModDiscordWebhook({
title: "User unbanned",
fields: [
{
name: "Unbanned user",
value: userAdminPageLink(args.unbannedUser),
},
{
name: "Unbanned by",
value: userPageLink(args.unbannedBy),
},
],
});
}

View File

@@ -0,0 +1,56 @@
import { addPendingPlusTiers } from "~/features/leaderboards/core/leaderboards.server";
import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server";
import * as Seasons from "~/features/mmr/core/Seasons";
import { seasonToVotingRange } from "~/features/plus-voting/core/voting-time";
import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server";
import invariant from "~/utils/invariant";
import { userIsBanned } from "../../ban/core/banned.server";
export async function plusTiersFromVotingAndLeaderboard() {
const newMembersFromVoting =
await PlusVotingRepository.findAllPlusTiersFromLatestVoting();
const newMembersFromLeaderboard = await fromLeaderboard(newMembersFromVoting);
return [
...newMembersFromLeaderboard,
// filter to ensure that user gets their highest tier
...newMembersFromVoting.filter(
(member) =>
!newMembersFromLeaderboard.some(
(leaderboardMember) => leaderboardMember.userId === member.userId,
),
),
].filter(({ userId }) => !userIsBanned(userId));
}
async function fromLeaderboard(
newMembersFromVoting: Array<{ userId: number; plusTier: number }>,
) {
const now = new Date();
const lastCompletedSeason = Seasons.previous();
invariant(lastCompletedSeason, "No previous season found");
const currSeason = Seasons.current();
if (currSeason) {
const range = seasonToVotingRange(currSeason);
// we are in the period of a season where the season's voting
// has ended but we don't yet have the latest leaderboard results
// -> last season's results are no longer valid
if (range.endDate < now) return [];
}
const leaderboard = addPendingPlusTiers(
await LeaderboardRepository.findUserSPLeaderboard(lastCompletedSeason.nth),
newMembersFromVoting,
lastCompletedSeason.nth,
);
return leaderboard.flatMap((entry) => {
if (!entry.pendingPlusTier) return [];
return {
userId: entry.id,
plusTier: entry.pendingPlusTier,
};
});
}

View File

@@ -0,0 +1,33 @@
import type { LoaderFunctionArgs } from "react-router";
import {
getRealUserId,
isImpersonating,
requireUser,
} from "~/features/auth/core/user.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { isAdmin, isDev, isStaff } from "~/modules/permissions/utils";
import { normalizeFriendCode } from "~/utils/zod";
import { adminSearchParams } from "../admin-search-params";
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "../core/dev-controls";
export const loader = async ({ request }: LoaderFunctionArgs) => {
if (!DANGEROUS_CAN_ACCESS_DEV_CONTROLS) {
const user = requireUser();
const realUserId = await getRealUserId(request);
const userToCheck =
realUserId && realUserId !== user.id ? { id: realUserId } : user;
if (!isAdmin(userToCheck) && !isStaff(userToCheck) && !isDev(userToCheck)) {
throw new Response("Forbidden", { status: 403 });
}
}
const { friendCode } = adminSearchParams.parse(request);
return {
isImpersonating: await isImpersonating(request),
friendCodeSearchUsers: friendCode
? await UserRepository.findByFriendCode(normalizeFriendCode(friendCode))
: [],
};
};

View File

@@ -0,0 +1,10 @@
import { requireRole } from "~/modules/permissions/guards.server";
import * as ExternalStreamRepository from "../ExternalStreamRepository.server";
export const loader = async () => {
requireRole("ADMIN");
return {
streams: await ExternalStreamRepository.findAll(),
};
};

View File

@@ -0,0 +1,12 @@
.streamRow {
display: flex;
align-items: center;
gap: var(--s-2);
}
.streamAvatar {
width: 2.5rem;
height: 2.5rem;
border-radius: 100%;
object-fit: cover;
}

View File

@@ -0,0 +1,98 @@
import type { MetaFunction } from "react-router";
import { Link, useLoaderData } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { LocaleTime } from "~/components/LocaleTime";
import { Main } from "~/components/Main";
import { SendouForm } from "~/form/SendouForm";
import { metaTags } from "~/utils/remix";
import { action } from "../actions/admin.streams.server";
import { createExternalStreamSchema } from "../admin-schemas";
import { loader } from "../loaders/admin.streams.server";
import styles from "./admin.streams.module.css";
export { action, loader };
export const meta: MetaFunction = (args) => {
return metaTags({
title: "External streams",
location: args.location,
});
};
export default function AdminStreamsPage() {
return (
<Main className="stack lg">
<SendouForm
schema={createExternalStreamSchema}
title="Add external stream"
>
{({ FormField }) => (
<>
<FormField name="name" />
<FormField name="url" />
<FormField name="avatar" />
<FormField name="startTime" />
</>
)}
</SendouForm>
<ExternalStreamList />
</Main>
);
}
function ExternalStreamList() {
const { streams } = useLoaderData<typeof loader>();
if (streams.length === 0) {
return <div className="text-lighter">No external streams</div>;
}
return (
<div className="stack md">
<h2>Current external streams</h2>
<ul className="stack sm">
{streams.map((stream) => (
<li key={stream.id} className={styles.streamRow}>
{stream.avatarUrl ? (
<img
src={stream.avatarUrl}
alt=""
className={styles.streamAvatar}
/>
) : null}
<div className="stack xxs">
<Link to={stream.url} className="text-main-forced">
{stream.name}
</Link>
<span className="text-xs text-lighter">
<LocaleTime
date={stream.startsAt}
inline
options={{
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
}}
/>
</span>
</div>
<FormWithConfirm
dialogHeading={`Delete external stream "${stream.name}"?`}
fields={[
["_action", "DELETE"],
["id", stream.id],
]}
>
<SendouButton variant="minimal-destructive" className="ml-auto">
Delete
</SendouButton>
</FormWithConfirm>
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,377 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import * as BuildFactory from "~/db/seed/factories/BuildFactory";
import * as PlusVoteFactory from "~/db/seed/factories/PlusVoteFactory";
import * as SkillFactory from "~/db/seed/factories/SkillFactory";
import * as TeamFactory from "~/db/seed/factories/TeamFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import { db } from "~/db/sql";
import * as BuildRepository from "~/features/builds/BuildRepository.server";
import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants";
import * as TeamRepository from "~/features/team/TeamRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { assertResponseErrored, wrappedAction } from "~/utils/Test";
import type { adminActionSchema } from "../admin-schemas";
import { action } from "./admin";
const adminAction = wrappedAction<typeof adminActionSchema>({
action,
isJsonSubmission: true,
});
const users = UserFactory.pool();
// account migration is asserted through Discord ids, so the tests give the users
// one they can name
const createUsers = (count = 2) =>
users.create(count, (index) => ({ discordId: String(index) }));
const countPlusTierMembers = (tier = 1) =>
db
.selectFrom("PlusTier")
.where("PlusTier.tier", "=", tier)
.select(({ fn }) => fn.count<number>("PlusTier.tier").as("count"))
.executeTakeFirstOrThrow()
.then((row) => row.count);
/** Ranks the given users, the first of them topping the leaderboard. */
const createLeaderboard = (userIds: number[]) =>
SkillFactory.createMany(
userIds.length,
(index) => ({ userId: userIds[index], mu: userIds.length - index }),
{ matchesCount: MATCHES_COUNT_NEEDED_FOR_LEADERBOARD },
);
/** Marks a user as skipping the plus server for the given season. */
const skipPlusForSeason = (userId: number, seasonNth: number) =>
// the app only ever reads the column; the one thing that sets it is
// `scripts/skip-plus.ts`, itself a raw update with no repository function behind it
// biome-ignore lint/plugin: no production write reaches the column
db
.updateTable("User")
.set({ plusSkippedForSeasonNth: seasonNth })
.where("User.id", "=", userId)
.execute();
describe("Plus voting", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(async () => {
vi.useRealTimers();
});
test("gives correct amount of plus tiers", async () => {
vi.setSystemTime(new Date("2023-12-12T00:00:00.000Z"));
await createUsers(10);
await PlusVoteFactory.createMany(10, (index) => ({
authorId: users.id(1),
votedId: users.id(index + 1),
score: index < 5 ? -1 : 1,
}));
await adminAction({ _action: "REFRESH" }, { user: "admin" });
expect(await countPlusTierMembers()).toBe(5);
});
test("60% or more guarantees pass", async () => {
vi.setSystemTime(new Date("2023-12-12T00:00:00.000Z"));
await createUsers(10);
// 60% - auto-pass
await PlusVoteFactory.createMany(10, (index) => ({
authorId: users.id(index + 1),
votedId: users.id(1),
score: index < 4 ? -1 : 1,
}));
await adminAction({ _action: "REFRESH" }, { user: "admin" });
expect(await countPlusTierMembers()).toBe(1);
});
test("40% or less does not pass", async () => {
vi.setSystemTime(new Date("2023-12-12T00:00:00.000Z"));
await createUsers(10);
// 40% - auto-fail
await PlusVoteFactory.createMany(10, (index) => ({
authorId: users.id(index + 1),
votedId: users.id(1),
score: index < 6 ? -1 : 1,
}));
await adminAction({ _action: "REFRESH" }, { user: "admin" });
expect(await countPlusTierMembers()).toBe(0);
});
test("middle zone (40-60%) passes when quota has room", async () => {
vi.setSystemTime(new Date("2023-12-12T00:00:00.000Z"));
await createUsers(10);
// 50% - middle zone, should pass (quota=50 for tier 1)
await PlusVoteFactory.createMany(10, (index) => ({
authorId: users.id(index + 1),
votedId: users.id(1),
score: index < 5 ? -1 : 1,
}));
await adminAction({ _action: "REFRESH" }, { user: "admin" });
expect(await countPlusTierMembers()).toBe(1);
});
test("combines leaderboard and voting results (after season over)", async () => {
vi.setSystemTime(new Date("2023-11-29T00:00:00.000Z"));
await createUsers();
await PlusVoteFactory.create({
authorId: users.id(1),
votedId: users.id(1),
score: 1,
});
await createLeaderboard([users.id(2)]);
await adminAction({ _action: "REFRESH" }, { user: "admin" });
expect(await countPlusTierMembers()).toBe(2);
});
test("skips users from leaderboard with the skip flag for the season", async () => {
vi.setSystemTime(new Date("2023-11-29T00:00:00.000Z"));
await createUsers(11);
await createLeaderboard(users.ids());
await skipPlusForSeason(users.id(1), 1);
await adminAction({ _action: "REFRESH" }, { user: "admin" });
expect(await countPlusTierMembers(1)).toBe(5);
expect(await countPlusTierMembers(2)).toBe(5);
});
test("plus server skip flag ignored if for past season", async () => {
vi.setSystemTime(new Date("2023-11-29T00:00:00.000Z"));
await createUsers(11);
await createLeaderboard(users.ids());
await skipPlusForSeason(users.id(1), 0);
await adminAction({ _action: "REFRESH" }, { user: "admin" });
expect(await countPlusTierMembers(1)).toBe(5);
expect(await countPlusTierMembers(2)).toBe(6);
});
test("ignores leaderboard while season is ongoing", async () => {
vi.setSystemTime(new Date("2024-02-15T00:00:00.000Z"));
await createUsers();
await PlusVoteFactory.create({
authorId: users.id(1),
votedId: users.id(1),
score: 1,
});
await createLeaderboard([users.id(2)]);
await adminAction({ _action: "REFRESH" }, { user: "admin" });
expect(await countPlusTierMembers()).toBe(1);
expect(await countPlusTierMembers(2)).toBe(0);
});
test("leaderboard gives members to all tiers", async () => {
vi.setSystemTime(new Date("2023-11-20T00:00:00.000Z"));
await createUsers(60);
await createLeaderboard(users.ids());
await adminAction({ _action: "REFRESH" }, { user: "admin" });
expect(await countPlusTierMembers()).toBeGreaterThan(0);
expect(await countPlusTierMembers(2)).toBeGreaterThan(0);
expect(await countPlusTierMembers(3)).toBeGreaterThan(0);
});
test("gives membership if failed voting and is on the leaderboard", async () => {
vi.setSystemTime(new Date("2023-11-29T00:00:00.000Z"));
await createUsers(1);
await PlusVoteFactory.create({
authorId: users.id(1),
votedId: users.id(1),
score: -1,
});
await createLeaderboard([users.id(1)]);
await adminAction({ _action: "REFRESH" }, { user: "admin" });
expect(await countPlusTierMembers(1)).toBe(1);
});
test("gives membership if failed voting and is on the leaderboard and season ended last month", async () => {
vi.setSystemTime(new Date("2023-12-29T00:00:00.000Z"));
await createUsers(1);
await PlusVoteFactory.create({
authorId: users.id(1),
votedId: users.id(1),
score: -1,
});
await createLeaderboard([users.id(1)]);
await adminAction({ _action: "REFRESH" }, { user: "admin" });
expect(await countPlusTierMembers(1)).toBe(1);
});
test("members who fails voting drops one tier", async () => {
vi.setSystemTime(new Date("2024-02-15T00:00:00.000Z"));
await createUsers(1);
await PlusVoteFactory.create({
authorId: users.id(1),
votedId: users.id(1),
score: 1,
month: 11,
year: 2023,
});
await PlusVoteFactory.create({
authorId: users.id(1),
votedId: users.id(1),
score: -1,
month: 2,
year: 2024,
});
await adminAction({ _action: "REFRESH" }, { user: "admin" });
expect(await countPlusTierMembers(2)).toBe(1);
});
});
const migrateUserAction = () =>
adminAction(
{
_action: "MIGRATE",
oldUser: users.id(1),
newUser: users.id(2),
},
{ user: "admin" },
);
describe("Account migration", () => {
beforeEach(async () => {
await createUsers(2);
});
test("migrates a blank account", async () => {
expect(await UserRepository.findProfileByIdentifier("0")).toBeDefined();
expect(await UserRepository.findProfileByIdentifier("1")).toBeDefined();
await migrateUserAction();
const oldUser = await UserRepository.findProfileByIdentifier("0"); // these are discord ids
const newUser = await UserRepository.findProfileByIdentifier("1");
expect(oldUser).toBeNull();
expect(newUser?.id).toBe(users.id(1)); // took the old user's id
});
test("two accounts with teams results in an error", async () => {
await TeamFactory.create({ memberUserIds: [users.id(1)] });
await TeamFactory.create({ memberUserIds: [users.id(2)] });
const response = await migrateUserAction();
assertResponseErrored(response, "both old and new user are in teams");
});
const membershipOf = (userId: number) =>
db
.selectFrom("AllTeamMember")
.select(["userId"])
.where("userId", "=", userId)
.executeTakeFirst();
test("deletes past team membership status of the new user", async () => {
const team = await TeamFactory.create({ memberUserIds: [users.id(2)] });
await TeamRepository.deleteById(team.id);
const membershipBeforeMigration = await membershipOf(users.id(2));
expect(membershipBeforeMigration).toBeDefined();
await migrateUserAction();
const membershipAfterMigration = await membershipOf(users.id(2));
expect(membershipAfterMigration).toBeUndefined();
});
test("handles old user member of the same team as new user (old user has left the team, new user current)", async () => {
const team = await TeamFactory.create({
memberUserIds: [users.id(2), users.id(1)],
});
await TeamRepository.handleMemberLeaving({
teamId: team.id,
userId: users.id(1),
});
for (const id of [users.id(1), users.id(2)]) {
const membership = await membershipOf(id);
expect(membership).toBeDefined();
}
await migrateUserAction();
const membershipOldUser = await membershipOf(users.id(1));
const membershipNewUser = await membershipOf(users.id(2));
expect(membershipOldUser).toBeDefined();
expect(membershipNewUser).toBeUndefined();
});
test("deletes weapon pool from the new user when migrating (takes weapon pool from the old user)", async () => {
await UserFactory.grant(users.id(1), {
weapons: [{ weaponSplId: 1, isFavorite: 1 }],
});
await UserFactory.grant(users.id(2), { weapons: [{ weaponSplId: 10 }] });
await migrateUserAction();
const oldUser = await UserRepository.findProfileByIdentifier("0");
const newUser = await UserRepository.findProfileByIdentifier("1");
expect(oldUser).toBeNull();
expect(newUser?.weapons).toEqual([
{ weaponSplId: 1, isFavorite: 1, isTenStar: 0 },
]);
});
test("deletes builds from the new user when migrating", async () => {
await BuildFactory.create({ ownerId: users.id(2) });
const buildsBefore = await BuildRepository.findAllByUserId(users.id(2));
expect(buildsBefore.length).toBe(1);
await migrateUserAction();
const oldUser = await UserRepository.findProfileByIdentifier("0");
expect(oldUser).toBeNull();
for (const id of [users.id(1), users.id(2)]) {
const buildsAfter = await BuildRepository.findAllByUserId(id);
expect(buildsAfter.length).toBe(0);
}
});
});

View File

@@ -0,0 +1,331 @@
import * as React from "react";
import type { MetaFunction } from "react-router";
import { Form, useFetcher, useLoaderData } from "react-router";
import { Catcher } from "~/components/Catcher";
import { SendouButton } from "~/components/elements/Button";
import {
SendouTab,
SendouTabList,
SendouTabPanel,
SendouTabs,
} from "~/components/elements/Tabs";
import { UserSearch } from "~/components/elements/UserSearch";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { UserLink } from "~/components/UserLink";
import { SendouForm } from "~/form/SendouForm";
import { useHasRole } from "~/modules/permissions/hooks";
import { useSearchParam } from "~/modules/search-params/hooks";
import { metaTags } from "~/utils/remix";
import { impersonateUrl, SEED_URL, STOP_IMPERSONATING_URL } from "~/utils/urls";
import { action } from "../actions/admin.server";
import {
banUserSchema,
forcePatronSchema,
friendCodeSearchSchema,
giveApiAccessSchema,
giveArtistSchema,
giveTournamentOrganizerSchema,
giveVideoAdderSchema,
linkPlayerSchema,
migrateUserSchema,
refreshPlusTiersSchema,
unbanUserSchema,
updateFriendCodeSchema,
} from "../admin-schemas";
import { adminSearchParams } from "../admin-search-params";
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "../core/dev-controls";
import { loader } from "../loaders/admin.server";
export { action, loader };
export const meta: MetaFunction = (args) => {
return metaTags({
title: "Admin Panel",
location: args.location,
});
};
export default function AdminPage() {
const isStaff = useHasRole("STAFF");
// is dev user or is someone impersonating another user (allow them to stop)
if (!isStaff && !DANGEROUS_CAN_ACCESS_DEV_CONTROLS) {
return (
<Main>
<Impersonate />
</Main>
);
}
return (
<Main halfWidth>
<SendouTabs>
<SendouTabList>
<SendouTab id="actions">Actions</SendouTab>
<SendouTab id="friend-code-look-up">Friend code look-up</SendouTab>
</SendouTabList>
<SendouTabPanel id="actions">
<AdminActions />
</SendouTabPanel>
<SendouTabPanel id="friend-code-look-up">
<FriendCodeLookUp />
</SendouTabPanel>
</SendouTabs>
</Main>
);
}
function FriendCodeLookUp() {
const data = useLoaderData<typeof loader>();
const [friendCode, setFriendCode] = useSearchParam(
adminSearchParams,
"friendCode",
);
return (
<div className="stack lg">
<SendouForm
schema={friendCodeSearchSchema}
defaultValues={{ friendCode: friendCode ?? "" }}
submitButtonText="Search"
onApply={({ friendCode }) => setFriendCode(friendCode)}
>
{({ FormField }) => <FormField name="friendCode" />}
</SendouForm>
<div className="stack lg">
{data.friendCodeSearchUsers?.map((user) => (
<UserLink key={user.id} user={user} size="sm" />
))}
</div>
</div>
);
}
function AdminActions() {
const isStaff = useHasRole("STAFF");
const isAdmin = useHasRole("ADMIN");
const isDev = useHasRole("DEV");
return (
<div className="stack lg">
{DANGEROUS_CAN_ACCESS_DEV_CONTROLS ? <Seed /> : null}
{DANGEROUS_CAN_ACCESS_DEV_CONTROLS || isAdmin || isDev ? (
<Impersonate />
) : null}
{isStaff ? <LinkPlayer /> : null}
{isStaff ? <GiveArtist /> : null}
{isStaff ? <GiveVideoAdder /> : null}
{isAdmin ? <GiveTournamentOrganizer /> : null}
{isAdmin ? <GiveApiAccess /> : null}
{isStaff ? <UpdateFriendCode /> : null}
{isStaff ? <MigrateUser /> : null}
{isAdmin ? <ForcePatron /> : null}
{isStaff ? <BanUser /> : null}
{isStaff ? <UnbanUser /> : null}
{isAdmin ? <RefreshPlusTiers /> : null}
</div>
);
}
function Impersonate() {
const [userId, setUserId] = React.useState<number>();
const { isImpersonating } = useLoaderData<typeof loader>();
return (
<Form
method="post"
action={impersonateUrl(userId ?? 0)}
className="stack md"
reloadDocument
>
<h2>Impersonate user</h2>
<UserSearch
label="User to log in as"
onChange={(newUser) => setUserId(newUser?.id)}
/>
<div className="stack horizontal md">
<SendouButton type="submit" isDisabled={!userId}>
Go
</SendouButton>
{isImpersonating ? (
<SendouButton type="submit" formAction={STOP_IMPERSONATING_URL}>
Stop impersonating
</SendouButton>
) : null}
</div>
</Form>
);
}
function MigrateUser() {
return (
<SendouForm
schema={migrateUserSchema}
title="Migrate user data"
submitButtonText="Migrate"
>
{({ FormField }) => (
<>
<FormField name="oldUser" />
<FormField name="newUser" />
</>
)}
</SendouForm>
);
}
function LinkPlayer() {
return (
<SendouForm
schema={linkPlayerSchema}
title="Link player"
submitButtonText="Link player"
>
{({ FormField }) => (
<>
<FormField name="user" />
<FormField name="playerId" />
</>
)}
</SendouForm>
);
}
function GiveArtist() {
return (
<SendouForm
schema={giveArtistSchema}
title="Add as artist"
submitButtonText="Add as artist"
>
{({ FormField }) => <FormField name="user" />}
</SendouForm>
);
}
function GiveVideoAdder() {
return (
<SendouForm
schema={giveVideoAdderSchema}
title="Give video adder"
submitButtonText="Add as video adder"
>
{({ FormField }) => <FormField name="user" />}
</SendouForm>
);
}
function GiveTournamentOrganizer() {
return (
<SendouForm
schema={giveTournamentOrganizerSchema}
title="Give tournament organizer"
submitButtonText="Add as tournament organizer"
>
{({ FormField }) => <FormField name="user" />}
</SendouForm>
);
}
function GiveApiAccess() {
return (
<SendouForm
schema={giveApiAccessSchema}
title="Give API access"
submitButtonText="Grant API access"
>
{({ FormField }) => <FormField name="user" />}
</SendouForm>
);
}
function UpdateFriendCode() {
return (
<SendouForm schema={updateFriendCodeSchema} title="Update friend code">
{({ FormField }) => (
<>
<FormField name="user" />
<FormField name="friendCode" />
</>
)}
</SendouForm>
);
}
function ForcePatron() {
return (
<SendouForm
schema={forcePatronSchema}
title="Force patron"
submitButtonText="Save"
>
{({ FormField }) => (
<>
<FormField name="user" />
<FormField name="patronTier" />
<FormField name="patronExpiresAt" />
</>
)}
</SendouForm>
);
}
function BanUser() {
return (
<SendouForm
schema={banUserSchema}
title={<span className="text-warning">Ban user</span>}
submitButtonText="Save"
>
{({ FormField }) => (
<>
<FormField name="user" />
<FormField name="expiresAt" />
<FormField name="reason" />
</>
)}
</SendouForm>
);
}
function UnbanUser() {
return (
<SendouForm
schema={unbanUserSchema}
title={<span className="text-warning">Unban user</span>}
submitButtonText="Save"
>
{({ FormField }) => <FormField name="user" />}
</SendouForm>
);
}
function RefreshPlusTiers() {
return (
<SendouForm
schema={refreshPlusTiersSchema}
title="Refresh Plus Tiers"
submitButtonText="Refresh"
>
{null}
</SendouForm>
);
}
function Seed() {
const fetcher = useFetcher();
return (
<fetcher.Form
className="stack md items-start"
method="post"
action={SEED_URL}
>
<SubmitButton state={fetcher.state}>Seed</SubmitButton>
</fetcher.Form>
);
}
export const ErrorBoundary = Catcher;

View File

@@ -0,0 +1,157 @@
import { Divider } from "~/components/Divider";
import { Image } from "~/components/Image";
import { Main } from "~/components/Main";
import { Config } from "~/config";
import { list } from "~/features/mmr/core/Seasons";
import { navIconUrl, tierImageUrl } from "~/utils/urls";
// this page is not accessible in production, just used to generate images for social media
export default function GenerateImages() {
return (
<Main className="stack lg">
<h1>Generate Images</h1>
<Divider />
<SendouQSeason />
</Main>
);
}
const season = list.at(-1)!;
function SendouQSeason() {
return (
<div className="text-center stack lg">
<div>
<h2 style={{ fontSize: "2rem" }}>Season {season.nth}</h2>
<div className="font-semi-bold text-md italic">
{season.starts.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})}{" "}
-{" "}
{season.ends.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})}
</div>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: "1rem",
placeItems: "center",
}}
>
<InfoSquare title="Gain Plus Server access">
<Image width={124} height={124} path={navIconUrl("plus")} alt="" />
</InfoSquare>
<InfoSquare title="Compete in Season Finale">
<Image width={124} height={124} path={navIconUrl("sendouq")} alt="" />
</InfoSquare>
<InfoSquare title="Rank Up to Leviathan">
<Image
width={124}
height={124}
path={tierImageUrl("LEVIATHAN")}
alt=""
/>
</InfoSquare>
</div>
<Divider smallText className="font-bold text-xs">
EVENTS
</Divider>
<EventSchedule
name="In The Zone 44"
imgId="itz"
date={new Date("2025-07-05T12:00:00Z")}
/>
<EventSchedule
name="In The Zone 45"
imgId="itz"
date={new Date("2025-08-16T12:00:00Z")}
/>
<EventSchedule
name="Season Finale"
imgId="sf"
date={new Date("2025-08-30T12:00:00Z")}
/>
<div className="text-xs font-semi-bold mt-6">
Check all ranked tournaments on the calendar!
</div>
</div>
);
}
function InfoSquare({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div className="font-semi-bold">
{title}
<div
className="bg-lighter"
style={{
width: "12rem",
height: "12rem",
borderRadius: "var(--radius-box)",
marginTop: "1rem",
display: "grid",
placeItems: "center",
borderColor: "var(--color-border)",
borderWidth: "2px",
borderStyle: "solid",
}}
>
{children}
</div>
</div>
);
}
function EventSchedule({
name,
date,
imgId,
}: {
name: string;
date: Date;
imgId: string;
}) {
return (
<div className="stack md horizontal items-center mx-auto">
<img
alt=""
src={`${Config.staticAssetsUrl}/img/tournament-logos/${imgId}.avif`}
style={{
width: "4rem",
height: "4rem",
borderRadius: "100%",
}}
/>
<div className="stack items-start">
<div className="font-semi-bold text-lg">{name}</div>
<div className="text-sm">
{date.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})}
</div>
</div>
</div>
);
}