mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 15:48:23 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
458
apps/web-react/app/features/admin/AdminRepository.server.test.ts
Normal file
458
apps/web-react/app/features/admin/AdminRepository.server.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
507
apps/web-react/app/features/admin/AdminRepository.server.ts
Normal file
507
apps/web-react/app/features/admin/AdminRepository.server.ts
Normal 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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
198
apps/web-react/app/features/admin/actions/admin.server.ts
Normal file
198
apps/web-react/app/features/admin/actions/admin.server.ts
Normal 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);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
22
apps/web-react/app/features/admin/admin-constants.ts
Normal file
22
apps/web-react/app/features/admin/admin-constants.ts
Normal 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",
|
||||
];
|
||||
136
apps/web-react/app/features/admin/admin-schemas.ts
Normal file
136
apps/web-react/app/features/admin/admin-schemas.ts
Normal 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,
|
||||
]);
|
||||
@@ -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"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
10
apps/web-react/app/features/admin/admin-search-params.ts
Normal file
10
apps/web-react/app/features/admin/admin-search-params.ts
Normal 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,
|
||||
}),
|
||||
});
|
||||
5
apps/web-react/app/features/admin/core/dev-controls.ts
Normal file
5
apps/web-react/app/features/admin/core/dev-controls.ts
Normal 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;
|
||||
@@ -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),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
56
apps/web-react/app/features/admin/core/plus-tier.server.ts
Normal file
56
apps/web-react/app/features/admin/core/plus-tier.server.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
33
apps/web-react/app/features/admin/loaders/admin.server.ts
Normal file
33
apps/web-react/app/features/admin/loaders/admin.server.ts
Normal 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))
|
||||
: [],
|
||||
};
|
||||
};
|
||||
@@ -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(),
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
98
apps/web-react/app/features/admin/routes/admin.streams.tsx
Normal file
98
apps/web-react/app/features/admin/routes/admin.streams.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
377
apps/web-react/app/features/admin/routes/admin.test.ts
Normal file
377
apps/web-react/app/features/admin/routes/admin.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
331
apps/web-react/app/features/admin/routes/admin.tsx
Normal file
331
apps/web-react/app/features/admin/routes/admin.tsx
Normal 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;
|
||||
157
apps/web-react/app/features/admin/routes/generate-images.tsx
Normal file
157
apps/web-react/app/features/admin/routes/generate-images.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user