mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 07:37:55 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
458
apps/web-react/app/features/admin/AdminRepository.server.test.ts
Normal file
458
apps/web-react/app/features/admin/AdminRepository.server.test.ts
Normal file
@@ -0,0 +1,458 @@
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import * as BuildFactory from "~/db/seed/factories/BuildFactory";
|
||||
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
|
||||
import * as TournamentOrganizationFactory from "~/db/seed/factories/TournamentOrganizationFactory";
|
||||
import * as TrophyFactory from "~/db/seed/factories/TrophyFactory";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import { db } from "~/db/sql";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { databaseTimestampNow } from "~/utils/dates";
|
||||
import * as AdminRepository from "./AdminRepository.server";
|
||||
|
||||
const users = UserFactory.pool();
|
||||
|
||||
// the ban log records who banned by their Discord id, so the tests give the users
|
||||
// one they can name
|
||||
const createUsers = (count: number) =>
|
||||
users.create(count, (index) => ({ discordId: String(index) }));
|
||||
|
||||
describe("migrate", () => {
|
||||
let oldUserId: number;
|
||||
let newUserId: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
oldUserId = (await UserFactory.create()).id;
|
||||
newUserId = (await UserFactory.create()).id;
|
||||
});
|
||||
|
||||
const insertOwnership = async (args: {
|
||||
trophyId: number;
|
||||
tournamentId: number;
|
||||
userId: number;
|
||||
}) => {
|
||||
// biome-ignore lint/plugin: awarding a trophy requires playing out a whole tournament; migrate only cares that the rows exist
|
||||
await db.insertInto("TrophyOwner").values(args).execute();
|
||||
// biome-ignore lint/plugin: special trophies are synced from X rank placements; migrate only cares that the rows exist
|
||||
await db
|
||||
.insertInto("SpecialTrophyOwner")
|
||||
.values({
|
||||
trophyId: args.trophyId,
|
||||
userId: args.userId,
|
||||
createdAt: databaseTimestampNow(),
|
||||
})
|
||||
.execute();
|
||||
};
|
||||
|
||||
const createPendingWithApprovals = async (args: {
|
||||
submitterUserId: number;
|
||||
managerId: number;
|
||||
approverUserIds: number[];
|
||||
}) => {
|
||||
const organization = await TournamentOrganizationFactory.create({
|
||||
ownerId: oldUserId,
|
||||
});
|
||||
|
||||
return TrophyFactory.createPending(
|
||||
{
|
||||
organizationId: organization.id,
|
||||
submitterUserId: args.submitterUserId,
|
||||
managerId: args.managerId,
|
||||
},
|
||||
{ approverUserIds: args.approverUserIds },
|
||||
);
|
||||
};
|
||||
|
||||
test("re-points trophy data to the remaining user", async () => {
|
||||
const trophy = await TrophyFactory.create({
|
||||
creatorId: newUserId,
|
||||
managerId: newUserId,
|
||||
});
|
||||
const tournament = await TournamentFactory.create({ authorId: oldUserId });
|
||||
await insertOwnership({
|
||||
trophyId: trophy.id,
|
||||
tournamentId: tournament.id,
|
||||
userId: newUserId,
|
||||
});
|
||||
await createPendingWithApprovals({
|
||||
submitterUserId: newUserId,
|
||||
managerId: newUserId,
|
||||
approverUserIds: [newUserId],
|
||||
});
|
||||
|
||||
expect(await AdminRepository.migrate({ newUserId, oldUserId })).toBe(null);
|
||||
|
||||
const migrated = await db
|
||||
.selectFrom("Trophy")
|
||||
.select(["creatorId", "managerId"])
|
||||
.where("id", "=", trophy.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(migrated).toEqual({
|
||||
creatorId: oldUserId,
|
||||
managerId: oldUserId,
|
||||
});
|
||||
|
||||
expect(
|
||||
await db.selectFrom("TrophyOwner").select("userId").execute(),
|
||||
).toEqual([{ userId: oldUserId }]);
|
||||
expect(
|
||||
await db.selectFrom("SpecialTrophyOwner").select("userId").execute(),
|
||||
).toEqual([{ userId: oldUserId }]);
|
||||
|
||||
const pending = await db
|
||||
.selectFrom("PendingTrophy")
|
||||
.select(["submitterUserId", "managerId"])
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(pending).toEqual({
|
||||
submitterUserId: oldUserId,
|
||||
managerId: oldUserId,
|
||||
});
|
||||
|
||||
expect(
|
||||
await db.selectFrom("PendingTrophyApproval").select("userId").execute(),
|
||||
).toEqual([{ userId: oldUserId }]);
|
||||
});
|
||||
|
||||
test("drops the migrated account's duplicate rows when both accounts own the same trophy", async () => {
|
||||
const trophy = await TrophyFactory.create({
|
||||
creatorId: oldUserId,
|
||||
managerId: oldUserId,
|
||||
});
|
||||
const tournament = await TournamentFactory.create({ authorId: oldUserId });
|
||||
for (const userId of [oldUserId, newUserId]) {
|
||||
await insertOwnership({
|
||||
trophyId: trophy.id,
|
||||
tournamentId: tournament.id,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
await createPendingWithApprovals({
|
||||
submitterUserId: oldUserId,
|
||||
managerId: oldUserId,
|
||||
approverUserIds: [oldUserId, newUserId],
|
||||
});
|
||||
|
||||
expect(await AdminRepository.migrate({ newUserId, oldUserId })).toBe(null);
|
||||
|
||||
expect(
|
||||
await db.selectFrom("TrophyOwner").select("userId").execute(),
|
||||
).toEqual([{ userId: oldUserId }]);
|
||||
expect(
|
||||
await db.selectFrom("SpecialTrophyOwner").select("userId").execute(),
|
||||
).toEqual([{ userId: oldUserId }]);
|
||||
expect(
|
||||
await db.selectFrom("PendingTrophyApproval").select("userId").execute(),
|
||||
).toEqual([{ userId: oldUserId }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findAllBannedUsers", () => {
|
||||
let admin: { id: number };
|
||||
|
||||
const createBannedUser = (bannedReason: string, banned: 1 | Date = 1) =>
|
||||
UserFactory.create(null, {
|
||||
ban: { banned, bannedReason, bannedByUserId: admin.id },
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
admin = await UserFactory.create();
|
||||
});
|
||||
|
||||
test("returns empty Map when no users are banned", async () => {
|
||||
await UserFactory.create();
|
||||
|
||||
const result = await AdminRepository.findAllBannedUsers();
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
test("returns Map with single banned user", async () => {
|
||||
const banned = await createBannedUser("Test ban");
|
||||
|
||||
const result = await AdminRepository.findAllBannedUsers();
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get(banned.id)).toBeDefined();
|
||||
expect(result.get(banned.id)?.userId).toBe(banned.id);
|
||||
expect(result.get(banned.id)?.banned).toBe(1);
|
||||
expect(result.get(banned.id)?.bannedReason).toBe("Test ban");
|
||||
});
|
||||
|
||||
test("returns Map with multiple banned users", async () => {
|
||||
const first = await createBannedUser("Reason 1");
|
||||
const second = await createBannedUser("Reason 2");
|
||||
|
||||
const result = await AdminRepository.findAllBannedUsers();
|
||||
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.get(first.id)?.userId).toBe(first.id);
|
||||
expect(result.get(second.id)?.userId).toBe(second.id);
|
||||
});
|
||||
|
||||
test("excludes non-banned users from results", async () => {
|
||||
const banned = await createBannedUser("Test ban");
|
||||
const [other, another] = await UserFactory.createMany(2);
|
||||
|
||||
const result = await AdminRepository.findAllBannedUsers();
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get(banned.id)).toBeDefined();
|
||||
expect(result.get(other.id)).toBeUndefined();
|
||||
expect(result.get(another.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("includes both permanently and temporarily banned users", async () => {
|
||||
const futureDate = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7);
|
||||
|
||||
const permanent = await createBannedUser("Permanent ban");
|
||||
const temporary = await createBannedUser("Temporary ban", futureDate);
|
||||
|
||||
const result = await AdminRepository.findAllBannedUsers();
|
||||
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.get(permanent.id)?.banned).toBe(1);
|
||||
expect(result.get(temporary.id)?.banned).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("banUser", () => {
|
||||
beforeEach(async () => {
|
||||
await createUsers(3);
|
||||
});
|
||||
|
||||
test("permanently bans user (banned = 1)", async () => {
|
||||
await AdminRepository.banUser({
|
||||
userId: users.id(1),
|
||||
banned: 1,
|
||||
bannedReason: "Test permanent ban",
|
||||
bannedByUserId: users.id(2),
|
||||
});
|
||||
|
||||
const result = await AdminRepository.findAllBannedUsers();
|
||||
|
||||
expect(result.get(users.id(1))?.banned).toBe(1);
|
||||
expect(result.get(users.id(1))?.bannedReason).toBe("Test permanent ban");
|
||||
});
|
||||
|
||||
test("temporarily bans user (banned = Date)", async () => {
|
||||
const futureDate = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7);
|
||||
|
||||
await AdminRepository.banUser({
|
||||
userId: users.id(1),
|
||||
banned: futureDate,
|
||||
bannedReason: "Test temporary ban",
|
||||
bannedByUserId: users.id(2),
|
||||
});
|
||||
|
||||
const result = await AdminRepository.findAllBannedUsers();
|
||||
|
||||
expect(result.get(users.id(1))?.banned).toBeGreaterThan(1);
|
||||
expect(result.get(users.id(1))?.bannedReason).toBe("Test temporary ban");
|
||||
});
|
||||
|
||||
test("sets bannedReason correctly", async () => {
|
||||
const reason = "Violating terms of service";
|
||||
|
||||
await AdminRepository.banUser({
|
||||
userId: users.id(1),
|
||||
banned: 1,
|
||||
bannedReason: reason,
|
||||
bannedByUserId: users.id(2),
|
||||
});
|
||||
|
||||
const result = await AdminRepository.findAllBannedUsers();
|
||||
|
||||
expect(result.get(users.id(1))?.bannedReason).toBe(reason);
|
||||
});
|
||||
|
||||
test("creates BanLog entry when bannedByUserId is provided", async () => {
|
||||
await AdminRepository.banUser({
|
||||
userId: users.id(1),
|
||||
banned: 1,
|
||||
bannedReason: "Test ban",
|
||||
bannedByUserId: users.id(2),
|
||||
});
|
||||
|
||||
const modInfo = await UserRepository.findModInfoById(users.id(1));
|
||||
|
||||
expect(modInfo?.banLogs).toHaveLength(1);
|
||||
expect(modInfo?.banLogs[0].banned).toBe(1);
|
||||
expect(modInfo?.banLogs[0].bannedReason).toBe("Test ban");
|
||||
expect(modInfo?.banLogs[0].id).toBe(users.id(2));
|
||||
});
|
||||
|
||||
test("does not create BanLog when bannedByUserId is null (automatic ban)", async () => {
|
||||
await AdminRepository.banUser({
|
||||
userId: users.id(1),
|
||||
banned: 1,
|
||||
bannedReason: "Automatic ban",
|
||||
bannedByUserId: null,
|
||||
});
|
||||
|
||||
const modInfo = await UserRepository.findModInfoById(users.id(1));
|
||||
|
||||
expect(modInfo?.banLogs).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("updates existing user correctly", async () => {
|
||||
const bannedUsers = await AdminRepository.findAllBannedUsers();
|
||||
expect(bannedUsers.size).toBe(0);
|
||||
|
||||
await AdminRepository.banUser({
|
||||
userId: users.id(1),
|
||||
banned: 1,
|
||||
bannedReason: "First ban",
|
||||
bannedByUserId: users.id(2),
|
||||
});
|
||||
|
||||
let result = await AdminRepository.findAllBannedUsers();
|
||||
expect(result.size).toBe(1);
|
||||
|
||||
await AdminRepository.banUser({
|
||||
userId: users.id(1),
|
||||
banned: 1,
|
||||
bannedReason: "Updated ban reason",
|
||||
bannedByUserId: users.id(2),
|
||||
});
|
||||
|
||||
result = await AdminRepository.findAllBannedUsers();
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get(users.id(1))?.bannedReason).toBe("Updated ban reason");
|
||||
|
||||
const modInfo = await UserRepository.findModInfoById(users.id(1));
|
||||
expect(modInfo?.banLogs).toHaveLength(2);
|
||||
expect(modInfo?.banLogs[0].bannedReason).toBe("First ban");
|
||||
expect(modInfo?.banLogs[1].bannedReason).toBe("Updated ban reason");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unbanUser", () => {
|
||||
let banner: { id: number };
|
||||
let unbanner: { id: number };
|
||||
|
||||
const createBannedUser = (bannedReason: string, banned: 1 | Date = 1) =>
|
||||
UserFactory.create(null, {
|
||||
ban: { banned, bannedReason, bannedByUserId: banner.id },
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// the unban log records who unbanned by their Discord id
|
||||
banner = await UserFactory.create({ discordId: "1" });
|
||||
unbanner = await UserFactory.create({ discordId: "2" });
|
||||
});
|
||||
|
||||
test("unbans a previously banned user", async () => {
|
||||
const banned = await createBannedUser("Test ban");
|
||||
|
||||
let result = await AdminRepository.findAllBannedUsers();
|
||||
expect(result.size).toBe(1);
|
||||
|
||||
await AdminRepository.unbanUser({
|
||||
userId: banned.id,
|
||||
unbannedByUserId: unbanner.id,
|
||||
});
|
||||
|
||||
result = await AdminRepository.findAllBannedUsers();
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
test("creates BanLog entry with correct unbannedByUserId", async () => {
|
||||
const banned = await createBannedUser("Test ban");
|
||||
|
||||
await AdminRepository.unbanUser({
|
||||
userId: banned.id,
|
||||
unbannedByUserId: unbanner.id,
|
||||
});
|
||||
|
||||
const modInfo = await UserRepository.findModInfoById(banned.id);
|
||||
|
||||
expect(modInfo?.banLogs).toHaveLength(2);
|
||||
|
||||
const unbanLog = modInfo?.banLogs.find((log) => log.banned === 0);
|
||||
expect(unbanLog).toBeDefined();
|
||||
expect(unbanLog?.bannedReason).toBeNull();
|
||||
expect(unbanLog?.id).toBe(unbanner.id);
|
||||
});
|
||||
|
||||
test("can unban permanently banned user", async () => {
|
||||
const banned = await createBannedUser("Permanent ban");
|
||||
|
||||
await AdminRepository.unbanUser({
|
||||
userId: banned.id,
|
||||
unbannedByUserId: unbanner.id,
|
||||
});
|
||||
|
||||
const result = await AdminRepository.findAllBannedUsers();
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
test("can unban temporarily banned user", async () => {
|
||||
const futureDate = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7);
|
||||
|
||||
const banned = await createBannedUser("Temporary ban", futureDate);
|
||||
|
||||
await AdminRepository.unbanUser({
|
||||
userId: banned.id,
|
||||
unbannedByUserId: unbanner.id,
|
||||
});
|
||||
|
||||
const result = await AdminRepository.findAllBannedUsers();
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("replacePlusTiers", () => {
|
||||
// tier * 2 + 1, so tier 1 is 3 and the tier 4 users default to is 9
|
||||
const TIER_1_SORT_VALUE = 3;
|
||||
const NO_TIER_SORT_VALUE = 9;
|
||||
const SPLATTERSHOT = 40;
|
||||
|
||||
const createBuild = (ownerId: number) =>
|
||||
BuildFactory.create({
|
||||
ownerId,
|
||||
weaponSplIds: [SPLATTERSHOT],
|
||||
isPrivate: 0,
|
||||
});
|
||||
|
||||
const sortValueByBuildId = async (buildId: number) => {
|
||||
const buildWeapon = await db
|
||||
.selectFrom("BuildWeapon")
|
||||
.select("sortValue")
|
||||
.where("buildId", "=", buildId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return buildWeapon.sortValue;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await createUsers(2);
|
||||
});
|
||||
|
||||
test("refreshes the sort values of the builds a new tier applies to", async () => {
|
||||
const build = await createBuild(users.id(1));
|
||||
|
||||
expect(await sortValueByBuildId(build.id)).toBe(NO_TIER_SORT_VALUE);
|
||||
|
||||
await AdminRepository.replacePlusTiers([
|
||||
{ userId: users.id(1), plusTier: 1 },
|
||||
]);
|
||||
|
||||
expect(await sortValueByBuildId(build.id)).toBe(TIER_1_SORT_VALUE);
|
||||
});
|
||||
|
||||
test("refreshes the sort values of the builds of a user left out of the new tiers", async () => {
|
||||
await AdminRepository.replacePlusTiers([
|
||||
{ userId: users.id(1), plusTier: 1 },
|
||||
]);
|
||||
const build = await createBuild(users.id(1));
|
||||
|
||||
expect(await sortValueByBuildId(build.id)).toBe(TIER_1_SORT_VALUE);
|
||||
|
||||
await AdminRepository.replacePlusTiers([
|
||||
{ userId: users.id(2), plusTier: 1 },
|
||||
]);
|
||||
|
||||
expect(await sortValueByBuildId(build.id)).toBe(NO_TIER_SORT_VALUE);
|
||||
});
|
||||
});
|
||||
507
apps/web-react/app/features/admin/AdminRepository.server.ts
Normal file
507
apps/web-react/app/features/admin/AdminRepository.server.ts
Normal file
@@ -0,0 +1,507 @@
|
||||
import type { Transaction } from "kysely";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB, Tables, TablesInsertable } from "~/db/tables";
|
||||
import { actorId } from "~/features/auth/core/user.server";
|
||||
import * as BadgeRepository from "~/features/badges/BadgeRepository.server";
|
||||
import * as BuildRepository from "~/features/builds/BuildRepository.server";
|
||||
import * as XRankPlacementRepository from "~/features/top-search/XRankPlacementRepository.server";
|
||||
import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import invariant from "~/utils/invariant";
|
||||
|
||||
/**
|
||||
* Migrates user-related data. Takes data from the "old user" and remaps it to the Discord ID of the "new user". Used when user switches their Discord accounts.
|
||||
*
|
||||
* @param args - An object containing:
|
||||
* - `newUserId`: The ID of the user whose data will be migrated and then deleted.
|
||||
* - `oldUserId`: The ID of the user who will receive the migrated data.
|
||||
* @returns A promise that resolves to `null` if the migration succeeds, or an error message if validation fails.
|
||||
*/
|
||||
export function migrate(args: { newUserId: number; oldUserId: number }) {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
const error = await validateMigration(trx, args);
|
||||
if (error) {
|
||||
return error;
|
||||
}
|
||||
|
||||
// delete some limited data from the target user
|
||||
// idea is to make the migration a bit more smooth
|
||||
// since it won't fail if some small thing has been added
|
||||
// but for bigger things (e.g. has played tournaments)
|
||||
// it will still fail
|
||||
await trx
|
||||
.deleteFrom("UserWeapon")
|
||||
.where("userId", "=", args.newUserId)
|
||||
.execute();
|
||||
await trx
|
||||
.deleteFrom("Build")
|
||||
.where("ownerId", "=", args.newUserId)
|
||||
.execute();
|
||||
await trx
|
||||
.deleteFrom("UserFriendCode")
|
||||
.where("userId", "=", args.newUserId)
|
||||
.execute();
|
||||
await trx
|
||||
.deleteFrom("LFGPost")
|
||||
.where("authorId", "=", args.newUserId)
|
||||
.execute();
|
||||
await trx
|
||||
.deleteFrom("BanLog")
|
||||
.where("userId", "=", args.newUserId)
|
||||
.execute();
|
||||
|
||||
await trx
|
||||
.updateTable("GroupMember")
|
||||
.where("userId", "=", args.newUserId)
|
||||
.set({ userId: args.oldUserId })
|
||||
.execute();
|
||||
await trx
|
||||
.updateTable("UnvalidatedUserSubmittedImage")
|
||||
.where("submitterUserId", "=", args.newUserId)
|
||||
.set({ submitterUserId: args.oldUserId })
|
||||
.execute();
|
||||
await trx
|
||||
.updateTable("ModNote")
|
||||
.where("userId", "=", args.newUserId)
|
||||
.set({ userId: args.oldUserId })
|
||||
.execute();
|
||||
|
||||
// reports between the two merged accounts would become self-reports
|
||||
await trx
|
||||
.deleteFrom("UserReport")
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb.and([
|
||||
eb("reporterUserId", "=", args.newUserId),
|
||||
eb("reportedUserId", "=", args.oldUserId),
|
||||
]),
|
||||
eb.and([
|
||||
eb("reporterUserId", "=", args.oldUserId),
|
||||
eb("reportedUserId", "=", args.newUserId),
|
||||
]),
|
||||
]),
|
||||
)
|
||||
.execute();
|
||||
await deleteOlderCollidingUserReports(trx, args, "reporterUserId");
|
||||
await deleteOlderCollidingUserReports(trx, args, "reportedUserId");
|
||||
await trx
|
||||
.updateTable("UserReport")
|
||||
.where("reporterUserId", "=", args.newUserId)
|
||||
.set({ reporterUserId: args.oldUserId })
|
||||
.execute();
|
||||
await trx
|
||||
.updateTable("UserReport")
|
||||
.where("reportedUserId", "=", args.newUserId)
|
||||
.set({ reportedUserId: args.oldUserId })
|
||||
.execute();
|
||||
|
||||
// special case: delete same team membership to avoid unique constraint violation
|
||||
await trx
|
||||
.deleteFrom("AllTeamMember")
|
||||
.where("userId", "=", args.oldUserId)
|
||||
.where("leftAt", "is not", null)
|
||||
.where((eb) =>
|
||||
eb(
|
||||
"AllTeamMember.teamId",
|
||||
"in",
|
||||
eb
|
||||
.selectFrom("AllTeamMember")
|
||||
.select("teamId")
|
||||
.where("userId", "=", args.newUserId)
|
||||
.where("leftAt", "is", null),
|
||||
),
|
||||
)
|
||||
.execute();
|
||||
|
||||
// delete past team membership data (not user visible)
|
||||
await trx
|
||||
.deleteFrom("AllTeamMember")
|
||||
.where("userId", "=", args.newUserId)
|
||||
.where("leftAt", "is not", null)
|
||||
.execute();
|
||||
// existing team membership will stay
|
||||
await trx
|
||||
.updateTable("AllTeamMember")
|
||||
.where("userId", "=", args.newUserId)
|
||||
.set({ userId: args.oldUserId })
|
||||
.execute();
|
||||
|
||||
// If both accounts own the same trophy, drop the
|
||||
// migrated account's duplicate rows
|
||||
await trx
|
||||
.deleteFrom("TrophyOwner")
|
||||
.where("userId", "=", args.newUserId)
|
||||
.where((eb) =>
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom("TrophyOwner as existing")
|
||||
.select("existing.trophyId")
|
||||
.where("existing.userId", "=", args.oldUserId)
|
||||
.whereRef("existing.trophyId", "=", "TrophyOwner.trophyId")
|
||||
.whereRef("existing.tournamentId", "=", "TrophyOwner.tournamentId"),
|
||||
),
|
||||
)
|
||||
.execute();
|
||||
await trx
|
||||
.deleteFrom("SpecialTrophyOwner")
|
||||
.where("userId", "=", args.newUserId)
|
||||
.where((eb) =>
|
||||
eb(
|
||||
"SpecialTrophyOwner.trophyId",
|
||||
"in",
|
||||
eb
|
||||
.selectFrom("SpecialTrophyOwner")
|
||||
.select("trophyId")
|
||||
.where("userId", "=", args.oldUserId),
|
||||
),
|
||||
)
|
||||
.execute();
|
||||
await trx
|
||||
.deleteFrom("PendingTrophyApproval")
|
||||
.where("userId", "=", args.newUserId)
|
||||
.where((eb) =>
|
||||
eb(
|
||||
"PendingTrophyApproval.pendingTrophyId",
|
||||
"in",
|
||||
eb
|
||||
.selectFrom("PendingTrophyApproval")
|
||||
.select("pendingTrophyId")
|
||||
.where("userId", "=", args.oldUserId),
|
||||
),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await trx
|
||||
.updateTable("TrophyOwner")
|
||||
.where("userId", "=", args.newUserId)
|
||||
.set({ userId: args.oldUserId })
|
||||
.execute();
|
||||
await trx
|
||||
.updateTable("SpecialTrophyOwner")
|
||||
.where("userId", "=", args.newUserId)
|
||||
.set({ userId: args.oldUserId })
|
||||
.execute();
|
||||
await trx
|
||||
.updateTable("Trophy")
|
||||
.where("creatorId", "=", args.newUserId)
|
||||
.set({ creatorId: args.oldUserId })
|
||||
.execute();
|
||||
await trx
|
||||
.updateTable("Trophy")
|
||||
.where("managerId", "=", args.newUserId)
|
||||
.set({ managerId: args.oldUserId })
|
||||
.execute();
|
||||
await trx
|
||||
.updateTable("PendingTrophy")
|
||||
.where("submitterUserId", "=", args.newUserId)
|
||||
.set({ submitterUserId: args.oldUserId })
|
||||
.execute();
|
||||
await trx
|
||||
.updateTable("PendingTrophy")
|
||||
.where("managerId", "=", args.newUserId)
|
||||
.set({ managerId: args.oldUserId })
|
||||
.execute();
|
||||
await trx
|
||||
.updateTable("PendingTrophy")
|
||||
.where("declinedByUserId", "=", args.newUserId)
|
||||
.set({ declinedByUserId: args.oldUserId })
|
||||
.execute();
|
||||
await trx
|
||||
.updateTable("PendingTrophyApproval")
|
||||
.where("userId", "=", args.newUserId)
|
||||
.set({ userId: args.oldUserId })
|
||||
.execute();
|
||||
|
||||
const deletedUser = await trx
|
||||
.deleteFrom("User")
|
||||
.where("User.id", "=", args.newUserId)
|
||||
.returning("discordId")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
await trx
|
||||
.updateTable("User")
|
||||
.set({ discordId: deletedUser.discordId })
|
||||
.where("User.id", "=", args.oldUserId)
|
||||
.execute();
|
||||
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merging accounts can collide on the one-report-per-pair unique index; the newer
|
||||
* report (by `createdAt`, id as tie-breaker) wins and the other row is dropped.
|
||||
*/
|
||||
function deleteOlderCollidingUserReports(
|
||||
trx: Transaction<DB>,
|
||||
args: { newUserId: number; oldUserId: number },
|
||||
column: "reporterUserId" | "reportedUserId",
|
||||
) {
|
||||
const otherColumn =
|
||||
column === "reporterUserId" ? "reportedUserId" : "reporterUserId";
|
||||
|
||||
return trx
|
||||
.deleteFrom("UserReport")
|
||||
.where(column, "in", [args.newUserId, args.oldUserId])
|
||||
.where((eb) =>
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom("UserReport as newer")
|
||||
.select("newer.id")
|
||||
.where(`newer.${column}`, "in", [args.newUserId, args.oldUserId])
|
||||
.whereRef(`newer.${otherColumn}`, "=", `UserReport.${otherColumn}`)
|
||||
.whereRef("newer.id", "!=", "UserReport.id")
|
||||
.where((inner) =>
|
||||
inner.or([
|
||||
inner("newer.createdAt", ">", inner.ref("UserReport.createdAt")),
|
||||
inner.and([
|
||||
inner(
|
||||
"newer.createdAt",
|
||||
"=",
|
||||
inner.ref("UserReport.createdAt"),
|
||||
),
|
||||
inner("newer.id", ">", inner.ref("UserReport.id")),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function validateMigration(
|
||||
trx: Transaction<DB>,
|
||||
args: { newUserId: number; oldUserId: number },
|
||||
) {
|
||||
const newUserTeam = await trx
|
||||
.selectFrom("TournamentTeamMember")
|
||||
.select(["tournamentTeamId"])
|
||||
.where("userId", "=", args.newUserId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (newUserTeam) {
|
||||
return "new user is in a tournament team";
|
||||
}
|
||||
|
||||
const oldUserCurrentTeam = await trx
|
||||
.selectFrom("TeamMember")
|
||||
.select(["teamId"])
|
||||
.where("userId", "=", args.oldUserId)
|
||||
.executeTakeFirst();
|
||||
|
||||
const newUserCurrentTeam = await trx
|
||||
.selectFrom("TeamMember")
|
||||
.select(["teamId"])
|
||||
.where("userId", "=", args.newUserId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (oldUserCurrentTeam && newUserCurrentTeam) {
|
||||
return "both old and new user are in teams";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Replaces every `PlusTier` row, also refreshing the build sort values derived from them. */
|
||||
export function replacePlusTiers(
|
||||
plusTiers: Array<{ userId: number; plusTier: number }>,
|
||||
) {
|
||||
invariant(plusTiers.length > 0, "plusTiers must not be empty");
|
||||
|
||||
return db.transaction().execute(async (trx) => {
|
||||
await trx.deleteFrom("PlusTier").execute();
|
||||
await trx
|
||||
.insertInto("PlusTier")
|
||||
.values(
|
||||
plusTiers.map(({ plusTier, userId }) => ({ userId, tier: plusTier })),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await BuildRepository.recalculateAllSortValues(undefined, trx);
|
||||
});
|
||||
}
|
||||
|
||||
export function makeVideoAdderByUserId(userId: number) {
|
||||
return db
|
||||
.updateTable("User")
|
||||
.set({ isVideoAdder: 1 })
|
||||
.where("User.id", "=", userId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export function makeArtistByUserId(userId: number) {
|
||||
return db
|
||||
.updateTable("User")
|
||||
.set({ isArtist: 1 })
|
||||
.where("User.id", "=", userId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export function makeTournamentOrganizerByUserId(userId: number) {
|
||||
return db
|
||||
.updateTable("User")
|
||||
.set({ isTournamentOrganizer: 1 })
|
||||
.where("User.id", "=", userId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export function makeApiAccesserByUserId(userId: number) {
|
||||
return db
|
||||
.updateTable("User")
|
||||
.set({ isApiAccesser: 1 })
|
||||
.where("User.id", "=", userId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function linkUserAndPlayer({
|
||||
userId,
|
||||
playerId,
|
||||
}: {
|
||||
userId: number;
|
||||
playerId: number;
|
||||
}) {
|
||||
await db
|
||||
.updateTable("SplatoonPlayer")
|
||||
.set({ userId: null })
|
||||
.where("SplatoonPlayer.userId", "=", userId)
|
||||
.execute();
|
||||
|
||||
await db
|
||||
.updateTable("SplatoonPlayer")
|
||||
.set({ userId })
|
||||
.where("SplatoonPlayer.id", "=", playerId)
|
||||
.execute();
|
||||
|
||||
await BadgeRepository.syncXPBadges();
|
||||
await TrophyRepository.syncSpecialTrophies();
|
||||
|
||||
await BuildRepository.recalculateAllSortValues(userId);
|
||||
await XRankPlacementRepository.refreshTenStarWeapons(userId);
|
||||
}
|
||||
|
||||
export async function forcePatron(args: {
|
||||
id: number;
|
||||
patronTier: Tables["User"]["patronTier"];
|
||||
patronStartedAt: Date;
|
||||
patronExpiresAt: Date;
|
||||
}) {
|
||||
await db
|
||||
.updateTable("User")
|
||||
.set({
|
||||
patronTier: args.patronTier,
|
||||
patronStartedAt: dateToDatabaseTimestamp(args.patronStartedAt),
|
||||
patronExpiresAt: dateToDatabaseTimestamp(args.patronExpiresAt),
|
||||
})
|
||||
.where("User.id", "=", args.id)
|
||||
.execute();
|
||||
|
||||
await TrophyRepository.syncSpecialTrophies();
|
||||
}
|
||||
|
||||
export async function findAllBannedUsers() {
|
||||
const rows = await db
|
||||
.selectFrom("User")
|
||||
.select(["User.id as userId", "User.banned", "User.bannedReason"])
|
||||
.where("User.banned", "!=", 0)
|
||||
.execute();
|
||||
|
||||
const result: Map<number, (typeof rows)[number]> = new Map();
|
||||
|
||||
for (const row of rows) {
|
||||
result.set(row.userId, row);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function banUser({
|
||||
userId,
|
||||
banned,
|
||||
bannedReason,
|
||||
bannedByUserId,
|
||||
}: {
|
||||
userId: number;
|
||||
banned: 1 | Date;
|
||||
bannedReason: string | null;
|
||||
/** Which user banned the user? If null then it means it was an automatic ban. */
|
||||
bannedByUserId: number | null;
|
||||
}) {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
const banArgs = {
|
||||
banned: banned === 1 ? banned : dateToDatabaseTimestamp(banned),
|
||||
bannedReason,
|
||||
};
|
||||
|
||||
await trx
|
||||
.updateTable("User")
|
||||
.set(banArgs)
|
||||
.where("User.id", "=", userId)
|
||||
.execute();
|
||||
|
||||
if (typeof bannedByUserId === "number") {
|
||||
await trx
|
||||
.insertInto("BanLog")
|
||||
.values({
|
||||
...banArgs,
|
||||
userId,
|
||||
bannedByUserId,
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function unbanUser({
|
||||
userId,
|
||||
unbannedByUserId,
|
||||
}: {
|
||||
userId: number;
|
||||
unbannedByUserId: number;
|
||||
}) {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
const banArgs = {
|
||||
banned: 0,
|
||||
bannedReason: null,
|
||||
};
|
||||
|
||||
await trx
|
||||
.updateTable("User")
|
||||
.set(banArgs)
|
||||
.where("User.id", "=", userId)
|
||||
.execute();
|
||||
|
||||
await trx
|
||||
.insertInto("BanLog")
|
||||
.values({
|
||||
...banArgs,
|
||||
userId,
|
||||
bannedByUserId: unbannedByUserId,
|
||||
})
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
|
||||
export function addModNote(
|
||||
args: Omit<TablesInsertable["ModNote"], "authorId">,
|
||||
) {
|
||||
return db
|
||||
.insertInto("ModNote")
|
||||
.values({ ...args, authorId: actorId() })
|
||||
.execute();
|
||||
}
|
||||
|
||||
export function findModNoteById(id: number) {
|
||||
return db
|
||||
.selectFrom("ModNote")
|
||||
.selectAll()
|
||||
.where("ModNote.id", "=", id)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
export function deleteModNote(id: number) {
|
||||
return db
|
||||
.updateTable("ModNote")
|
||||
.set({ isDeleted: 1 })
|
||||
.where("ModNote.id", "=", id)
|
||||
.execute();
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { db } from "~/db/sql";
|
||||
import type { TablesInsertable } from "~/db/tables";
|
||||
import { databaseTimestampNow } from "~/utils/dates";
|
||||
import { concatUserSubmittedImagePrefix } from "~/utils/kysely.server";
|
||||
|
||||
/** Number of seconds an external stream keeps showing in the sidebar after its start time. */
|
||||
const SIDEBAR_VISIBLE_SECONDS = 6 * 60 * 60;
|
||||
/** Number of seconds after the start time before an external stream row is deleted. */
|
||||
const RETENTION_SECONDS = 24 * 60 * 60;
|
||||
|
||||
/** Inserts a new admin-curated external stream. */
|
||||
export function insert(
|
||||
args: Pick<
|
||||
TablesInsertable["ExternalStream"],
|
||||
"name" | "url" | "avatarImgId" | "startsAt"
|
||||
>,
|
||||
) {
|
||||
return db.insertInto("ExternalStream").values(args).execute();
|
||||
}
|
||||
|
||||
/** Deletes an external stream by its id. */
|
||||
export function deleteById(id: number) {
|
||||
return db.deleteFrom("ExternalStream").where("id", "=", id).execute();
|
||||
}
|
||||
|
||||
/** Lists all external streams (for the admin management page), soonest start time first. */
|
||||
export function findAll() {
|
||||
return db
|
||||
.selectFrom("ExternalStream")
|
||||
.leftJoin(
|
||||
"UserSubmittedImage",
|
||||
"UserSubmittedImage.id",
|
||||
"ExternalStream.avatarImgId",
|
||||
)
|
||||
.select((eb) => [
|
||||
"ExternalStream.id",
|
||||
"ExternalStream.name",
|
||||
"ExternalStream.url",
|
||||
"ExternalStream.startsAt",
|
||||
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
|
||||
"avatarUrl",
|
||||
),
|
||||
])
|
||||
.orderBy("ExternalStream.startsAt", "asc")
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** External streams that should currently show in the sidebar (started under 6h ago or upcoming). */
|
||||
export function findAllForSidebar() {
|
||||
return db
|
||||
.selectFrom("ExternalStream")
|
||||
.leftJoin(
|
||||
"UserSubmittedImage",
|
||||
"UserSubmittedImage.id",
|
||||
"ExternalStream.avatarImgId",
|
||||
)
|
||||
.select((eb) => [
|
||||
"ExternalStream.id",
|
||||
"ExternalStream.name",
|
||||
"ExternalStream.url",
|
||||
"ExternalStream.startsAt",
|
||||
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
|
||||
"avatarUrl",
|
||||
),
|
||||
])
|
||||
.where(
|
||||
"ExternalStream.startsAt",
|
||||
">=",
|
||||
databaseTimestampNow() - SIDEBAR_VISIBLE_SECONDS,
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** Deletes external streams whose start time is more than 24h in the past. */
|
||||
export function deleteOld() {
|
||||
return db
|
||||
.deleteFrom("ExternalStream")
|
||||
.where("startsAt", "<", databaseTimestampNow() - RETENTION_SECONDS)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
198
apps/web-react/app/features/admin/actions/admin.server.ts
Normal file
198
apps/web-react/app/features/admin/actions/admin.server.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import * as AdminRepository from "~/features/admin/AdminRepository.server";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { refreshBannedCache } from "~/features/ban/core/banned.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { parseFormData } from "~/form/parse.server";
|
||||
import { requireRole } from "~/modules/permissions/guards.server";
|
||||
import {
|
||||
errorToast,
|
||||
notFoundIfNullish,
|
||||
successToast,
|
||||
} from "~/utils/remix.server";
|
||||
import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { normalizeFriendCode } from "~/utils/zod";
|
||||
import { adminActionSchema } from "../admin-schemas";
|
||||
import {
|
||||
sendUserBannedWebhook,
|
||||
sendUserUnbannedWebhook,
|
||||
} from "../core/discord-webhook.server";
|
||||
import { plusTiersFromVotingAndLeaderboard } from "../core/plus-tier.server";
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const result = await parseFormData({
|
||||
request,
|
||||
schema: adminActionSchema,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { fieldErrors: result.fieldErrors };
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
const user = requireUser();
|
||||
|
||||
let message: string;
|
||||
switch (data._action) {
|
||||
case "MIGRATE": {
|
||||
requireRole("STAFF");
|
||||
|
||||
try {
|
||||
const errorMessage = await AdminRepository.migrate({
|
||||
oldUserId: data.oldUser,
|
||||
newUserId: data.newUser,
|
||||
});
|
||||
|
||||
if (errorMessage) {
|
||||
errorToast(`Migration failed. Reason: ${errorMessage}`);
|
||||
}
|
||||
|
||||
await refreshBannedCache();
|
||||
|
||||
message = "Account migrated";
|
||||
break;
|
||||
} catch (err) {
|
||||
if (errorIsSqliteForeignKeyConstraintFailure(err)) {
|
||||
errorToast(
|
||||
"New user has data preventing the migration (e.g. member of tournament teams or SendouQ played)",
|
||||
);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
case "REFRESH": {
|
||||
requireRole("ADMIN");
|
||||
|
||||
await AdminRepository.replacePlusTiers(
|
||||
await plusTiersFromVotingAndLeaderboard(),
|
||||
);
|
||||
|
||||
message = "Plus tiers refreshed";
|
||||
break;
|
||||
}
|
||||
case "FORCE_PATRON": {
|
||||
requireRole("ADMIN");
|
||||
|
||||
await AdminRepository.forcePatron({
|
||||
id: data.user,
|
||||
patronStartedAt: new Date(),
|
||||
patronTier: Number(data.patronTier),
|
||||
patronExpiresAt: data.patronExpiresAt,
|
||||
});
|
||||
|
||||
message = "Patron status updated";
|
||||
break;
|
||||
}
|
||||
case "ARTIST": {
|
||||
requireRole("STAFF");
|
||||
|
||||
await AdminRepository.makeArtistByUserId(data.user);
|
||||
|
||||
message = "Artist permissions given";
|
||||
break;
|
||||
}
|
||||
case "VIDEO_ADDER": {
|
||||
requireRole("STAFF");
|
||||
|
||||
await AdminRepository.makeVideoAdderByUserId(data.user);
|
||||
|
||||
message = "VoD adder permissions given";
|
||||
break;
|
||||
}
|
||||
case "TOURNAMENT_ORGANIZER": {
|
||||
requireRole("ADMIN");
|
||||
|
||||
await AdminRepository.makeTournamentOrganizerByUserId(data.user);
|
||||
|
||||
message = "Tournament permissions given";
|
||||
break;
|
||||
}
|
||||
case "LINK_PLAYER": {
|
||||
requireRole("STAFF");
|
||||
|
||||
await AdminRepository.linkUserAndPlayer({
|
||||
userId: data.user,
|
||||
playerId: data.playerId,
|
||||
});
|
||||
|
||||
message = "Linked user and player";
|
||||
break;
|
||||
}
|
||||
case "BAN_USER": {
|
||||
requireRole("STAFF");
|
||||
|
||||
const bannedUser = notFoundIfNullish(
|
||||
await UserRepository.findLeanById(data.user),
|
||||
);
|
||||
const banExpiresAt = data.expiresAt ?? null;
|
||||
|
||||
await AdminRepository.banUser({
|
||||
bannedReason: data.reason ?? null,
|
||||
userId: data.user,
|
||||
banned: banExpiresAt ?? 1,
|
||||
bannedByUserId: user.id,
|
||||
});
|
||||
|
||||
await refreshBannedCache();
|
||||
|
||||
sendUserBannedWebhook({
|
||||
bannedUser,
|
||||
bannedBy: user,
|
||||
reason: data.reason ?? null,
|
||||
expiresAt: banExpiresAt,
|
||||
});
|
||||
|
||||
message = "User banned";
|
||||
break;
|
||||
}
|
||||
case "UNBAN_USER": {
|
||||
requireRole("STAFF");
|
||||
|
||||
const unbannedUser = notFoundIfNullish(
|
||||
await UserRepository.findLeanById(data.user),
|
||||
);
|
||||
|
||||
await AdminRepository.unbanUser({
|
||||
userId: data.user,
|
||||
unbannedByUserId: user.id,
|
||||
});
|
||||
|
||||
await refreshBannedCache();
|
||||
|
||||
sendUserUnbannedWebhook({
|
||||
unbannedUser,
|
||||
unbannedBy: user,
|
||||
});
|
||||
|
||||
message = "User unbanned";
|
||||
break;
|
||||
}
|
||||
case "UPDATE_FRIEND_CODE": {
|
||||
requireRole("STAFF");
|
||||
|
||||
await UserRepository.insertFriendCode({
|
||||
friendCode: normalizeFriendCode(data.friendCode),
|
||||
submitterUserId: user.id,
|
||||
userId: data.user,
|
||||
});
|
||||
|
||||
message = "Friend code updated";
|
||||
break;
|
||||
}
|
||||
case "API_ACCESS": {
|
||||
requireRole("ADMIN");
|
||||
|
||||
await AdminRepository.makeApiAccesserByUserId(data.user);
|
||||
|
||||
message = "API access granted";
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
}
|
||||
|
||||
return successToast(message);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { clearCombinedStreamsCache } from "~/features/core/streams/streams.server";
|
||||
import { parseFormDataWithImages } from "~/form/parse.server";
|
||||
import { requireRole } from "~/modules/permissions/guards.server";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { externalStreamActionSchema } from "../admin-schemas";
|
||||
import * as ExternalStreamRepository from "../ExternalStreamRepository.server";
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
requireRole("ADMIN");
|
||||
|
||||
const result = await parseFormDataWithImages({
|
||||
request,
|
||||
schema: externalStreamActionSchema,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { fieldErrors: result.fieldErrors };
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
|
||||
switch (data._action) {
|
||||
case "CREATE": {
|
||||
await ExternalStreamRepository.insert({
|
||||
name: data.name,
|
||||
url: data.url,
|
||||
avatarImgId: data.avatar,
|
||||
startsAt: dateToDatabaseTimestamp(data.startTime),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "DELETE": {
|
||||
await ExternalStreamRepository.deleteById(data.id);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
}
|
||||
|
||||
clearCombinedStreamsCache();
|
||||
|
||||
return null;
|
||||
};
|
||||
22
apps/web-react/app/features/admin/admin-constants.ts
Normal file
22
apps/web-react/app/features/admin/admin-constants.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export const BAN_REASON_MAX_LENGTH = 200;
|
||||
|
||||
export const ADMIN_DISCORD_ID = "79237403620945920";
|
||||
export const ADMIN_ID = process.env.NODE_ENV === "test" ? 1 : 274;
|
||||
|
||||
// Panda Scep Acing Baja Michi
|
||||
export const STAFF_IDS = [11329, 9719, 9342, 20774, 23094];
|
||||
// hfcRed
|
||||
export const DEV_IDS = [27883];
|
||||
// hfcRed Dreamy Cafy Acing
|
||||
export const QA_IDS: number[] = [27883, 38176, 10654, 9342];
|
||||
// xi
|
||||
/** Users who can access the scanner while it is not yet enabled for everyone */
|
||||
export const SCANNER_TESTER_IDS: number[] = [30228];
|
||||
|
||||
export const STAFF_DISCORD_IDS = [
|
||||
"138757634500067328",
|
||||
"184478601171828737",
|
||||
"342369454719631361",
|
||||
"785678909555474462",
|
||||
"725416982681223259",
|
||||
];
|
||||
136
apps/web-react/app/features/admin/admin-schemas.ts
Normal file
136
apps/web-react/app/features/admin/admin-schemas.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { z } from "zod";
|
||||
import { friendCodeField } from "~/features/sendouq/q-schemas";
|
||||
import {
|
||||
datetime,
|
||||
datetimeOptional,
|
||||
image,
|
||||
numberField,
|
||||
select,
|
||||
stringConstant,
|
||||
textField,
|
||||
textFieldOptional,
|
||||
userSearch,
|
||||
} from "~/form/fields";
|
||||
import { id } from "~/utils/zod";
|
||||
import { BAN_REASON_MAX_LENGTH } from "./admin-constants";
|
||||
|
||||
const userField = userSearch({ label: "labels.user" });
|
||||
|
||||
export const friendCodeSearchSchema = z.object({
|
||||
friendCode: friendCodeField,
|
||||
});
|
||||
|
||||
export const migrateUserSchema = z.object({
|
||||
_action: stringConstant("MIGRATE"),
|
||||
oldUser: userSearch({ label: "labels.adminOldUser" }),
|
||||
newUser: userSearch({
|
||||
label: "labels.adminNewUser",
|
||||
bottomText: "bottomTexts.adminMigrateNewUser",
|
||||
}),
|
||||
});
|
||||
|
||||
export const linkPlayerSchema = z.object({
|
||||
_action: stringConstant("LINK_PLAYER"),
|
||||
user: userField,
|
||||
playerId: numberField({ label: "labels.adminPlayerId", min: 1 }),
|
||||
});
|
||||
|
||||
export const giveArtistSchema = z.object({
|
||||
_action: stringConstant("ARTIST"),
|
||||
user: userField,
|
||||
});
|
||||
|
||||
export const giveVideoAdderSchema = z.object({
|
||||
_action: stringConstant("VIDEO_ADDER"),
|
||||
user: userField,
|
||||
});
|
||||
|
||||
export const giveTournamentOrganizerSchema = z.object({
|
||||
_action: stringConstant("TOURNAMENT_ORGANIZER"),
|
||||
user: userField,
|
||||
});
|
||||
|
||||
export const giveApiAccessSchema = z.object({
|
||||
_action: stringConstant("API_ACCESS"),
|
||||
user: userField,
|
||||
});
|
||||
|
||||
export const updateFriendCodeSchema = z.object({
|
||||
_action: stringConstant("UPDATE_FRIEND_CODE"),
|
||||
user: userField,
|
||||
friendCode: friendCodeField,
|
||||
});
|
||||
|
||||
export const forcePatronSchema = z.object({
|
||||
_action: stringConstant("FORCE_PATRON"),
|
||||
user: userField,
|
||||
patronTier: select({
|
||||
label: "labels.patronTier",
|
||||
items: [
|
||||
{ value: "1", label: "options.patronTier.1" },
|
||||
{ value: "2", label: "options.patronTier.2" },
|
||||
{ value: "3", label: "options.patronTier.3" },
|
||||
],
|
||||
}),
|
||||
patronExpiresAt: datetime({ label: "labels.patronExpiresAt" }),
|
||||
});
|
||||
|
||||
export const banUserSchema = z.object({
|
||||
_action: stringConstant("BAN_USER"),
|
||||
user: userField,
|
||||
expiresAt: datetimeOptional({
|
||||
label: "labels.banUserExpiresAt",
|
||||
bottomText: "bottomTexts.banUserExpiresAtHelp",
|
||||
min: () => new Date(),
|
||||
minMessage: "errors.dateInPast",
|
||||
}),
|
||||
reason: textFieldOptional({
|
||||
label: "labels.reason",
|
||||
maxLength: BAN_REASON_MAX_LENGTH,
|
||||
}),
|
||||
});
|
||||
|
||||
export const unbanUserSchema = z.object({
|
||||
_action: stringConstant("UNBAN_USER"),
|
||||
user: userField,
|
||||
});
|
||||
|
||||
export const refreshPlusTiersSchema = z.object({
|
||||
_action: stringConstant("REFRESH"),
|
||||
});
|
||||
|
||||
export const adminActionSchema = z.union([
|
||||
migrateUserSchema,
|
||||
linkPlayerSchema,
|
||||
giveArtistSchema,
|
||||
giveVideoAdderSchema,
|
||||
giveTournamentOrganizerSchema,
|
||||
giveApiAccessSchema,
|
||||
updateFriendCodeSchema,
|
||||
forcePatronSchema,
|
||||
banUserSchema,
|
||||
unbanUserSchema,
|
||||
refreshPlusTiersSchema,
|
||||
]);
|
||||
|
||||
export const createExternalStreamSchema = z.object({
|
||||
_action: stringConstant("CREATE"),
|
||||
name: textField({ label: "labels.name", maxLength: 64 }),
|
||||
url: textField({
|
||||
label: "labels.link",
|
||||
maxLength: 200,
|
||||
validate: "url",
|
||||
}),
|
||||
avatar: image({ label: "labels.logo", autoValidate: true }),
|
||||
startTime: datetime({ label: "labels.startTime" }),
|
||||
});
|
||||
|
||||
const deleteExternalStreamSchema = z.object({
|
||||
_action: stringConstant("DELETE"),
|
||||
id,
|
||||
});
|
||||
|
||||
export const externalStreamActionSchema = z.union([
|
||||
createExternalStreamSchema,
|
||||
deleteExternalStreamSchema,
|
||||
]);
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, test } from "vitest";
|
||||
import {
|
||||
assertDecodesToDefault,
|
||||
assertRoundTrips,
|
||||
} from "~/modules/search-params/search-params-test-utils";
|
||||
import { adminSearchParams } from "./admin-search-params";
|
||||
|
||||
describe("adminSearchParams", () => {
|
||||
test("round-trips", () => {
|
||||
assertRoundTrips(adminSearchParams, {
|
||||
friendCode: ["1234-5678-9012", "SW-1234-5678-9012", "123456789012"],
|
||||
});
|
||||
});
|
||||
|
||||
test("garbage decodes to default", () => {
|
||||
assertDecodesToDefault(adminSearchParams, "friendCode", [
|
||||
["not-a-friend-code"],
|
||||
["1234-5678"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
10
apps/web-react/app/features/admin/admin-search-params.ts
Normal file
10
apps/web-react/app/features/admin/admin-search-params.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { z } from "zod";
|
||||
import { FRIEND_CODE_REGEXP } from "~/features/sendouq/q-constants";
|
||||
import * as SearchParams from "~/modules/search-params/search-params";
|
||||
import { SP } from "~/modules/search-params/search-params";
|
||||
|
||||
export const adminSearchParams = SearchParams.define({
|
||||
friendCode: SP.param(z.string().regex(FRIEND_CODE_REGEXP).nullable(), {
|
||||
loader: true,
|
||||
}),
|
||||
});
|
||||
5
apps/web-react/app/features/admin/core/dev-controls.ts
Normal file
5
apps/web-react/app/features/admin/core/dev-controls.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
|
||||
|
||||
/** Should the user be able to access dev controls? Seeding & user impersonation without auth. */
|
||||
export const DANGEROUS_CAN_ACCESS_DEV_CONTROLS =
|
||||
process.env.NODE_ENV === "development" || IS_E2E_TEST_RUN;
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
sendModDiscordWebhook,
|
||||
truncateEmbedValue,
|
||||
userAdminPageLink,
|
||||
userPageLink,
|
||||
type WebhookUser,
|
||||
} from "~/modules/discord-webhook.server";
|
||||
|
||||
/**
|
||||
* Posts a rich embed about a user getting banned to the mod channel Discord webhook.
|
||||
* Fire-and-forget (see `sendModDiscordWebhook`).
|
||||
*/
|
||||
export function sendUserBannedWebhook(args: {
|
||||
bannedUser: WebhookUser;
|
||||
bannedBy: WebhookUser;
|
||||
reason: string | null;
|
||||
/** When the ban ends, null when the ban has no end date */
|
||||
expiresAt: Date | null;
|
||||
}) {
|
||||
sendModDiscordWebhook({
|
||||
title: "User banned",
|
||||
fields: [
|
||||
{
|
||||
name: "Banned user",
|
||||
value: userAdminPageLink(args.bannedUser),
|
||||
},
|
||||
{
|
||||
name: "Banned by",
|
||||
value: userPageLink(args.bannedBy),
|
||||
},
|
||||
{
|
||||
name: "Expires",
|
||||
value: args.expiresAt
|
||||
? `<t:${Math.floor(args.expiresAt.getTime() / 1000)}:f>`
|
||||
: "No end date",
|
||||
},
|
||||
...(args.reason
|
||||
? [
|
||||
{
|
||||
name: "Reason",
|
||||
value: truncateEmbedValue(args.reason),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts a rich embed about a user getting unbanned to the mod channel Discord webhook.
|
||||
* Fire-and-forget (see `sendModDiscordWebhook`).
|
||||
*/
|
||||
export function sendUserUnbannedWebhook(args: {
|
||||
unbannedUser: WebhookUser;
|
||||
unbannedBy: WebhookUser;
|
||||
}) {
|
||||
sendModDiscordWebhook({
|
||||
title: "User unbanned",
|
||||
fields: [
|
||||
{
|
||||
name: "Unbanned user",
|
||||
value: userAdminPageLink(args.unbannedUser),
|
||||
},
|
||||
{
|
||||
name: "Unbanned by",
|
||||
value: userPageLink(args.unbannedBy),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
56
apps/web-react/app/features/admin/core/plus-tier.server.ts
Normal file
56
apps/web-react/app/features/admin/core/plus-tier.server.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { addPendingPlusTiers } from "~/features/leaderboards/core/leaderboards.server";
|
||||
import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import { seasonToVotingRange } from "~/features/plus-voting/core/voting-time";
|
||||
import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { userIsBanned } from "../../ban/core/banned.server";
|
||||
|
||||
export async function plusTiersFromVotingAndLeaderboard() {
|
||||
const newMembersFromVoting =
|
||||
await PlusVotingRepository.findAllPlusTiersFromLatestVoting();
|
||||
const newMembersFromLeaderboard = await fromLeaderboard(newMembersFromVoting);
|
||||
return [
|
||||
...newMembersFromLeaderboard,
|
||||
// filter to ensure that user gets their highest tier
|
||||
...newMembersFromVoting.filter(
|
||||
(member) =>
|
||||
!newMembersFromLeaderboard.some(
|
||||
(leaderboardMember) => leaderboardMember.userId === member.userId,
|
||||
),
|
||||
),
|
||||
].filter(({ userId }) => !userIsBanned(userId));
|
||||
}
|
||||
|
||||
async function fromLeaderboard(
|
||||
newMembersFromVoting: Array<{ userId: number; plusTier: number }>,
|
||||
) {
|
||||
const now = new Date();
|
||||
const lastCompletedSeason = Seasons.previous();
|
||||
invariant(lastCompletedSeason, "No previous season found");
|
||||
|
||||
const currSeason = Seasons.current();
|
||||
if (currSeason) {
|
||||
const range = seasonToVotingRange(currSeason);
|
||||
|
||||
// we are in the period of a season where the season's voting
|
||||
// has ended but we don't yet have the latest leaderboard results
|
||||
// -> last season's results are no longer valid
|
||||
if (range.endDate < now) return [];
|
||||
}
|
||||
|
||||
const leaderboard = addPendingPlusTiers(
|
||||
await LeaderboardRepository.findUserSPLeaderboard(lastCompletedSeason.nth),
|
||||
newMembersFromVoting,
|
||||
lastCompletedSeason.nth,
|
||||
);
|
||||
|
||||
return leaderboard.flatMap((entry) => {
|
||||
if (!entry.pendingPlusTier) return [];
|
||||
|
||||
return {
|
||||
userId: entry.id,
|
||||
plusTier: entry.pendingPlusTier,
|
||||
};
|
||||
});
|
||||
}
|
||||
33
apps/web-react/app/features/admin/loaders/admin.server.ts
Normal file
33
apps/web-react/app/features/admin/loaders/admin.server.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import {
|
||||
getRealUserId,
|
||||
isImpersonating,
|
||||
requireUser,
|
||||
} from "~/features/auth/core/user.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { isAdmin, isDev, isStaff } from "~/modules/permissions/utils";
|
||||
import { normalizeFriendCode } from "~/utils/zod";
|
||||
import { adminSearchParams } from "../admin-search-params";
|
||||
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "../core/dev-controls";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
if (!DANGEROUS_CAN_ACCESS_DEV_CONTROLS) {
|
||||
const user = requireUser();
|
||||
const realUserId = await getRealUserId(request);
|
||||
const userToCheck =
|
||||
realUserId && realUserId !== user.id ? { id: realUserId } : user;
|
||||
|
||||
if (!isAdmin(userToCheck) && !isStaff(userToCheck) && !isDev(userToCheck)) {
|
||||
throw new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
const { friendCode } = adminSearchParams.parse(request);
|
||||
|
||||
return {
|
||||
isImpersonating: await isImpersonating(request),
|
||||
friendCodeSearchUsers: friendCode
|
||||
? await UserRepository.findByFriendCode(normalizeFriendCode(friendCode))
|
||||
: [],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { requireRole } from "~/modules/permissions/guards.server";
|
||||
import * as ExternalStreamRepository from "../ExternalStreamRepository.server";
|
||||
|
||||
export const loader = async () => {
|
||||
requireRole("ADMIN");
|
||||
|
||||
return {
|
||||
streams: await ExternalStreamRepository.findAll(),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
.streamRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.streamAvatar {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
98
apps/web-react/app/features/admin/routes/admin.streams.tsx
Normal file
98
apps/web-react/app/features/admin/routes/admin.streams.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { MetaFunction } from "react-router";
|
||||
import { Link, useLoaderData } from "react-router";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
||||
import { LocaleTime } from "~/components/LocaleTime";
|
||||
import { Main } from "~/components/Main";
|
||||
import { SendouForm } from "~/form/SendouForm";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import { action } from "../actions/admin.streams.server";
|
||||
import { createExternalStreamSchema } from "../admin-schemas";
|
||||
import { loader } from "../loaders/admin.streams.server";
|
||||
|
||||
import styles from "./admin.streams.module.css";
|
||||
|
||||
export { action, loader };
|
||||
|
||||
export const meta: MetaFunction = (args) => {
|
||||
return metaTags({
|
||||
title: "External streams",
|
||||
location: args.location,
|
||||
});
|
||||
};
|
||||
|
||||
export default function AdminStreamsPage() {
|
||||
return (
|
||||
<Main className="stack lg">
|
||||
<SendouForm
|
||||
schema={createExternalStreamSchema}
|
||||
title="Add external stream"
|
||||
>
|
||||
{({ FormField }) => (
|
||||
<>
|
||||
<FormField name="name" />
|
||||
<FormField name="url" />
|
||||
<FormField name="avatar" />
|
||||
<FormField name="startTime" />
|
||||
</>
|
||||
)}
|
||||
</SendouForm>
|
||||
<ExternalStreamList />
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
function ExternalStreamList() {
|
||||
const { streams } = useLoaderData<typeof loader>();
|
||||
|
||||
if (streams.length === 0) {
|
||||
return <div className="text-lighter">No external streams</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack md">
|
||||
<h2>Current external streams</h2>
|
||||
<ul className="stack sm">
|
||||
{streams.map((stream) => (
|
||||
<li key={stream.id} className={styles.streamRow}>
|
||||
{stream.avatarUrl ? (
|
||||
<img
|
||||
src={stream.avatarUrl}
|
||||
alt=""
|
||||
className={styles.streamAvatar}
|
||||
/>
|
||||
) : null}
|
||||
<div className="stack xxs">
|
||||
<Link to={stream.url} className="text-main-forced">
|
||||
{stream.name}
|
||||
</Link>
|
||||
<span className="text-xs text-lighter">
|
||||
<LocaleTime
|
||||
date={stream.startsAt}
|
||||
inline
|
||||
options={{
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<FormWithConfirm
|
||||
dialogHeading={`Delete external stream "${stream.name}"?`}
|
||||
fields={[
|
||||
["_action", "DELETE"],
|
||||
["id", stream.id],
|
||||
]}
|
||||
>
|
||||
<SendouButton variant="minimal-destructive" className="ml-auto">
|
||||
Delete
|
||||
</SendouButton>
|
||||
</FormWithConfirm>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
377
apps/web-react/app/features/admin/routes/admin.test.ts
Normal file
377
apps/web-react/app/features/admin/routes/admin.test.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import * as BuildFactory from "~/db/seed/factories/BuildFactory";
|
||||
import * as PlusVoteFactory from "~/db/seed/factories/PlusVoteFactory";
|
||||
import * as SkillFactory from "~/db/seed/factories/SkillFactory";
|
||||
import * as TeamFactory from "~/db/seed/factories/TeamFactory";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import { db } from "~/db/sql";
|
||||
import * as BuildRepository from "~/features/builds/BuildRepository.server";
|
||||
import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants";
|
||||
import * as TeamRepository from "~/features/team/TeamRepository.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { assertResponseErrored, wrappedAction } from "~/utils/Test";
|
||||
import type { adminActionSchema } from "../admin-schemas";
|
||||
import { action } from "./admin";
|
||||
|
||||
const adminAction = wrappedAction<typeof adminActionSchema>({
|
||||
action,
|
||||
isJsonSubmission: true,
|
||||
});
|
||||
|
||||
const users = UserFactory.pool();
|
||||
|
||||
// account migration is asserted through Discord ids, so the tests give the users
|
||||
// one they can name
|
||||
const createUsers = (count = 2) =>
|
||||
users.create(count, (index) => ({ discordId: String(index) }));
|
||||
|
||||
const countPlusTierMembers = (tier = 1) =>
|
||||
db
|
||||
.selectFrom("PlusTier")
|
||||
.where("PlusTier.tier", "=", tier)
|
||||
.select(({ fn }) => fn.count<number>("PlusTier.tier").as("count"))
|
||||
.executeTakeFirstOrThrow()
|
||||
.then((row) => row.count);
|
||||
|
||||
/** Ranks the given users, the first of them topping the leaderboard. */
|
||||
const createLeaderboard = (userIds: number[]) =>
|
||||
SkillFactory.createMany(
|
||||
userIds.length,
|
||||
(index) => ({ userId: userIds[index], mu: userIds.length - index }),
|
||||
{ matchesCount: MATCHES_COUNT_NEEDED_FOR_LEADERBOARD },
|
||||
);
|
||||
|
||||
/** Marks a user as skipping the plus server for the given season. */
|
||||
const skipPlusForSeason = (userId: number, seasonNth: number) =>
|
||||
// the app only ever reads the column; the one thing that sets it is
|
||||
// `scripts/skip-plus.ts`, itself a raw update with no repository function behind it
|
||||
// biome-ignore lint/plugin: no production write reaches the column
|
||||
db
|
||||
.updateTable("User")
|
||||
.set({ plusSkippedForSeasonNth: seasonNth })
|
||||
.where("User.id", "=", userId)
|
||||
.execute();
|
||||
|
||||
describe("Plus voting", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("gives correct amount of plus tiers", async () => {
|
||||
vi.setSystemTime(new Date("2023-12-12T00:00:00.000Z"));
|
||||
|
||||
await createUsers(10);
|
||||
await PlusVoteFactory.createMany(10, (index) => ({
|
||||
authorId: users.id(1),
|
||||
votedId: users.id(index + 1),
|
||||
score: index < 5 ? -1 : 1,
|
||||
}));
|
||||
|
||||
await adminAction({ _action: "REFRESH" }, { user: "admin" });
|
||||
|
||||
expect(await countPlusTierMembers()).toBe(5);
|
||||
});
|
||||
|
||||
test("60% or more guarantees pass", async () => {
|
||||
vi.setSystemTime(new Date("2023-12-12T00:00:00.000Z"));
|
||||
|
||||
await createUsers(10);
|
||||
|
||||
// 60% - auto-pass
|
||||
await PlusVoteFactory.createMany(10, (index) => ({
|
||||
authorId: users.id(index + 1),
|
||||
votedId: users.id(1),
|
||||
score: index < 4 ? -1 : 1,
|
||||
}));
|
||||
|
||||
await adminAction({ _action: "REFRESH" }, { user: "admin" });
|
||||
|
||||
expect(await countPlusTierMembers()).toBe(1);
|
||||
});
|
||||
|
||||
test("40% or less does not pass", async () => {
|
||||
vi.setSystemTime(new Date("2023-12-12T00:00:00.000Z"));
|
||||
|
||||
await createUsers(10);
|
||||
|
||||
// 40% - auto-fail
|
||||
await PlusVoteFactory.createMany(10, (index) => ({
|
||||
authorId: users.id(index + 1),
|
||||
votedId: users.id(1),
|
||||
score: index < 6 ? -1 : 1,
|
||||
}));
|
||||
|
||||
await adminAction({ _action: "REFRESH" }, { user: "admin" });
|
||||
|
||||
expect(await countPlusTierMembers()).toBe(0);
|
||||
});
|
||||
|
||||
test("middle zone (40-60%) passes when quota has room", async () => {
|
||||
vi.setSystemTime(new Date("2023-12-12T00:00:00.000Z"));
|
||||
|
||||
await createUsers(10);
|
||||
|
||||
// 50% - middle zone, should pass (quota=50 for tier 1)
|
||||
await PlusVoteFactory.createMany(10, (index) => ({
|
||||
authorId: users.id(index + 1),
|
||||
votedId: users.id(1),
|
||||
score: index < 5 ? -1 : 1,
|
||||
}));
|
||||
|
||||
await adminAction({ _action: "REFRESH" }, { user: "admin" });
|
||||
|
||||
expect(await countPlusTierMembers()).toBe(1);
|
||||
});
|
||||
|
||||
test("combines leaderboard and voting results (after season over)", async () => {
|
||||
vi.setSystemTime(new Date("2023-11-29T00:00:00.000Z"));
|
||||
|
||||
await createUsers();
|
||||
await PlusVoteFactory.create({
|
||||
authorId: users.id(1),
|
||||
votedId: users.id(1),
|
||||
score: 1,
|
||||
});
|
||||
await createLeaderboard([users.id(2)]);
|
||||
|
||||
await adminAction({ _action: "REFRESH" }, { user: "admin" });
|
||||
|
||||
expect(await countPlusTierMembers()).toBe(2);
|
||||
});
|
||||
|
||||
test("skips users from leaderboard with the skip flag for the season", async () => {
|
||||
vi.setSystemTime(new Date("2023-11-29T00:00:00.000Z"));
|
||||
|
||||
await createUsers(11);
|
||||
await createLeaderboard(users.ids());
|
||||
|
||||
await skipPlusForSeason(users.id(1), 1);
|
||||
|
||||
await adminAction({ _action: "REFRESH" }, { user: "admin" });
|
||||
|
||||
expect(await countPlusTierMembers(1)).toBe(5);
|
||||
expect(await countPlusTierMembers(2)).toBe(5);
|
||||
});
|
||||
|
||||
test("plus server skip flag ignored if for past season", async () => {
|
||||
vi.setSystemTime(new Date("2023-11-29T00:00:00.000Z"));
|
||||
|
||||
await createUsers(11);
|
||||
await createLeaderboard(users.ids());
|
||||
|
||||
await skipPlusForSeason(users.id(1), 0);
|
||||
|
||||
await adminAction({ _action: "REFRESH" }, { user: "admin" });
|
||||
|
||||
expect(await countPlusTierMembers(1)).toBe(5);
|
||||
expect(await countPlusTierMembers(2)).toBe(6);
|
||||
});
|
||||
|
||||
test("ignores leaderboard while season is ongoing", async () => {
|
||||
vi.setSystemTime(new Date("2024-02-15T00:00:00.000Z"));
|
||||
|
||||
await createUsers();
|
||||
await PlusVoteFactory.create({
|
||||
authorId: users.id(1),
|
||||
votedId: users.id(1),
|
||||
score: 1,
|
||||
});
|
||||
await createLeaderboard([users.id(2)]);
|
||||
|
||||
await adminAction({ _action: "REFRESH" }, { user: "admin" });
|
||||
|
||||
expect(await countPlusTierMembers()).toBe(1);
|
||||
expect(await countPlusTierMembers(2)).toBe(0);
|
||||
});
|
||||
|
||||
test("leaderboard gives members to all tiers", async () => {
|
||||
vi.setSystemTime(new Date("2023-11-20T00:00:00.000Z"));
|
||||
|
||||
await createUsers(60);
|
||||
await createLeaderboard(users.ids());
|
||||
|
||||
await adminAction({ _action: "REFRESH" }, { user: "admin" });
|
||||
|
||||
expect(await countPlusTierMembers()).toBeGreaterThan(0);
|
||||
expect(await countPlusTierMembers(2)).toBeGreaterThan(0);
|
||||
expect(await countPlusTierMembers(3)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("gives membership if failed voting and is on the leaderboard", async () => {
|
||||
vi.setSystemTime(new Date("2023-11-29T00:00:00.000Z"));
|
||||
|
||||
await createUsers(1);
|
||||
await PlusVoteFactory.create({
|
||||
authorId: users.id(1),
|
||||
votedId: users.id(1),
|
||||
score: -1,
|
||||
});
|
||||
await createLeaderboard([users.id(1)]);
|
||||
|
||||
await adminAction({ _action: "REFRESH" }, { user: "admin" });
|
||||
|
||||
expect(await countPlusTierMembers(1)).toBe(1);
|
||||
});
|
||||
|
||||
test("gives membership if failed voting and is on the leaderboard and season ended last month", async () => {
|
||||
vi.setSystemTime(new Date("2023-12-29T00:00:00.000Z"));
|
||||
|
||||
await createUsers(1);
|
||||
await PlusVoteFactory.create({
|
||||
authorId: users.id(1),
|
||||
votedId: users.id(1),
|
||||
score: -1,
|
||||
});
|
||||
await createLeaderboard([users.id(1)]);
|
||||
|
||||
await adminAction({ _action: "REFRESH" }, { user: "admin" });
|
||||
|
||||
expect(await countPlusTierMembers(1)).toBe(1);
|
||||
});
|
||||
|
||||
test("members who fails voting drops one tier", async () => {
|
||||
vi.setSystemTime(new Date("2024-02-15T00:00:00.000Z"));
|
||||
|
||||
await createUsers(1);
|
||||
await PlusVoteFactory.create({
|
||||
authorId: users.id(1),
|
||||
votedId: users.id(1),
|
||||
score: 1,
|
||||
month: 11,
|
||||
year: 2023,
|
||||
});
|
||||
|
||||
await PlusVoteFactory.create({
|
||||
authorId: users.id(1),
|
||||
votedId: users.id(1),
|
||||
score: -1,
|
||||
month: 2,
|
||||
year: 2024,
|
||||
});
|
||||
|
||||
await adminAction({ _action: "REFRESH" }, { user: "admin" });
|
||||
|
||||
expect(await countPlusTierMembers(2)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
const migrateUserAction = () =>
|
||||
adminAction(
|
||||
{
|
||||
_action: "MIGRATE",
|
||||
oldUser: users.id(1),
|
||||
newUser: users.id(2),
|
||||
},
|
||||
{ user: "admin" },
|
||||
);
|
||||
|
||||
describe("Account migration", () => {
|
||||
beforeEach(async () => {
|
||||
await createUsers(2);
|
||||
});
|
||||
|
||||
test("migrates a blank account", async () => {
|
||||
expect(await UserRepository.findProfileByIdentifier("0")).toBeDefined();
|
||||
expect(await UserRepository.findProfileByIdentifier("1")).toBeDefined();
|
||||
|
||||
await migrateUserAction();
|
||||
|
||||
const oldUser = await UserRepository.findProfileByIdentifier("0"); // these are discord ids
|
||||
const newUser = await UserRepository.findProfileByIdentifier("1");
|
||||
|
||||
expect(oldUser).toBeNull();
|
||||
expect(newUser?.id).toBe(users.id(1)); // took the old user's id
|
||||
});
|
||||
|
||||
test("two accounts with teams results in an error", async () => {
|
||||
await TeamFactory.create({ memberUserIds: [users.id(1)] });
|
||||
await TeamFactory.create({ memberUserIds: [users.id(2)] });
|
||||
|
||||
const response = await migrateUserAction();
|
||||
|
||||
assertResponseErrored(response, "both old and new user are in teams");
|
||||
});
|
||||
|
||||
const membershipOf = (userId: number) =>
|
||||
db
|
||||
.selectFrom("AllTeamMember")
|
||||
.select(["userId"])
|
||||
.where("userId", "=", userId)
|
||||
.executeTakeFirst();
|
||||
|
||||
test("deletes past team membership status of the new user", async () => {
|
||||
const team = await TeamFactory.create({ memberUserIds: [users.id(2)] });
|
||||
await TeamRepository.deleteById(team.id);
|
||||
|
||||
const membershipBeforeMigration = await membershipOf(users.id(2));
|
||||
expect(membershipBeforeMigration).toBeDefined();
|
||||
|
||||
await migrateUserAction();
|
||||
|
||||
const membershipAfterMigration = await membershipOf(users.id(2));
|
||||
|
||||
expect(membershipAfterMigration).toBeUndefined();
|
||||
});
|
||||
|
||||
test("handles old user member of the same team as new user (old user has left the team, new user current)", async () => {
|
||||
const team = await TeamFactory.create({
|
||||
memberUserIds: [users.id(2), users.id(1)],
|
||||
});
|
||||
await TeamRepository.handleMemberLeaving({
|
||||
teamId: team.id,
|
||||
userId: users.id(1),
|
||||
});
|
||||
|
||||
for (const id of [users.id(1), users.id(2)]) {
|
||||
const membership = await membershipOf(id);
|
||||
expect(membership).toBeDefined();
|
||||
}
|
||||
|
||||
await migrateUserAction();
|
||||
|
||||
const membershipOldUser = await membershipOf(users.id(1));
|
||||
const membershipNewUser = await membershipOf(users.id(2));
|
||||
|
||||
expect(membershipOldUser).toBeDefined();
|
||||
expect(membershipNewUser).toBeUndefined();
|
||||
});
|
||||
|
||||
test("deletes weapon pool from the new user when migrating (takes weapon pool from the old user)", async () => {
|
||||
await UserFactory.grant(users.id(1), {
|
||||
weapons: [{ weaponSplId: 1, isFavorite: 1 }],
|
||||
});
|
||||
await UserFactory.grant(users.id(2), { weapons: [{ weaponSplId: 10 }] });
|
||||
|
||||
await migrateUserAction();
|
||||
|
||||
const oldUser = await UserRepository.findProfileByIdentifier("0");
|
||||
const newUser = await UserRepository.findProfileByIdentifier("1");
|
||||
|
||||
expect(oldUser).toBeNull();
|
||||
expect(newUser?.weapons).toEqual([
|
||||
{ weaponSplId: 1, isFavorite: 1, isTenStar: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("deletes builds from the new user when migrating", async () => {
|
||||
await BuildFactory.create({ ownerId: users.id(2) });
|
||||
|
||||
const buildsBefore = await BuildRepository.findAllByUserId(users.id(2));
|
||||
|
||||
expect(buildsBefore.length).toBe(1);
|
||||
|
||||
await migrateUserAction();
|
||||
|
||||
const oldUser = await UserRepository.findProfileByIdentifier("0");
|
||||
expect(oldUser).toBeNull();
|
||||
|
||||
for (const id of [users.id(1), users.id(2)]) {
|
||||
const buildsAfter = await BuildRepository.findAllByUserId(id);
|
||||
expect(buildsAfter.length).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
331
apps/web-react/app/features/admin/routes/admin.tsx
Normal file
331
apps/web-react/app/features/admin/routes/admin.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
import * as React from "react";
|
||||
import type { MetaFunction } from "react-router";
|
||||
import { Form, useFetcher, useLoaderData } from "react-router";
|
||||
import { Catcher } from "~/components/Catcher";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import {
|
||||
SendouTab,
|
||||
SendouTabList,
|
||||
SendouTabPanel,
|
||||
SendouTabs,
|
||||
} from "~/components/elements/Tabs";
|
||||
import { UserSearch } from "~/components/elements/UserSearch";
|
||||
import { Main } from "~/components/Main";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { UserLink } from "~/components/UserLink";
|
||||
import { SendouForm } from "~/form/SendouForm";
|
||||
import { useHasRole } from "~/modules/permissions/hooks";
|
||||
import { useSearchParam } from "~/modules/search-params/hooks";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import { impersonateUrl, SEED_URL, STOP_IMPERSONATING_URL } from "~/utils/urls";
|
||||
import { action } from "../actions/admin.server";
|
||||
import {
|
||||
banUserSchema,
|
||||
forcePatronSchema,
|
||||
friendCodeSearchSchema,
|
||||
giveApiAccessSchema,
|
||||
giveArtistSchema,
|
||||
giveTournamentOrganizerSchema,
|
||||
giveVideoAdderSchema,
|
||||
linkPlayerSchema,
|
||||
migrateUserSchema,
|
||||
refreshPlusTiersSchema,
|
||||
unbanUserSchema,
|
||||
updateFriendCodeSchema,
|
||||
} from "../admin-schemas";
|
||||
import { adminSearchParams } from "../admin-search-params";
|
||||
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "../core/dev-controls";
|
||||
import { loader } from "../loaders/admin.server";
|
||||
|
||||
export { action, loader };
|
||||
|
||||
export const meta: MetaFunction = (args) => {
|
||||
return metaTags({
|
||||
title: "Admin Panel",
|
||||
location: args.location,
|
||||
});
|
||||
};
|
||||
|
||||
export default function AdminPage() {
|
||||
const isStaff = useHasRole("STAFF");
|
||||
|
||||
// is dev user or is someone impersonating another user (allow them to stop)
|
||||
if (!isStaff && !DANGEROUS_CAN_ACCESS_DEV_CONTROLS) {
|
||||
return (
|
||||
<Main>
|
||||
<Impersonate />
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Main halfWidth>
|
||||
<SendouTabs>
|
||||
<SendouTabList>
|
||||
<SendouTab id="actions">Actions</SendouTab>
|
||||
<SendouTab id="friend-code-look-up">Friend code look-up</SendouTab>
|
||||
</SendouTabList>
|
||||
<SendouTabPanel id="actions">
|
||||
<AdminActions />
|
||||
</SendouTabPanel>
|
||||
<SendouTabPanel id="friend-code-look-up">
|
||||
<FriendCodeLookUp />
|
||||
</SendouTabPanel>
|
||||
</SendouTabs>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
function FriendCodeLookUp() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [friendCode, setFriendCode] = useSearchParam(
|
||||
adminSearchParams,
|
||||
"friendCode",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="stack lg">
|
||||
<SendouForm
|
||||
schema={friendCodeSearchSchema}
|
||||
defaultValues={{ friendCode: friendCode ?? "" }}
|
||||
submitButtonText="Search"
|
||||
onApply={({ friendCode }) => setFriendCode(friendCode)}
|
||||
>
|
||||
{({ FormField }) => <FormField name="friendCode" />}
|
||||
</SendouForm>
|
||||
<div className="stack lg">
|
||||
{data.friendCodeSearchUsers?.map((user) => (
|
||||
<UserLink key={user.id} user={user} size="sm" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminActions() {
|
||||
const isStaff = useHasRole("STAFF");
|
||||
const isAdmin = useHasRole("ADMIN");
|
||||
const isDev = useHasRole("DEV");
|
||||
|
||||
return (
|
||||
<div className="stack lg">
|
||||
{DANGEROUS_CAN_ACCESS_DEV_CONTROLS ? <Seed /> : null}
|
||||
{DANGEROUS_CAN_ACCESS_DEV_CONTROLS || isAdmin || isDev ? (
|
||||
<Impersonate />
|
||||
) : null}
|
||||
|
||||
{isStaff ? <LinkPlayer /> : null}
|
||||
{isStaff ? <GiveArtist /> : null}
|
||||
{isStaff ? <GiveVideoAdder /> : null}
|
||||
{isAdmin ? <GiveTournamentOrganizer /> : null}
|
||||
{isAdmin ? <GiveApiAccess /> : null}
|
||||
{isStaff ? <UpdateFriendCode /> : null}
|
||||
{isStaff ? <MigrateUser /> : null}
|
||||
{isAdmin ? <ForcePatron /> : null}
|
||||
{isStaff ? <BanUser /> : null}
|
||||
{isStaff ? <UnbanUser /> : null}
|
||||
{isAdmin ? <RefreshPlusTiers /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Impersonate() {
|
||||
const [userId, setUserId] = React.useState<number>();
|
||||
const { isImpersonating } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<Form
|
||||
method="post"
|
||||
action={impersonateUrl(userId ?? 0)}
|
||||
className="stack md"
|
||||
reloadDocument
|
||||
>
|
||||
<h2>Impersonate user</h2>
|
||||
<UserSearch
|
||||
label="User to log in as"
|
||||
onChange={(newUser) => setUserId(newUser?.id)}
|
||||
/>
|
||||
<div className="stack horizontal md">
|
||||
<SendouButton type="submit" isDisabled={!userId}>
|
||||
Go
|
||||
</SendouButton>
|
||||
{isImpersonating ? (
|
||||
<SendouButton type="submit" formAction={STOP_IMPERSONATING_URL}>
|
||||
Stop impersonating
|
||||
</SendouButton>
|
||||
) : null}
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function MigrateUser() {
|
||||
return (
|
||||
<SendouForm
|
||||
schema={migrateUserSchema}
|
||||
title="Migrate user data"
|
||||
submitButtonText="Migrate"
|
||||
>
|
||||
{({ FormField }) => (
|
||||
<>
|
||||
<FormField name="oldUser" />
|
||||
<FormField name="newUser" />
|
||||
</>
|
||||
)}
|
||||
</SendouForm>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkPlayer() {
|
||||
return (
|
||||
<SendouForm
|
||||
schema={linkPlayerSchema}
|
||||
title="Link player"
|
||||
submitButtonText="Link player"
|
||||
>
|
||||
{({ FormField }) => (
|
||||
<>
|
||||
<FormField name="user" />
|
||||
<FormField name="playerId" />
|
||||
</>
|
||||
)}
|
||||
</SendouForm>
|
||||
);
|
||||
}
|
||||
|
||||
function GiveArtist() {
|
||||
return (
|
||||
<SendouForm
|
||||
schema={giveArtistSchema}
|
||||
title="Add as artist"
|
||||
submitButtonText="Add as artist"
|
||||
>
|
||||
{({ FormField }) => <FormField name="user" />}
|
||||
</SendouForm>
|
||||
);
|
||||
}
|
||||
|
||||
function GiveVideoAdder() {
|
||||
return (
|
||||
<SendouForm
|
||||
schema={giveVideoAdderSchema}
|
||||
title="Give video adder"
|
||||
submitButtonText="Add as video adder"
|
||||
>
|
||||
{({ FormField }) => <FormField name="user" />}
|
||||
</SendouForm>
|
||||
);
|
||||
}
|
||||
|
||||
function GiveTournamentOrganizer() {
|
||||
return (
|
||||
<SendouForm
|
||||
schema={giveTournamentOrganizerSchema}
|
||||
title="Give tournament organizer"
|
||||
submitButtonText="Add as tournament organizer"
|
||||
>
|
||||
{({ FormField }) => <FormField name="user" />}
|
||||
</SendouForm>
|
||||
);
|
||||
}
|
||||
|
||||
function GiveApiAccess() {
|
||||
return (
|
||||
<SendouForm
|
||||
schema={giveApiAccessSchema}
|
||||
title="Give API access"
|
||||
submitButtonText="Grant API access"
|
||||
>
|
||||
{({ FormField }) => <FormField name="user" />}
|
||||
</SendouForm>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateFriendCode() {
|
||||
return (
|
||||
<SendouForm schema={updateFriendCodeSchema} title="Update friend code">
|
||||
{({ FormField }) => (
|
||||
<>
|
||||
<FormField name="user" />
|
||||
<FormField name="friendCode" />
|
||||
</>
|
||||
)}
|
||||
</SendouForm>
|
||||
);
|
||||
}
|
||||
|
||||
function ForcePatron() {
|
||||
return (
|
||||
<SendouForm
|
||||
schema={forcePatronSchema}
|
||||
title="Force patron"
|
||||
submitButtonText="Save"
|
||||
>
|
||||
{({ FormField }) => (
|
||||
<>
|
||||
<FormField name="user" />
|
||||
<FormField name="patronTier" />
|
||||
<FormField name="patronExpiresAt" />
|
||||
</>
|
||||
)}
|
||||
</SendouForm>
|
||||
);
|
||||
}
|
||||
|
||||
function BanUser() {
|
||||
return (
|
||||
<SendouForm
|
||||
schema={banUserSchema}
|
||||
title={<span className="text-warning">Ban user</span>}
|
||||
submitButtonText="Save"
|
||||
>
|
||||
{({ FormField }) => (
|
||||
<>
|
||||
<FormField name="user" />
|
||||
<FormField name="expiresAt" />
|
||||
<FormField name="reason" />
|
||||
</>
|
||||
)}
|
||||
</SendouForm>
|
||||
);
|
||||
}
|
||||
|
||||
function UnbanUser() {
|
||||
return (
|
||||
<SendouForm
|
||||
schema={unbanUserSchema}
|
||||
title={<span className="text-warning">Unban user</span>}
|
||||
submitButtonText="Save"
|
||||
>
|
||||
{({ FormField }) => <FormField name="user" />}
|
||||
</SendouForm>
|
||||
);
|
||||
}
|
||||
|
||||
function RefreshPlusTiers() {
|
||||
return (
|
||||
<SendouForm
|
||||
schema={refreshPlusTiersSchema}
|
||||
title="Refresh Plus Tiers"
|
||||
submitButtonText="Refresh"
|
||||
>
|
||||
{null}
|
||||
</SendouForm>
|
||||
);
|
||||
}
|
||||
|
||||
function Seed() {
|
||||
const fetcher = useFetcher();
|
||||
|
||||
return (
|
||||
<fetcher.Form
|
||||
className="stack md items-start"
|
||||
method="post"
|
||||
action={SEED_URL}
|
||||
>
|
||||
<SubmitButton state={fetcher.state}>Seed</SubmitButton>
|
||||
</fetcher.Form>
|
||||
);
|
||||
}
|
||||
|
||||
export const ErrorBoundary = Catcher;
|
||||
157
apps/web-react/app/features/admin/routes/generate-images.tsx
Normal file
157
apps/web-react/app/features/admin/routes/generate-images.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { Divider } from "~/components/Divider";
|
||||
import { Image } from "~/components/Image";
|
||||
import { Main } from "~/components/Main";
|
||||
import { Config } from "~/config";
|
||||
import { list } from "~/features/mmr/core/Seasons";
|
||||
import { navIconUrl, tierImageUrl } from "~/utils/urls";
|
||||
|
||||
// this page is not accessible in production, just used to generate images for social media
|
||||
|
||||
export default function GenerateImages() {
|
||||
return (
|
||||
<Main className="stack lg">
|
||||
<h1>Generate Images</h1>
|
||||
<Divider />
|
||||
<SendouQSeason />
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
const season = list.at(-1)!;
|
||||
|
||||
function SendouQSeason() {
|
||||
return (
|
||||
<div className="text-center stack lg">
|
||||
<div>
|
||||
<h2 style={{ fontSize: "2rem" }}>Season {season.nth}</h2>
|
||||
<div className="font-semi-bold text-md italic">
|
||||
{season.starts.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}{" "}
|
||||
-{" "}
|
||||
{season.ends.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, 1fr)",
|
||||
gap: "1rem",
|
||||
placeItems: "center",
|
||||
}}
|
||||
>
|
||||
<InfoSquare title="Gain Plus Server access">
|
||||
<Image width={124} height={124} path={navIconUrl("plus")} alt="" />
|
||||
</InfoSquare>
|
||||
<InfoSquare title="Compete in Season Finale">
|
||||
<Image width={124} height={124} path={navIconUrl("sendouq")} alt="" />
|
||||
</InfoSquare>
|
||||
<InfoSquare title="Rank Up to Leviathan">
|
||||
<Image
|
||||
width={124}
|
||||
height={124}
|
||||
path={tierImageUrl("LEVIATHAN")}
|
||||
alt=""
|
||||
/>
|
||||
</InfoSquare>
|
||||
</div>
|
||||
|
||||
<Divider smallText className="font-bold text-xs">
|
||||
EVENTS
|
||||
</Divider>
|
||||
|
||||
<EventSchedule
|
||||
name="In The Zone 44"
|
||||
imgId="itz"
|
||||
date={new Date("2025-07-05T12:00:00Z")}
|
||||
/>
|
||||
|
||||
<EventSchedule
|
||||
name="In The Zone 45"
|
||||
imgId="itz"
|
||||
date={new Date("2025-08-16T12:00:00Z")}
|
||||
/>
|
||||
|
||||
<EventSchedule
|
||||
name="Season Finale"
|
||||
imgId="sf"
|
||||
date={new Date("2025-08-30T12:00:00Z")}
|
||||
/>
|
||||
|
||||
<div className="text-xs font-semi-bold mt-6">
|
||||
Check all ranked tournaments on the calendar!
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoSquare({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="font-semi-bold">
|
||||
{title}
|
||||
<div
|
||||
className="bg-lighter"
|
||||
style={{
|
||||
width: "12rem",
|
||||
height: "12rem",
|
||||
borderRadius: "var(--radius-box)",
|
||||
marginTop: "1rem",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
borderColor: "var(--color-border)",
|
||||
borderWidth: "2px",
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventSchedule({
|
||||
name,
|
||||
date,
|
||||
imgId,
|
||||
}: {
|
||||
name: string;
|
||||
date: Date;
|
||||
imgId: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="stack md horizontal items-center mx-auto">
|
||||
<img
|
||||
alt=""
|
||||
src={`${Config.staticAssetsUrl}/img/tournament-logos/${imgId}.avif`}
|
||||
style={{
|
||||
width: "4rem",
|
||||
height: "4rem",
|
||||
borderRadius: "100%",
|
||||
}}
|
||||
/>
|
||||
<div className="stack items-start">
|
||||
<div className="font-semi-bold text-lg">{name}</div>
|
||||
<div className="text-sm">
|
||||
{date.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
13
apps/web-react/app/features/api-private/routes/end-season.ts
Normal file
13
apps/web-react/app/features/api-private/routes/end-season.ts
Normal 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);
|
||||
};
|
||||
23
apps/web-react/app/features/api-private/routes/patrons.ts
Normal file
23
apps/web-react/app/features/api-private/routes/patrons.ts
Normal 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();
|
||||
};
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
16
apps/web-react/app/features/api-private/routes/seed.ts
Normal file
16
apps/web-react/app/features/api-private/routes/seed.ts
Normal 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);
|
||||
};
|
||||
14
apps/web-react/app/features/api-private/routes/users.ts
Normal file
14
apps/web-react/app/features/api-private/routes/users.ts
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
73
apps/web-react/app/features/api-public/routes/org.$id.ts
Normal file
73
apps/web-react/app/features/api-public/routes/org.$id.ts
Normal 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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
44
apps/web-react/app/features/api-public/routes/team.$id.ts
Normal file
44
apps/web-react/app/features/api-public/routes/team.$id.ts
Normal 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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
600
apps/web-react/app/features/api-public/schema.ts
Normal file
600
apps/web-react/app/features/api-public/schema.ts
Normal 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;
|
||||
}
|
||||
165
apps/web-react/app/features/api/ApiRepository.server.test.ts
Normal file
165
apps/web-react/app/features/api/ApiRepository.server.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
80
apps/web-react/app/features/api/ApiRepository.server.ts
Normal file
80
apps/web-react/app/features/api/ApiRepository.server.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
40
apps/web-react/app/features/api/actions/api.server.ts
Normal file
40
apps/web-react/app/features/api/actions/api.server.ts
Normal 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;
|
||||
};
|
||||
11
apps/web-react/app/features/api/api-schemas.ts
Normal file
11
apps/web-react/app/features/api/api-schemas.ts
Normal 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"),
|
||||
}),
|
||||
]);
|
||||
1
apps/web-react/app/features/api/api-types.ts
Normal file
1
apps/web-react/app/features/api/api-types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type ApiTokenType = "read" | "write";
|
||||
127
apps/web-react/app/features/api/core/perms.test.ts
Normal file
127
apps/web-react/app/features/api/core/perms.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
23
apps/web-react/app/features/api/core/perms.ts
Normal file
23
apps/web-react/app/features/api/core/perms.ts
Normal 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);
|
||||
}
|
||||
28
apps/web-react/app/features/api/loaders/api.server.ts
Normal file
28
apps/web-react/app/features/api/loaders/api.server.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
122
apps/web-react/app/features/api/routes/api.tsx
Normal file
122
apps/web-react/app/features/api/routes/api.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
357
apps/web-react/app/features/art/ArtRepository.server.test.ts
Normal file
357
apps/web-react/app/features/art/ArtRepository.server.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
503
apps/web-react/app/features/art/ArtRepository.server.ts
Normal file
503
apps/web-react/app/features/art/ArtRepository.server.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
88
apps/web-react/app/features/art/actions/art.new.server.ts
Normal file
88
apps/web-react/app/features/art/actions/art.new.server.ts
Normal 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));
|
||||
};
|
||||
9
apps/web-react/app/features/art/art-constants.ts
Normal file
9
apps/web-react/app/features/art/art-constants.ts
Normal 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,
|
||||
};
|
||||
43
apps/web-react/app/features/art/art-image.server.ts
Normal file
43
apps/web-react/app/features/art/art-image.server.ts
Normal 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);
|
||||
}
|
||||
81
apps/web-react/app/features/art/art-image.ts
Normal file
81
apps/web-react/app/features/art/art-image.ts
Normal 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;
|
||||
}
|
||||
17
apps/web-react/app/features/art/art-schemas.server.ts
Normal file
17
apps/web-react/app/features/art/art-schemas.server.ts
Normal 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,
|
||||
]);
|
||||
52
apps/web-react/app/features/art/art-schemas.ts
Normal file
52
apps/web-react/app/features/art/art-schemas.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
});
|
||||
53
apps/web-react/app/features/art/art-search-params.test.ts
Normal file
53
apps/web-react/app/features/art/art-search-params.test.ts
Normal 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"]]);
|
||||
});
|
||||
});
|
||||
25
apps/web-react/app/features/art/art-search-params.ts
Normal file
25
apps/web-react/app/features/art/art-search-params.ts
Normal 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 }),
|
||||
});
|
||||
29
apps/web-react/app/features/art/art-types.ts
Normal file
29
apps/web-react/app/features/art/art-types.ts
Normal 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];
|
||||
12
apps/web-react/app/features/art/art-urls.test.ts
Normal file
12
apps/web-react/app/features/art/art-urls.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
27
apps/web-react/app/features/art/art-urls.ts
Normal file
27
apps/web-react/app/features/art/art-urls.ts
Normal 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 });
|
||||
9
apps/web-react/app/features/art/art-utils.ts
Normal file
9
apps/web-react/app/features/art/art-utils.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
297
apps/web-react/app/features/art/components/ArtGrid.tsx
Normal file
297
apps/web-react/app/features/art/components/ArtGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.preview {
|
||||
max-width: 100%;
|
||||
}
|
||||
115
apps/web-react/app/features/art/components/ArtImageFormField.tsx
Normal file
115
apps/web-react/app/features/art/components/ArtImageFormField.tsx
Normal 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,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
152
apps/web-react/app/features/art/components/ArtTagsFormField.tsx
Normal file
152
apps/web-react/app/features/art/components/ArtTagsFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
apps/web-react/app/features/art/components/TagSelect.tsx
Normal file
34
apps/web-react/app/features/art/components/TagSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
apps/web-react/app/features/art/loaders/art.new.server.ts
Normal file
23
apps/web-react/app/features/art/loaders/art.new.server.ts
Normal 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() };
|
||||
};
|
||||
35
apps/web-react/app/features/art/loaders/art.server.ts
Normal file
35
apps/web-react/app/features/art/loaders/art.server.ts
Normal 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),
|
||||
};
|
||||
};
|
||||
98
apps/web-react/app/features/art/routes/art.new.tsx
Normal file
98
apps/web-react/app/features/art/routes/art.new.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
144
apps/web-react/app/features/art/routes/art.tsx
Normal file
144
apps/web-react/app/features/art/routes/art.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const ARTICLES_FOLDER_PATH = "content/articles";
|
||||
@@ -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(),
|
||||
});
|
||||
56
apps/web-react/app/features/articles/core/bySlug.server.ts
Normal file
56
apps/web-react/app/features/articles/core/bySlug.server.ts
Normal 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];
|
||||
}
|
||||
35
apps/web-react/app/features/articles/core/list.server.ts
Normal file
35
apps/web-react/app/features/articles/core/list.server.ts
Normal 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);
|
||||
}
|
||||
@@ -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 };
|
||||
};
|
||||
9
apps/web-react/app/features/articles/loaders/a.server.ts
Normal file
9
apps/web-react/app/features/articles/loaders/a.server.ts
Normal 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),
|
||||
};
|
||||
};
|
||||
127
apps/web-react/app/features/articles/routes/a.$slug.tsx
Normal file
127
apps/web-react/app/features/articles/routes/a.$slug.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
}
|
||||
12
apps/web-react/app/features/articles/routes/a.module.css
Normal file
12
apps/web-react/app/features/articles/routes/a.module.css
Normal 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);
|
||||
}
|
||||
68
apps/web-react/app/features/articles/routes/a.tsx
Normal file
68
apps/web-react/app/features/articles/routes/a.tsx
Normal 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
Reference in New Issue
Block a user