sendou.ink/app/features/tournament/TournamentAuditLogRepository.server.ts
2026-06-14 14:08:23 +03:00

241 lines
6.8 KiB
TypeScript

import { sub } from "date-fns";
import type { Transaction } from "kysely";
import { jsonObjectFrom } from "kysely/helpers/sqlite";
import { db } from "~/db/sql";
import type { DB, Tables, TournamentAuditLogMetadata } from "~/db/tables";
import { actorId } from "~/features/auth/core/user.server";
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
import { commonUserSelect } from "~/utils/kysely.server";
export const AUDIT_LOG_PAGE_SIZE = 30;
type TournamentAuditLogType = Tables["TournamentAuditLog"]["type"];
interface InsertArgs {
type: TournamentAuditLogType;
/** The team the event concerns. Its identity is preserved in `TournamentTeamHistory`. */
tournamentTeamId: number;
/** The affected member, for member-level events. */
subjectUserId?: number | null;
metadata?: TournamentAuditLogMetadata | null;
}
/**
* Inserts an audit log event within the caller's transaction (so it commits or
* rolls back atomically with the mutation it records). The acting user is resolved
* from request context via `actorId()`. Ensures a stable `TournamentTeamHistory`
* row exists for the team, so the event remains readable even after the team is
* hard-deleted.
*/
export async function insert(trx: Transaction<DB>, args: InsertArgs) {
const team = await trx
.selectFrom("TournamentTeam")
.select([
"TournamentTeam.tournamentId",
"TournamentTeam.name",
"TournamentTeam.tournamentTeamHistoryId",
])
.where("TournamentTeam.id", "=", args.tournamentTeamId)
.executeTakeFirstOrThrow();
const tournamentTeamHistoryId =
team.tournamentTeamHistoryId ??
(await createTeamHistory(trx, {
tournamentTeamId: args.tournamentTeamId,
tournamentId: team.tournamentId,
name: team.name,
}));
await trx
.insertInto("TournamentAuditLog")
.values({
tournamentId: team.tournamentId,
type: args.type,
actorUserId: actorId(),
subjectUserId: args.subjectUserId ?? null,
tournamentTeamHistoryId,
metadata: args.metadata ? JSON.stringify(args.metadata) : null,
createdAt: databaseTimestampNow(),
})
.execute();
}
/**
* Creates a fresh history row for a team and links it back from the team, so a
* `TournamentTeam.id` reused by SQLite after a hard-deletion always gets its own
* history row instead of inheriting the deleted team's identity. Returns the new
* history id.
*/
async function createTeamHistory(
trx: Transaction<DB>,
{
tournamentTeamId,
tournamentId,
name,
}: { tournamentTeamId: number; tournamentId: number; name: string },
) {
const { id } = await trx
.insertInto("TournamentTeamHistory")
.values({ tournamentTeamId, tournamentId, name })
.returning("id")
.executeTakeFirstOrThrow();
await trx
.updateTable("TournamentTeam")
.set({ tournamentTeamHistoryId: id })
.where("TournamentTeam.id", "=", tournamentTeamId)
.execute();
return id;
}
/**
* Keeps the team's preserved name current after a rename. No-op when the team
* has no history row yet (it will be created with the up-to-date name on its
* first audited event).
*/
export function updateTeamHistoryName(
trx: Transaction<DB>,
{ tournamentTeamId, name }: { tournamentTeamId: number; name: string },
) {
return trx
.updateTable("TournamentTeamHistory")
.set({ name })
.where("TournamentTeamHistory.id", "=", (eb) =>
eb
.selectFrom("TournamentTeam")
.select("TournamentTeam.tournamentTeamHistoryId")
.where("TournamentTeam.id", "=", tournamentTeamId),
)
.execute();
}
/**
* Returns a page of audit log events for a tournament, newest first, optionally
* filtered by event type and/or team. Resolves the actor, the affected member
* (when present) and the team name (preserved even for deleted teams).
*/
export function findByTournamentId({
tournamentId,
type,
tournamentTeamHistoryId,
limit,
offset,
}: {
tournamentId: number;
type?: TournamentAuditLogType;
tournamentTeamHistoryId?: number;
limit: number;
offset: number;
}) {
let query = db
.selectFrom("TournamentAuditLog")
.select((eb) => [
"TournamentAuditLog.id",
"TournamentAuditLog.type",
"TournamentAuditLog.createdAt",
"TournamentAuditLog.metadata",
jsonObjectFrom(
eb
.selectFrom("User")
.select((eb) => commonUserSelect(eb))
.whereRef("User.id", "=", "TournamentAuditLog.actorUserId"),
).as("actor"),
jsonObjectFrom(
eb
.selectFrom("User")
.select((eb) => commonUserSelect(eb))
.whereRef("User.id", "=", "TournamentAuditLog.subjectUserId"),
).as("subject"),
jsonObjectFrom(
eb
.selectFrom("TournamentTeamHistory")
.select([
"TournamentTeamHistory.id",
"TournamentTeamHistory.tournamentTeamId",
"TournamentTeamHistory.name",
])
.whereRef(
"TournamentTeamHistory.id",
"=",
"TournamentAuditLog.tournamentTeamHistoryId",
),
).as("team"),
])
.where("TournamentAuditLog.tournamentId", "=", tournamentId)
.orderBy("TournamentAuditLog.createdAt", "desc")
.orderBy("TournamentAuditLog.id", "desc")
.limit(limit)
.offset(offset);
if (type) {
query = query.where("TournamentAuditLog.type", "=", type);
}
if (typeof tournamentTeamHistoryId === "number") {
query = query.where(
"TournamentAuditLog.tournamentTeamHistoryId",
"=",
tournamentTeamHistoryId,
);
}
return query.execute();
}
/** Counts audit log events for a tournament matching the same optional filters as {@link findByTournamentId}. Used for pagination. */
export async function countByTournamentId({
tournamentId,
type,
tournamentTeamHistoryId,
}: {
tournamentId: number;
type?: TournamentAuditLogType;
tournamentTeamHistoryId?: number;
}) {
let query = db
.selectFrom("TournamentAuditLog")
.select((eb) => eb.fn.countAll<number>().as("count"))
.where("TournamentAuditLog.tournamentId", "=", tournamentId);
if (type) {
query = query.where("TournamentAuditLog.type", "=", type);
}
if (typeof tournamentTeamHistoryId === "number") {
query = query.where(
"TournamentAuditLog.tournamentTeamHistoryId",
"=",
tournamentTeamHistoryId,
);
}
const result = await query.executeTakeFirstOrThrow();
return result.count;
}
/** Deletes audit log events older than three months. */
export function deleteOld() {
return db
.deleteFrom("TournamentAuditLog")
.where(
"createdAt",
"<",
dateToDatabaseTimestamp(sub(new Date(), { months: 3 })),
)
.executeTakeFirst();
}
/** Returns every team (including deleted ones) that has appeared in the tournament's audit log, for the team filter dropdown. */
export function findTeamsByTournamentId(tournamentId: number) {
return db
.selectFrom("TournamentTeamHistory")
.select([
"TournamentTeamHistory.id",
"TournamentTeamHistory.tournamentTeamId",
"TournamentTeamHistory.name",
])
.where("TournamentTeamHistory.tournamentId", "=", tournamentId)
.orderBy("TournamentTeamHistory.name", "asc")
.execute();
}