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>
);
}

View File

@@ -0,0 +1,30 @@
import { refreshApiTokensCache } from "~/features/api-public/api-public-utils.server";
import { refreshBannedCache } from "~/features/ban/core/banned.server";
import { clearParticipationInfoMap } from "~/features/front-page/core/ShowcaseTournaments.server";
import { refreshSendouQInstance } from "~/features/sendouq/core/SendouQ.server";
import { clearSeasonSkillsCache } from "~/features/sendouq/q-utils.server";
import {
clearAllTournamentDataCache,
refreshRunningTournaments,
} from "~/features/tournament-bracket/core/Tournament.server";
import { refreshTentativeTiersCache } from "~/features/tournament-organization/core/tentativeTiers.server";
import { clearUserCardCache } from "~/features/user-card/UserCardRepository.server";
import { cache } from "~/utils/cache.server";
/**
* Clears and refreshes every in-process cache, so the server serves what is in
* the database right now. E2E workers call this (via the route) after writing
* test data straight into the database file.
*/
export async function refreshCaches() {
clearAllTournamentDataCache();
clearParticipationInfoMap();
clearSeasonSkillsCache();
clearUserCardCache();
cache.clear();
await refreshBannedCache();
await refreshSendouQInstance();
await refreshTentativeTiersCache();
await refreshApiTokensCache();
await refreshRunningTournaments();
}

View File

@@ -0,0 +1,13 @@
import type { ActionFunction } from "react-router";
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls";
import * as Seasons from "~/features/mmr/core/Seasons";
export const action: ActionFunction = async () => {
if (!DANGEROUS_CAN_ACCESS_DEV_CONTROLS) {
throw new Response(null, { status: 400 });
}
Seasons.DANGEROUS_setSeasonEndedOverride(true);
return Response.json(null);
};

View File

@@ -0,0 +1,23 @@
import type { ActionFunction, LoaderFunctionArgs } from "react-router";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { updatePatreonData } from "~/modules/patreon";
import {
canAccessLohiEndpoint,
unauthorizedIfFalsy,
} from "~/utils/remix.server";
export const action: ActionFunction = async ({ request }) => {
if (!canAccessLohiEndpoint(request)) {
throw new Response("Not authorized", { status: 403 });
}
await updatePatreonData();
return null;
};
export const loader = ({ request }: LoaderFunctionArgs) => {
unauthorizedIfFalsy(canAccessLohiEndpoint(request));
return UserRepository.findAllPatrons();
};

View File

@@ -0,0 +1,26 @@
import type { ActionFunctionArgs } from "react-router";
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls";
import * as Seasons from "~/features/mmr/core/Seasons";
import { refreshCaches } from "../core/refresh-caches.server";
export const action = async ({ request }: ActionFunctionArgs) => {
if (!DANGEROUS_CAN_ACCESS_DEV_CONTROLS) {
throw new Response(null, { status: 400 });
}
// only the start of an e2e test asks for this. mid-test cache flushes must
// leave what the running test set up alone
if (await wantsDevOverridesReset(request)) {
Seasons.DANGEROUS_setSeasonEndedOverride(false);
}
await refreshCaches();
return Response.json(null);
};
async function wantsDevOverridesReset(request: Request) {
if (!request.headers.get("content-type")) return false;
return (await request.formData()).get("resetDevOverrides") === "true";
}

View File

@@ -0,0 +1,28 @@
import type { ActionFunctionArgs } from "react-router";
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls";
export const action = async ({ request }: ActionFunctionArgs) => {
if (!DANGEROUS_CAN_ACCESS_DEV_CONTROLS) {
throw new Response(null, { status: 400 });
}
const routineName = (await request.formData()).get("name");
const { everyHourAt00, everyHourAt30, daily, everyTwoMinutes } = await import(
"~/routines/list.server"
);
const routine = [
...everyHourAt00,
...everyHourAt30,
...daily,
...everyTwoMinutes,
].find((routine) => routine.name === routineName);
if (!routine) {
throw new Response(`Unknown routine: ${routineName}`, { status: 400 });
}
await routine.run();
return Response.json(null);
};

View File

@@ -0,0 +1,16 @@
import type { ActionFunction } from "react-router";
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls";
import { refreshCaches } from "../core/refresh-caches.server";
export const action: ActionFunction = async () => {
if (!DANGEROUS_CAN_ACCESS_DEV_CONTROLS) {
throw new Response(null, { status: 400 });
}
const { seed } = await import("~/db/seed");
await seed();
await refreshCaches();
return Response.json(null);
};

View File

@@ -0,0 +1,14 @@
import type { ActionFunction } from "react-router";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { canAccessLohiEndpoint } from "~/utils/remix.server";
export const action: ActionFunction = async ({ request }) => {
if (!canAccessLohiEndpoint(request)) {
throw new Response(null, { status: 403 });
}
// input untyped but we trust Lohi to give us correctly shaped request here
await UserRepository.updateMany(await request.json());
return null;
};

View File

@@ -0,0 +1,52 @@
import type { ActionFunction, ActionFunctionArgs } from "react-router";
/**
* Wraps an existing action function for API use.
* Converts redirect-based success/error responses to JSON responses.
*
* The existing actions use:
* - `successToast(message)` which returns `redirect("?__success=message")`
* - `errorToastIfFalsy/errorToastIfErr` which throw `redirect("?__error=message")`
* - `{ fieldErrors }` returns for form validation failures
*/
export async function wrapActionForApi(
actionFn: ActionFunction,
args: ActionFunctionArgs,
): Promise<Response> {
try {
const response = await actionFn(args);
if (response instanceof Response && response.status === 302) {
return new Response(null, { status: 200 });
}
if (response && typeof response === "object" && "fieldErrors" in response) {
return new Response(
JSON.stringify({
error: "Validation failed",
fieldErrors: response.fieldErrors,
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}
return response as Response;
} catch (e) {
if (e instanceof Response && e.status === 302) {
const location = e.headers.get("Location") ?? "";
const search = location.slice(location.indexOf("?") + 1);
const errorMsg = new URLSearchParams(search).get("__error");
if (errorMsg !== null) {
return new Response(JSON.stringify({ error: errorMsg }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
}
throw e;
}
}

View File

@@ -0,0 +1,64 @@
import { userAsyncLocalStorage } from "~/features/auth/core/user-context.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { getTokenInfo } from "./api-public-utils.server";
const USER_IDS_PATTERN = /^\/api\/user\/[^/]+\/ids$/;
type MiddlewareArgs = {
request: Request;
context: unknown;
};
type MiddlewareFn = (
args: MiddlewareArgs,
next: () => Promise<Response>,
) => Promise<Response>;
function extractToken(req: Request): string | null {
const authHeader = req.headers.get("Authorization");
if (!authHeader) return null;
return authHeader.replace("Bearer ", "");
}
function isPublicRoute(request: Request): boolean {
if (request.method !== "GET") return false;
const url = new URL(request.url);
return USER_IDS_PATTERN.test(url.pathname);
}
export const apiAuthMiddleware: MiddlewareFn = async ({ request }, next) => {
if (request.method === "OPTIONS") {
return next();
}
if (isPublicRoute(request)) {
return next();
}
const token = extractToken(request);
if (!token) {
return Response.json(
{ error: "Missing Authorization header" },
{ status: 401 },
);
}
const tokenInfo = getTokenInfo(token);
if (!tokenInfo) {
return Response.json({ error: "Invalid token" }, { status: 401 });
}
if (request.method === "POST" && tokenInfo.type !== "write") {
return Response.json({ error: "Write token required" }, { status: 403 });
}
if (request.method === "POST") {
const user = await UserRepository.findLeanById(tokenInfo.userId);
if (!user) {
return Response.json({ error: "User not found" }, { status: 401 });
}
return userAsyncLocalStorage.run({ user }, () => next());
}
return next();
};

View File

@@ -0,0 +1,38 @@
type MiddlewareArgs = {
request: Request;
context: unknown;
};
type MiddlewareFn = (
args: MiddlewareArgs,
next: () => Promise<Response>,
) => Promise<Response>;
const CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, HEAD, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400",
};
export const apiCorsMiddleware: MiddlewareFn = async ({ request }, next) => {
if (request.method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: CORS_HEADERS,
});
}
const response = await next();
const newHeaders = new Headers(response.headers);
for (const [key, value] of Object.entries(CORS_HEADERS)) {
newHeaders.set(key, value);
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: newHeaders,
});
};

View File

@@ -0,0 +1,26 @@
import * as ApiRepository from "~/features/api/ApiRepository.server";
import type { ApiTokenType } from "~/features/api/api-types";
type CachedToken = { type: ApiTokenType; userId: number };
async function loadApiTokensCache() {
const dbTokens = await ApiRepository.findAllApiTokens();
const tokenMap = new Map<string, CachedToken>();
for (const { token, type, userId } of dbTokens) {
tokenMap.set(token, { type, userId });
}
return tokenMap;
}
let apiTokens: Map<string, CachedToken> = await loadApiTokensCache();
export function getTokenInfo(token: string): CachedToken | undefined {
return apiTokens.get(token);
}
export async function refreshApiTokensCache() {
apiTokens = await loadApiTokensCache();
}

View File

@@ -0,0 +1,8 @@
import { apiAuthMiddleware } from "../api-auth-middleware.server";
import { apiCorsMiddleware } from "../api-cors-middleware.server";
import type { Route } from "./+types/api.layout";
export const middleware: Route.MiddlewareFunction[] = [
apiCorsMiddleware,
apiAuthMiddleware,
];

View File

@@ -0,0 +1,34 @@
import { describe, expect, test } from "vitest";
import * as CalendarEventFactory from "~/db/seed/factories/CalendarEventFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { wrappedLoader } from "~/utils/Test";
import type { GetCalendarWeekResponse } from "../schema";
import { loader } from "./calendar.$year.$week";
const weekLoader = wrappedLoader<Response>({ loader });
const fetchWeek = async (year: number, week: number) => {
const response = await weekLoader({
params: { year: String(year), week: String(week) },
});
return (await response.json()) as GetCalendarWeekResponse;
};
describe("GET /api/calendar/:year/:week", () => {
test("an event starting exactly at the week boundary is returned for exactly one week", async () => {
const user = await UserFactory.createRegular();
// Monday 2025-01-13 00:00 UTC, i.e. Sunday 7 PM EST — the boundary
// between ISO weeks 2 and 3 of 2025
await CalendarEventFactory.create({
authorId: user.id,
startTimes: [dateToDatabaseTimestamp(new Date("2025-01-13T00:00:00Z"))],
});
const weekTwoEvents = await fetchWeek(2025, 2);
const weekThreeEvents = await fetchWeek(2025, 3);
expect(weekTwoEvents.length + weekThreeEvents.length).toBe(1);
});
});

View File

@@ -0,0 +1,62 @@
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import { db } from "~/db/sql";
import {
databaseTimestampToDate,
dateToDatabaseTimestamp,
weekNumberToDateRange,
} from "~/utils/dates";
import { parseParams } from "~/utils/remix.server";
import type { GetCalendarWeekResponse } from "../schema";
const paramsSchema = z.object({
year: z.coerce.number().int().min(2020).max(2100),
week: z.coerce.number().int().min(1).max(53),
});
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { week, year } = parseParams({ params, schema: paramsSchema });
const events = await fetchEventsOfWeek({
week,
year,
});
const result: GetCalendarWeekResponse = events.map((event) => ({
name: event.name,
startTime: databaseTimestampToDate(event.startsAt).toISOString(),
tournamentId: event.tournamentId,
tournamentUrl: event.tournamentId
? `https://sendou.ink/to/${event.tournamentId}/brackets`
: null,
}));
return Response.json(result);
};
function fetchEventsOfWeek(args: { week: number; year: number }) {
const { startTime, endTime } = weekNumberToDateRange(args);
return db
.selectFrom("CalendarEvent")
.innerJoin(
"CalendarEventDate",
"CalendarEvent.id",
"CalendarEventDate.eventId",
)
.leftJoin("Tournament", "CalendarEvent.tournamentId", "Tournament.id")
.select([
"Tournament.id as tournamentId",
"CalendarEvent.name",
"CalendarEventDate.startsAt",
])
.where(
"CalendarEventDate.startsAt",
">=",
dateToDatabaseTimestamp(startTime),
)
.where("CalendarEventDate.startsAt", "<", dateToDatabaseTimestamp(endTime))
.where("CalendarEvent.hidden", "=", 0)
.orderBy("CalendarEventDate.startsAt", "asc")
.execute();
}

View File

@@ -0,0 +1,73 @@
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import { db } from "~/db/sql";
import {
concatUserSubmittedImagePrefix,
jsonArrayFrom,
} from "~/utils/kysely.server";
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import type { GetTournamentOrganizationResponse } from "../schema";
const paramsSchema = z.object({
id,
});
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { id } = parseParams({ params, schema: paramsSchema });
const organization = notFoundIfNullish(
await db
.selectFrom("TournamentOrganization")
.leftJoin(
"UserSubmittedImage",
"UserSubmittedImage.id",
"TournamentOrganization.avatarImgId",
)
.select((eb) => [
"TournamentOrganization.id",
"TournamentOrganization.name",
"TournamentOrganization.description",
"TournamentOrganization.socials",
"TournamentOrganization.slug",
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"logoUrl",
),
jsonArrayFrom(
eb
.selectFrom("TournamentOrganizationMember")
.innerJoin("User", "User.id", "TournamentOrganizationMember.userId")
.select([
"User.id",
"User.discordId",
"User.username",
"User.pronouns",
"TournamentOrganizationMember.role",
"TournamentOrganizationMember.roleDisplayName",
])
.where("TournamentOrganizationMember.organizationId", "=", id),
).as("members"),
])
.where("TournamentOrganization.id", "=", id)
.executeTakeFirst(),
);
const result: GetTournamentOrganizationResponse = {
id: organization.id,
name: organization.name,
description: organization.description,
logoUrl: organization.logoUrl,
socialLinkUrls: organization.socials ?? [],
url: `https://sendou.ink/org/${organization.slug}`,
members: organization.members.map((member) => ({
userId: member.id,
discordId: member.discordId,
name: member.username,
pronouns: member.pronouns,
role: member.role,
roleDisplayName: member.roleDisplayName,
})),
};
return Response.json(result);
};

View File

@@ -0,0 +1,25 @@
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
import { parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import type { GetUsersActiveSendouqMatchResponse } from "../schema";
const paramsSchema = z.object({
userId: id,
});
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { userId } = parseParams({
params,
schema: paramsSchema,
});
const current = SendouQ.findOwnGroup(userId);
const result: GetUsersActiveSendouqMatchResponse = {
matchId: current?.matchId ?? null,
};
return Response.json(result);
};

View File

@@ -0,0 +1,82 @@
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
import { getFixedTForLanguage } from "~/modules/i18n/i18next.server";
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import type { GetSendouqMatchResponse, MapListMap } from "../schema";
const paramsSchema = z.object({
matchId: id,
});
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { matchId } = parseParams({
params,
schema: paramsSchema,
});
const match = notFoundIfNullish(await SQMatchRepository.findById(matchId));
const t = await getFixedTForLanguage("en", ["game-misc"]);
const userIdToRank = (userId: number) => {
const memento = match.memento;
if (!memento) return null;
const userMemento = memento.users[userId];
return userMemento.skill && userMemento.skill !== "CALCULATING"
? userMemento.skill.tier
: null;
};
const score = match.mapList.reduce(
(acc, cur) => {
if (!cur.winnerGroupId) return acc;
if (cur.winnerGroupId === match.groupAlpha.id) {
return [acc[0] + 1, acc[1]];
}
return [acc[0], acc[1] + 1];
},
[0, 0],
);
const result: GetSendouqMatchResponse = {
mapList: match.mapList.map((map) => ({
map: {
mode: map.mode,
stage: {
id: map.stageId,
name: t(`game-misc:STAGE_${map.stageId}`),
},
},
winnerTeamId: map.winnerGroupId,
source: Number.isNaN(Number(map.source))
? (map.source as MapListMap["source"])
: Number(map.source),
participatedUserIds: null,
ko: null,
})),
teamAlpha: {
id: match.groupAlpha.id,
score: score[0],
players: match.groupAlpha.members.map((member) => ({
userId: member.id,
rank: userIdToRank(member.id),
})),
},
teamBravo: {
id: match.groupBravo.id,
score: score[1],
players: match.groupBravo.members.map((member) => ({
userId: member.id,
rank: userIdToRank(member.id),
})),
},
};
return Response.json(result);
};

View File

@@ -0,0 +1,44 @@
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import { db } from "~/db/sql";
import { concatUserSubmittedImagePrefix } from "~/utils/kysely.server";
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import type { GetTeamResponse } from "../schema";
const paramsSchema = z.object({
id,
});
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { id: teamId } = parseParams({ params, schema: paramsSchema });
const team = notFoundIfNullish(
await db
.selectFrom("Team")
.leftJoin(
"UserSubmittedImage",
"UserSubmittedImage.id",
"Team.avatarImgId",
)
.select((eb) => [
"Team.id",
"Team.name",
"Team.customUrl",
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"logoUrl",
),
])
.where("Team.id", "=", teamId)
.executeTakeFirst(),
);
const result: GetTeamResponse = {
id: team.id,
name: team.name,
logoUrl: team.logoUrl,
teamPageUrl: `https://sendou.ink/t/${team.customUrl}`,
};
return Response.json(result);
};

View File

@@ -0,0 +1,169 @@
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import { db } from "~/db/sql";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import { tournamentSharedCached } from "~/features/tournament-bracket/core/Tournament.server";
import { resolveMapList } from "~/features/tournament-match/core/mapList.server";
import { getFixedTForLanguage } from "~/modules/i18n/i18next.server";
import { parseMaplistSource } from "~/modules/tournament-map-list-generator/source";
import { jsonArrayFrom } from "~/utils/kysely.server";
import { logger } from "~/utils/logger";
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import type { GetTournamentMatchResponse } from "../schema";
const paramsSchema = z.object({
id,
});
export const loader = async ({ params }: LoaderFunctionArgs) => {
const t = await getFixedTForLanguage("en", ["game-misc"]);
const { id } = parseParams({
params,
schema: paramsSchema,
});
const match = notFoundIfNullish(
await db
.selectFrom("TournamentMatch")
.innerJoin(
"TournamentStage",
"TournamentStage.id",
"TournamentMatch.stageId",
)
.innerJoin("Tournament", "Tournament.id", "TournamentStage.tournamentId")
.innerJoin(
"TournamentRound",
"TournamentRound.id",
"TournamentMatch.roundId",
)
.select(({ eb }) => [
"TournamentStage.tournamentId",
"TournamentMatch.id",
"TournamentMatch.opponentOne",
"TournamentMatch.opponentTwo",
"TournamentMatch.winnerSide",
"Tournament.mapPickingStyle",
"TournamentRound.maps",
jsonArrayFrom(
eb
.selectFrom("TournamentMatchGameResult")
.select(({ eb: innerEb }) => [
"TournamentMatchGameResult.stageId",
"TournamentMatchGameResult.mode",
"TournamentMatchGameResult.winnerTeamId",
"TournamentMatchGameResult.source",
"TournamentMatchGameResult.ko",
jsonArrayFrom(
innerEb
.selectFrom("TournamentMatchGameResultParticipant")
.select("TournamentMatchGameResultParticipant.userId")
.whereRef(
"TournamentMatchGameResultParticipant.matchGameResultId",
"=",
"TournamentMatchGameResult.id",
),
).as("participants"),
])
.where("TournamentMatchGameResult.matchId", "=", id)
.orderBy("TournamentMatchGameResult.number", "asc"),
).as("playedMapList"),
])
.where("TournamentMatch.id", "=", id)
.executeTakeFirst(),
);
const tournament = await tournamentSharedCached(match.tournamentId);
const mapList = async (): Promise<GetTournamentMatchResponse["mapList"]> => {
const { opponentOne, opponentTwo } = match;
if (!opponentOne?.id || !opponentTwo?.id) {
return null;
}
if (match.winnerSide) {
return match.playedMapList.map((playedMap) => ({
map: {
mode: playedMap.mode,
stage: {
id: playedMap.stageId,
name: t(`game-misc:STAGE_${playedMap.stageId}`),
},
},
participatedUserIds: playedMap.participants.map((p) => p.userId),
winnerTeamId: playedMap.winnerTeamId,
source: parseMaplistSource(playedMap.source),
ko: playedMap.ko !== null ? Boolean(playedMap.ko) : null,
}));
}
const pickBanEvents = match.maps?.pickBan
? await TournamentRepository.findPickBanEventsByMatchId(match.id)
: [];
const mapPools = await TournamentTeamRepository.findMapPoolsByTeamIds([
opponentOne.id,
opponentTwo.id,
]);
return resolveMapList({
tournamentId: match.tournamentId,
matchId: id,
teams: [opponentOne.id, opponentTwo.id],
mapPoolByTeamId: (teamId) => mapPools.get(teamId) ?? [],
mapPickingStyle: match.mapPickingStyle,
maps: match.maps,
tieBreakerMapPool: tournament.ctx.tieBreakerMapPool,
pickBanEvents,
recentlyPlayedMaps:
match.mapPickingStyle !== "TO"
? await TournamentTeamRepository.findRecentlyPlayedMapsByIds({
teamIds: [opponentOne.id, opponentTwo.id],
excludeMatchId: id,
}).catch((error) => {
logger.error("Failed to fetch recently played maps", error);
return [];
})
: undefined,
}).map((mapListMap) => {
return {
map: {
mode: mapListMap.mode,
stage: {
id: mapListMap.stageId,
name: t(`game-misc:STAGE_${mapListMap.stageId}`),
},
},
participatedUserIds: null,
winnerTeamId: null,
source: mapListMap.source,
ko: null,
};
});
};
const { bracketName, roundNameWithoutMatchIdentifier } =
tournament.matchContextNamesById(id);
const result: GetTournamentMatchResponse = {
teamOne: match.opponentOne?.id
? {
id: match.opponentOne.id,
score: match.opponentOne.score ?? 0,
}
: null,
teamTwo: match.opponentTwo?.id
? {
id: match.opponentTwo.id,
score: match.opponentTwo.score ?? 0,
}
: null,
url: `https://sendou.ink/to/${match.tournamentId}/matches/${id}`,
mapList: await mapList(),
bracketName: bracketName ?? null,
roundName: roundNameWithoutMatchIdentifier ?? null,
};
return Response.json(result);
};

View File

@@ -0,0 +1,44 @@
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server";
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import type { GetTournamentBracketStandingsResponse } from "../schema";
const paramsSchema = z.object({
id,
bidx: z.coerce.number().int(),
});
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { id, bidx } = parseParams({ params, schema: paramsSchema });
const tournament = await tournamentFromDB(id);
const bracket = notFoundIfNullish(tournament.bracketByIdx(bidx));
if (bracket.preview) throw new Response(null, { status: 404 });
const result: GetTournamentBracketStandingsResponse = {
finished: bracket.standingsAreFinal,
standings: bracket.liveStandings.map((standing) => ({
tournamentTeamId: standing.team.id,
placement: standing.placement,
groupId: standing.groupId,
stats: standing.stats
? {
setWins: standing.stats.setWins,
setLosses: standing.stats.setLosses,
mapWins: standing.stats.mapWins,
mapLosses: standing.stats.mapLosses,
koCount: standing.stats.koCount,
winsAgainstTied: standing.stats.winsAgainstTied,
lossesAgainstTied: standing.stats.lossesAgainstTied,
opponentSetWinPercentage: standing.stats.opponentSetWinPercentage,
opponentMapWinPercentage: standing.stats.opponentMapWinPercentage,
}
: undefined,
})),
};
return Response.json(result);
};

View File

@@ -0,0 +1,61 @@
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import type { Bracket } from "~/features/tournament-bracket/core/Bracket";
import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server";
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import type { GetTournamentBracketResponse } from "../schema";
const paramsSchema = z.object({
id,
bidx: z.coerce.number().int(),
});
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { id, bidx } = parseParams({ params, schema: paramsSchema });
const tournament = await tournamentFromDB(id);
const bracket = notFoundIfNullish(tournament.bracketByIdx(bidx));
const result: GetTournamentBracketResponse = {
data: bracket.data,
teams: teams(bracket),
meta: {
teamsPerGroup:
bracket.type === "round_robin"
? (bracket.settings?.teamsPerGroup ??
tournament.ctx.settings.teamsPerGroup)
: undefined,
groupCount:
bracket.type === "swiss"
? (bracket.settings?.groupCount ??
tournament.ctx.settings.swiss?.groupCount)
: undefined,
roundCount:
bracket.type === "swiss"
? (bracket.settings?.roundCount ??
tournament.ctx.settings.swiss?.roundCount)
: undefined,
},
};
return Response.json(result);
};
function teams(bracket: Bracket) {
const checkedIn = bracket.seeding ?? bracket.participantTournamentTeamIds;
const pending = bracket.teamsPendingCheckIn ?? [];
return checkedIn
.map((teamId) => ({
id: teamId,
checkedIn: true,
}))
.concat(
pending.map((teamId) => ({
id: teamId,
checkedIn: false,
})),
);
}

View File

@@ -0,0 +1,46 @@
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import { db } from "~/db/sql";
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import type { GetCastedTournamentMatchesResponse } from "../schema";
const paramsSchema = z.object({
id,
});
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { id } = parseParams({
params,
schema: paramsSchema,
});
const tournament = notFoundIfNullish(
await db
.selectFrom("Tournament")
.select(["Tournament.castedMatchesInfo"])
.where("Tournament.id", "=", id)
.executeTakeFirst(),
);
const result: GetCastedTournamentMatchesResponse = {
current:
tournament.castedMatchesInfo?.castedMatches.map((match) => ({
matchId: match.matchId,
channel: {
type: "TWITCH",
channelId: match.twitchAccount,
},
})) ?? [],
future:
tournament.castedMatchesInfo?.lockedMatches.map((lm) => ({
matchId: lm.matchId,
channel: {
type: "TWITCH" as const,
channelId: lm.twitchAccount,
},
})) ?? [],
};
return Response.json(result);
};

View File

@@ -0,0 +1,22 @@
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import * as TournamentMatchRepository from "~/features/tournament-match/TournamentMatchRepository.server";
import { parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import type { GetTournamentPlayersResponse } from "../schema";
const paramsSchema = z.object({
id,
});
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { id } = parseParams({
params,
schema: paramsSchema,
});
const participants: GetTournamentPlayersResponse =
await TournamentMatchRepository.findUserParticipationByTournamentId(id);
return Response.json(participants);
};

View File

@@ -0,0 +1,40 @@
import type { ActionFunctionArgs } from "react-router";
import { z } from "zod";
import { action as adminAction } from "~/features/tournament-admin/actions/to.$id.admin.seeds.server";
import { parseBody, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import { wrapActionForApi } from "../api-action-wrapper.server";
const paramsSchema = z.object({
id,
});
const bodySchema = z.object({
tournamentTeamIds: z.array(id),
});
export const action = async (args: ActionFunctionArgs) => {
const { id: tournamentId } = parseParams({
params: args.params,
schema: paramsSchema,
});
const { tournamentTeamIds } = await parseBody({
request: args.request,
schema: bodySchema,
});
const internalRequest = new Request(args.request.url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
_action: "UPDATE_SEEDS",
seeds: tournamentTeamIds,
}),
});
return wrapActionForApi(adminAction, {
...args,
params: { id: String(tournamentId) },
request: internalRequest,
});
};

View File

@@ -0,0 +1,45 @@
import type { ActionFunctionArgs } from "react-router";
import { z } from "zod";
import { action as adminAction } from "~/features/tournament-admin/actions/to.$id.admin.seeds.server";
import { parseBody, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import { wrapActionForApi } from "../api-action-wrapper.server";
const paramsSchema = z.object({
id,
});
const bodySchema = z.object({
startingBrackets: z.array(
z.object({
tournamentTeamId: id,
startingBracketIdx: z.number().int().min(0),
}),
),
});
export const action = async (args: ActionFunctionArgs) => {
const { id: tournamentId } = parseParams({
params: args.params,
schema: paramsSchema,
});
const { startingBrackets } = await parseBody({
request: args.request,
schema: bodySchema,
});
const internalRequest = new Request(args.request.url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
_action: "UPDATE_STARTING_BRACKETS",
startingBrackets,
}),
});
return wrapActionForApi(adminAction, {
...args,
params: { id: String(tournamentId) },
request: internalRequest,
});
};

View File

@@ -0,0 +1,80 @@
import { sql } from "kysely";
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import { db } from "~/db/sql";
import { jsonArrayFrom } from "~/utils/kysely.server";
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import type { GetTournamentStreamsResponse } from "../schema";
const paramsSchema = z.object({
id,
});
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { id } = parseParams({
params,
schema: paramsSchema,
});
const tournament = notFoundIfNullish(
await db
.selectFrom("Tournament")
.select([
jsonArrayFrom(
db
.selectFrom("LiveStream")
.leftJoin(
"TournamentTeamMember",
"TournamentTeamMember.userId",
"LiveStream.userId",
)
.leftJoin(
"TournamentTeam",
"TournamentTeam.id",
"TournamentTeamMember.tournamentTeamId",
)
.select([
"LiveStream.userId",
"LiveStream.twitch",
"LiveStream.viewerCount",
])
.where("TournamentTeam.tournamentId", "=", id),
).as("playerStreams"),
jsonArrayFrom(
db
.selectFrom("LiveStream")
.select(["LiveStream.twitch", "LiveStream.viewerCount"])
.where(
sql<boolean>`"LiveStream"."twitch" IN (SELECT value FROM json_each("Tournament"."castTwitchAccounts"))`,
),
).as("castStreams"),
])
.where("Tournament.id", "=", id)
.executeTakeFirst(),
);
const playerStreams: GetTournamentStreamsResponse =
tournament?.playerStreams.map((stream) => ({
type: "PLAYER",
userId: stream.userId!,
platform: "TWITCH",
channelId: stream.twitch!,
viewerCount: stream.viewerCount!,
})) ?? [];
const castStreams: GetTournamentStreamsResponse =
tournament?.castStreams.map((stream) => ({
type: "CAST",
platform: "TWITCH",
channelId: stream.twitch!,
viewerCount: stream.viewerCount!,
})) ?? [];
const result: GetTournamentStreamsResponse = [
...playerStreams,
...castStreams,
].sort((a, b) => b.viewerCount - a.viewerCount);
return Response.json(result);
};

View File

@@ -0,0 +1,143 @@
import type { ActionFunctionArgs } from "react-router";
import { z } from "zod";
import { requireUser } from "~/features/auth/core/user.server";
import { userIsBanned } from "~/features/ban/core/banned.server";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
import { notify } from "~/features/notifications/core/notify.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import {
clearTournamentDataCache,
requireTournamentOrganizer,
tournamentFromDB,
} from "~/features/tournament-bracket/core/Tournament.server";
import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server";
import { syncPickupChatMetadata } from "~/features/tournament-lfg/tournament-lfg-utils.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import {
errorToastIfFalsy,
parseBody,
parseParams,
} from "~/utils/remix.server";
import { id } from "~/utils/zod";
import { wrapActionForApi } from "../api-action-wrapper.server";
const paramsSchema = z.object({
id,
teamId: id,
});
const bodySchema = z.object({
userId: id,
});
export const action = async (args: ActionFunctionArgs) => {
const { id: tournamentId, teamId } = parseParams({
params: args.params,
schema: paramsSchema,
});
const { userId } = await parseBody({
request: args.request,
schema: bodySchema,
});
return wrapActionForApi(async () => {
const user = requireUser();
const tournament = await tournamentFromDB(tournamentId);
requireTournamentOrganizer(tournament, user);
const team = tournament.teamById(teamId);
errorToastIfFalsy(team, "Invalid team id");
const previousTeam = tournament.teamMemberOfByUser({ id: userId });
errorToastIfFalsy(
!previousTeam?.id || previousTeam.id !== team.id,
"User is already in this team",
);
errorToastIfFalsy(
tournament.hasStarted || !previousTeam,
"User is already in a team",
);
errorToastIfFalsy(
!userIsBanned(userId),
"User trying to be added currently has an active ban from sendou.ink",
);
const addMemberUser = await UserRepository.findLeanById(userId);
errorToastIfFalsy(addMemberUser?.friendCode, "User has no friend code set");
errorToastIfFalsy(
!tournament.ctx.settings.requireInGameNames || addMemberUser?.inGameName,
"User has no in-game name set",
);
await TournamentLFGRepository.leaveLfg({
userId,
tournamentId,
});
// this team is not checked in & tournament started, so we can simply delete it
const previousTeamIdToDelete =
previousTeam &&
previousTeam.checkIns.length === 0 &&
tournament.hasStarted
? previousTeam.id
: undefined;
const previousTeamPickupChat = previousTeamIdToDelete
? await TournamentLFGRepository.findPickupChatTeamById(
previousTeamIdToDelete,
)
: null;
await TournamentTeamRepository.join({
userId,
newTeamId: team.id,
previousTeamIdToDelete,
isOrganizerAdded: true,
});
if (previousTeamPickupChat) {
ChatSystemMessage.removeRoom(previousTeamPickupChat.chatCode);
}
ShowcaseTournaments.addToCached({
tournamentId,
type: "participant",
userId,
});
await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId);
await syncPickupChatMetadata({
teamId: team.id,
tournament: {
id: tournamentId,
name: tournament.ctx.name,
logoUrl: tournament.ctx.logoUrl,
startTime: tournament.ctx.startsAt,
},
});
if (!tournament.isTest && !tournament.isDraft) {
notify({
userIds: [userId],
notification: {
type: "TO_ADDED_TO_TEAM",
pictureUrl: team.logoUrl ?? tournament.ctx.logoUrl,
meta: {
adderUsername: user.username,
teamName: team.name,
tournamentId,
tournamentName: tournament.ctx.name,
tournamentTeamId: team.id,
},
},
});
}
clearTournamentDataCache(tournamentId);
return null;
}, args);
};

View File

@@ -0,0 +1,91 @@
import type { ActionFunctionArgs } from "react-router";
import { z } from "zod";
import { requireUser } from "~/features/auth/core/user.server";
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import {
clearTournamentDataCache,
requireTournamentOrganizer,
tournamentFromDB,
} from "~/features/tournament-bracket/core/Tournament.server";
import { syncPickupChatMetadata } from "~/features/tournament-lfg/tournament-lfg-utils.server";
import {
errorToastIfFalsy,
parseBody,
parseParams,
} from "~/utils/remix.server";
import { id } from "~/utils/zod";
import { wrapActionForApi } from "../api-action-wrapper.server";
const paramsSchema = z.object({
id,
teamId: id,
});
const bodySchema = z.object({
userId: id,
});
export const action = async (args: ActionFunctionArgs) => {
const { id: tournamentId, teamId } = parseParams({
params: args.params,
schema: paramsSchema,
});
const { userId } = await parseBody({
request: args.request,
schema: bodySchema,
});
return wrapActionForApi(async () => {
const user = requireUser();
const tournament = await tournamentFromDB(tournamentId);
requireTournamentOrganizer(tournament, user);
const team = tournament.teamById(teamId);
errorToastIfFalsy(team, "Invalid team id");
errorToastIfFalsy(
team.checkIns.length === 0 ||
team.memberUserIds.length > tournament.minMembersPerTeam,
"Can't remove last member from checked in team",
);
errorToastIfFalsy(team.ownerUserId !== userId, "Cannot remove team owner");
errorToastIfFalsy(
!tournament.hasStarted ||
!tournament.participatedPlayerUserIdsByTeamId(teamId).includes(userId),
"Cannot remove player that has participated in the tournament",
);
if (team.activeRosterUserIds?.includes(userId)) {
await TournamentTeamRepository.setActiveRoster({
teamId: team.id,
activeRosterUserIds: null,
});
}
await TournamentTeamRepository.leave({
userId,
teamId: team.id,
});
ShowcaseTournaments.removeFromCached({
tournamentId,
type: "participant",
userId,
});
await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId);
await syncPickupChatMetadata({
teamId: team.id,
tournament: {
id: tournamentId,
name: tournament.ctx.name,
logoUrl: tournament.ctx.logoUrl,
startTime: tournament.ctx.startsAt,
},
});
clearTournamentDataCache(tournamentId);
return null;
}, args);
};

View File

@@ -0,0 +1,58 @@
import type { ActionFunctionArgs } from "react-router";
import { z } from "zod";
import { requireUser } from "~/features/auth/core/user.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import {
clearTournamentDataCache,
requireTournamentOrganizer,
tournamentFromDB,
} from "~/features/tournament-bracket/core/Tournament.server";
import { inGameNameIsValid } from "~/features/user-page/in-game-name";
import {
badRequestIfFalsy,
parseBody,
parseParams,
} from "~/utils/remix.server";
import { id } from "~/utils/zod";
import { wrapActionForApi } from "../api-action-wrapper.server";
const paramsSchema = z.object({
id,
teamId: id,
});
const bodySchema = z.object({
userId: id,
inGameName: z.string().refine(inGameNameIsValid),
});
export const action = async (args: ActionFunctionArgs) => {
const { id: tournamentId } = parseParams({
params: args.params,
schema: paramsSchema,
});
const { userId, inGameName } = await parseBody({
request: args.request,
schema: bodySchema,
});
return wrapActionForApi(async () => {
const user = requireUser();
const tournament = await tournamentFromDB(tournamentId);
requireTournamentOrganizer(tournament, user);
const teamMemberOf = badRequestIfFalsy(
tournament.teamMemberOfByUser({ id: userId }),
);
await TournamentTeamRepository.updateMemberInGameName({
userId,
inGameName,
tournamentTeamId: teamMemberOf.id,
});
clearTournamentDataCache(tournamentId);
return null;
}, args);
};

View File

@@ -0,0 +1,72 @@
import { describe, expect, test } from "vitest";
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import { withUserId, wrappedLoader } from "~/utils/Test";
import type { GetTournamentTeamsResponse } from "../schema";
import { loader } from "./tournament.$id.teams";
const TEAM_NAME = "Team Olive";
const teamsLoader = wrappedLoader<Response>({ loader });
const fetchTeams = async (tournamentId: number) => {
const response = await teamsLoader({
params: { id: String(tournamentId) },
});
return (await response.json()) as GetTournamentTeamsResponse;
};
const registeredPlayer = async () => {
const organizer = await UserFactory.create();
const player = await UserFactory.create({
discordName: "xXsplatlordXx",
profile: null,
});
const tournament = await TournamentFactory.create({
authorId: organizer.id,
});
const team = await TournamentTeamFactory.create({
tournamentId: tournament.id,
memberUserIds: [player.id],
team: { name: TEAM_NAME, prefersNotToHost: 0, teamId: null },
});
return { organizer, player, tournament, team };
};
describe("GET /api/tournament/:id/teams", () => {
test("returns the tournament name organizers gave a player instead of their username", async () => {
const { organizer, player, tournament, team } = await registeredPlayer();
await withUserId(organizer.id, () =>
TournamentTeamRepository.upsertRegistration({
tournamentTeamId: team.id,
tournamentId: tournament.id,
name: TEAM_NAME,
teamId: null,
avatarImgId: null,
ownerUserId: player.id,
ownerChange: null,
membersToAdd: [],
membersToRemove: [],
inGameNameUpdates: [],
tournamentNameUpdates: [{ userId: player.id, tournamentName: "Riko" }],
}),
);
const teams = await fetchTeams(tournament.id);
expect(teams[0].members[0].name).toBe("Riko");
});
test("falls back to the username of a player without a tournament name", async () => {
const { tournament } = await registeredPlayer();
const teams = await fetchTeams(tournament.id);
expect(teams[0].members[0].name).toBe("xXsplatlordXx");
});
});

View File

@@ -0,0 +1,184 @@
import { sql } from "kysely";
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import { db } from "~/db/sql";
import { ordinalToSp } from "~/features/mmr/mmr-utils";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import { getFixedTForLanguage } from "~/modules/i18n/i18next.server";
import { nullifyingAvg } from "~/utils/arrays";
import { databaseTimestampToDate } from "~/utils/dates";
import {
concatUserSubmittedImagePrefix,
jsonArrayFrom,
jsonObjectFrom,
tournamentUsername,
} from "~/utils/kysely.server";
import { parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import type { GetTournamentTeamsResponse } from "../schema";
const paramsSchema = z.object({
id,
});
export const loader = async ({ params }: LoaderFunctionArgs) => {
const t = await getFixedTForLanguage("en", ["game-misc"]);
const { id } = parseParams({
params,
schema: paramsSchema,
});
const teams = await db
.selectFrom("TournamentTeam")
.leftJoin("UserSubmittedImage", "avatarImgId", "UserSubmittedImage.id")
.leftJoin("TournamentTeamCheckIn", (join) =>
join
.onRef(
"TournamentTeam.id",
"=",
"TournamentTeamCheckIn.tournamentTeamId",
)
.on("TournamentTeamCheckIn.bracketIdx", "is", null),
)
.select(({ eb }) => [
"TournamentTeam.id",
"TournamentTeam.name",
"TournamentTeam.seed",
"TournamentTeam.createdAt",
"TournamentTeamCheckIn.checkedInAt",
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"avatarUrl",
),
jsonObjectFrom(
eb
.selectFrom("AllTeam")
.leftJoin(
"UserSubmittedImage",
"AllTeam.avatarImgId",
"UserSubmittedImage.id",
)
.whereRef("AllTeam.id", "=", "TournamentTeam.teamId")
.select([
"AllTeam.customUrl",
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"logoUrl",
),
"AllTeam.deletedAt",
]),
).as("team"),
jsonArrayFrom(
eb
.selectFrom("TournamentTeamMember")
.innerJoin("User", "User.id", "TournamentTeamMember.userId")
.leftJoin("SeedingSkill as RankedSeedingSkill", (join) =>
join
.onRef("User.id", "=", "RankedSeedingSkill.userId")
.on("RankedSeedingSkill.type", "=", "RANKED"),
)
.leftJoin("SeedingSkill as UnrankedSeedingSkill", (join) =>
join
.onRef("User.id", "=", "UnrankedSeedingSkill.userId")
.on("UnrankedSeedingSkill.type", "=", "UNRANKED"),
)
.select([
"User.id as userId",
tournamentUsername().as("username"),
"User.discordId",
"User.discordAvatar",
"User.battlefy",
"User.country",
"User.pronouns",
"TournamentTeamMember.inGameName",
"TournamentTeamMember.role",
"TournamentTeamMember.createdAt",
"RankedSeedingSkill.ordinal as rankedOrdinal",
"UnrankedSeedingSkill.ordinal as unrankedOrdinal",
])
.whereRef(
"TournamentTeamMember.tournamentTeamId",
"=",
"TournamentTeam.id",
)
.orderBy(sql`"TournamentTeamMember"."role" = 'OWNER'`, "desc")
.orderBy("TournamentTeamMember.createdAt", "asc"),
).as("members"),
jsonArrayFrom(
eb
.selectFrom("MapPoolMap")
.select(["MapPoolMap.stageId", "MapPoolMap.mode"])
.whereRef("MapPoolMap.tournamentTeamId", "=", "TournamentTeam.id"),
).as("mapPool"),
])
.where("TournamentTeam.tournamentId", "=", id)
.where("TournamentTeam.isPlaceholder", "=", 0)
.orderBy("TournamentTeam.createdAt", "asc")
.execute();
const friendCodes =
await TournamentRepository.findFriendCodesByTournamentId(id);
const result: GetTournamentTeamsResponse = teams.map((team) => {
return {
id: team.id,
name: team.name,
url: `https://sendou.ink/to/${id}/teams/${team.id}`,
teamPageUrl:
team.team?.customUrl && !team.team.deletedAt
? `https://sendou.ink/t/${team.team.customUrl}`
: null,
seed: team.seed,
registeredAt: databaseTimestampToDate(team.createdAt).toISOString(),
checkedIn: Boolean(team.checkedInAt),
seedingPower: {
ranked: toSeedingPowerSP(
team.members.map((member) => member.rankedOrdinal),
),
unranked: toSeedingPowerSP(
team.members.map((member) => member.unrankedOrdinal),
),
},
members: team.members.map((member) => {
return {
userId: member.userId,
name: member.username,
battlefy: member.battlefy,
discordId: member.discordId,
avatarUrl: member.discordAvatar
? `https://cdn.discordapp.com/avatars/${member.discordId}/${member.discordAvatar}.png`
: null,
country: member.country,
captain: member.role === "OWNER",
inGameName: member.inGameName,
pronouns: member.pronouns,
friendCode: friendCodes[member.userId],
joinedAt: databaseTimestampToDate(member.createdAt).toISOString(),
};
}),
logoUrl: team.team?.logoUrl ?? team.avatarUrl,
mapPool:
team.mapPool.length > 0
? team.mapPool.map((map) => {
return {
mode: map.mode,
stage: {
id: map.stageId,
name: t(`game-misc:STAGE_${map.stageId}`),
},
};
})
: null,
};
});
return Response.json(result);
};
function toSeedingPowerSP(ordinals: (number | null)[]) {
const avg = nullifyingAvg(
ordinals.filter((ordinal) => typeof ordinal === "number"),
);
if (typeof avg !== "number") return null;
return ordinalToSp(avg);
}

View File

@@ -0,0 +1,88 @@
import { describe, expect, test } from "vitest";
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import { db } from "~/db/sql";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import { withUserId, wrappedAction } from "~/utils/Test";
import { action } from "./tournament.$id.teams.upsert";
const TEAM_NAME = "Team Olive";
const upsertAction = wrappedAction({ action, isJsonSubmission: true });
const tournamentNameOf = async (userId: number) =>
(
await db
.selectFrom("User")
.select("User.tournamentName")
.where("User.id", "=", userId)
.executeTakeFirstOrThrow()
).tournamentName;
/** A one player team of a tournament the API token holder organizes, the player named "Riko". */
const namedPlayerTeam = async () => {
await UserFactory.createAdmin();
const player = await UserFactory.create();
const tournament = await TournamentFactory.create({ authorId: ADMIN_ID });
const team = await TournamentTeamFactory.create({
tournamentId: tournament.id,
memberUserIds: [player.id],
team: { name: TEAM_NAME, prefersNotToHost: 0, teamId: null },
});
await withUserId(ADMIN_ID, () =>
TournamentTeamRepository.upsertRegistration({
tournamentTeamId: team.id,
tournamentId: tournament.id,
name: TEAM_NAME,
teamId: null,
avatarImgId: null,
ownerUserId: player.id,
ownerChange: null,
membersToAdd: [],
membersToRemove: [],
inGameNameUpdates: [],
tournamentNameUpdates: [{ userId: player.id, tournamentName: "Riko" }],
}),
);
return { player, tournament, team };
};
// tournament names are readable through the API but never writable, not even by a
// token holder who is allowed to edit them in the admin form
describe("POST /api/tournament/:id/teams/upsert", () => {
test("keeps the tournament names of the roster", async () => {
const { player, tournament, team } = await namedPlayerTeam();
await upsertAction(
{
tournamentTeamId: team.id,
name: "Renamed Team",
ownerUserId: player.id,
members: [{ userId: player.id }],
},
{ user: "admin", params: { id: String(tournament.id) } },
);
expect(await tournamentNameOf(player.id)).toBe("Riko");
});
test("ignores a tournament name submitted for a member", async () => {
const { player, tournament, team } = await namedPlayerTeam();
await upsertAction(
{
tournamentTeamId: team.id,
name: TEAM_NAME,
ownerUserId: player.id,
members: [{ userId: player.id, tournamentName: "Not Riko" }],
},
{ user: "admin", params: { id: String(tournament.id) } },
);
expect(await tournamentNameOf(player.id)).toBe("Riko");
});
});

View File

@@ -0,0 +1,96 @@
import type { ActionFunctionArgs } from "react-router";
import { z } from "zod";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import { upsertRegistrationAction } from "~/features/tournament-admin/actions/to.$id.admin.registration.server";
import { ADMIN_REGISTRATION_MAX_MEMBERS } from "~/features/tournament-admin/tournament-admin-registration-schemas";
import { existingImage } from "~/form/image-field";
import { parseBody, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import { wrapActionForApi } from "../api-action-wrapper.server";
const paramsSchema = z.object({
id,
});
const bodySchema = z.object({
tournamentTeamId: id.optional(),
name: z.string().max(TOURNAMENT.TEAM_NAME_MAX_LENGTH).optional(),
teamId: id.optional(),
ownerUserId: id,
members: z
.array(
z.object({
userId: id,
inGameName: z.string().optional(),
}),
)
.min(1)
.max(ADMIN_REGISTRATION_MAX_MEMBERS),
});
export const action = async (args: ActionFunctionArgs) => {
const { id: tournamentId } = parseParams({
params: args.params,
schema: paramsSchema,
});
const body = await parseBody({
request: args.request,
schema: bodySchema,
});
const existingTeam =
typeof body.tournamentTeamId === "number"
? (
await TournamentRepository.findTeamsFullByTournamentId(tournamentId)
).find((team) => team.id === body.tournamentTeamId)
: undefined;
if (typeof body.tournamentTeamId === "number" && !existingTeam) {
return Response.json(
{ error: "Invalid tournament team id" },
{
status: 400,
},
);
}
const linkedTeam = typeof body.teamId === "number";
// the API can't upload logos, so an existing pickup logo is carried over as is
const logo =
!linkedTeam && existingTeam
? existingImage(existingTeam.avatarImgId, existingTeam.pickupAvatarUrl)
: null;
const internalRequest = new Request(args.request.url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
_action: "UPSERT_REGISTRATION",
tournamentTeamId: body.tournamentTeamId,
linkedTeam,
pickUpName: body.name ?? null,
logo,
teamId: body.teamId ?? null,
ownerId: String(body.ownerUserId),
members: body.members.map((member) => ({
userId: member.userId,
inGameName: member.inGameName ?? null,
})),
// the API can't edit counterpick maps, so the team's existing pool is carried over as is
mapPool: existingTeam?.mapPool ?? [],
}),
});
return wrapActionForApi(
(actionArgs) =>
upsertRegistrationAction(actionArgs, {
// tournament names can only be read through the API, never written
allowTournamentNameUpdates: false,
}),
{
...args,
params: { id: String(tournamentId) },
request: internalRequest,
},
);
};

View File

@@ -0,0 +1,81 @@
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import { db } from "~/db/sql";
import { databaseTimestampToDate } from "~/utils/dates";
import { jsonArrayFrom } from "~/utils/kysely.server";
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import type { GetTournamentResponse } from "../schema";
const paramsSchema = z.object({
id,
});
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { id } = parseParams({ params, schema: paramsSchema });
const tournament = notFoundIfNullish(
await db
.selectFrom("Tournament")
.innerJoin("CalendarEvent", "CalendarEvent.tournamentId", "Tournament.id")
.innerJoin(
"CalendarEventDate",
"CalendarEventDate.eventId",
"CalendarEvent.id",
)
.select(({ eb, exists, selectFrom }) => [
"CalendarEvent.name",
"CalendarEvent.organizationId",
"CalendarEventDate.startsAt",
"Tournament.settings",
exists(
selectFrom("TournamentResult")
.where("TournamentResult.tournamentId", "=", id)
.select("TournamentResult.tournamentId"),
).as("isFinalized"),
eb
.selectFrom("UserSubmittedImage")
.select(["UserSubmittedImage.url"])
.whereRef("CalendarEvent.avatarImgId", "=", "UserSubmittedImage.id")
.as("logoUrl"),
jsonArrayFrom(
eb
.selectFrom("TournamentTeam")
.leftJoin("TournamentTeamCheckIn", (join) =>
join
.onRef(
"TournamentTeam.id",
"=",
"TournamentTeamCheckIn.tournamentTeamId",
)
.on("TournamentTeamCheckIn.bracketIdx", "is", null),
)
.select(["TournamentTeamCheckIn.checkedInAt"])
.where("TournamentTeam.tournamentId", "=", id)
.where("TournamentTeam.isPlaceholder", "=", 0),
).as("teams"),
])
.where("Tournament.id", "=", id)
.executeTakeFirst(),
);
const result: GetTournamentResponse = {
name: tournament.name,
startTime: databaseTimestampToDate(tournament.startsAt).toISOString(),
url: `https://sendou.ink/to/${id}/brackets`,
logoUrl: tournament.logoUrl,
teams: {
checkedInCount: tournament.teams.filter((team) => team.checkedInAt)
.length,
registeredCount: tournament.teams.length,
},
brackets: tournament.settings.bracketProgression.map((bp) => ({
name: bp.name,
type: bp.type,
})),
organizationId: tournament.organizationId,
isFinalized: Boolean(tournament.isFinalized),
};
return Response.json(result);
};

View File

@@ -0,0 +1,27 @@
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod/v4";
import { userByIdentifierQuery } from "~/utils/kysely.server";
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
import type { GetUserIdsResponse } from "../schema";
const paramsSchema = z.object({
identifier: z.string(),
});
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { identifier } = parseParams({ params, schema: paramsSchema });
const user = notFoundIfNullish(
await userByIdentifierQuery(identifier)
.select(["User.discordId", "User.customUrl"])
.executeTakeFirst(),
);
const result: GetUserIdsResponse = {
id: user.id,
discordId: user.discordId,
customUrl: user.customUrl,
};
return Response.json(result);
};

View File

@@ -0,0 +1,121 @@
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import { db } from "~/db/sql";
import * as Seasons from "~/features/mmr/core/Seasons";
import { userSkills as _userSkills } from "~/features/mmr/tiered.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { getFixedTForLanguage } from "~/modules/i18n/i18next.server";
import { jsonArrayFrom, peakXpOverallSql } from "~/utils/kysely.server";
import { safeNumberParse } from "~/utils/number";
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
import { badgeUrl } from "~/utils/urls";
import type { GetUserResponse } from "../schema";
const paramsSchema = z.object({
identifier: z.string(),
});
export const loader = async ({ params }: LoaderFunctionArgs) => {
const t = await getFixedTForLanguage("en", ["weapons"]);
const { identifier } = parseParams({ params, schema: paramsSchema });
const user = notFoundIfNullish(
await db
.selectFrom("User")
.leftJoin("PlusTier", "PlusTier.userId", "User.id")
.leftJoin("SplatoonPlayer", "SplatoonPlayer.userId", "User.id")
.select(({ eb }) => [
"User.id",
"User.country",
"User.discordName",
"User.twitch",
"User.battlefy",
"User.bsky",
"User.customUrl",
"User.discordId",
"User.discordAvatar",
"User.inGameName",
"User.pronouns",
"PlusTier.tier",
jsonArrayFrom(
eb
.selectFrom("UserWeapon")
.select(["UserWeapon.isFavorite", "UserWeapon.weaponSplId"])
.whereRef("UserWeapon.userId", "=", "User.id")
.orderBy("UserWeapon.order", "asc"),
).as("weapons"),
peakXpOverallSql().as("peakXp"),
jsonArrayFrom(
eb
.selectFrom("TeamMemberWithSecondary")
.innerJoin("Team", "Team.id", "TeamMemberWithSecondary.teamId")
.select(["Team.id", "TeamMemberWithSecondary.role"])
.whereRef("TeamMemberWithSecondary.userId", "=", "User.id")
.orderBy("TeamMemberWithSecondary.isMainTeam", "desc")
.orderBy("TeamMemberWithSecondary.createdAt", "asc"),
).as("teams"),
])
.where((eb) => {
// we don't want to parse discord id's as numbers (length = 18)
const parsedId =
identifier.length < 10 ? safeNumberParse(identifier) : null;
if (parsedId) {
return eb("User.id", "=", parsedId);
}
return eb("User.discordId", "=", identifier);
})
.executeTakeFirst(),
);
const badges = await UserRepository.findOwnedBadgesByUserId(user.id);
const season = Seasons.currentOrPrevious(new Date())!.nth;
const { isAccurateTiers, userSkills } = await _userSkills(season);
const skill = isAccurateTiers ? userSkills[user.id] : null;
const result: GetUserResponse = {
id: user.id,
name: user.discordName,
discordId: user.discordId,
avatarUrl: user.discordAvatar
? `https://cdn.discordapp.com/avatars/${user.discordId}/${user.discordAvatar}.png`
: null,
url: `https://sendou.ink/u/${user.customUrl ?? user.discordId}`,
country: user.country,
inGameName: user.inGameName,
pronouns: user.pronouns,
plusServerTier: user.tier as GetUserResponse["plusServerTier"],
socials: {
twitch: user.twitch,
battlefy: user.battlefy,
bsky: user.bsky,
twitter: null, // deprecated field
},
currentRank: skill?.tier
? {
season,
tier: skill.tier,
}
: null,
peakXp: user.peakXp,
weaponPool: user.weapons.map((weapon) => ({
id: weapon.weaponSplId,
name: t(`weapons:MAIN_${weapon.weaponSplId}`),
isFiveStar: Boolean(weapon.isFavorite),
})),
badges: badges.map((badge) => ({
name: badge.displayName,
count: badge.count,
gifUrl: badgeUrl({ code: badge.code, extension: "gif" }),
imageUrl: badgeUrl({ code: badge.code }),
})),
teams: user.teams.map((team) => ({
id: team.id,
role: team.role,
})),
};
return Response.json(result);
};

View File

@@ -0,0 +1,50 @@
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
import { RunningTournaments } from "~/features/tournament-bracket/core/RunningTournaments.server";
import { parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import type { GetUsersActiveMatchResponse } from "../schema";
const paramsSchema = z.object({
userId: id,
});
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { userId } = parseParams({
params,
schema: paramsSchema,
});
const sendouqGroup = SendouQ.findOwnGroup(userId);
if (sendouqGroup?.matchId) {
const result: GetUsersActiveMatchResponse = {
matchId: sendouqGroup.matchId,
lobby: "sendouq",
tournamentId: null,
bracketIdx: null,
};
return Response.json(result);
}
for (const tournament of RunningTournaments.all) {
const status = tournament.teamMemberOfProgressStatus({ id: userId });
if (status?.type === "MATCH") {
const result: GetUsersActiveMatchResponse = {
matchId: status.matchId,
lobby: "tournament",
tournamentId: tournament.ctx.id,
bracketIdx: tournament.matchIdToBracketIdx(status.matchId),
};
return Response.json(result);
}
}
const result: GetUsersActiveMatchResponse = {
matchId: null,
lobby: null,
tournamentId: null,
bracketIdx: null,
};
return Response.json(result);
};

View File

@@ -0,0 +1,600 @@
import type { Pronouns } from "~/db/tables-json";
import type { TierName } from "~/features/mmr/mmr-constants";
import type { BracketData } from "~/features/tournament-bracket/core/engine/types";
/** GET /api/user/{userId|discordId} */
export interface GetUserResponse {
id: number;
/**
* @example "Sendou"
*/
name: string;
/**
* @example "79237403620945920"
*/
discordId: string;
/**
* @example "https://sendou.ink/u/sendou"
*/
url: string;
/**
* @example "https://cdn.discordapp.com/avatars/79237403620945920/6fc41a44b069a0d2152ac06d1e496c6c.png"
*/
avatarUrl: string | null;
/**
* @example "FI"
*/
country: string | null;
socials: {
twitch: string | null;
// @deprecated
twitter: null;
battlefy: string | null;
bsky: string | null;
};
plusServerTier: 1 | 2 | 3 | null;
weaponPool: Array<ProfileWeapon>;
badges: Array<Badge>;
/** Teams user is member of. The main team is always first in the array. */
teams: Array<GlobalTeamMembership>;
/**
* Splatoon 3 splashtag name & ID, if one is set.
*
* @example "Sendou#2955"
*/
inGameName: string | null;
/**
* User's pronouns.
*
* @example { "subject": "he", "object": "him" }
*/
pronouns: Pronouns | null;
peakXp: number | null;
/** Users current (or previous if it's off-season) ranked season (SendouQ & ranked tournaments) rank. Null if no rank for the season in question or the season does not have yet enough players on the leaderboard. */
currentRank: SeasonalRank | null;
}
/** GET /api/user/{userId|discordId|customUrl}/ids */
export interface GetUserIdsResponse {
id: number;
/**
* @example "79237403620945920"
*/
discordId: string;
/**
* @example "sendou"
*/
customUrl: string | null;
}
/** GET /api/team/{teamId} */
export interface GetTeamResponse {
id: number;
/**
* Name of the global team.
*
* @example "Moonlight"
*/
name: string;
/**
* URL for the global team page.
*
* @example "https://sendou.ink/t/moonlight"
*/
teamPageUrl: string;
/**
* URL for the global team logo.
*
* @example "https://sendou.nyc3.cdn.digitaloceanspaces.com/pickup-logo-uReSb1b1XS3TWGLCKMDUD-1719054364813.webp"
*/
logoUrl: string | null;
}
/** GET /api/calendar/{year}/{week} */
export type GetCalendarWeekResponse = Array<{
/**
* @example "In The Zone 30"
*/
name: string;
tournamentId: number | null;
/**
* @example "https://sendou.ink/to/9/brackets"
*/
tournamentUrl: string | null;
/**
* @example "2024-01-12T20:00:00.000Z"
*/
startTime: string;
}>;
/** GET /api/user/{userId}/active-match */
export interface GetUsersActiveMatchResponse {
/** The user's current match ID or null if none */
matchId: number | null;
/** What kind of match the user is in right now */
lobby: "sendouq" | "tournament" | null;
/** The ID of the tournament (null for sendouq or no match) */
tournamentId: number | null;
/** The bracket index within the tournament (null for sendouq or no match). Can be used with GET /api/tournament/{tournamentId}/brackets/{bracketIdx} */
bracketIdx: number | null;
}
/** GET /api/sendouq/active-match/{userId} */
export interface GetUsersActiveSendouqMatchResponse {
/** The user's current match ID or null if none */
matchId: number | null;
}
/** GET /api/sendouq/match/{matchId} */
export interface GetSendouqMatchResponse {
teamAlpha: SendouqMatchTeam | null;
teamBravo: SendouqMatchTeam | null;
mapList: Array<MapListMap>;
}
type SendouqMatchTeam = {
id: number;
score: number;
players: Array<SendouqMatchPlayer>;
};
type SendouqMatchPlayer = {
userId: number;
/** User's at the start time of the match */
rank: SendouQRank | null;
};
type SendouQRank = { name: TierName; isPlus: boolean };
/** GET /api/tournament/{tournamentId} */
export interface GetTournamentResponse {
/**
* @example "In The Zone 30"
*/
name: string;
/**
* @example "https://sendou.ink/to/9/brackets"
*/
url: string;
/**
* @example "https://sendou-assets.nyc3.cdn.digitaloceanspaces.com/img/tournament-logos/itz.avif"
*/
logoUrl: string | null;
/**
* @example "2024-01-12T20:00:00.000Z"
*/
startTime: string;
teams: {
registeredCount: number;
checkedInCount: number;
};
brackets: TournamentBracket[];
organizationId: number | null;
/** Has the tournament concluded (results added to user profiles & no editing possible anymore) */
isFinalized: boolean;
}
/** GET /api/tournament/{tournamentId}/teams */
export type GetTournamentTeamsResponse = Array<{
id: number;
/**
* @example "Team Olive"
*/
name: string;
/**
* @example "2024-01-12T20:00:00.000Z"
*/
registeredAt: string;
checkedIn: boolean;
/**
* URL for the tournament team page.
*
* @example "https://sendou.ink/to/9/teams/327"
*/
url: string;
/**
* URL for the global team page.
*
* @example "https://sendou.ink/t/moonlight"
*/
teamPageUrl: string | null;
/**
* @example "https://sendou.nyc3.cdn.digitaloceanspaces.com/pickup-logo-uReSb1b1XS3TWGLCKMDUD-1719054364813.webp"
*/
logoUrl: string | null;
seed: number | null;
mapPool: Array<StageWithMode> | null;
/**
* Seeding power is a non-resetting MMR value that is used for sendou.ink's autoseeding capabilities.
* It is calculated as the average of the team's members' seeding power.
* Ranked and unranked tournaments contribute to different seeding power values.
*/
seedingPower: {
ranked: number | null;
unranked: number | null;
};
members: Array<{
userId: number;
/**
* @example "Sendou"
*/
name: string;
/**
* @example "79237403620945920"
*/
discordId: string;
/**
* @example "sendouc"
*/
battlefy: string | null;
/**
* @example "https://cdn.discordapp.com/avatars/79237403620945920/6fc41a44b069a0d2152ac06d1e496c6c.png"
*/
avatarUrl: string | null;
/**
* @example "FI"
*/
country: string | null;
captain: boolean;
/**
* Splatoon 3 splashtag name & ID. Notice the value returned is the player's set name at the time of the tournament.
* Only available for tournaments with the "Require IGN's" option enabled.
*
* @example "Sendou#2955"
*/
inGameName: string | null;
/**
* User's pronouns.
*
* @example { "subject": "he", "object": "him" }
*/
pronouns: Pronouns | null;
/**
* Switch friend code used for identification purposes.
*
* @example "1234-5678-9101"
*/
friendCode: string;
/**
* @example "2024-01-12T20:00:00.000Z"
*/
joinedAt: string;
}>;
}>;
/** GET /api/tournament/{tournamentId}/players */
export type GetTournamentPlayersResponse = Array<{
userId: number;
matchIds: number[];
}>;
/** GET /api/tournament/{tournamentId}/casted */
export interface GetCastedTournamentMatchesResponse {
/*
* Matches that are currently being played and casted. Note: at the moment only one match can be casted at a time but this is an array for future proofing.
*/
current: Array<{
matchId: number;
channel: TournamentCastChannel;
}>;
/*
* Matches that are locked to be casted.
*/
future: Array<{
matchId: number;
channel: TournamentCastChannel;
}>;
}
type TournamentCastChannel = {
type: "TWITCH";
/**
* @example "iplsplatoon"
*/
channelId: string;
};
/** GET /api/tournament/{tournamentId}/streams */
export type GetTournamentStreamsResponse = Array<
{
platform: "TWITCH";
channelId: string;
viewerCount: number;
} & ({ type: "PLAYER"; userId: number } | { type: "CAST" })
>;
/** GET /api/tournament-match/{matchId} */
export interface GetTournamentMatchResponse {
teamOne: TournamentMatchTeam | null;
teamTwo: TournamentMatchTeam | null;
/**
* Name of the bracket this match belongs to.
*
* @example "Alpha Bracket"
*/
bracketName: string | null;
/**
* Name of the round this match belongs to.
*
* @example "Grand Finals"
*/
roundName: string | null;
mapList: Array<MapListMap> | null;
/**
* @example "https://sendou.ink/to/9/matches/695"
*/
url: string;
}
/** GET /api/tournament/{tournamentId}/brackets/{bracketIndex} */
export interface GetTournamentBracketResponse {
data: TournamentBracketData;
teams: Array<{
id: number;
checkedIn: boolean;
}>;
meta: {
/** How many teams per group? (round robin only) */
teamsPerGroup?: number;
/** How many groups? (swiss only) */
groupCount?: number;
/** How many rounds? (swiss only) */
roundCount?: number;
};
}
/** GET /api/tournament/{tournamentId}/brackets/{bracketIndex}/standings */
export interface GetTournamentBracketStandingsResponse {
finished: boolean;
standings: Array<{
tournamentTeamId: number;
placement: number;
/** (round robin & swiss only) id of the group the team played in. Placements are shared across groups, meaning e.g. every group's winner has the placement 1. */
groupId?: number;
stats?: {
setWins: number;
setLosses: number;
mapWins: number;
mapLosses: number;
/** @deprecated points are no longer tracked, see koCount instead */
points?: number;
/** (round robin only) how many knockout wins the team has */
koCount?: number;
winsAgainstTied: number;
lossesAgainstTied?: number;
/** (swiss only) average win percentage of the team's opponents in sets, used as a tiebreaker */
opponentSetWinPercentage?: number;
/** (swiss only) average win percentage of the team's opponents in maps, used as a tiebreaker */
opponentMapWinPercentage?: number;
};
}>;
}
/** GET /api/org/{organizationId} */
export interface GetTournamentOrganizationResponse {
id: number;
/**
* @example "Dapple Productions"
*/
name: string;
description: string | null;
/**
* @example "https://sendou.ink/org/dapple-productions"
*/
url: string;
/**
* @example "https://sendou.nyc3.cdn.digitaloceanspaces.com/gBn45bbUMXM6359ZDQS5_-1722059432073.webp"
*/
logoUrl: string | null;
members: Array<TournamentOrganizationMember>;
socialLinkUrls: Array<string>;
}
interface TournamentOrganizationMember {
userId: number;
/**
* @example "Sendou"
*/
name: string;
/**
* @example "79237403620945920"
*/
discordId: string;
/**
* User's pronouns.
*
* @example { "subject": "he", "object": "him" }
*/
pronouns: Pronouns | null;
role: "ADMIN" | "MEMBER" | "ORGANIZER" | "STREAMER";
roleDisplayName: string | null;
}
/* ----------------------------------------- */
type Weapon = {
id: number;
name: string;
};
type ProfileWeapon = Weapon & { isFiveStar: boolean };
interface GlobalTeamMembership {
/**
* ID for the global team page.
*/
id: number;
/**
* Role of the user in the team.
*/
role: TeamMemberRole | null;
}
type TeamMemberRole =
| "CAPTAIN"
| "CO_CAPTAIN"
| "FRONTLINE"
| "SLAYER"
| "SKIRMISHER"
| "SUPPORT"
| "MIDLINE"
| "BACKLINE"
| "FLEX"
| "SUB"
| "COACH"
| "CHEERLEADER";
interface SeasonalRank {
tier: {
name: RankTierName;
isPlus: boolean;
};
/**
* Which season this rank is for.
*
* @example 7
*/
season: number;
}
type RankTierName =
| "LEVIATHAN"
| "DIAMOND"
| "PLATINUM"
| "GOLD"
| "SILVER"
| "BRONZE"
| "IRON";
type Badge = {
/**
* @example "Monday Afterparty"
*/
name: string;
count: number;
/**
* @example "https://sendou-assets.nyc3.cdn.digitaloceanspaces.com/badges/monday.avif"
*/
imageUrl: string;
/**
* @example "https://sendou-assets.nyc3.cdn.digitaloceanspaces.com/badges/monday.gif"
*/
gifUrl: string;
};
type ModeShort = "TW" | "SZ" | "TC" | "RM" | "CB";
type Stage = {
id: number;
name: string;
};
type StageWithMode = {
mode: ModeShort;
stage: Stage;
};
export type MapListMap = {
map: StageWithMode;
/**
* One of the following:
* - id of the team that picked the map
* - "DEFAULT" if it was a default map, something went wrong with the algorithm typically
* - "TIEBREAKER" if it was a tiebreaker map (selected by the TO)
* - "BOTH" both teams picked the map
* - "TO" if it was a TO pick (from predefined maplist)
* - "COUNTERPICK" if it was a counterpick
* - "ROLL" if it was randomly selected
*/
source:
| number
| "DEFAULT"
| "TIEBREAKER"
| "BOTH"
| "TO"
| "COUNTERPICK"
| "ROLL";
winnerTeamId: number | null;
participatedUserIds: Array<number> | null;
/** (round robin only) whether the map ended in a knockout. `null` if not tracked. */
ko: boolean | null;
};
type TournamentMatchTeam = {
id: number;
score: number;
};
type TournamentBracket = {
type: "double_elimination" | "single_elimination" | "round_robin" | "swiss";
name: string;
};
type TournamentBracketData = BracketData;
/** POST /api/tournament/{id}/seeds */
/** @lintignore */
export interface TournamentSeedsBody {
tournamentTeamIds: number[];
}
/** POST /api/tournament/{id}/starting-brackets */
/** @lintignore */
export interface TournamentStartingBracketsBody {
startingBrackets: Array<{
tournamentTeamId: number;
startingBracketIdx: number;
}>;
}
/** POST /api/tournament/{id}/teams/upsert */
/** @lintignore */
export interface TournamentUpsertTeamBody {
/** Present when editing an existing registration, absent when adding a new team. */
tournamentTeamId?: number;
/** Team name for a pickup team. Either `name` or `teamId` must be given. */
name?: string;
/** Linked sendou.ink team id. Name and logo are sourced from the team. */
teamId?: number;
/** Roster member that is the team owner/captain. */
ownerUserId: number;
/** Full roster; members missing from the list are removed from the team. */
members: Array<{
userId: number;
inGameName?: string;
}>;
}
/** POST /api/tournament/{id}/teams/{tournamentTeamId}/add-member */
/** POST /api/tournament/{id}/teams/{tournamentTeamId}/remove-member */
/** @lintignore */
export interface TournamentTeamMemberBody {
userId: number;
}
/** POST /api/tournament/{id}/teams/{tournamentTeamId}/update-member-ign */
/** @lintignore */
export interface TournamentUpdateMemberIgnBody {
userId: number;
inGameName: string;
}

View File

@@ -0,0 +1,165 @@
import { beforeEach, describe, expect, test } from "vitest";
import * as ApiTokenFactory from "~/db/seed/factories/ApiTokenFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import * as ApiRepository from "./ApiRepository.server";
const users = UserFactory.pool();
describe("findTokenByUserId", () => {
beforeEach(async () => {
await users.create(3);
});
test("returns undefined when user has no token", async () => {
const result = await ApiRepository.findTokenByUserId(users.id(1), "read");
expect(result).toBeUndefined();
});
test("finds existing token for user", async () => {
await ApiTokenFactory.create({ userId: users.id(1), type: "read" });
const result = await ApiRepository.findTokenByUserId(users.id(1), "read");
expect(result).toBeDefined();
expect(result?.userId).toBe(users.id(1));
expect(result?.token).toBeDefined();
});
test("returns correct token for specific user", async () => {
const token1 = await ApiTokenFactory.create({
userId: users.id(1),
type: "read",
});
const token2 = await ApiTokenFactory.create({
userId: users.id(2),
type: "read",
});
const result1 = await ApiRepository.findTokenByUserId(users.id(1), "read");
const result2 = await ApiRepository.findTokenByUserId(users.id(2), "read");
expect(result1?.token).toBe(token1.token);
expect(result2?.token).toBe(token2.token);
expect(result1?.token).not.toBe(result2?.token);
});
test("finds correct token by type", async () => {
await ApiTokenFactory.create({ userId: users.id(1), type: "read" });
await ApiTokenFactory.create({ userId: users.id(1), type: "write" });
const readResult = await ApiRepository.findTokenByUserId(
users.id(1),
"read",
);
const writeResult = await ApiRepository.findTokenByUserId(
users.id(1),
"write",
);
expect(readResult).toBeDefined();
expect(writeResult).toBeDefined();
expect(readResult?.token).not.toBe(writeResult?.token);
expect(readResult?.type).toBe("read");
expect(writeResult?.type).toBe("write");
});
});
describe("generateToken", () => {
beforeEach(async () => {
await users.create(3);
});
test("creates new token for user", async () => {
const result = await ApiRepository.generateToken(users.id(1), "read");
expect(result.token).toBeDefined();
expect(typeof result.token).toBe("string");
expect(result.token.length).toBeGreaterThan(0);
});
test("deletes existing token before creating new one", async () => {
const firstToken = await ApiRepository.generateToken(users.id(1), "read");
const secondToken = await ApiRepository.generateToken(users.id(1), "read");
expect(firstToken.token).not.toBe(secondToken.token);
const storedToken = await ApiRepository.findTokenByUserId(
users.id(1),
"read",
);
expect(storedToken?.token).toBe(secondToken.token);
});
test("generates unique tokens for different users", async () => {
const token1 = await ApiRepository.generateToken(users.id(1), "read");
const token2 = await ApiRepository.generateToken(users.id(2), "read");
const token3 = await ApiRepository.generateToken(users.id(3), "read");
expect(token1.token).not.toBe(token2.token);
expect(token1.token).not.toBe(token3.token);
expect(token2.token).not.toBe(token3.token);
});
test("replaces only the specific user's token", async () => {
const user1FirstToken = await ApiRepository.generateToken(
users.id(1),
"read",
);
const user2Token = await ApiRepository.generateToken(users.id(2), "read");
const user1SecondToken = await ApiRepository.generateToken(
users.id(1),
"read",
);
const result1 = await ApiRepository.findTokenByUserId(users.id(1), "read");
const result2 = await ApiRepository.findTokenByUserId(users.id(2), "read");
expect(result1?.token).toBe(user1SecondToken.token);
expect(result1?.token).not.toBe(user1FirstToken.token);
expect(result2?.token).toBe(user2Token.token);
});
test("allows same user to have both read and write tokens", async () => {
const readToken = await ApiRepository.generateToken(users.id(1), "read");
const writeToken = await ApiRepository.generateToken(users.id(1), "write");
const readResult = await ApiRepository.findTokenByUserId(
users.id(1),
"read",
);
const writeResult = await ApiRepository.findTokenByUserId(
users.id(1),
"write",
);
expect(readResult?.token).toBe(readToken.token);
expect(writeResult?.token).toBe(writeToken.token);
});
});
describe("findAllApiTokens", () => {
beforeEach(async () => {
await users.create(1);
});
test("returns empty array when no tokens exist", async () => {
const result = await ApiRepository.findAllApiTokens();
expect(result).toEqual([]);
});
test("returns array of token objects with type", async () => {
await ApiTokenFactory.create({ userId: users.id(1), type: "read" });
const result = await ApiRepository.findAllApiTokens();
expect(Array.isArray(result)).toBe(true);
expect(
result.every(
(item) =>
typeof item.token === "string" && typeof item.type === "string",
),
).toBe(true);
});
});

View File

@@ -0,0 +1,80 @@
import { nanoid } from "nanoid";
import { db } from "~/db/sql";
import type { ApiTokenType } from "~/features/api/api-types";
const API_TOKEN_LENGTH = 20;
/** Finds an API token for the given user ID and type. */
export function findTokenByUserId(userId: number, type: ApiTokenType) {
return db
.selectFrom("ApiToken")
.selectAll()
.where("userId", "=", userId)
.where("type", "=", type)
.executeTakeFirst();
}
/** Generates a new API token for the given user. Deletes any existing token of the same type before creating a new one. */
export function generateToken(userId: number, type: ApiTokenType) {
const token = nanoid(API_TOKEN_LENGTH);
return db.transaction().execute(async (trx) => {
await trx
.deleteFrom("ApiToken")
.where("userId", "=", userId)
.where("type", "=", type)
.execute();
return trx
.insertInto("ApiToken")
.values({
userId,
token,
type,
})
.returning("token")
.executeTakeFirstOrThrow();
});
}
/** Retrieves all valid API tokens and their types from users with API access. */
export async function findAllApiTokens() {
const tokens = await db
.selectFrom("ApiToken")
.innerJoin("User", "User.id", "ApiToken.userId")
.leftJoin(
"TournamentOrganizationMember",
"TournamentOrganizationMember.userId",
"ApiToken.userId",
)
.leftJoin(
"TournamentOrganization",
"TournamentOrganization.id",
"TournamentOrganizationMember.organizationId",
)
.select(["ApiToken.token", "ApiToken.type", "ApiToken.userId"])
// NOTE: permissions logic also exists in checkUserHasApiAccess function
.where((eb) =>
eb.or([
eb("User.isApiAccesser", "=", 1),
eb("User.isTournamentOrganizer", "=", 1),
eb("User.patronTier", ">=", 2),
eb.and([
eb("TournamentOrganization.isEstablished", "=", 1),
eb.or([
eb("TournamentOrganizationMember.role", "=", "ADMIN"),
eb("TournamentOrganizationMember.role", "=", "ORGANIZER"),
eb("TournamentOrganizationMember.role", "=", "STREAMER"),
]),
]),
]),
)
.groupBy("ApiToken.token")
.execute();
return tokens.map((row) => ({
token: row.token,
type: row.type,
userId: row.userId,
}));
}

View File

@@ -0,0 +1,40 @@
import type { ActionFunctionArgs } from "react-router";
import { refreshApiTokensCache } from "~/features/api-public/api-public-utils.server";
import { requireUser } from "~/features/auth/core/user.server";
import { parseRequestPayload, successToast } from "~/utils/remix.server";
import * as ApiRepository from "../ApiRepository.server";
import { apiActionSchema } from "../api-schemas";
import { checkUserHasApiAccess } from "../core/perms";
export const action = async ({ request }: ActionFunctionArgs) => {
const data = await parseRequestPayload({
request,
schema: apiActionSchema,
});
const user = requireUser();
const hasApiAccess = await checkUserHasApiAccess(user);
if (!hasApiAccess) {
throw new Response("Forbidden", { status: 403 });
}
switch (data._action) {
case "GENERATE_READ": {
await ApiRepository.generateToken(user.id, "read");
await refreshApiTokensCache();
successToast("Read token generated successfully");
break;
}
case "GENERATE_WRITE": {
await ApiRepository.generateToken(user.id, "write");
await refreshApiTokensCache();
successToast("Write token generated successfully");
break;
}
default: {
throw new Error("Invalid action");
}
}
return null;
};

View File

@@ -0,0 +1,11 @@
import { z } from "zod";
import { _action } from "~/utils/zod";
export const apiActionSchema = z.union([
z.object({
_action: _action("GENERATE_READ"),
}),
z.object({
_action: _action("GENERATE_WRITE"),
}),
]);

View File

@@ -0,0 +1 @@
export type ApiTokenType = "read" | "write";

View File

@@ -0,0 +1,127 @@
import { describe, expect, test } from "vitest";
import * as ApiTokenFactory from "~/db/seed/factories/ApiTokenFactory";
import * as TournamentOrganizationFactory from "~/db/seed/factories/TournamentOrganizationFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import * as ApiRepository from "../ApiRepository.server";
import { checkUserHasApiAccess } from "./perms";
describe("Permission logic consistency between findAllApiTokens and checkUserHasApiAccess", () => {
test("both functions grant access for isApiAccesser flag", async () => {
const { id } = await UserFactory.create(null, { roles: ["API_ACCESSER"] });
await ApiTokenFactory.create({ userId: id });
const tokens = await ApiRepository.findAllApiTokens();
const user = await UserRepository.findLeanById(id);
const hasAccess = await checkUserHasApiAccess(user!);
expect(tokens).toHaveLength(1);
expect(hasAccess).toBe(true);
});
test("both functions grant access for isTournamentOrganizer flag", async () => {
const { id } = await UserFactory.create(
{},
{ roles: ["TOURNAMENT_ORGANIZER"] },
);
await ApiTokenFactory.create({ userId: id });
const tokens = await ApiRepository.findAllApiTokens();
const user = await UserRepository.findLeanById(id);
const hasAccess = await checkUserHasApiAccess(user!);
expect(tokens).toHaveLength(1);
expect(hasAccess).toBe(true);
});
test("both functions grant access for patronTier >= 2", async () => {
const { id } = await UserFactory.create(null, { patronTier: 2 });
await ApiTokenFactory.create({ userId: id });
const tokens = await ApiRepository.findAllApiTokens();
const user = await UserRepository.findLeanById(id);
const hasAccess = await checkUserHasApiAccess(user!);
expect(tokens).toHaveLength(1);
expect(hasAccess).toBe(true);
});
test("both functions deny access for patronTier < 2", async () => {
const { id } = await UserFactory.create(null, { patronTier: 1 });
await ApiTokenFactory.create({ userId: id });
const tokens = await ApiRepository.findAllApiTokens();
const user = await UserRepository.findLeanById(id);
const hasAccess = await checkUserHasApiAccess(user!);
expect(tokens).toHaveLength(0);
expect(hasAccess).toBe(false);
});
test("both functions grant access for ADMIN/ORGANIZER/STREAMER of established org", async () => {
const [owner, admin, organizer, streamer] = await UserFactory.createMany(4);
await TournamentOrganizationFactory.create(
{ ownerId: owner.id },
{
isEstablished: true,
members: [
{ userId: admin.id, role: "ADMIN" },
{ userId: organizer.id, role: "ORGANIZER" },
{ userId: streamer.id, role: "STREAMER" },
],
},
);
for (const userId of [admin.id, organizer.id, streamer.id]) {
await ApiTokenFactory.create({ userId });
const tokens = await ApiRepository.findAllApiTokens();
const user = await UserRepository.findLeanById(userId);
const hasAccess = await checkUserHasApiAccess(user!);
expect(tokens.length).toBeGreaterThan(0);
expect(hasAccess).toBe(true);
}
});
test("both functions deny access for MEMBER of established org", async () => {
const [owner, member] = await UserFactory.createMany(2);
await TournamentOrganizationFactory.create(
{ ownerId: owner.id },
{ isEstablished: true, members: [{ userId: member.id, role: "MEMBER" }] },
);
await ApiTokenFactory.create({ userId: member.id });
const tokens = await ApiRepository.findAllApiTokens();
const user = await UserRepository.findLeanById(member.id);
const hasAccess = await checkUserHasApiAccess(user!);
expect(tokens).toHaveLength(0);
expect(hasAccess).toBe(false);
});
test("both functions deny access for ADMIN of non-established org", async () => {
const [owner, member] = await UserFactory.createMany(2);
await TournamentOrganizationFactory.create(
{ ownerId: owner.id },
{ members: [{ userId: member.id, role: "ADMIN" }] },
);
await ApiTokenFactory.create({ userId: member.id });
const tokens = await ApiRepository.findAllApiTokens();
const user = await UserRepository.findLeanById(member.id);
const hasAccess = await checkUserHasApiAccess(user!);
expect(tokens).toHaveLength(0);
expect(hasAccess).toBe(false);
});
});

View File

@@ -0,0 +1,23 @@
import type { AuthenticatedUser } from "~/features/auth/core/user.server";
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
/**
* Checks whether a user has permission to access the API.
* A user has API access if they either have the API_ACCESSER role (includes supporters),
* or are an admin/organizer/streamer of an established tournament organization.
*
* @param user - The authenticated user to check permissions for
* @returns True if the user has API access, false otherwise
*/
export async function checkUserHasApiAccess(user: AuthenticatedUser) {
// NOTE: permissions logic also exists in ApiRepository.findAllApiTokens function
if (user.roles.includes("API_ACCESSER")) {
return true;
}
const orgs = await TournamentOrganizationRepository.findByUserId(user.id, {
roles: ["ADMIN", "ORGANIZER", "STREAMER"],
});
return orgs.some((org) => org.isEstablished);
}

View File

@@ -0,0 +1,28 @@
import { requireUser } from "~/features/auth/core/user.server";
import * as ApiRepository from "../ApiRepository.server";
import { checkUserHasApiAccess } from "../core/perms";
export const loader = async () => {
const user = requireUser();
const hasApiAccess = await checkUserHasApiAccess(user);
if (!hasApiAccess) {
return {
hasAccess: false,
readToken: null,
writeToken: null,
};
}
const [readToken, writeToken] = await Promise.all([
ApiRepository.findTokenByUserId(user.id, "read"),
ApiRepository.findTokenByUserId(user.id, "write"),
]);
return {
hasAccess: true,
readToken: readToken?.token ?? null,
writeToken: writeToken?.token ?? null,
};
};

View File

@@ -0,0 +1,122 @@
import { Eye, RefreshCcw } from "lucide-react";
import { Trans, useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { useLoaderData } from "react-router";
import { ActionButton } from "~/components/ActionButton";
import { CopyToClipboardPopover } from "~/components/CopyToClipboardPopover";
import { SendouButton } from "~/components/elements/Button";
import { FormMessage } from "~/components/FormMessage";
import { Main } from "~/components/Main";
import { metaTags } from "~/utils/remix";
import { API_DOC_LINK } from "~/utils/urls";
import { action } from "../actions/api.server";
import { apiActionSchema } from "../api-schemas";
import { loader } from "../loaders/api.server";
export { action, loader };
export const meta: MetaFunction = (args) => {
return metaTags({
title: "API Access",
location: args.location,
});
};
export default function ApiPage() {
const data = useLoaderData<typeof loader>();
const { t } = useTranslation(["common"]);
return (
<Main className="stack lg">
<div>
<h1 className="text-lg">{t("common:api.title")}</h1>
<p className="text-sm">
<Trans t={t} i18nKey="common:api.description">
Generate an API token to access the sendou.ink API. See the
<a href={API_DOC_LINK} className="text-theme">
API documentation
</a>
for available endpoints, usage examples and guidelines to follow.
</Trans>
</p>
</div>
{!data.hasAccess ? (
<div>
<FormMessage type="info">{t("common:api.noAccess")}</FormMessage>
</div>
) : (
<div className="stack lg">
<TokenSection
token={data.readToken}
tokenType="read"
generateAction="GENERATE_READ"
/>
<TokenSection
token={data.writeToken}
tokenType="write"
generateAction="GENERATE_WRITE"
/>
</div>
)}
</Main>
);
}
function TokenSection({
token,
tokenType,
generateAction,
}: {
token: string | null;
tokenType: "read" | "write";
generateAction: "GENERATE_READ" | "GENERATE_WRITE";
}) {
const { t } = useTranslation(["common"]);
const isWriteToken = tokenType === "write";
const labelKey = isWriteToken
? "common:api.writeTokenLabel"
: "common:api.readTokenLabel";
const descriptionKey = isWriteToken
? "common:api.writeTokenDescription"
: "common:api.readTokenDescription";
return (
<div className="stack md">
<div>
<h2 className="text-md">{t(labelKey)}</h2>
<p className="text-xs text-lighter">{t(descriptionKey)}</p>
</div>
{token ? (
<div>
<CopyToClipboardPopover
url={token}
trigger={
<SendouButton icon={<Eye />}>
{t("common:api.revealButton")}
</SendouButton>
}
/>
</div>
) : null}
<ActionButton
schema={apiActionSchema}
action={generateAction}
variant={token ? "outlined" : undefined}
icon={token ? <RefreshCcw /> : undefined}
confirm={
token
? {
dialogHeading: t("common:api.regenerate.heading"),
submitButtonText: t("common:api.regenerate.confirm"),
}
: undefined
}
>
{token ? t("common:api.regenerate.button") : t("common:api.generate")}
</ActionButton>
</div>
);
}

View File

@@ -0,0 +1,357 @@
import { beforeEach, describe, expect, test } from "vitest";
import * as ArtFactory from "~/db/seed/factories/ArtFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import { databaseTimestampNow } from "~/utils/dates";
import { withUserId } from "~/utils/Test";
import * as ArtRepository from "./ArtRepository.server";
const users = UserFactory.pool();
describe("findShowcaseArts", () => {
beforeEach(async () => {
await users.create(5);
});
test("shows one art per artist", async () => {
await ArtFactory.create({ authorId: users.id(1) });
await ArtFactory.create({ authorId: users.id(2) });
await ArtFactory.create({ authorId: users.id(3) });
const result = await ArtRepository.findShowcaseArts();
expect(result).toHaveLength(3);
const authorIds = result.map((art) => art.author?.discordId);
expect(new Set(authorIds).size).toBe(3);
});
test("prioritizes showcase art over regular art for same artist", async () => {
// first create art should be showcase
const showcaseArt = await ArtFactory.create({ authorId: users.id(1) });
await ArtFactory.create({ authorId: users.id(1) });
const result = await ArtRepository.findShowcaseArts();
expect(result[0].id).toBe(showcaseArt.id);
});
test("shows only one art per artist even with multiple pieces", async () => {
await ArtFactory.create({ authorId: users.id(1) });
await ArtFactory.create({ authorId: users.id(1) });
await ArtFactory.create({ authorId: users.id(1) });
const result = await ArtRepository.findShowcaseArts();
expect(result).toHaveLength(1);
});
test("shows artist even if no showcase art exists", async () => {
const showcaseArt = await ArtFactory.create({ authorId: users.id(1) });
const nonShowcaseArt = await ArtFactory.create({ authorId: users.id(1) });
await ArtRepository.deleteById(showcaseArt.id);
const result = await ArtRepository.findShowcaseArts();
expect(result).toHaveLength(1);
expect(result[0].id).toBe(nonShowcaseArt.id);
});
test("returns empty array when no art exists", async () => {
const result = await ArtRepository.findShowcaseArts();
expect(result).toHaveLength(0);
});
});
describe("findAllTags", () => {
beforeEach(async () => {
await users.create(1);
});
test("returns all art tags", async () => {
await ArtFactory.create({
authorId: users.id(1),
tags: [{ name: "Character" }, { name: "Weapon" }, { name: "Landscape" }],
});
const result = await ArtRepository.findAllTags();
expect(result).toHaveLength(3);
expect(result.map((t) => t.name).sort()).toEqual([
"Character",
"Landscape",
"Weapon",
]);
});
test("returns empty array when no tags exist", async () => {
const result = await ArtRepository.findAllTags();
expect(result).toHaveLength(0);
});
});
describe("unlinkUserFromArt", () => {
beforeEach(async () => {
await users.create(2);
});
test("removes user link from art", async () => {
const art = await ArtFactory.create({
authorId: users.id(1),
linkedUsers: [users.id(2)],
});
await withUserId(users.id(2), () => ArtRepository.unlinkOwnFromArt(art.id));
const result = await ArtRepository.findArtsByUserId(users.id(2), {
includeAuthored: false,
});
expect(result).toHaveLength(0);
});
});
describe("findShowcaseArtsByTag", () => {
beforeEach(async () => {
await users.create(3);
});
test("returns arts filtered by tag", async () => {
const characterArt = await ArtFactory.create({
authorId: users.id(1),
tags: [{ name: "Character" }],
});
await ArtFactory.create({
authorId: users.id(2),
tags: [{ name: "Weapon" }],
});
const tags = await ArtRepository.findAllTags();
const characterTag = tags.find((t) => t.name === "Character");
const result = await ArtRepository.findShowcaseArtsByTag(
characterTag?.id ?? 0,
);
expect(result).toHaveLength(1);
expect(result[0].id).toBe(characterArt.id);
});
test("shows only one art per artist", async () => {
await ArtFactory.create({
authorId: users.id(1),
tags: [{ name: "Character" }],
});
const tags = await ArtRepository.findAllTags();
const characterTag = tags.find((t) => t.name === "Character");
await ArtFactory.create({
authorId: users.id(1),
tags: [{ id: characterTag?.id }],
});
const result = await ArtRepository.findShowcaseArtsByTag(
characterTag?.id ?? 0,
);
expect(result).toHaveLength(1);
});
});
describe("findRecentlyUploadedArts", () => {
beforeEach(async () => {
await users.create(3);
});
test("returns recently uploaded arts", async () => {
const art = await ArtFactory.create({ authorId: users.id(1) });
const result = await ArtRepository.findRecentlyUploadedArts();
expect(result.length).toBeGreaterThan(0);
expect(result.some((uploaded) => uploaded.id === art.id)).toBe(true);
});
});
describe("findArtsByUserId", () => {
beforeEach(async () => {
await users.create(3);
});
test("returns authored art", async () => {
const art = await ArtFactory.create({ authorId: users.id(1) });
const result = await ArtRepository.findArtsByUserId(users.id(1));
expect(result).toHaveLength(1);
expect(result[0].id).toBe(art.id);
});
test("returns tagged art", async () => {
const art = await ArtFactory.create({
authorId: users.id(1),
linkedUsers: [users.id(2)],
});
const result = await ArtRepository.findArtsByUserId(users.id(2));
expect(result).toHaveLength(1);
expect(result[0].id).toBe(art.id);
});
});
describe("deleteById", () => {
beforeEach(async () => {
await users.create(1);
});
test("deletes art by id", async () => {
const art = await ArtFactory.create({ authorId: users.id(1) });
await ArtRepository.deleteById(art.id);
const result = await ArtRepository.findArtsByUserId(users.id(1));
expect(result).toHaveLength(0);
});
test("deletes only the specified art", async () => {
const [firstArt, secondArt] = await ArtFactory.createMany(2, {
authorId: users.id(1),
});
await ArtRepository.deleteById(firstArt.id);
const result = await ArtRepository.findArtsByUserId(users.id(1));
expect(result).toHaveLength(1);
expect(result[0].id).toBe(secondArt.id);
});
});
describe("deleteOrphanTags", () => {
beforeEach(async () => {
await users.create(1);
});
test("deletes tags with no associated art", async () => {
const art = await ArtFactory.create({
authorId: users.id(1),
tags: [{ name: "Orphan1" }, { name: "Orphan2" }],
});
await ArtRepository.deleteById(art.id);
const deletedCount = await ArtRepository.deleteOrphanTags();
expect(deletedCount).toBe(2);
const tags = await ArtRepository.findAllTags();
expect(tags).toHaveLength(0);
});
test("does not delete tags that are still linked to art", async () => {
await ArtFactory.create({
authorId: users.id(1),
tags: [{ name: "InUse" }],
});
const deletedCount = await ArtRepository.deleteOrphanTags();
expect(deletedCount).toBe(0);
const tags = await ArtRepository.findAllTags();
expect(tags).toHaveLength(1);
expect(tags[0].name).toBe("InUse");
});
});
describe("insert", () => {
beforeEach(async () => {
await users.create(2);
});
test("inserts art with all metadata", async () => {
const art = await withUserId(users.id(1), () =>
ArtRepository.insert({
url: "https://example.com/image-1.png",
validatedAt: databaseTimestampNow(),
description: "Test description",
linkedUsers: [users.id(2)],
tags: [{ name: "Character" }],
}),
);
const result = await ArtRepository.findArtsByUserId(users.id(1));
expect(result).toHaveLength(1);
expect(result[0].id).toBe(art.id);
expect(result[0].description).toBe("Test description");
expect(result[0].tags).toHaveLength(1);
expect(result[0].linkedUsers).toHaveLength(1);
});
test("sets first art as showcase", async () => {
await withUserId(users.id(1), () =>
ArtRepository.insert({
url: "https://example.com/image-1.png",
validatedAt: databaseTimestampNow(),
description: null,
linkedUsers: [],
tags: [],
}),
);
const result = await ArtRepository.findArtsByUserId(users.id(1));
expect(result[0].isShowcase).toBe(true);
});
});
describe("update", () => {
beforeEach(async () => {
await users.create(3);
});
test("updates art metadata", async () => {
const art = await ArtFactory.create({
authorId: users.id(1),
description: "Original",
linkedUsers: [users.id(2)],
tags: [{ name: "Character" }],
});
await ArtRepository.update(art.id, {
description: "Updated",
linkedUsers: [users.id(3)],
tags: [{ name: "Weapon" }],
isShowcase: 1,
});
const result = await ArtRepository.findArtsByUserId(users.id(1));
expect(result[0].description).toBe("Updated");
expect(result[0].linkedUsers).toHaveLength(1);
expect(result[0].linkedUsers?.[0].id).toBe(users.id(3));
expect(result[0].tags).toHaveLength(1);
expect(result[0].tags?.[0].name).toBe("Weapon");
});
test("unsets other showcase art when setting new showcase", async () => {
const [firstArt, secondArt] = await ArtFactory.createMany(2, {
authorId: users.id(1),
});
await ArtRepository.update(secondArt.id, {
description: null,
linkedUsers: [],
tags: [],
isShowcase: 1,
});
const result = await ArtRepository.findArtsByUserId(users.id(1));
expect(result).toHaveLength(2);
const showcaseArt = result.find((art) => art.id === secondArt.id);
const nonShowcaseArt = result.find((art) => art.id === firstArt.id);
expect(showcaseArt?.isShowcase).toBe(true);
expect(nonShowcaseArt?.isShowcase).toBe(false);
});
});

View File

@@ -0,0 +1,503 @@
import { type ExpressionBuilder, sql, type Transaction } from "kysely";
import { db } from "~/db/sql";
import type { DB, Tables } from "~/db/tables";
import { actorId } from "~/features/auth/core/user.server";
import {
commonUserSelect,
concatUserSubmittedImagePrefix,
jsonArrayFrom,
} from "~/utils/kysely.server";
import { seededRandom } from "~/utils/random";
import type { ListedArt } from "./art-types";
export function unlinkOwnFromArt(artId: number) {
return db
.deleteFrom("ArtUserMetadata")
.where("artId", "=", artId)
.where("userId", "=", actorId())
.execute();
}
function getDailySeed() {
const today = new Date();
const year = today.getFullYear();
const month = today.getMonth() + 1;
const day = today.getDate();
return `${year}-${month}-${day}`;
}
export async function findShowcaseArts(): Promise<ListedArt[]> {
const arts = await db
.selectFrom((eb) =>
eb
// each author's most recent art (showcase ones first) via SQLite's
// max() + bare column rule, packed into one integer since createdAt
// always stays below the isShowcase component
.selectFrom("Art")
.innerJoin("User", "User.id", "Art.authorId")
.innerJoin("UserSubmittedImage", "UserSubmittedImage.id", "Art.imgId")
.select(({ fn }) => [
"Art.id as artId",
fn
.max(
sql<number>`"Art"."isShowcase" * 10000000000 + "Art"."createdAt"`,
)
.as("packedShowcaseCreatedAt"),
])
.groupBy("Art.authorId")
.as("BestOfAuthor"),
)
.innerJoin("Art", "Art.id", "BestOfAuthor.artId")
.innerJoin("User", "User.id", "Art.authorId")
.innerJoin("UserSubmittedImage", "UserSubmittedImage.id", "Art.imgId")
.select((eb) => [
"Art.id",
"Art.createdAt",
"Art.isShowcase",
...commonUserSelect(eb, { idAs: "userId" }),
"User.commissionsOpen",
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"url",
),
linkedUsersSubquery(eb).as("linkedUsers"),
])
.orderBy("Art.isShowcase", "desc")
.orderBy("Art.createdAt", "desc")
.orderBy("User.id", "asc")
.execute();
const mappedArts = arts.map((a) => ({
id: a.id,
createdAt: a.createdAt,
url: a.url,
isShowcase: Boolean(a.isShowcase),
author: {
commissionsOpen: a.commissionsOpen,
discordAvatar: a.discordAvatar,
customAvatarUrl: a.customAvatarUrl,
discordId: a.discordId,
username: a.username,
},
permissions: artPermissions({
authorId: a.userId,
linkedUsers: a.linkedUsers,
}),
}));
const { seededShuffle } = seededRandom(getDailySeed());
return seededShuffle(mappedArts);
}
export async function findShowcaseArtsByTag(
tagId: Tables["ArtTag"]["id"],
): Promise<ListedArt[]> {
const arts = await db
.selectFrom("TaggedArt")
.innerJoin("Art", "Art.id", "TaggedArt.artId")
.innerJoin("User", "User.id", "Art.authorId")
.innerJoin("UserSubmittedImage", "UserSubmittedImage.id", "Art.imgId")
.select((eb) => [
"Art.id",
"Art.createdAt",
"Art.isShowcase",
...commonUserSelect(eb, { idAs: "userId" }),
"User.commissionsOpen",
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"url",
),
linkedUsersSubquery(eb).as("linkedUsers"),
])
.where("TaggedArt.tagId", "=", tagId)
.orderBy("Art.isShowcase", "desc")
.orderBy("Art.createdAt", "desc")
.execute();
const encounteredUserIds = new Set<number>();
return arts
.filter((row) => {
if (encounteredUserIds.has(row.userId)) {
return false;
}
encounteredUserIds.add(row.userId);
return true;
})
.map((a) => ({
id: a.id,
createdAt: a.createdAt,
url: a.url,
isShowcase: Boolean(a.isShowcase),
author: {
commissionsOpen: a.commissionsOpen,
discordAvatar: a.discordAvatar,
customAvatarUrl: a.customAvatarUrl,
discordId: a.discordId,
username: a.username,
},
permissions: artPermissions({
authorId: a.userId,
linkedUsers: a.linkedUsers,
}),
}));
}
export async function findRecentlyUploadedArts(): Promise<ListedArt[]> {
const arts = await db
.selectFrom("Art")
.innerJoin("User", "User.id", "Art.authorId")
.innerJoin("UserSubmittedImage", "UserSubmittedImage.id", "Art.imgId")
.select((eb) => [
"Art.id",
"Art.createdAt",
"Art.isShowcase",
...commonUserSelect(eb, { idAs: "userId" }),
"User.commissionsOpen",
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"url",
),
linkedUsersSubquery(eb).as("linkedUsers"),
])
.orderBy("Art.createdAt", "desc")
.limit(100)
.execute();
return arts.map((a) => ({
id: a.id,
createdAt: a.createdAt,
url: a.url,
isShowcase: Boolean(a.isShowcase),
author: {
commissionsOpen: a.commissionsOpen,
discordAvatar: a.discordAvatar,
customAvatarUrl: a.customAvatarUrl,
discordId: a.discordId,
username: a.username,
},
permissions: artPermissions({
authorId: a.userId,
linkedUsers: a.linkedUsers,
}),
}));
}
export async function findAllTags() {
return db.selectFrom("ArtTag").select(["id", "name"]).execute();
}
export async function deleteOrphanTags() {
const result = await db
.deleteFrom("ArtTag")
.where("id", "not in", db.selectFrom("TaggedArt").select("TaggedArt.tagId"))
.executeTakeFirst();
return Number(result.numDeletedRows);
}
/** Art by its id, with the ids of the users tagged in it. */
export async function findById(id: Tables["Art"]["id"]) {
const row = await db
.selectFrom("Art")
.select(({ eb }) => [
"Art.id",
"Art.authorId",
linkedUsersSubquery(eb).as("linkedUsers"),
])
.where("Art.id", "=", id)
.executeTakeFirst();
if (!row) return;
return {
id: row.id,
linkedUserIds: row.linkedUsers.map((linkedUser) => linkedUser.id),
permissions: artPermissions({
authorId: row.authorId,
linkedUsers: row.linkedUsers,
}),
};
}
export async function findArtsByUserId(
userId: number,
{ includeAuthored = true, includeTagged = true } = {},
): Promise<ListedArt[]> {
const taggedButNotAuthored = includeTagged
? await db
.selectFrom("Art")
.innerJoin("ArtUserMetadata", "ArtUserMetadata.artId", "Art.id")
.innerJoin("UserSubmittedImage", "UserSubmittedImage.id", "Art.imgId")
.innerJoin("User", "User.id", "Art.authorId")
.select(({ eb }) => [
"Art.id",
"Art.description",
"Art.createdAt",
"Art.isShowcase",
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"url",
),
...commonUserSelect(eb, { idAs: "userId" }),
"User.commissionsOpen",
jsonArrayFrom(
eb
.selectFrom("TaggedArt")
.innerJoin("ArtTag", "ArtTag.id", "TaggedArt.tagId")
.select(["ArtTag.id", "ArtTag.name"])
.whereRef("TaggedArt.artId", "=", "Art.id"),
).as("tags"),
jsonArrayFrom(
eb
.selectFrom("ArtUserMetadata")
.innerJoin(
"User as LinkedUser",
"LinkedUser.id",
"ArtUserMetadata.userId",
)
.select((linkedEb) =>
commonUserSelect(linkedEb, { alias: "LinkedUser" }),
)
.whereRef("ArtUserMetadata.artId", "=", "Art.id"),
).as("linkedUsers"),
])
.where("ArtUserMetadata.userId", "=", userId)
.where("Art.authorId", "!=", userId)
.execute()
: [];
const authored = includeAuthored
? await db
.selectFrom("Art")
.innerJoin("UserSubmittedImage", "UserSubmittedImage.id", "Art.imgId")
.select(({ eb }) => [
"Art.id",
"Art.description",
"Art.createdAt",
"Art.isShowcase",
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"url",
),
jsonArrayFrom(
eb
.selectFrom("TaggedArt")
.innerJoin("ArtTag", "ArtTag.id", "TaggedArt.tagId")
.select(["ArtTag.id", "ArtTag.name"])
.whereRef("TaggedArt.artId", "=", "Art.id"),
).as("tags"),
jsonArrayFrom(
eb
.selectFrom("ArtUserMetadata")
.innerJoin(
"User as LinkedUser",
"LinkedUser.id",
"ArtUserMetadata.userId",
)
.select((linkedEb) =>
commonUserSelect(linkedEb, { alias: "LinkedUser" }),
)
.whereRef("ArtUserMetadata.artId", "=", "Art.id"),
).as("linkedUsers"),
])
.where("Art.authorId", "=", userId)
.execute()
: [];
const combined = [
...taggedButNotAuthored.map((row) => ({
id: row.id,
url: row.url,
description: row.description ?? undefined,
createdAt: row.createdAt,
isShowcase: Boolean(row.isShowcase),
tags: row.tags.length > 0 ? row.tags : undefined,
linkedUsers: row.linkedUsers.length > 0 ? row.linkedUsers : undefined,
author: {
discordId: row.discordId,
username: row.username,
discordAvatar: row.discordAvatar,
customAvatarUrl: row.customAvatarUrl,
commissionsOpen: row.commissionsOpen,
},
permissions: artPermissions({
authorId: row.userId,
linkedUsers: row.linkedUsers,
}),
})),
...authored.map((row) => ({
id: row.id,
url: row.url,
description: row.description ?? undefined,
createdAt: row.createdAt,
isShowcase: Boolean(row.isShowcase),
tags: row.tags.length > 0 ? row.tags : undefined,
linkedUsers: row.linkedUsers.length > 0 ? row.linkedUsers : undefined,
author: undefined,
permissions: artPermissions({
authorId: userId,
linkedUsers: row.linkedUsers,
}),
})),
];
return combined.sort((a, b) => b.createdAt - a.createdAt);
}
export async function deleteById(id: number) {
return db.deleteFrom("Art").where("id", "=", id).execute();
}
type TagsToAdd = Array<Partial<Pick<Tables["ArtTag"], "name" | "id">>>;
type InsertArtArgs = Pick<Tables["Art"], "description"> &
Pick<Tables["UserSubmittedImage"], "url" | "validatedAt"> & {
linkedUsers: number[];
tags: TagsToAdd;
};
export async function insert(args: InsertArtArgs) {
const authorId = actorId();
return await db.transaction().execute(async (trx) => {
const img = await trx
.insertInto("UnvalidatedUserSubmittedImage")
.values({
submitterUserId: authorId,
url: args.url,
validatedAt: args.validatedAt,
})
.returningAll()
.executeTakeFirstOrThrow();
const hasExistingArt = await trx
.selectFrom("Art")
.select("id")
.where("authorId", "=", authorId)
.executeTakeFirst();
const art = await trx
.insertInto("Art")
.values({
authorId,
description: args.description,
imgId: img.id,
isShowcase: hasExistingArt ? 0 : 1,
})
.returningAll()
.executeTakeFirstOrThrow();
await trx
.insertInto("ArtUserMetadata")
.values(args.linkedUsers.map((userId) => ({ artId: art.id, userId })))
.execute();
await insertTags({ tags: args.tags, authorId, artId: art.id }, trx);
return art;
});
}
type UpdateArtArgs = Pick<Tables["Art"], "description" | "isShowcase"> & {
linkedUsers: number[];
tags: TagsToAdd;
};
export async function update(id: number, args: UpdateArtArgs) {
return await db.transaction().execute(async (trx) => {
const { authorId } = await trx
.selectFrom("Art")
.select("authorId")
.where("id", "=", id)
.executeTakeFirstOrThrow();
if (args.isShowcase) {
await trx
.updateTable("Art")
.set({ isShowcase: 0 })
.where("authorId", "=", authorId)
.execute();
}
await trx
.updateTable("Art")
.set({
description: args.description,
isShowcase: args.isShowcase ? 1 : 0,
})
.where("id", "=", id)
.execute();
await trx.deleteFrom("ArtUserMetadata").where("artId", "=", id).execute();
await trx
.insertInto("ArtUserMetadata")
.values(args.linkedUsers.map((userId) => ({ artId: id, userId })))
.execute();
await trx.deleteFrom("TaggedArt").where("artId", "=", id).execute();
await insertTags({ tags: args.tags, authorId, artId: id }, trx);
return id;
});
}
async function insertTags(
{
tags,
authorId,
artId,
}: {
tags: TagsToAdd;
authorId: number;
artId: number;
},
trx: Transaction<DB>,
) {
const newTagNames = tags
.filter((tag) => !tag.id)
.map((tag) => {
if (!tag.name) {
throw new Error("tag name must be provided if no id");
}
return tag.name;
});
const newTagIds = (
await trx
.insertInto("ArtTag")
.values(newTagNames.map((name) => ({ name, authorId })))
.returning("ArtTag.id")
.execute()
).map((tag) => tag.id);
const tagIds = [
...tags.flatMap((tag) => (tag.id ? [tag.id] : [])),
...newTagIds,
];
await trx
.insertInto("TaggedArt")
.values(tagIds.map((tagId) => ({ artId, tagId })))
.execute();
}
function linkedUsersSubquery(eb: ExpressionBuilder<DB, "Art">) {
return jsonArrayFrom(
eb
.selectFrom("ArtUserMetadata")
.select("ArtUserMetadata.userId as id")
.whereRef("ArtUserMetadata.artId", "=", "Art.id"),
);
}
function artPermissions({
authorId,
linkedUsers,
}: {
authorId: number;
linkedUsers: Array<{ id: number }>;
}): ListedArt["permissions"] {
return {
EDIT: [authorId],
UNLINK: linkedUsers.map((linkedUser) => linkedUser.id),
};
}

View File

@@ -0,0 +1,88 @@
import type { ActionFunction } from "react-router";
import { redirect } from "react-router";
import * as R from "remeda";
import * as ArtRepository from "~/features/art/ArtRepository.server";
import { userArtPage } from "~/features/art/art-urls";
import { requireUser } from "~/features/auth/core/user.server";
import { notify } from "~/features/notifications/core/notify.server";
import { parseFormData } from "~/form/parse.server";
import {
requirePermission,
requireRole,
} from "~/modules/permissions/guards.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { badRequestIfFalsy, errorToastIfFalsy } from "~/utils/remix.server";
import { toDBBoolean } from "~/utils/sql";
import { ART_FORM_MAX_BODY_BYTES } from "../art-image";
import { uploadArtImage } from "../art-image.server";
import { artFormSchema } from "../art-schemas";
export const action: ActionFunction = async ({ request }) => {
const user = requireUser();
requireRole("ARTIST");
const result = await parseFormData({
request,
schema: artFormSchema,
maxBodyBytes: ART_FORM_MAX_BODY_BYTES,
});
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
const data = result.data;
const linkedUsers = R.unique(
data.linkedUsers.filter((userId) => typeof userId === "number"),
);
if (data.artId) {
const existingArt = badRequestIfFalsy(
await ArtRepository.findById(data.artId),
);
requirePermission(existingArt, "EDIT");
const editedArtId = await ArtRepository.update(data.artId, {
description: data.description,
isShowcase: toDBBoolean(data.isShowcase),
linkedUsers,
tags: data.tags,
});
notify({
userIds: R.difference(linkedUsers, existingArt.linkedUserIds),
notification: {
type: "TAGGED_TO_ART",
meta: {
adderUsername: user.username,
adderDiscordId: user.discordId,
artId: editedArtId,
},
},
});
} else {
errorToastIfFalsy(data.img?.type === "NEW", "Art image is missing");
const addedArt = await ArtRepository.insert({
description: data.description,
url: await uploadArtImage(data.img),
validatedAt: user.patronTier ? dateToDatabaseTimestamp(new Date()) : null,
linkedUsers,
tags: data.tags,
});
notify({
userIds: linkedUsers,
notification: {
type: "TAGGED_TO_ART",
meta: {
adderUsername: user.username,
adderDiscordId: user.discordId,
artId: addedArt.id,
},
},
});
}
throw redirect(userArtPage(user));
};

View File

@@ -0,0 +1,9 @@
export const ART_PER_PAGE = 14;
export const ART = {
DESCRIPTION_MAX_LENGTH: 140,
LINKED_USERS_MAX_LENGTH: 10,
THUMBNAIL_WIDTH: 640,
TAG_MAX_LENGTH: 100,
TAGS_MAX_LENGTH: 25,
};

View File

@@ -0,0 +1,43 @@
import { basename } from "node:path";
import { Readable } from "node:stream";
import { dataUrlToImageBuffer } from "~/features/img-upload/image-bytes.server";
import { uploadStreamToS3 } from "~/features/img-upload/s3.server";
import { shortNanoid } from "~/utils/id";
import invariant from "~/utils/invariant";
import { previewUrl } from "./art-utils";
const ALLOWED_ART_IMAGE_EXTENSIONS = ["png", "jpeg", "webp"] as const;
/**
* Uploads both assets a newly submitted art needs — the full image and its thumbnail, following
* the `<name>-small.<ext>` convention {@link previewUrl} resolves — and returns the full image's
* file name to store on the art's image row.
*/
export async function uploadArtImage({
dataUrl,
thumbnailDataUrl,
}: {
dataUrl: string;
thumbnailDataUrl: string;
}): Promise<string> {
const image = dataUrlToImageBuffer(dataUrl, ALLOWED_ART_IMAGE_EXTENSIONS);
const thumbnail = dataUrlToImageBuffer(
thumbnailDataUrl,
ALLOWED_ART_IMAGE_EXTENSIONS,
);
invariant(
image.extension === thumbnail.extension,
"Art image and its thumbnail are of a different format",
);
const fileName = `art-${Date.now()}-${shortNanoid()}.${image.extension}`;
const [uploadedLocation] = await Promise.all([
uploadStreamToS3(Readable.from(image.buffer), fileName),
uploadStreamToS3(Readable.from(thumbnail.buffer), previewUrl(fileName)),
]);
invariant(uploadedLocation, "Art image upload failed");
return basename(uploadedLocation);
}

View File

@@ -0,0 +1,81 @@
import { z } from "zod";
/**
* Allowed prefixes for an art data URL. Unlike the generic `image()` form field, art keeps the
* uploaded image's own format instead of normalizing everything to webp.
*/
const ART_IMAGE_DATA_URL_PREFIX_REGEX = /^data:image\/(png|jpeg|webp);base64,/;
/**
* Largest full-size art image accepted, decoded. Art is submitted at its original resolution and
* png keeps its detail losslessly, so this needs the same headroom the multipart upload flow used
* to allow.
*/
const ART_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
/**
* Largest art thumbnail accepted, decoded. The client caps the thumbnail's width to
* `ART.THUMBNAIL_WIDTH`, which lands well below this.
*/
const ART_THUMBNAIL_MAX_BYTES = 2 * 1024 * 1024;
/**
* Ceiling for the whole art submit body. Fits both data URLs at their maximum plus the rest of the
* form's fields.
*/
export const ART_FORM_MAX_BODY_BYTES =
maxDataUrlLength(ART_IMAGE_MAX_BYTES + ART_THUMBNAIL_MAX_BYTES) + 100_000;
/** Error shown when a picked image doesn't fit within the limits above. */
export const ART_IMAGE_TOO_LARGE_ERROR = "forms:errors.imageTooLarge";
const artImageDataUrl = (maxBytes: number) =>
z
.string()
.max(maxDataUrlLength(maxBytes), ART_IMAGE_TOO_LARGE_ERROR)
.regex(ART_IMAGE_DATA_URL_PREFIX_REGEX);
/**
* JSON-serializable value of the art image form field. Art can't use the generic `image()` field:
* it preserves aspect ratio, keeps the original format and derives a separate thumbnail, which is
* why a `NEW` value carries two data URLs. An `EXISTING` value marks art whose image was already
* uploaded (only the preview url rides along, never bytes) — art images can't be swapped after
* upload.
*/
export const artImageValue = z
.union([
z.object({
type: z.literal("EXISTING"),
url: z.string(),
}),
z.object({
type: z.literal("NEW"),
dataUrl: artImageDataUrl(ART_IMAGE_MAX_BYTES),
thumbnailDataUrl: artImageDataUrl(ART_THUMBNAIL_MAX_BYTES),
}),
])
.nullable();
export type ArtImageValue = z.infer<typeof artImageValue>;
/**
* Does a freshly compressed art image exceed what the schema accepts? Lets the form field reject
* an oversized pick right away instead of only when the filled-out form is submitted.
*/
export function isArtImageTooLarge({
dataUrl,
thumbnailDataUrl,
}: {
dataUrl: string;
thumbnailDataUrl: string;
}) {
return (
dataUrl.length > maxDataUrlLength(ART_IMAGE_MAX_BYTES) ||
thumbnailDataUrl.length > maxDataUrlLength(ART_THUMBNAIL_MAX_BYTES)
);
}
/** Length a base64 data URL encoding `bytes` decoded bytes can reach, `data:` prefix included. */
function maxDataUrlLength(bytes: number) {
return Math.ceil(bytes / 3) * 4 + 32;
}

View File

@@ -0,0 +1,17 @@
import { z } from "zod";
import { _action, id } from "~/utils/zod";
const deleteArtSchema = z.object({
_action: _action("DELETE_ART"),
id,
});
const unlinkArtSchema = z.object({
_action: _action("UNLINK_ART"),
id,
});
export const userArtPageActionSchema = z.union([
deleteArtSchema,
unlinkArtSchema,
]);

View File

@@ -0,0 +1,52 @@
import { z } from "zod";
import {
array,
customField,
idConstantOptional,
textAreaOptional,
toggle,
userSearchOptional,
} from "~/form/fields";
import { id } from "~/utils/zod";
import { ART } from "./art-constants";
import { artImageValue } from "./art-image";
const artTags = z
.array(
z.object({
name: z.string().min(1).max(ART.TAG_MAX_LENGTH).optional(),
id: id.optional(),
}),
)
.max(ART.TAGS_MAX_LENGTH);
export const artFormSchema = z
.object({
artId: idConstantOptional(),
img: customField({ initialValue: null }, artImageValue),
description: textAreaOptional({
label: "labels.description",
maxLength: ART.DESCRIPTION_MAX_LENGTH,
}),
tags: customField({ initialValue: [] }, artTags),
linkedUsers: array({
label: "labels.linkedUsers",
bottomText: "bottomTexts.linkedUsers",
max: ART.LINKED_USERS_MAX_LENGTH,
field: userSearchOptional({ label: "labels.user" }),
}),
isShowcase: toggle({
label: "labels.showcase",
bottomText: "bottomTexts.showcase",
}),
})
.superRefine((data, ctx) => {
// existing art keeps its image, new art must bring one
if (!data.artId && data.img?.type !== "NEW") {
ctx.addIssue({
path: ["img"],
code: "custom",
message: "forms:errors.required",
});
}
});

View File

@@ -0,0 +1,53 @@
import { describe, test } from "vitest";
import {
assertDecodesToDefault,
assertRoundTrips,
} from "~/modules/search-params/search-params-test-utils";
import {
artGridSearchParams,
artNewSearchParams,
artSearchParams,
} from "./art-search-params";
describe("artSearchParams", () => {
test("round-trips", () => {
assertRoundTrips(artSearchParams, {
tag: ["cat", "some tag"],
tab: ["recently-uploaded", "showcase"],
open: [true, false],
});
});
test("garbage decodes to default", () => {
assertDecodesToDefault(artSearchParams, "tab", [["not-a-tab"]]);
assertDecodesToDefault(artSearchParams, "open", [["yes"], ["1"]]);
});
});
describe("artGridSearchParams", () => {
test("round-trips", () => {
assertRoundTrips(artGridSearchParams, {
big: [1, 42],
});
});
test("garbage decodes to default", () => {
assertDecodesToDefault(artGridSearchParams, "big", [
["abc"],
["-1"],
["1.5"],
]);
});
});
describe("artNewSearchParams", () => {
test("round-trips", () => {
assertRoundTrips(artNewSearchParams, {
art: [1, 999],
});
});
test("garbage decodes to default", () => {
assertDecodesToDefault(artNewSearchParams, "art", [["abc"], ["0"]]);
});
});

View File

@@ -0,0 +1,25 @@
import { z } from "zod";
import * as SearchParams from "~/modules/search-params/search-params";
import { SP } from "~/modules/search-params/search-params";
export const ART_TABS = {
RECENTLY_UPLOADED: "recently-uploaded",
SHOWCASE: "showcase",
} as const;
export const artSearchParams = SearchParams.define({
tag: SP.param(z.string().nullable(), { loader: true }),
tab: SP.param(z.enum([ART_TABS.RECENTLY_UPLOADED, ART_TABS.SHOWCASE]), {
default: ART_TABS.RECENTLY_UPLOADED,
loader: false,
}),
open: SP.param(z.boolean(), { default: false, loader: false }),
});
export const artGridSearchParams = SearchParams.define({
big: SP.param(z.number().int().positive().nullable(), { loader: false }),
});
export const artNewSearchParams = SearchParams.define({
art: SP.param(z.number().int().positive().nullable(), { loader: true }),
});

View File

@@ -0,0 +1,29 @@
import type { Tables } from "~/db/tables";
import type { CommonUser } from "~/utils/kysely.server";
export interface ListedArt {
id: Tables["Art"]["id"];
createdAt: Tables["Art"]["createdAt"];
url: Tables["UserSubmittedImage"]["url"];
description?: Tables["Art"]["description"];
isShowcase: boolean;
tags?: Array<{
id: Tables["ArtTag"]["id"];
name: Tables["ArtTag"]["name"];
}>;
linkedUsers?: Array<CommonUser>;
author?: {
discordId: Tables["User"]["discordId"];
username: Tables["User"]["username"];
discordAvatar: Tables["User"]["discordAvatar"];
customAvatarUrl: string | null;
commissionsOpen: Tables["User"]["commissionsOpen"];
};
permissions: {
EDIT: Array<number>;
UNLINK: Array<number>;
};
}
export const ART_SOURCES = ["ALL", "MADE-BY", "MADE-OF"] as const;
export type ArtSource = (typeof ART_SOURCES)[number];

View File

@@ -0,0 +1,12 @@
import { describe, expect, test } from "vitest";
import { userArtPage } from "./art-urls";
describe("userArtPage()", () => {
test("joins source and bigArtId params with a single query string", () => {
const url = userArtPage({ discordId: "123" }, "MADE-BY", 456);
const params = new URLSearchParams(url.split("?")[1]);
expect(params.get("source")).toBe("MADE-BY");
expect(params.get("big")).toBe("456");
});
});

View File

@@ -0,0 +1,27 @@
import type { Tables } from "~/db/tables";
import { userArtSearchParams } from "~/features/user-page/user-page-search-params";
import { type UserLinkArgs, userPage } from "~/utils/urls";
import {
artGridSearchParams,
artNewSearchParams,
artSearchParams,
} from "./art-search-params";
import type { ArtSource } from "./art-types";
export const artPage = (tag?: string) =>
artSearchParams.href("/art", { tag: tag ?? null });
export const userArtPage = (
user: UserLinkArgs,
source?: ArtSource,
bigArtId?: number,
) =>
artGridSearchParams.href(
userArtSearchParams.href(`${userPage(user)}/art`, {
...(source ? { source } : {}),
}),
{ big: bigArtId ?? null },
);
export const newArtPage = (artId?: Tables["Art"]["id"]) =>
artNewSearchParams.href(`${artPage()}/new`, { art: artId ?? null });

View File

@@ -0,0 +1,9 @@
export function previewUrl(url: string) {
const lastDotIndex = url.lastIndexOf(".");
if (lastDotIndex === -1) return url;
const urlWithoutExtension = url.slice(0, lastDotIndex);
const extension = url.slice(lastDotIndex + 1);
return `${urlWithoutExtension}-small.${extension}`;
}

View File

@@ -0,0 +1,44 @@
.thumbnail {
cursor: pointer;
transition: all 0.2s ease;
&:hover {
scale: 1.025;
}
}
.dialogTag {
background-color: #fff;
border-radius: var(--radius-box);
color: #000;
font-size: var(--font-2xs);
padding-inline: var(--s-1);
margin-block: var(--s-1) var(--s-0-5);
}
.dialogTagUser {
background-color: var(--color-accent);
}
.dialogDescription {
font-size: var(--font-sm);
text-align: center;
color: #fff;
}
.dialogImg {
max-width: 100%;
max-height: 75vh;
width: 100%;
height: auto;
object-fit: contain;
display: block;
}
.tagsContainer {
display: flex;
gap: var(--s-0-5) var(--s-2);
justify-content: center;
flex-wrap: wrap;
margin-block-start: var(--s-0-5);
}

View File

@@ -0,0 +1,297 @@
import clsx from "clsx";
import { SquarePen, Trash, Unlink, X } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { Avatar } from "~/components/Avatar";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Pagination } from "~/components/Pagination";
import { artPage, newArtPage, userArtPage } from "~/features/art/art-urls";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useFormatDistanceToNow } from "~/hooks/intl/useFormatDistanceToNow";
import { useHydrated } from "~/hooks/useHydrated";
import { usePagination } from "~/hooks/usePagination";
import { useHasPermission } from "~/modules/permissions/hooks";
import { useSearchParam } from "~/modules/search-params/hooks";
import { databaseTimestampToDate } from "~/utils/dates";
import { userPage } from "~/utils/urls";
import { ResponsiveMasonry } from "../../../modules/responsive-masonry/components/ResponsiveMasonry";
import { ART_PER_PAGE } from "../art-constants";
import { artGridSearchParams } from "../art-search-params";
import type { ListedArt } from "../art-types";
import { previewUrl } from "../art-utils";
import styles from "./ArtGrid.module.css";
export function ArtGrid({
arts,
enablePreview = false,
showUploadDate = false,
}: {
arts: ListedArt[];
enablePreview?: boolean;
showUploadDate?: boolean;
}) {
const {
itemsToDisplay,
everythingVisible,
currentPage,
pagesCount,
nextPage,
previousPage,
setPage,
} = usePagination({
items: arts,
pageSize: ART_PER_PAGE,
});
const [bigArtId, setBigArtId] = useSearchParam(artGridSearchParams, "big");
const isHydrated = useHydrated();
if (!isHydrated) return null;
const bigArt = itemsToDisplay.find((art) => art.id === bigArtId);
return (
<>
{bigArt ? (
<BigImageDialog close={() => setBigArtId(null)} art={bigArt} />
) : null}
<ResponsiveMasonry>
{itemsToDisplay.map((art) => (
<ImagePreview
key={art.id}
art={art}
enablePreview={enablePreview}
showUploadDate={showUploadDate}
onClick={enablePreview ? () => setBigArtId(art.id) : undefined}
/>
))}
</ResponsiveMasonry>
{!everythingVisible ? (
<div className="mt-6">
<Pagination
currentPage={currentPage}
pagesCount={pagesCount}
nextPage={nextPage}
previousPage={previousPage}
setPage={setPage}
/>
</div>
) : null}
</>
);
}
function BigImageDialog({ close, art }: { close: () => void; art: ListedArt }) {
const [imageSettled, setImageSettled] = React.useState(false);
const { formatter } = useDateTimeFormat({
year: "numeric",
month: "numeric",
day: "numeric",
});
return (
<SendouDialog
heading={formatter.format(databaseTimestampToDate(art.createdAt)) ?? ""}
onClose={close}
isFullScreen
>
<img
alt=""
src={art.url}
loading="lazy"
className={styles.dialogImg}
onLoad={() => setImageSettled(true)}
onError={() => setImageSettled(true)}
/>
{art.tags || art.linkedUsers ? (
<div
className={clsx(styles.tagsContainer, { invisible: !imageSettled })}
>
{art.linkedUsers?.map((user) => (
<Link
to={userPage(user)}
key={user.discordId}
className={clsx(styles.dialogTag, styles.dialogTagUser)}
>
{user.username}
</Link>
))}
{art.tags?.map((tag) => (
<Link
to={artPage(tag.name)}
key={tag.id}
className={styles.dialogTag}
>
#{tag.name}
</Link>
))}
</div>
) : null}
{art.description ? (
<div
className={clsx(styles.dialogDescription, {
invisible: !imageSettled,
})}
>
{art.description}
</div>
) : null}
<SendouButton
variant="destructive"
className="mx-auto mt-6"
onPress={close}
icon={<X />}
>
Close
</SendouButton>
</SendouDialog>
);
}
function ImagePreview({
art,
onClick,
enablePreview = false,
showUploadDate = false,
}: {
art: ListedArt;
onClick?: () => void;
enablePreview?: boolean;
showUploadDate?: boolean;
}) {
const canEdit = useHasPermission(art, "EDIT");
const canUnlink = useHasPermission(art, "UNLINK");
const [imageSettled, setImageSettled] = React.useState(false);
const { t } = useTranslation(["common", "art"]);
const formatDistanceToNow = useFormatDistanceToNow();
const img = (
// biome-ignore lint/a11y/noStaticElementInteractions: Biome v2 migration
<img
alt=""
src={previewUrl(art.url)}
loading="lazy"
onClick={onClick}
onLoad={() => setImageSettled(true)}
onError={() => setImageSettled(true)}
className={enablePreview ? styles.thumbnail : undefined}
data-testid="art-image"
/>
);
if (!art.author && canEdit) {
return (
<div>
{img}
<div
className={clsx("stack horizontal justify-between mt-2", {
invisible: !imageSettled,
})}
>
<LinkButton
to={newArtPage(art.id)}
size="small"
variant="outlined"
icon={<SquarePen />}
>
{t("common:actions.edit")}
</LinkButton>
<FormWithConfirm
dialogHeading={t("art:delete.title")}
fields={[
["id", art.id],
["_action", "DELETE_ART"],
]}
>
<SendouButton icon={<Trash />} variant="destructive" size="small" />
</FormWithConfirm>
</div>
</div>
);
}
if (!art.author) return img;
const uploadDateText = showUploadDate
? formatDistanceToNow(databaseTimestampToDate(art.createdAt), {
addSuffix: true,
})
: null;
// whole thing is not a link so we can preview the image
if (enablePreview) {
return (
<div>
{img}
<div
className={clsx("stack horizontal justify-between", {
"mt-2": canUnlink,
})}
>
<Link
to={userArtPage(art.author, "MADE-BY")}
className={clsx("stack sm horizontal text-xs items-center mt-1", {
invisible: !imageSettled,
})}
>
<Avatar user={art.author} size="xxs" />
{t("art:madeBy")} {art.author.username}
</Link>
{uploadDateText ? (
<div
className={clsx("text-xs text-lighter", {
invisible: !imageSettled,
})}
>
{uploadDateText}
</div>
) : null}
{canUnlink ? (
<FormWithConfirm
dialogHeading={t("art:unlink.title", {
username: art.author.username,
})}
fields={[
["id", art.id],
["_action", "UNLINK_ART"],
]}
submitButtonText={t("common:actions.remove")}
>
<SendouButton
icon={<Unlink />}
variant="destructive"
size="small"
/>
</FormWithConfirm>
) : null}
</div>
</div>
);
}
return (
<Link to={userArtPage(art.author, "MADE-BY")}>
{img}
<div className="stack horizontal justify-between">
<div
className={clsx("stack sm horizontal text-xs items-center mt-1", {
invisible: !imageSettled,
})}
>
<Avatar user={art.author} size="xxs" />
{art.author.username}
</div>
{uploadDateText ? (
<div
className={clsx("text-xxs mt-1 text-lighter", {
invisible: !imageSettled,
})}
>
{uploadDateText}
</div>
) : null}
</div>
</Link>
);
}

View File

@@ -0,0 +1,3 @@
.preview {
max-width: 100%;
}

View File

@@ -0,0 +1,115 @@
import clsx from "clsx";
import Compressor from "compressorjs";
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { CustomFieldRenderProps } from "~/form";
import { FormFieldWrapper } from "~/form/fields/FormFieldWrapper";
import { logger } from "~/utils/logger";
import { ART } from "../art-constants";
import {
ART_IMAGE_TOO_LARGE_ERROR,
type ArtImageValue,
isArtImageTooLarge,
} from "../art-image";
import { previewUrl } from "../art-utils";
import styles from "./ArtImageFormField.module.css";
type ArtImageFormFieldProps = Omit<
CustomFieldRenderProps<ArtImageValue>,
"name"
>;
/**
* Image picker for art. Produces both derived assets the art pipeline needs — the full image with
* its aspect ratio and format preserved, and a thumbnail — as base64 data URLs so they can ride
* along in `SendouForm`'s single JSON submit. Art of already uploaded art can't be swapped, so an
* `EXISTING` value renders as a plain preview.
*/
export function ArtImageFormField({
value,
onChange,
error,
}: ArtImageFormFieldProps) {
const id = React.useId();
const [tooLargeError, setTooLargeError] = React.useState<string>();
const { t } = useTranslation(["common"]);
if (value?.type === "EXISTING") {
return <img src={previewUrl(value.url)} alt="" />;
}
const handleFileChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
setTooLargeError(undefined);
const uploadedFile = event.target.files?.[0];
if (!uploadedFile) {
onChange(null);
return;
}
try {
const [dataUrl, thumbnailDataUrl] = await Promise.all([
compressToDataUrl(uploadedFile, {}),
compressToDataUrl(uploadedFile, { maxWidth: ART.THUMBNAIL_WIDTH }),
]);
if (isArtImageTooLarge({ dataUrl, thumbnailDataUrl })) {
setTooLargeError(ART_IMAGE_TOO_LARGE_ERROR);
onChange(null);
return;
}
onChange({ type: "NEW", dataUrl, thumbnailDataUrl });
} catch (err) {
logger.error(err);
onChange(null);
}
};
return (
<FormFieldWrapper
id={id}
name="img"
label={t("common:upload.imageToUpload")}
error={tooLargeError ?? error}
required
>
<div className="stack sm items-start">
<input
id={id}
type="file"
accept="image/png, image/jpeg, image/jpg, image/webp"
onChange={handleFileChange}
/>
{value ? (
<img
src={value.dataUrl}
alt=""
className={clsx(styles.preview, "rounded")}
/>
) : null}
</div>
</FormFieldWrapper>
);
}
function compressToDataUrl(
file: File,
options: Compressor.Options,
): Promise<string> {
return new Promise((resolve, reject) => {
new Compressor(file, {
...options,
success(result) {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () =>
reject(new Error("Failed to read compressed image"));
reader.readAsDataURL(result);
},
error: reject,
});
});
}

View File

@@ -0,0 +1,11 @@
/* the minimal button variant keeps the full field height and its own font size, which breaks
the baseline when it sits inline in a line of helper text */
.switcherButton {
height: auto;
font-size: inherit;
}
/* the new tag input is stretched to the field width, so the button must keep its own */
.addButton {
flex-shrink: 0;
}

View File

@@ -0,0 +1,152 @@
import { X } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { SendouButton } from "~/components/elements/Button";
import type { CustomFieldRenderProps } from "~/form";
import { FormFieldWrapper } from "~/form/fields/FormFieldWrapper";
import { ART } from "../art-constants";
import styles from "./ArtTagsFormField.module.css";
import { TagSelect } from "./TagSelect";
export type ArtTag = { name?: string; id?: number };
type ArtTagsFormFieldProps = Omit<CustomFieldRenderProps<ArtTag[]>, "name"> & {
/** All tags that exist in the database, selectable without creating a new one. */
existingTags: Array<{ id: number; name: string }>;
};
// note: not handling edge case where a tag was added by another user while this
// user was adding a new art with the same tag -> will crash
export function ArtTagsFormField({
value,
onChange,
error,
existingTags,
}: ArtTagsFormFieldProps) {
const id = React.useId();
const { t } = useTranslation(["art", "common"]);
const [creationMode, setCreationMode] = React.useState(false);
const [newTagValue, setNewTagValue] = React.useState("");
const handleAddNewTag = () => {
const normalizedNewTagValue = newTagValue
.trim()
// replace many whitespaces with one
.replace(/\s\s+/g, " ")
.toLowerCase();
if (
normalizedNewTagValue.length === 0 ||
normalizedNewTagValue.length > ART.TAG_MAX_LENGTH
) {
return;
}
const alreadyCreatedTag = existingTags.find(
(tag) => tag.name === normalizedNewTagValue,
);
if (alreadyCreatedTag) {
onChange([...value, alreadyCreatedTag]);
} else if (value.every((tag) => tag.name !== normalizedNewTagValue)) {
onChange([...value, { name: normalizedNewTagValue }]);
}
setNewTagValue("");
setCreationMode(false);
};
return (
<FormFieldWrapper
id={id}
name="tags"
label={t("art:forms.tags.title")}
error={error}
>
<div className="stack xs">
{value.length >= ART.TAGS_MAX_LENGTH ? (
<div className="text-sm text-warning">
{t("art:forms.tags.maxReached")}
</div>
) : creationMode ? (
<>
<div className="stack horizontal sm items-center">
<input
id={id}
placeholder={t("art:forms.tags.addNew.placeholder")}
value={newTagValue}
onChange={(e) => setNewTagValue(e.target.value)}
onKeyDown={(event) => {
if (event.code === "Enter") {
handleAddNewTag();
}
}}
/>
<SendouButton
size="small"
variant="outlined"
className={styles.addButton}
onPress={handleAddNewTag}
>
{t("common:actions.add")}
</SendouButton>
</div>
<div className="text-xs text-lighter">
<SendouButton
variant="minimal"
className={styles.switcherButton}
onPress={() => setCreationMode(false)}
>
{t("art:forms.tags.selectFromExisting")}
</SendouButton>
</div>
</>
) : (
<>
<TagSelect
// empty combobox on select
key={value.length}
tags={existingTags}
disabledKeys={value
.map((tag) => tag.id)
.filter((id) => id !== undefined)}
onSelectionChange={(tagName) =>
onChange([
...value,
existingTags.find((tag) => tag.name === tagName)!,
])
}
/>
<div className="stack horizontal xs items-center text-xs text-lighter">
{t("art:forms.tags.cantFindExisting")}
<SendouButton
variant="minimal"
className={styles.switcherButton}
onPress={() => setCreationMode(true)}
>
{t("art:forms.tags.addNew")}
</SendouButton>
</div>
</>
)}
{value.length > 0 ? (
<div className="text-sm stack sm flex-wrap horizontal">
{value.map((tag) => (
<div key={tag.name} className="stack horizontal xs items-center">
{tag.name}
<SendouButton
icon={<X />}
size="miniscule"
variant="minimal-destructive"
onPress={() =>
onChange(value.filter((it) => it.name !== tag.name))
}
/>
</div>
))}
</div>
) : null}
</div>
</FormFieldWrapper>
);
}

View File

@@ -0,0 +1,34 @@
import { useTranslation } from "react-i18next";
import {
SendouSelect,
SendouSelectItem,
type SendouSelectProps,
} from "~/components/elements/Select";
interface TagSelectProps<T extends object>
extends Omit<SendouSelectProps<T>, "items" | "children"> {
tags: Array<{ id: number; name: string }>;
}
export function TagSelect<T extends object>({
tags,
...rest
}: TagSelectProps<T>) {
const { t } = useTranslation(["art"]);
return (
<SendouSelect
items={tags}
placeholder={t("art:forms.tags.placeholder")}
search={{ placeholder: t("art:forms.tags.search.placeholder") }}
aria-label={t("art:forms.tags.placeholder")}
{...rest}
>
{({ id, name, ...item }) => (
<SendouSelectItem key={id} id={name} {...item}>
{name}
</SendouSelectItem>
)}
</SendouSelect>
);
}

View File

@@ -0,0 +1,23 @@
import type { LoaderFunctionArgs } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import * as ArtRepository from "../ArtRepository.server";
import { artNewSearchParams } from "../art-search-params";
export const loader = async ({ url }: LoaderFunctionArgs) => {
const user = requireUser();
const { art: artId } = artNewSearchParams.parse(url);
if (artId === null) {
return { art: null, tags: await ArtRepository.findAllTags() };
}
const userArts = await ArtRepository.findArtsByUserId(user.id, {
includeTagged: false,
});
const art = userArts.find((a) => a.id === artId);
if (!art) {
return { art: null, tags: await ArtRepository.findAllTags() };
}
return { art, tags: await ArtRepository.findAllTags() };
};

View File

@@ -0,0 +1,35 @@
import cachified from "@epic-web/cachified";
import type { LoaderFunctionArgs } from "react-router";
import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server";
import * as ArtRepository from "../ArtRepository.server";
import { artSearchParams } from "../art-search-params";
export const loader = async ({ url }: LoaderFunctionArgs) => {
const cachedArts = await cachified({
key: "arts",
cache,
ttl: ttl(IN_MILLISECONDS.TWO_HOURS),
async getFreshValue() {
return {
showcaseArts: await ArtRepository.findShowcaseArts(),
recentlyUploadedArts: await ArtRepository.findRecentlyUploadedArts(),
allTags: await ArtRepository.findAllTags(),
};
},
});
const { tag: filteredTagName } = artSearchParams.parse(url);
const filteredTag = filteredTagName
? cachedArts.allTags.find((t) => t.name === filteredTagName)
: null;
if (!filteredTag) {
return filteredTagName ? { ...cachedArts, showcaseArts: [] } : cachedArts;
}
return {
...cachedArts,
showcaseArts: await ArtRepository.findShowcaseArtsByTag(filteredTag.id),
};
};

View File

@@ -0,0 +1,98 @@
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { useLoaderData } from "react-router";
import { Alert } from "~/components/Alert";
import { FormMessage } from "~/components/FormMessage";
import { Main } from "~/components/Main";
import { artPage } from "~/features/art/art-urls";
import type { CustomFieldRenderProps } from "~/form";
import { SendouForm } from "~/form/SendouForm";
import { useHasRole } from "~/modules/permissions/hooks";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { navIconUrl } from "~/utils/urls";
import { metaTitle } from "../../../utils/remix";
import { action } from "../actions/art.new.server";
import type { ArtImageValue } from "../art-image";
import { artFormSchema } from "../art-schemas";
import { ArtImageFormField } from "../components/ArtImageFormField";
import { type ArtTag, ArtTagsFormField } from "../components/ArtTagsFormField";
import { loader } from "../loaders/art.new.server";
export { action, loader };
export const handle: SendouRouteHandle = {
i18n: ["art"],
breadcrumb: () => ({
imgPath: navIconUrl("art"),
href: artPage(),
type: "IMAGE",
}),
};
export const meta: MetaFunction = () => {
return metaTitle({
title: "New art",
});
};
export default function NewArtPage() {
const data = useLoaderData<typeof loader>();
const { t } = useTranslation(["art"]);
const isArtist = useHasRole("ARTIST");
if (!isArtist) {
return (
<Main className="stack items-center">
<Alert variation="WARNING">{t("art:gainPerms")}</Alert>
</Main>
);
}
const isCurrentlyShowcase = Boolean(data.art?.isShowcase);
return (
<Main halfWidth>
<SendouForm
schema={artFormSchema}
defaultValues={{
artId: data.art?.id,
img: data.art ? { type: "EXISTING", url: data.art.url } : null,
description: data.art?.description ?? "",
tags: data.art?.tags ?? [],
linkedUsers: data.art?.linkedUsers?.map((user) => user.id) ?? [],
isShowcase: isCurrentlyShowcase,
}}
>
{({ FormField }) => (
<>
<FormMessage type="info">{t("art:forms.caveats")}</FormMessage>
<FormField name="img">
{({ value, onChange, error }: CustomFieldRenderProps) => (
<ArtImageFormField
value={value as ArtImageValue}
onChange={onChange as (value: ArtImageValue) => void}
error={error}
/>
)}
</FormField>
<FormField name="description" />
<FormField name="tags">
{({ value, onChange, error }: CustomFieldRenderProps) => (
<ArtTagsFormField
value={value as ArtTag[]}
onChange={onChange as (value: ArtTag[]) => void}
error={error}
existingTags={data.tags}
/>
)}
</FormField>
<FormField name="linkedUsers" />
{data.art ? (
<FormField name="isShowcase" disabled={isCurrentlyShowcase} />
) : null}
</>
)}
</SendouForm>
</Main>
);
}

View File

@@ -0,0 +1,144 @@
import clsx from "clsx";
import { X } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { useLoaderData } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import { SendouSwitch } from "~/components/elements/Switch";
import {
SendouTab,
SendouTabList,
SendouTabPanel,
SendouTabs,
} from "~/components/elements/Tabs";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { artPage } from "~/features/art/art-urls";
import { useSearchParamsTyped } from "~/modules/search-params/hooks";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { navIconUrl } from "~/utils/urls";
import { metaTags, type SerializeFrom } from "../../../utils/remix";
import { ART_TABS, artSearchParams } from "../art-search-params";
import { ArtGrid } from "../components/ArtGrid";
import { TagSelect } from "../components/TagSelect";
import { loader } from "../loaders/art.server";
export { loader };
export const shouldRevalidate = artSearchParams.shouldRevalidate;
export const handle: SendouRouteHandle = {
i18n: ["art"],
breadcrumb: () => ({
imgPath: navIconUrl("art"),
href: artPage(),
type: "IMAGE",
}),
};
export const meta: MetaFunction = (args) => {
const data = args.loaderData as SerializeFrom<typeof loader> | null;
if (!data) return [];
return metaTags({
title: "Art",
ogTitle: "Splatoon art showcase",
description:
"Splatoon art filterable by various tags. Find artist to commission for your own custom art. Includes various styles such as traditional, digital, 3D and SFM.",
location: args.location,
});
};
export default function ArtPage() {
const { t } = useTranslation(["art", "common", "forms"]);
const data = useLoaderData<typeof loader>();
const [
{ tab: selectedTab, tag: filteredTag, open: showOpenCommissions },
setParams,
] = useSearchParamsTyped(artSearchParams);
const switchId = React.useId();
const showcaseArts = !showOpenCommissions
? data.showcaseArts
: data.showcaseArts.filter((art) => art.author?.commissionsOpen);
const recentlyUploadedArts = !showOpenCommissions
? data.recentlyUploadedArts
: data.recentlyUploadedArts.filter((art) => art.author?.commissionsOpen);
return (
<Main className="stack lg">
<div className="stack horizontal md justify-between items-center flex-wrap">
<div className="stack horizontal sm text-sm font-semi-bold">
<SendouSwitch
isSelected={showOpenCommissions}
onChange={() => setParams({ open: !showOpenCommissions })}
id={switchId}
/>
<Label htmlFor={switchId} className="m-auto-0">
{t("forms:labels.profileCommissionsOpen")}
</Label>
</div>
<div
className={clsx({
invisible: selectedTab !== ART_TABS.SHOWCASE,
})}
>
<TagSelect
key={filteredTag}
tags={data.allTags}
onSelectionChange={(tagName) => {
setParams({ tag: tagName as string });
}}
/>
</div>
</div>
{filteredTag ? (
<div className="text-xs text-lighter stack md horizontal items-center">
{t("art:filteringByTag", { tag: filteredTag })}
<SendouButton
size="small"
variant="minimal-destructive"
icon={<X />}
onPress={() => {
setParams({ tag: null });
}}
data-testid="clear-filter-button"
>
{t("common:actions.clear")}
</SendouButton>
</div>
) : null}
<SendouTabs
selectedKey={selectedTab}
onSelectionChange={(key) => {
const tab = key as (typeof ART_TABS)[keyof typeof ART_TABS];
setParams(
tab === ART_TABS.RECENTLY_UPLOADED ? { tab, tag: null } : { tab },
);
}}
>
<SendouTabList>
<SendouTab id={ART_TABS.RECENTLY_UPLOADED}>
{t("art:tabs.recentlyUploaded")}
</SendouTab>
<SendouTab id={ART_TABS.SHOWCASE}>{t("art:tabs.showcase")}</SendouTab>
</SendouTabList>
<SendouTabPanel id={ART_TABS.RECENTLY_UPLOADED}>
<ArtGrid arts={recentlyUploadedArts} showUploadDate />
</SendouTabPanel>
<SendouTabPanel id={ART_TABS.SHOWCASE}>
{filteredTag && showcaseArts.length === 0 ? (
<div className="no-results mt-4">
{t("art:noArtForTag", { tag: filteredTag })}
</div>
) : (
<ArtGrid arts={showcaseArts} />
)}
</SendouTabPanel>
</SendouTabs>
</Main>
);
}

View File

@@ -0,0 +1 @@
export const ARTICLES_FOLDER_PATH = "content/articles";

View File

@@ -0,0 +1,14 @@
import { z } from "zod";
const authorName = z.string().min(1);
const author = z.union([
authorName,
z.object({ name: authorName, link: z.string().url() }),
]);
export const articleDataSchema = z.object({
title: z.string().min(1),
author: z.union([author, z.array(author)]),
date: z.date(),
});

View File

@@ -0,0 +1,56 @@
import fs from "node:fs";
import path from "node:path";
import matter from "gray-matter";
import { ZodError, type z } from "zod";
import { ARTICLES_FOLDER_PATH } from "../articles-constants";
import { articleDataSchema } from "../articles-schemas.server";
const RESOLVED_ARTICLES_DIR = path.resolve(ARTICLES_FOLDER_PATH);
export function articleBySlug(slug: string) {
const validFiles = fs.globSync("*.md", { cwd: RESOLVED_ARTICLES_DIR });
if (!validFiles.includes(`${slug}.md`)) return null;
try {
const rawMarkdown = fs.readFileSync(
path.join(RESOLVED_ARTICLES_DIR, `${slug}.md`),
"utf8",
);
const { content, data } = matter(rawMarkdown);
const { date, ...restParsed } = articleDataSchema.parse(data);
return {
content,
date,
authors: normalizeAuthors(restParsed.author),
title: restParsed.title,
};
} catch (e) {
if (!(e instanceof Error)) throw e;
if (e.message.includes("ENOENT") || e instanceof ZodError) {
return null;
}
throw e;
}
}
export function normalizeAuthors(
authors: z.infer<typeof articleDataSchema>["author"],
): Array<{ name: string; link: string | null }> {
if (Array.isArray(authors)) {
return authors.map((author) => {
if (typeof author === "string") {
return { name: author, link: null };
}
return author;
});
}
if (typeof authors === "string") {
return [{ name: authors, link: null }];
}
return [authors];
}

View File

@@ -0,0 +1,35 @@
import fs from "node:fs";
import path from "node:path";
import matter from "gray-matter";
import { ARTICLES_FOLDER_PATH } from "../articles-constants";
import { articleDataSchema } from "../articles-schemas.server";
import { type articleBySlug, normalizeAuthors } from "./bySlug.server";
export async function mostRecentArticles(count: number) {
const files = await fs.promises.readdir(ARTICLES_FOLDER_PATH);
const articles: Array<
Omit<NonNullable<ReturnType<typeof articleBySlug>>, "content"> & {
slug: string;
}
> = [];
for (const file of files) {
const rawMarkdown = fs.readFileSync(
path.join(ARTICLES_FOLDER_PATH, file),
"utf8",
);
const { data } = matter(rawMarkdown);
const { date, ...restParsed } = articleDataSchema.parse(data);
articles.push({
date,
slug: file.replace(".md", ""),
authors: normalizeAuthors(restParsed.author),
title: restParsed.title,
});
}
return articles
.sort((a, b) => b.date.getTime() - a.date.getTime())
.slice(0, count);
}

View File

@@ -0,0 +1,12 @@
import type { LoaderFunctionArgs } from "react-router";
import invariant from "~/utils/invariant";
import { notFoundIfNullish } from "~/utils/remix.server";
import { articleBySlug } from "../core/bySlug.server";
export const loader = ({ params }: LoaderFunctionArgs) => {
invariant(params.slug);
const article = notFoundIfNullish(articleBySlug(params.slug));
return { ...article, slug: params.slug };
};

View File

@@ -0,0 +1,9 @@
import { mostRecentArticles } from "../core/list.server";
const MAX_ARTICLES_COUNT = 100;
export const loader = async () => {
return {
articles: await mostRecentArticles(MAX_ARTICLES_COUNT),
};
};

View File

@@ -0,0 +1,127 @@
import * as React from "react";
import type { MetaFunction } from "react-router";
import { Link, useLoaderData } from "react-router";
import { LocaleTime } from "~/components/LocaleTime";
import { Main } from "~/components/Main";
import { Markdown } from "~/components/Markdown";
import invariant from "~/utils/invariant";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
ARTICLES_MAIN_PAGE,
articlePreviewUrl,
navIconUrl,
} from "~/utils/urls";
import { metaTags, type SerializeFrom } from "../../../utils/remix";
import { loader } from "../loaders/a.$slug.server";
export { loader };
export const handle: SendouRouteHandle = {
breadcrumb: ({ match }) => {
const data = match.loaderData as SerializeFrom<typeof loader> | undefined;
if (!data) return [];
return [
{
imgPath: navIconUrl("articles"),
href: ARTICLES_MAIN_PAGE,
type: "IMAGE",
},
];
},
};
export const meta: MetaFunction = (args) => {
invariant(args.params.slug);
const data = args.loaderData as SerializeFrom<typeof loader> | null;
if (!data) return [];
const description = data.content.trim().split("\n")[0];
return metaTags({
title: data.title,
description,
image: {
url: articlePreviewUrl(args.params.slug),
},
location: args.location,
});
};
export default function ArticlePage() {
const data = useLoaderData<typeof loader>();
return (
<Main>
<article className="article">
<h1>{data.title}</h1>
<div className="text-sm text-lighter">
by <Author /> •{" "}
<LocaleTime
date={new Date(data.date)}
options={{
day: "numeric",
month: "numeric",
year: "numeric",
// article dates are calendar dates parsed as UTC midnight;
// formatting in the viewer's zone would shift the day west of UTC
timeZone: "UTC",
}}
/>
</div>
<Markdown>
{contentWithoutLeadingTitle(data.content, data.title)}
</Markdown>
</article>
</Main>
);
}
function normalizeText(text: string) {
return text
.replace(/\*+/g, "")
.replace(/…/g, "...")
.replace(/\\!/g, "!")
.trim();
}
function contentWithoutLeadingTitle(content: string, title: string) {
const trimmed = content.trimStart();
const firstLineEnd = trimmed.indexOf("\n");
const firstLine =
firstLineEnd === -1 ? trimmed : trimmed.slice(0, firstLineEnd);
if (
firstLine.startsWith("# ") &&
normalizeText(firstLine.slice(2)) === normalizeText(title)
) {
return trimmed.slice(firstLine.length).trimStart();
}
return content;
}
function Author() {
const data = useLoaderData<typeof loader>();
return data.authors.map((author, i) => {
if (!author.link) return author.name;
const authorLink = author.link.includes("https://sendou.ink")
? author.link.replace("https://sendou.ink", "")
: author.link;
return (
<React.Fragment key={author.name}>
{author.link.includes("https://sendou.ink") ? (
<Link to={authorLink}>{author.name}</Link>
) : (
<a href={author.link}>{author.name}</a>
)}
{i < data.authors.length - 1 ? " & " : ""}
</React.Fragment>
);
});
}

View File

@@ -0,0 +1,12 @@
.list {
display: flex;
flex-direction: column;
padding: 0;
gap: var(--s-6);
list-style: none;
}
.title {
color: var(--color-text-accent);
font-size: var(--font-md);
}

View File

@@ -0,0 +1,68 @@
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { Link, useLoaderData } from "react-router";
import { LocaleTime } from "~/components/LocaleTime";
import { Main } from "~/components/Main";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { ARTICLES_MAIN_PAGE, articlePage, navIconUrl } from "~/utils/urls";
import { metaTags } from "../../../utils/remix";
import { loader } from "../loaders/a.server";
import styles from "./a.module.css";
export { loader };
export const handle: SendouRouteHandle = {
breadcrumb: () => ({
imgPath: navIconUrl("articles"),
href: ARTICLES_MAIN_PAGE,
type: "IMAGE",
}),
};
export const meta: MetaFunction = (args) => {
return metaTags({
title: "Articles",
ogTitle: "Splatoon articles",
description:
"Articles about the competitive side of Splatoon. Written by various community members.",
location: args.location,
});
};
export default function ArticlesMainPage() {
const { t, i18n } = useTranslation(["common"]);
const data = useLoaderData<typeof loader>();
return (
<Main className="stack lg">
<ul className={styles.list}>
{data.articles.map((article) => (
<li key={article.title}>
<Link to={articlePage(article.slug)} className={styles.title}>
{article.title}
</Link>
<div className="text-xs text-lighter">
{t("common:articles.by", {
author: new Intl.ListFormat(i18n.language, {
style: "short",
}).format(article.authors.map((a) => a.name)),
})}{" "}
•{" "}
<LocaleTime
date={new Date(article.date)}
options={{
day: "numeric",
month: "numeric",
year: "numeric",
// article dates are calendar dates parsed as UTC midnight;
// formatting in the viewer's zone would shift the day west of UTC
timeZone: "UTC",
}}
/>
</div>
</li>
))}
</ul>
</Main>
);
}

Some files were not shown because too many files have changed in this diff Show More