sendou.ink/app/features/admin/AdminRepository.server.ts
2026-07-18 12:46:07 +03:00

444 lines
11 KiB
TypeScript

import type { Transaction } from "kysely";
import { db, sql } 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 { dateToDatabaseTimestamp } from "~/utils/dates";
import invariant from "~/utils/invariant";
const removeOldLikesStm = sql.prepare(/*sql*/ `
delete from
"GroupLike"
where
"GroupLike"."createdAt" < cast(strftime('%s', datetime('now', 'start of day', '-7 days')) as int)
`);
const removeOldGroupStm = sql.prepare(/*sql*/ `
delete from
"Group"
where "Group"."id" in (
select "Group"."id"
from "Group"
left join "GroupMatch" on "Group"."id" = "GroupMatch"."alphaGroupId" or "Group"."id" = "GroupMatch"."bravoGroupId"
where "Group"."status" = 'INACTIVE'
and "GroupMatch"."id" is null
)
`);
const cleanUpStm = sql.prepare(/*sql*/ `
vacuum
`);
export const cleanUp = () => {
removeOldLikesStm.run();
removeOldGroupStm.run();
cleanUpStm.run();
};
/**
* 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();
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;
}
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();
});
}
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 BuildRepository.recalculateAllSortValues(userId);
await XRankPlacementRepository.refreshTenStarWeapons(userId);
}
export function forcePatron(args: {
id: number;
patronTier: Tables["User"]["patronTier"];
patronSince: Date;
patronTill: Date;
}) {
return db
.updateTable("User")
.set({
patronTier: args.patronTier,
patronSince: dateToDatabaseTimestamp(args.patronSince),
patronTill: dateToDatabaseTimestamp(args.patronTill),
})
.where("User.id", "=", args.id)
.execute();
}
export async function allBannedUsers() {
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 findModeNoteById(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();
}