diff --git a/app/db/tables.ts b/app/db/tables.ts index 23d734911..8a91c86c5 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -866,6 +866,43 @@ export interface TournamentTeamMember { isLooking: Generated; } +/** Stable shadow of a tournament team's identity that survives the team's hard-deletion, so the audit log can still resolve its name. */ +export interface TournamentTeamHistory { + // xxx: maybe we need soft delete instead. it's possible the same id gets reused... alternative TournamentTeamHistory has its own tournamentTeamId that TournamentAuditLog refers to (also keep tournamentTeamId) + /** Mirrors the original `TournamentTeam.id`. Not a live foreign key so it is not cascade-deleted with the team. */ + tournamentTeamId: number; + tournamentId: number; + name: string; +} + +export const TOURNAMENT_AUDIT_LOG_TYPES = [ + "MEMBER_ADDED", + "MEMBER_REMOVED", + "TEAM_REGISTERED", + "TEAM_UNREGISTERED", + "TEAM_CHECKED_IN", + "TEAM_CHECKED_OUT", + "TEAM_DROPPED_OUT", + "TEAM_DROP_OUT_UNDONE", +] as const; + +export interface TournamentAuditLog { + id: GeneratedAlways; + tournamentId: number; + type: (typeof TOURNAMENT_AUDIT_LOG_TYPES)[number]; + /** The user who performed the action. */ + actorUserId: number; + /** The affected member, for member-level events. `null` for team-level events. */ + subjectUserId: number | null; + tournamentTeamId: number | null; + metadata: JSONColumnTypeNullable; + createdAt: number; +} + +export interface TournamentAuditLogMetadata { + bracketIdx?: number; +} + export interface TournamentOrganization { id: GeneratedAlways; name: string; @@ -1436,6 +1473,8 @@ export interface DB { TournamentTeam: TournamentTeam; TournamentTeamCheckIn: TournamentTeamCheckIn; TournamentTeamMember: TournamentTeamMember; + TournamentTeamHistory: TournamentTeamHistory; + TournamentAuditLog: TournamentAuditLog; TournamentOrganization: TournamentOrganization; TournamentOrganizationMember: TournamentOrganizationMember; TournamentOrganizationBadge: TournamentOrganizationBadge; diff --git a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts index ef451c4cf..956a6fc98 100644 --- a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts +++ b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts @@ -299,6 +299,7 @@ export const action: ActionFunction = async ({ params, request }) => { ); await TournamentTeamRepository.checkIn(teamMemberOf.id, { + actorUserId: user.id, bracketIdx: data.bracketIdx, }); diff --git a/app/features/tournament/TournamentAuditLogRepository.server.test.ts b/app/features/tournament/TournamentAuditLogRepository.server.test.ts new file mode 100644 index 000000000..8a0c9fe0a --- /dev/null +++ b/app/features/tournament/TournamentAuditLogRepository.server.test.ts @@ -0,0 +1,240 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { db } from "~/db/sql"; +import type { Tables } from "~/db/tables"; +import { dbInsertUsers, dbReset } from "~/utils/Test"; +import * as TournamentAuditLogRepository from "./TournamentAuditLogRepository.server"; + +const createTournament = () => + db + .insertInto("Tournament") + .values({ + mapPickingStyle: "TO", + settings: JSON.stringify({ bracketProgression: [] }), + }) + .returning("id") + .executeTakeFirstOrThrow(); + +const createTeam = (tournamentId: number, name: string) => + db + .insertInto("TournamentTeam") + .values({ + tournamentId, + name, + inviteCode: `inv-${tournamentId}-${name}`, + }) + .returning("id") + .executeTakeFirstOrThrow(); + +const insertEvent = (args: { + type: Tables["TournamentAuditLog"]["type"]; + actorUserId: number; + tournamentTeamId: number; + subjectUserId?: number; + metadata?: { bracketIdx?: number }; +}) => + db + .transaction() + .execute((trx) => TournamentAuditLogRepository.insert(trx, args)); + +describe("TournamentAuditLogRepository", () => { + beforeEach(async () => { + await dbInsertUsers(3); + }); + + afterEach(() => { + dbReset(); + }); + + test("insert creates a stable history row from the live team", async () => { + const tournament = await createTournament(); + const team = await createTeam(tournament.id, "Team Olive"); + + await insertEvent({ + type: "TEAM_REGISTERED", + actorUserId: 1, + tournamentTeamId: team.id, + }); + + const teams = await TournamentAuditLogRepository.findTeamsByTournamentId( + tournament.id, + ); + + expect(teams).toHaveLength(1); + expect(teams[0].id).toBe(team.id); + expect(teams[0].name).toBe("Team Olive"); + }); + + test("findByTournamentId returns events newest first with resolved relations", async () => { + const tournament = await createTournament(); + const team = await createTeam(tournament.id, "Team Olive"); + + await insertEvent({ + type: "TEAM_REGISTERED", + actorUserId: 1, + tournamentTeamId: team.id, + subjectUserId: 1, + }); + await insertEvent({ + type: "MEMBER_ADDED", + actorUserId: 1, + tournamentTeamId: team.id, + subjectUserId: 2, + }); + + const events = await TournamentAuditLogRepository.findByTournamentId({ + tournamentId: tournament.id, + limit: 30, + offset: 0, + }); + + expect(events).toHaveLength(2); + // newest first + expect(events[0].type).toBe("MEMBER_ADDED"); + expect(events[0].actor?.id).toBe(1); + expect(events[0].subject?.id).toBe(2); + expect(events[0].team?.name).toBe("Team Olive"); + expect(events[1].type).toBe("TEAM_REGISTERED"); + }); + + test("team name survives the team being deleted", async () => { + const tournament = await createTournament(); + const team = await createTeam(tournament.id, "Team Olive"); + + await insertEvent({ + type: "TEAM_UNREGISTERED", + actorUserId: 1, + tournamentTeamId: team.id, + }); + + await db + .deleteFrom("TournamentTeam") + .where("TournamentTeam.id", "=", team.id) + .execute(); + + const events = await TournamentAuditLogRepository.findByTournamentId({ + tournamentId: tournament.id, + limit: 30, + offset: 0, + }); + + expect(events).toHaveLength(1); + expect(events[0].team?.name).toBe("Team Olive"); + }); + + test("filters by event type and by team", async () => { + const tournament = await createTournament(); + const teamA = await createTeam(tournament.id, "Team A"); + const teamB = await createTeam(tournament.id, "Team B"); + + await insertEvent({ + type: "TEAM_REGISTERED", + actorUserId: 1, + tournamentTeamId: teamA.id, + }); + await insertEvent({ + type: "TEAM_CHECKED_IN", + actorUserId: 1, + tournamentTeamId: teamA.id, + }); + await insertEvent({ + type: "TEAM_REGISTERED", + actorUserId: 1, + tournamentTeamId: teamB.id, + }); + + const byType = await TournamentAuditLogRepository.findByTournamentId({ + tournamentId: tournament.id, + type: "TEAM_REGISTERED", + limit: 30, + offset: 0, + }); + expect(byType).toHaveLength(2); + + const byTeam = await TournamentAuditLogRepository.findByTournamentId({ + tournamentId: tournament.id, + tournamentTeamId: teamA.id, + limit: 30, + offset: 0, + }); + expect(byTeam).toHaveLength(2); + + const count = await TournamentAuditLogRepository.countByTournamentId({ + tournamentId: tournament.id, + type: "TEAM_REGISTERED", + }); + expect(count).toBe(2); + }); + + test("paginates via limit and offset", async () => { + const tournament = await createTournament(); + const team = await createTeam(tournament.id, "Team Olive"); + + for (let i = 0; i < 3; i++) { + await insertEvent({ + type: "TEAM_CHECKED_IN", + actorUserId: 1, + tournamentTeamId: team.id, + }); + } + + const firstPage = await TournamentAuditLogRepository.findByTournamentId({ + tournamentId: tournament.id, + limit: 2, + offset: 0, + }); + const secondPage = await TournamentAuditLogRepository.findByTournamentId({ + tournamentId: tournament.id, + limit: 2, + offset: 2, + }); + + expect(firstPage).toHaveLength(2); + expect(secondPage).toHaveLength(1); + }); + + test("stores and reads back metadata", async () => { + const tournament = await createTournament(); + const team = await createTeam(tournament.id, "Team Olive"); + + await insertEvent({ + type: "TEAM_CHECKED_IN", + actorUserId: 1, + tournamentTeamId: team.id, + metadata: { bracketIdx: 2 }, + }); + + const events = await TournamentAuditLogRepository.findByTournamentId({ + tournamentId: tournament.id, + limit: 30, + offset: 0, + }); + + expect(events[0].metadata?.bracketIdx).toBe(2); + }); + + test("updateTeamHistoryName keeps the preserved name current", async () => { + const tournament = await createTournament(); + const team = await createTeam(tournament.id, "Old Name"); + + await insertEvent({ + type: "TEAM_REGISTERED", + actorUserId: 1, + tournamentTeamId: team.id, + }); + + await db.transaction().execute((trx) => + TournamentAuditLogRepository.updateTeamHistoryName(trx, { + tournamentTeamId: team.id, + name: "New Name", + }), + ); + + const events = await TournamentAuditLogRepository.findByTournamentId({ + tournamentId: tournament.id, + limit: 30, + offset: 0, + }); + + expect(events[0].team?.name).toBe("New Name"); + }); +}); diff --git a/app/features/tournament/TournamentAuditLogRepository.server.ts b/app/features/tournament/TournamentAuditLogRepository.server.ts new file mode 100644 index 000000000..78e3d28ed --- /dev/null +++ b/app/features/tournament/TournamentAuditLogRepository.server.ts @@ -0,0 +1,196 @@ +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 { databaseTimestampNow } from "~/utils/dates"; +import { COMMON_USER_FIELDS } from "~/utils/kysely.server"; + +export const AUDIT_LOG_PAGE_SIZE = 30; + +type TournamentAuditLogType = Tables["TournamentAuditLog"]["type"]; + +// xxx: can we use TablesInsertable + Pick +interface InsertArgs { + type: TournamentAuditLogType; + /** The user who performed the action. */ + actorUserId: number; + /** 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). 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, args: InsertArgs) { // xxx: audit codebase, trx as second arg? + await trx + .insertInto("TournamentTeamHistory") + .columns(["tournamentTeamId", "tournamentId", "name"]) + .expression((eb) => + eb + .selectFrom("TournamentTeam") + .select([ + "TournamentTeam.id", + "TournamentTeam.tournamentId", + "TournamentTeam.name", + ]) + .where("TournamentTeam.id", "=", args.tournamentTeamId), + ) + .onConflict((oc) => oc.column("tournamentTeamId").doNothing()) + .execute(); + + const { tournamentId } = await trx + .selectFrom("TournamentTeamHistory") + .select("TournamentTeamHistory.tournamentId") + .where("TournamentTeamHistory.tournamentTeamId", "=", args.tournamentTeamId) + .executeTakeFirstOrThrow(); + + await trx + .insertInto("TournamentAuditLog") + .values({ + tournamentId, + type: args.type, + actorUserId: args.actorUserId, + subjectUserId: args.subjectUserId ?? null, + tournamentTeamId: args.tournamentTeamId, + metadata: args.metadata ? JSON.stringify(args.metadata) : null, + createdAt: databaseTimestampNow(), + }) + .execute(); +} + +/** + * 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, + { tournamentTeamId, name }: { tournamentTeamId: number; name: string }, +) { + return trx + .updateTable("TournamentTeamHistory") + .set({ name }) + .where("TournamentTeamHistory.tournamentTeamId", "=", 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, + tournamentTeamId, + limit, + offset, +}: { + tournamentId: number; + type?: TournamentAuditLogType; + tournamentTeamId?: 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(COMMON_USER_FIELDS) + .whereRef("User.id", "=", "TournamentAuditLog.actorUserId"), + ).as("actor"), + jsonObjectFrom( + eb + .selectFrom("User") + .select(COMMON_USER_FIELDS) + .whereRef("User.id", "=", "TournamentAuditLog.subjectUserId"), + ).as("subject"), + jsonObjectFrom( + eb + .selectFrom("TournamentTeamHistory") + .select([ + "TournamentTeamHistory.tournamentTeamId as id", + "TournamentTeamHistory.name", + ]) + .whereRef( + "TournamentTeamHistory.tournamentTeamId", + "=", + "TournamentAuditLog.tournamentTeamId", + ), + ).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 tournamentTeamId === "number") { + query = query.where( + "TournamentAuditLog.tournamentTeamId", + "=", + tournamentTeamId, + ); + } + + 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, + tournamentTeamId, +}: { + tournamentId: number; + type?: TournamentAuditLogType; + tournamentTeamId?: number; +}) { + let query = db + .selectFrom("TournamentAuditLog") + .select((eb) => eb.fn.countAll().as("count")) + .where("TournamentAuditLog.tournamentId", "=", tournamentId); + + if (type) { + query = query.where("TournamentAuditLog.type", "=", type); + } + if (typeof tournamentTeamId === "number") { + query = query.where( + "TournamentAuditLog.tournamentTeamId", + "=", + tournamentTeamId, + ); + } + + const result = await query.executeTakeFirstOrThrow(); + + return result.count; +} + +/** 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.tournamentTeamId as id", + "TournamentTeamHistory.name", + ]) + .where("TournamentTeamHistory.tournamentId", "=", tournamentId) + .orderBy("TournamentTeamHistory.name", "asc") + .execute(); +} diff --git a/app/features/tournament/TournamentTeamRepository.server.ts b/app/features/tournament/TournamentTeamRepository.server.ts index 64458bc1c..b9e8caefd 100644 --- a/app/features/tournament/TournamentTeamRepository.server.ts +++ b/app/features/tournament/TournamentTeamRepository.server.ts @@ -8,6 +8,7 @@ import { flatZip } from "~/utils/arrays"; import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates"; import { shortNanoid } from "~/utils/id"; import invariant from "~/utils/invariant"; +import * as TournamentAuditLogRepository from "./TournamentAuditLogRepository.server"; export function setActiveRoster({ teamId, @@ -111,11 +112,15 @@ export function create({ team, avatarFileName, userId, + actorUserId, tournamentId, }: { team: Pick; avatarFileName?: string; + /** The user who becomes the team owner. */ userId: number; + /** The user performing the registration (differs from `userId` when an organizer registers a team for someone). */ + actorUserId: number; tournamentId: number; }) { return db.transaction().execute(async (trx) => { @@ -152,6 +157,13 @@ export function create({ }) .execute(); + await TournamentAuditLogRepository.insert(trx, { + type: "TEAM_REGISTERED", + actorUserId, + tournamentTeamId: tournamentTeam.id, + subjectUserId: userId, + }); + return tournamentTeam; }); } @@ -317,6 +329,11 @@ export function update({ }) .where("TournamentTeam.id", "=", team.id) .execute(); + + await TournamentAuditLogRepository.updateTeamHistoryName(trx, { + tournamentTeamId: team.id, + name: team.name, + }); }); } @@ -416,9 +433,9 @@ export function updateAbDivisions( */ export function checkIn( tournamentTeamId: number, - options?: { bracketIdx: number }, + options: { actorUserId: number; bracketIdx?: number }, ) { - const bracketIdx = options?.bracketIdx ?? null; + const bracketIdx = options.bracketIdx ?? null; return db.transaction().execute(async (trx) => { let query = trx @@ -446,15 +463,24 @@ export function checkIn( bracketIdx, }) .execute(); + + await TournamentAuditLogRepository.insert(trx, { + type: "TEAM_CHECKED_IN", + actorUserId: options.actorUserId, + tournamentTeamId, + metadata: typeof bracketIdx === "number" ? { bracketIdx } : null, + }); }); } export function checkOut({ tournamentTeamId, bracketIdx, + actorUserId, }: { tournamentTeamId: number; bracketIdx: number | null; + actorUserId: number; }) { return db.transaction().execute(async (trx) => { let query = trx @@ -478,6 +504,13 @@ export function checkOut({ }) .execute(); } + + await TournamentAuditLogRepository.insert(trx, { + type: "TEAM_CHECKED_OUT", + actorUserId, + tournamentTeamId, + metadata: typeof bracketIdx === "number" ? { bracketIdx } : null, + }); }); } @@ -488,21 +521,30 @@ export function updateName({ tournamentTeamId: number; name: string; }) { - return db - .updateTable("TournamentTeam") - .set({ + return db.transaction().execute(async (trx) => { + await trx + .updateTable("TournamentTeam") + .set({ + name, + }) + .where("id", "=", tournamentTeamId) + .execute(); + + await TournamentAuditLogRepository.updateTeamHistoryName(trx, { + tournamentTeamId, name, - }) - .where("id", "=", tournamentTeamId) - .execute(); + }); + }); } export function dropOut({ tournamentTeamId, previewBracketIdxs, + actorUserId, }: { tournamentTeamId: number; previewBracketIdxs: number[]; + actorUserId: number; }) { return db.transaction().execute(async (trx) => { await trx @@ -518,17 +560,34 @@ export function dropOut({ }) .where("id", "=", tournamentTeamId) .execute(); + + await TournamentAuditLogRepository.insert(trx, { + type: "TEAM_DROPPED_OUT", + actorUserId, + tournamentTeamId, + }); }); } -export function undoDropOut(tournamentTeamId: number) { - return db - .updateTable("TournamentTeam") - .set({ - droppedOut: 0, - }) - .where("id", "=", tournamentTeamId) - .execute(); +export function undoDropOut( + tournamentTeamId: number, + { actorUserId }: { actorUserId: number }, +) { + return db.transaction().execute(async (trx) => { + await trx + .updateTable("TournamentTeam") + .set({ + droppedOut: 0, + }) + .where("id", "=", tournamentTeamId) + .execute(); + + await TournamentAuditLogRepository.insert(trx, { + type: "TEAM_DROP_OUT_UNDONE", + actorUserId, + tournamentTeamId, + }); + }); } export function join({ @@ -536,21 +595,36 @@ export function join({ whatToDoWithPreviousTeam, newTeamId, userId, + actorUserId, checkOutTeam = false, }: { previousTeamId?: number; whatToDoWithPreviousTeam?: "LEAVE" | "DELETE"; newTeamId: number; + /** The user joining the team. */ userId: number; + /** The user performing the action (differs from `userId` when an owner or organizer adds someone). */ + actorUserId: number; checkOutTeam?: boolean; }) { return db.transaction().execute(async (trx) => { if (whatToDoWithPreviousTeam === "DELETE") { + await TournamentAuditLogRepository.insert(trx, { + type: "TEAM_UNREGISTERED", + actorUserId, + tournamentTeamId: previousTeamId!, + }); await trx .deleteFrom("TournamentTeam") .where("TournamentTeam.id", "=", previousTeamId!) .execute(); } else if (whatToDoWithPreviousTeam === "LEAVE") { + await TournamentAuditLogRepository.insert(trx, { + type: "MEMBER_REMOVED", + actorUserId, + tournamentTeamId: previousTeamId!, + subjectUserId: userId, + }); await trx .deleteFrom("TournamentTeamMember") .where("TournamentTeamMember.tournamentTeamId", "=", previousTeamId!) @@ -587,11 +661,27 @@ export function join({ inGameName, }) .execute(); + + await TournamentAuditLogRepository.insert(trx, { + type: "MEMBER_ADDED", + actorUserId, + tournamentTeamId: newTeamId, + subjectUserId: userId, + }); }); } -export function del(tournamentTeamId: number) { +export function del( + tournamentTeamId: number, + { actorUserId }: { actorUserId: number }, +) { return db.transaction().execute(async (trx) => { + await TournamentAuditLogRepository.insert(trx, { + type: "TEAM_UNREGISTERED", + actorUserId, + tournamentTeamId, + }); + await trx .deleteFrom("MapPoolMap") .where("MapPoolMap.tournamentTeamId", "=", tournamentTeamId) @@ -604,12 +694,31 @@ export function del(tournamentTeamId: number) { }); } -export function leave({ teamId, userId }: { teamId: number; userId: number }) { - return db - .deleteFrom("TournamentTeamMember") - .where("TournamentTeamMember.tournamentTeamId", "=", teamId) - .where("TournamentTeamMember.userId", "=", userId) - .execute(); +export function leave({ + teamId, + userId, + actorUserId, +}: { + teamId: number; + /** The member leaving the team. */ + userId: number; + /** The user performing the action (differs from `userId` when an owner or organizer removes someone). */ + actorUserId: number; +}) { + return db.transaction().execute(async (trx) => { + await TournamentAuditLogRepository.insert(trx, { + type: "MEMBER_REMOVED", + actorUserId, + tournamentTeamId: teamId, + subjectUserId: userId, + }); + + await trx + .deleteFrom("TournamentTeamMember") + .where("TournamentTeamMember.tournamentTeamId", "=", teamId) + .where("TournamentTeamMember.userId", "=", userId) + .execute(); + }); } export function transferOwnership( diff --git a/app/features/tournament/actions/to.$id.admin.server.ts b/app/features/tournament/actions/to.$id.admin.server.ts index 76ac9ce98..2e1e13411 100644 --- a/app/features/tournament/actions/to.$id.admin.server.ts +++ b/app/features/tournament/actions/to.$id.admin.server.ts @@ -76,6 +76,7 @@ export const action: ActionFunction = async ({ request, params }) => { teamId: null, }, userId: data.userId, + actorUserId: user.id, tournamentId, }); await TournamentLFGRepository.leaveLfg({ @@ -141,11 +142,11 @@ export const action: ActionFunction = async ({ request, params }) => { invariant(bracket, "Invalid bracket idx"); errorToastIfFalsy(bracket.preview, "Bracket has been started"); - await TournamentTeamRepository.checkIn( - data.teamId, + await TournamentTeamRepository.checkIn(data.teamId, { + actorUserId: user.id, // no sources = regular check in - bracket.sources ? { bracketIdx: data.bracketIdx } : undefined, - ); + bracketIdx: bracket.sources ? data.bracketIdx : undefined, + }); message = "Checked team in"; break; @@ -165,6 +166,7 @@ export const action: ActionFunction = async ({ request, params }) => { await TournamentTeamRepository.checkOut({ tournamentTeamId: data.teamId, + actorUserId: user.id, // no sources = regular check in bracketIdx: !bracket.sources ? null : data.bracketIdx, }); @@ -206,6 +208,7 @@ export const action: ActionFunction = async ({ request, params }) => { await TournamentTeamRepository.leave({ userId: data.memberId, teamId: team.id, + actorUserId: user.id, }); ShowcaseTournaments.removeFromCached({ @@ -256,6 +259,7 @@ export const action: ActionFunction = async ({ request, params }) => { }); await TournamentTeamRepository.join({ userId: data.userId, + actorUserId: user.id, newTeamId: team.id, previousTeamId: previousTeam?.id, // this team is not checked in & tournament started, so we can simply delete it @@ -300,7 +304,7 @@ export const action: ActionFunction = async ({ request, params }) => { errorToastIfFalsy(team, "Invalid team id"); errorToastIfFalsy(!tournament.hasStarted, "Tournament has started"); - await TournamentTeamRepository.del(team.id); + await TournamentTeamRepository.del(team.id, { actorUserId: user.id }); for (const member of team.members) { ShowcaseTournaments.removeFromCached({ @@ -399,6 +403,7 @@ export const action: ActionFunction = async ({ request, params }) => { await TournamentTeamRepository.dropOut({ tournamentTeamId: data.teamId, + actorUserId: user.id, previewBracketIdxs: tournament.brackets.flatMap((b, idx) => b.preview ? idx : [], ), @@ -427,7 +432,9 @@ export const action: ActionFunction = async ({ request, params }) => { case "UNDO_DROP_TEAM_OUT": { validateIsTournamentOrganizer(); - await TournamentTeamRepository.undoDropOut(data.teamId); + await TournamentTeamRepository.undoDropOut(data.teamId, { + actorUserId: user.id, + }); message = "Team drop out undone"; break; diff --git a/app/features/tournament/actions/to.$id.join.server.ts b/app/features/tournament/actions/to.$id.join.server.ts index e3f936462..be09a246a 100644 --- a/app/features/tournament/actions/to.$id.join.server.ts +++ b/app/features/tournament/actions/to.$id.join.server.ts @@ -90,6 +90,7 @@ export const action: ActionFunction = async ({ request, params }) => { await TournamentLFGRepository.leaveLfg({ userId: user.id, tournamentId }); await TournamentTeamRepository.join({ userId: user.id, + actorUserId: user.id, newTeamId: teamToJoin.id, previousTeamId: previousTeam?.id, // making sure they aren't unfilling one checking in condition i.e. having full roster diff --git a/app/features/tournament/actions/to.$id.register.server.ts b/app/features/tournament/actions/to.$id.register.server.ts index 527159089..878042dc6 100644 --- a/app/features/tournament/actions/to.$id.register.server.ts +++ b/app/features/tournament/actions/to.$id.register.server.ts @@ -129,6 +129,7 @@ export const action: ActionFunction = async ({ request, params }) => { teamId: data.teamId ?? null, }, userId: user.id, + actorUserId: user.id, tournamentId, avatarFileName, }); @@ -165,6 +166,7 @@ export const action: ActionFunction = async ({ request, params }) => { await TournamentTeamRepository.leave({ teamId: ownTeam.id, userId: data.userId, + actorUserId: user.id, }); ShowcaseTournaments.removeFromCached({ @@ -191,6 +193,7 @@ export const action: ActionFunction = async ({ request, params }) => { await TournamentTeamRepository.leave({ teamId: teamMemberOf.id, userId: user.id, + actorUserId: user.id, }); ShowcaseTournaments.removeFromCached({ @@ -244,7 +247,9 @@ export const action: ActionFunction = async ({ request, params }) => { `Can't check-in - ${tournament.checkInConditionsFulfilledByTeamId(teamMemberOf.id).reason}`, ); - await TournamentTeamRepository.checkIn(teamMemberOf.id); + await TournamentTeamRepository.checkIn(teamMemberOf.id, { + actorUserId: user.id, + }); logger.info( `Checking in (success): tournament team id: ${teamMemberOf.id} - user id: ${user.id} - tournament id: ${tournamentId}`, ); @@ -286,6 +291,7 @@ export const action: ActionFunction = async ({ request, params }) => { }); await TournamentTeamRepository.join({ userId: data.userId, + actorUserId: user.id, newTeamId: ownTeam.id, }); @@ -330,7 +336,9 @@ export const action: ActionFunction = async ({ request, params }) => { "Unregistering from leagues is not possible after registration has closed", ); - await TournamentTeamRepository.del(ownTeam.id); + await TournamentTeamRepository.del(ownTeam.id, { + actorUserId: user.id, + }); for (const member of ownTeam.members) { ShowcaseTournaments.removeFromCached({ diff --git a/app/features/tournament/components/TournamentAdminAuditLog.module.css b/app/features/tournament/components/TournamentAdminAuditLog.module.css new file mode 100644 index 000000000..eb170a1e0 --- /dev/null +++ b/app/features/tournament/components/TournamentAdminAuditLog.module.css @@ -0,0 +1,6 @@ +.userCell { + display: inline-flex; + align-items: center; + gap: var(--s-1-5); + white-space: nowrap; +} diff --git a/app/features/tournament/components/TournamentAdminAuditLog.tsx b/app/features/tournament/components/TournamentAdminAuditLog.tsx new file mode 100644 index 000000000..c632f4d79 --- /dev/null +++ b/app/features/tournament/components/TournamentAdminAuditLog.tsx @@ -0,0 +1,211 @@ +import { useTranslation } from "react-i18next"; +import { Link, useLoaderData, useSearchParams } from "react-router"; +import { Avatar } from "~/components/Avatar"; +import { Label } from "~/components/Label"; +import { LocaleTime } from "~/components/LocaleTime"; +import { Pagination } from "~/components/Pagination"; +import { Table } from "~/components/Table"; +import { TOURNAMENT_AUDIT_LOG_TYPES } from "~/db/tables"; +import type { CommonUser } from "~/utils/kysely.server"; +import { tournamentTeamPage, userPage } from "~/utils/urls"; +import type { TournamentAdminPageLoader } from "../loaders/to.$id.admin.server"; +import { useTournament } from "../routes/to.$id"; +import styles from "./TournamentAdminAuditLog.module.css"; + +const WHEN_FORMAT_OPTIONS = { + day: "numeric", + month: "numeric", + year: "numeric", + hour: "numeric", + minute: "numeric", +} as const; + +// xxx: data not loaded before full page refresh + +export function TournamentAdminAuditLog() { + const { t } = useTranslation(["tournament"]); + const data = useLoaderData(); + const [, setSearchParams] = useSearchParams(); + + const auditLog = data?.auditLog; + if (!auditLog) return null; + + const setPage = (page: number) => { + setSearchParams((params) => { + params.set("page", String(page)); + return params; + }); + }; + + return ( +
+ + {auditLog.events.length === 0 ? ( +
+ {t("tournament:admin.audit.empty")} +
+ ) : ( + <> + + + + + + + + + + + + {auditLog.events.map((event) => ( + + ))} + +
{t("tournament:admin.audit.column.when")}{t("tournament:admin.audit.column.event")}{t("tournament:admin.audit.column.team")}{t("tournament:admin.audit.column.actor")}{t("tournament:admin.audit.column.subject")}
+ {auditLog.pagesCount > 1 ? ( + setPage(auditLog.currentPage + 1)} + previousPage={() => setPage(auditLog.currentPage - 1)} + setPage={setPage} + /> + ) : null} + + )} +
+ ); +} + +type AuditLogEvent = NonNullable< + NonNullable< + ReturnType> + >["auditLog"] +>["events"][number]; + +function AuditLogRow({ event }: { event: AuditLogEvent }) { + const { t } = useTranslation(["tournament"]); + const tournament = useTournament(); + + const bracketName = + typeof event.metadata?.bracketIdx === "number" + ? tournament.brackets[event.metadata.bracketIdx]?.name + : undefined; + + return ( + + + + + + {t(`tournament:admin.audit.event.${event.type}`)} + {bracketName ? ( +
{bracketName}
+ ) : null} + + + {event.team ? ( + tournament.teamById(event.team.id) ? ( + + {event.team.name} + + ) : ( + event.team.name + ) + ) : ( + "-" + )} + + + + + + + + + ); +} + +function UserCell({ user }: { user: CommonUser | null }) { + if (!user) return <>-; + + return ( + + + {user.username} + + ); +} + +function AuditLogFilters({ + teams, +}: { + teams: Array<{ id: number; name: string }>; +}) { + const { t } = useTranslation(["tournament"]); + const [searchParams, setSearchParams] = useSearchParams(); + + const setFilter = (key: string, value: string) => { + setSearchParams((params) => { + if (value) { + params.set(key, value); + } else { + params.delete(key); + } + params.delete("page"); + return params; + }); + }; + + return ( +
+
+ + +
+
+ + +
+
+ ); +} diff --git a/app/features/tournament/loaders/to.$id.admin.server.ts b/app/features/tournament/loaders/to.$id.admin.server.ts new file mode 100644 index 000000000..f85afd7d6 --- /dev/null +++ b/app/features/tournament/loaders/to.$id.admin.server.ts @@ -0,0 +1,70 @@ +import type { LoaderFunctionArgs } from "react-router"; +import { z } from "zod"; +import { TOURNAMENT_AUDIT_LOG_TYPES } from "~/db/tables"; +import { requireUser } from "~/features/auth/core/user.server"; +import * as TournamentAuditLogRepository from "~/features/tournament/TournamentAuditLogRepository.server"; +import { AUDIT_LOG_PAGE_SIZE } from "~/features/tournament/TournamentAuditLogRepository.server"; +import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server"; +import { + parseParams, + parseSearchParams, + redirectIfPageOutOfBounds, +} from "~/utils/remix.server"; +import { idObject } from "~/utils/zod"; + +// xxx: extract to different file +const auditSearchParamsSchema = z.object({ + tab: z.string().optional().catch(undefined), + page: z.coerce.number().int().min(1).catch(1), + auditType: z.enum(TOURNAMENT_AUDIT_LOG_TYPES).optional().catch(undefined), + auditTeam: z.coerce.number().int().optional().catch(undefined), +}); + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const user = requireUser(); + + const { id: tournamentId } = parseParams({ params, schema: idObject }); + + const tournament = await tournamentFromDBCached({ tournamentId, user }); + if (!tournament.isOrganizer(user)) return null; + + const { tab, page, auditType, auditTeam } = parseSearchParams({ + request, + schema: auditSearchParamsSchema, + }); + + // xxx: probably just make proper different routes? + // the audit log is paginated server-side, so only fetch it for its own tab + if (tab !== "audit") return { auditLog: null }; + + const [events, totalCount, teams] = await Promise.all([ + TournamentAuditLogRepository.findByTournamentId({ + tournamentId, + type: auditType, + tournamentTeamId: auditTeam, + limit: AUDIT_LOG_PAGE_SIZE, + offset: (page - 1) * AUDIT_LOG_PAGE_SIZE, + }), + TournamentAuditLogRepository.countByTournamentId({ + tournamentId, + type: auditType, + tournamentTeamId: auditTeam, + }), + TournamentAuditLogRepository.findTeamsByTournamentId(tournamentId), + ]); + + const pagesCount = Math.max(1, Math.ceil(totalCount / AUDIT_LOG_PAGE_SIZE)); + + redirectIfPageOutOfBounds({ request, page, pagesCount }); + + return { + auditLog: { + events, + teams, + currentPage: page, + pagesCount, + }, + }; +}; + +export type TournamentAdminPageLoader = typeof loader; diff --git a/app/features/tournament/routes/to.$id.admin.tsx b/app/features/tournament/routes/to.$id.admin.tsx index b3b24dacc..31da75e83 100644 --- a/app/features/tournament/routes/to.$id.admin.tsx +++ b/app/features/tournament/routes/to.$id.admin.tsx @@ -1,5 +1,13 @@ import clsx from "clsx"; -import { ListOrdered, Trash, Trophy, Tv, UserCog, Users } from "lucide-react"; +import { + History, + ListOrdered, + Trash, + Trophy, + Tv, + UserCog, + Users, +} from "lucide-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { useFetcher } from "react-router"; @@ -37,13 +45,15 @@ import { tournamentPage, } from "~/utils/urls"; import { BracketProgressionSelector } from "../../calendar/components/BracketProgressionSelector"; +import { TournamentAdminAuditLog } from "../components/TournamentAdminAuditLog"; import { TournamentSeeds } from "../components/TournamentSeeds"; import { useTournament } from "./to.$id"; import adminStyles from "./to.$id.admin.module.css"; export { action } from "../actions/to.$id.admin.server"; +export { loader } from "../loaders/to.$id.admin.server"; -type AdminTab = "teams" | "seeds" | "staff" | "stream" | "brackets"; +type AdminTab = "teams" | "seeds" | "staff" | "stream" | "brackets" | "audit"; export default function TournamentAdminPage() { const { t } = useTranslation(["tournament", "calendar"]); @@ -75,6 +85,7 @@ export default function TournamentAdminPage() { switch (tab) { case "teams": case "stream": + case "audit": return true; case "seeds": return showSeedsTab; @@ -160,6 +171,9 @@ export default function TournamentAdminPage() { {t("tournament:admin.tab.brackets")} ) : null} + }> + {t("tournament:admin.tab.audit")} + @@ -212,6 +226,9 @@ export default function TournamentAdminPage() { ) : null} ) : null} + + + ); diff --git a/app/features/tournament/tournament-test-utils.ts b/app/features/tournament/tournament-test-utils.ts index 8a903b025..b18353b7d 100644 --- a/app/features/tournament/tournament-test-utils.ts +++ b/app/features/tournament/tournament-test-utils.ts @@ -62,6 +62,7 @@ export async function dbInsertTournamentTeam({ teamId: null, }, userId: ownerId, + actorUserId: ownerId, tournamentId, }); @@ -70,11 +71,14 @@ export async function dbInsertTournamentTeam({ await TournamentTeamRepository.join({ userId: memberId, + actorUserId: memberId, newTeamId: tournamentTeam.id, }); } - await TournamentTeamRepository.checkIn(tournamentTeam.id); + await TournamentTeamRepository.checkIn(tournamentTeam.id, { + actorUserId: ownerId, + }); } /** diff --git a/db-test.sqlite3 b/db-test.sqlite3 index 69e1be911..938207de3 100644 Binary files a/db-test.sqlite3 and b/db-test.sqlite3 differ diff --git a/e2e/seeds/db-seed-AB_RR.sqlite3 b/e2e/seeds/db-seed-AB_RR.sqlite3 index 91ed39710..3f7b0ba66 100644 Binary files a/e2e/seeds/db-seed-AB_RR.sqlite3 and b/e2e/seeds/db-seed-AB_RR.sqlite3 differ diff --git a/e2e/seeds/db-seed-DEFAULT.sqlite3 b/e2e/seeds/db-seed-DEFAULT.sqlite3 index 394c2e555..eb0e46896 100644 Binary files a/e2e/seeds/db-seed-DEFAULT.sqlite3 and b/e2e/seeds/db-seed-DEFAULT.sqlite3 differ diff --git a/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 b/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 index a38d6d8bc..8cad1139e 100644 Binary files a/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 and b/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 differ diff --git a/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 b/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 index 2bc0b7179..9e5b33772 100644 Binary files a/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 and b/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 index 24f14ea88..80cd16665 100644 Binary files a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 and b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 index e598df12b..1ebbf7dbf 100644 Binary files a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 and b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 index 8b32b634d..6d3c2c0d2 100644 Binary files a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 and b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 index fd9c8331d..c45668c10 100644 Binary files a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 and b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 differ diff --git a/e2e/seeds/db-seed-REG_OPEN.sqlite3 b/e2e/seeds/db-seed-REG_OPEN.sqlite3 index d164f07a6..ae4012dcd 100644 Binary files a/e2e/seeds/db-seed-REG_OPEN.sqlite3 and b/e2e/seeds/db-seed-REG_OPEN.sqlite3 differ diff --git a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 index 7cfc15e74..726bc703f 100644 Binary files a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 and b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 differ diff --git a/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 b/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 index e598c3b1d..7df5aa073 100644 Binary files a/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 and b/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 differ diff --git a/locales/da/tournament.json b/locales/da/tournament.json index c68e942a8..1fefed6f8 100644 --- a/locales/da/tournament.json +++ b/locales/da/tournament.json @@ -80,6 +80,25 @@ "admin.tab.staff": "", "admin.tab.stream": "", "admin.tab.brackets": "", + "admin.tab.audit": "", + "admin.audit.empty": "", + "admin.audit.filter.event": "", + "admin.audit.filter.team": "", + "admin.audit.filter.allEvents": "", + "admin.audit.filter.allTeams": "", + "admin.audit.column.when": "", + "admin.audit.column.event": "", + "admin.audit.column.team": "", + "admin.audit.column.actor": "", + "admin.audit.column.subject": "", + "admin.audit.event.MEMBER_ADDED": "", + "admin.audit.event.MEMBER_REMOVED": "", + "admin.audit.event.TEAM_REGISTERED": "", + "admin.audit.event.TEAM_UNREGISTERED": "", + "admin.audit.event.TEAM_CHECKED_IN": "", + "admin.audit.event.TEAM_CHECKED_OUT": "", + "admin.audit.event.TEAM_DROPPED_OUT": "", + "admin.audit.event.TEAM_DROP_OUT_UNDONE": "", "admin.actions.CHANGE_TEAM_OWNER": "Ændr holdkaptajn", "admin.actions.CHANGE_TEAM_NAME": "Ændr holdnavn", "admin.actions.CHECK_IN": "Tjek ind", diff --git a/locales/de/tournament.json b/locales/de/tournament.json index 9c52de71c..4c6fa778a 100644 --- a/locales/de/tournament.json +++ b/locales/de/tournament.json @@ -80,6 +80,25 @@ "admin.tab.staff": "", "admin.tab.stream": "", "admin.tab.brackets": "", + "admin.tab.audit": "", + "admin.audit.empty": "", + "admin.audit.filter.event": "", + "admin.audit.filter.team": "", + "admin.audit.filter.allEvents": "", + "admin.audit.filter.allTeams": "", + "admin.audit.column.when": "", + "admin.audit.column.event": "", + "admin.audit.column.team": "", + "admin.audit.column.actor": "", + "admin.audit.column.subject": "", + "admin.audit.event.MEMBER_ADDED": "", + "admin.audit.event.MEMBER_REMOVED": "", + "admin.audit.event.TEAM_REGISTERED": "", + "admin.audit.event.TEAM_UNREGISTERED": "", + "admin.audit.event.TEAM_CHECKED_IN": "", + "admin.audit.event.TEAM_CHECKED_OUT": "", + "admin.audit.event.TEAM_DROPPED_OUT": "", + "admin.audit.event.TEAM_DROP_OUT_UNDONE": "", "admin.actions.CHANGE_TEAM_OWNER": "Kapitän ändern", "admin.actions.CHANGE_TEAM_NAME": "Teamnamen ändern", "admin.actions.CHECK_IN": "Einchecken", diff --git a/locales/en/tournament.json b/locales/en/tournament.json index f524bf4f6..c6545320e 100644 --- a/locales/en/tournament.json +++ b/locales/en/tournament.json @@ -80,6 +80,25 @@ "admin.tab.staff": "Staff", "admin.tab.stream": "Stream", "admin.tab.brackets": "Brackets", + "admin.tab.audit": "Audit log", + "admin.audit.empty": "No events yet", + "admin.audit.filter.event": "Event", + "admin.audit.filter.team": "Team", + "admin.audit.filter.allEvents": "All events", + "admin.audit.filter.allTeams": "All teams", + "admin.audit.column.when": "When", + "admin.audit.column.event": "Event", + "admin.audit.column.team": "Team", + "admin.audit.column.actor": "By", + "admin.audit.column.subject": "Affected player", + "admin.audit.event.MEMBER_ADDED": "Player joined team", + "admin.audit.event.MEMBER_REMOVED": "Player left team", + "admin.audit.event.TEAM_REGISTERED": "Team registered", + "admin.audit.event.TEAM_UNREGISTERED": "Team unregistered", + "admin.audit.event.TEAM_CHECKED_IN": "Team checked in", + "admin.audit.event.TEAM_CHECKED_OUT": "Team checked out", + "admin.audit.event.TEAM_DROPPED_OUT": "Team dropped out", + "admin.audit.event.TEAM_DROP_OUT_UNDONE": "Team drop out undone", "admin.actions.CHANGE_TEAM_OWNER": "Change captain", "admin.actions.CHANGE_TEAM_NAME": "Change team name", "admin.actions.CHECK_IN": "Check in", diff --git a/locales/es-ES/tournament.json b/locales/es-ES/tournament.json index 53b4199e3..d91cb35f1 100644 --- a/locales/es-ES/tournament.json +++ b/locales/es-ES/tournament.json @@ -81,6 +81,25 @@ "admin.tab.staff": "", "admin.tab.stream": "", "admin.tab.brackets": "", + "admin.tab.audit": "", + "admin.audit.empty": "", + "admin.audit.filter.event": "", + "admin.audit.filter.team": "", + "admin.audit.filter.allEvents": "", + "admin.audit.filter.allTeams": "", + "admin.audit.column.when": "", + "admin.audit.column.event": "", + "admin.audit.column.team": "", + "admin.audit.column.actor": "", + "admin.audit.column.subject": "", + "admin.audit.event.MEMBER_ADDED": "", + "admin.audit.event.MEMBER_REMOVED": "", + "admin.audit.event.TEAM_REGISTERED": "", + "admin.audit.event.TEAM_UNREGISTERED": "", + "admin.audit.event.TEAM_CHECKED_IN": "", + "admin.audit.event.TEAM_CHECKED_OUT": "", + "admin.audit.event.TEAM_DROPPED_OUT": "", + "admin.audit.event.TEAM_DROP_OUT_UNDONE": "", "admin.actions.CHANGE_TEAM_OWNER": "Cambiar capitán", "admin.actions.CHANGE_TEAM_NAME": "Cambiar nombre de equipo", "admin.actions.CHECK_IN": "Check in", diff --git a/locales/es-US/tournament.json b/locales/es-US/tournament.json index 35e3f14d8..4803faa3f 100644 --- a/locales/es-US/tournament.json +++ b/locales/es-US/tournament.json @@ -81,6 +81,25 @@ "admin.tab.staff": "", "admin.tab.stream": "", "admin.tab.brackets": "", + "admin.tab.audit": "", + "admin.audit.empty": "", + "admin.audit.filter.event": "", + "admin.audit.filter.team": "", + "admin.audit.filter.allEvents": "", + "admin.audit.filter.allTeams": "", + "admin.audit.column.when": "", + "admin.audit.column.event": "", + "admin.audit.column.team": "", + "admin.audit.column.actor": "", + "admin.audit.column.subject": "", + "admin.audit.event.MEMBER_ADDED": "", + "admin.audit.event.MEMBER_REMOVED": "", + "admin.audit.event.TEAM_REGISTERED": "", + "admin.audit.event.TEAM_UNREGISTERED": "", + "admin.audit.event.TEAM_CHECKED_IN": "", + "admin.audit.event.TEAM_CHECKED_OUT": "", + "admin.audit.event.TEAM_DROPPED_OUT": "", + "admin.audit.event.TEAM_DROP_OUT_UNDONE": "", "admin.actions.CHANGE_TEAM_OWNER": "Cambiar capitán", "admin.actions.CHANGE_TEAM_NAME": "Cambiar nombre de equipo", "admin.actions.CHECK_IN": "Check in", diff --git a/locales/fr-CA/tournament.json b/locales/fr-CA/tournament.json index 257c759e6..5da7c1439 100644 --- a/locales/fr-CA/tournament.json +++ b/locales/fr-CA/tournament.json @@ -81,6 +81,25 @@ "admin.tab.staff": "", "admin.tab.stream": "", "admin.tab.brackets": "", + "admin.tab.audit": "", + "admin.audit.empty": "", + "admin.audit.filter.event": "", + "admin.audit.filter.team": "", + "admin.audit.filter.allEvents": "", + "admin.audit.filter.allTeams": "", + "admin.audit.column.when": "", + "admin.audit.column.event": "", + "admin.audit.column.team": "", + "admin.audit.column.actor": "", + "admin.audit.column.subject": "", + "admin.audit.event.MEMBER_ADDED": "", + "admin.audit.event.MEMBER_REMOVED": "", + "admin.audit.event.TEAM_REGISTERED": "", + "admin.audit.event.TEAM_UNREGISTERED": "", + "admin.audit.event.TEAM_CHECKED_IN": "", + "admin.audit.event.TEAM_CHECKED_OUT": "", + "admin.audit.event.TEAM_DROPPED_OUT": "", + "admin.audit.event.TEAM_DROP_OUT_UNDONE": "", "admin.actions.CHANGE_TEAM_OWNER": "Changer le capitaine", "admin.actions.CHANGE_TEAM_NAME": "", "admin.actions.CHECK_IN": "S'enregister", diff --git a/locales/fr-EU/tournament.json b/locales/fr-EU/tournament.json index 0f6064dfb..cddb3b5a2 100644 --- a/locales/fr-EU/tournament.json +++ b/locales/fr-EU/tournament.json @@ -81,6 +81,25 @@ "admin.tab.staff": "", "admin.tab.stream": "", "admin.tab.brackets": "", + "admin.tab.audit": "", + "admin.audit.empty": "", + "admin.audit.filter.event": "", + "admin.audit.filter.team": "", + "admin.audit.filter.allEvents": "", + "admin.audit.filter.allTeams": "", + "admin.audit.column.when": "", + "admin.audit.column.event": "", + "admin.audit.column.team": "", + "admin.audit.column.actor": "", + "admin.audit.column.subject": "", + "admin.audit.event.MEMBER_ADDED": "", + "admin.audit.event.MEMBER_REMOVED": "", + "admin.audit.event.TEAM_REGISTERED": "", + "admin.audit.event.TEAM_UNREGISTERED": "", + "admin.audit.event.TEAM_CHECKED_IN": "", + "admin.audit.event.TEAM_CHECKED_OUT": "", + "admin.audit.event.TEAM_DROPPED_OUT": "", + "admin.audit.event.TEAM_DROP_OUT_UNDONE": "", "admin.actions.CHANGE_TEAM_OWNER": "Changer le capitaine", "admin.actions.CHANGE_TEAM_NAME": "Changer le nom de l'équipe ", "admin.actions.CHECK_IN": "S'enregister", diff --git a/locales/he/tournament.json b/locales/he/tournament.json index d56648e3a..3b00e324f 100644 --- a/locales/he/tournament.json +++ b/locales/he/tournament.json @@ -81,6 +81,25 @@ "admin.tab.staff": "", "admin.tab.stream": "", "admin.tab.brackets": "", + "admin.tab.audit": "", + "admin.audit.empty": "", + "admin.audit.filter.event": "", + "admin.audit.filter.team": "", + "admin.audit.filter.allEvents": "", + "admin.audit.filter.allTeams": "", + "admin.audit.column.when": "", + "admin.audit.column.event": "", + "admin.audit.column.team": "", + "admin.audit.column.actor": "", + "admin.audit.column.subject": "", + "admin.audit.event.MEMBER_ADDED": "", + "admin.audit.event.MEMBER_REMOVED": "", + "admin.audit.event.TEAM_REGISTERED": "", + "admin.audit.event.TEAM_UNREGISTERED": "", + "admin.audit.event.TEAM_CHECKED_IN": "", + "admin.audit.event.TEAM_CHECKED_OUT": "", + "admin.audit.event.TEAM_DROPPED_OUT": "", + "admin.audit.event.TEAM_DROP_OUT_UNDONE": "", "admin.actions.CHANGE_TEAM_OWNER": "שינוי קפטן", "admin.actions.CHANGE_TEAM_NAME": "", "admin.actions.CHECK_IN": "קבלה", diff --git a/locales/it/tournament.json b/locales/it/tournament.json index cf0ccecf2..4bfa34755 100644 --- a/locales/it/tournament.json +++ b/locales/it/tournament.json @@ -81,6 +81,25 @@ "admin.tab.staff": "", "admin.tab.stream": "", "admin.tab.brackets": "", + "admin.tab.audit": "", + "admin.audit.empty": "", + "admin.audit.filter.event": "", + "admin.audit.filter.team": "", + "admin.audit.filter.allEvents": "", + "admin.audit.filter.allTeams": "", + "admin.audit.column.when": "", + "admin.audit.column.event": "", + "admin.audit.column.team": "", + "admin.audit.column.actor": "", + "admin.audit.column.subject": "", + "admin.audit.event.MEMBER_ADDED": "", + "admin.audit.event.MEMBER_REMOVED": "", + "admin.audit.event.TEAM_REGISTERED": "", + "admin.audit.event.TEAM_UNREGISTERED": "", + "admin.audit.event.TEAM_CHECKED_IN": "", + "admin.audit.event.TEAM_CHECKED_OUT": "", + "admin.audit.event.TEAM_DROPPED_OUT": "", + "admin.audit.event.TEAM_DROP_OUT_UNDONE": "", "admin.actions.CHANGE_TEAM_OWNER": "Cambia capitano", "admin.actions.CHANGE_TEAM_NAME": "Cambia nome team", "admin.actions.CHECK_IN": "Check-in", diff --git a/locales/ja/tournament.json b/locales/ja/tournament.json index 428712df1..6df979394 100644 --- a/locales/ja/tournament.json +++ b/locales/ja/tournament.json @@ -78,6 +78,25 @@ "admin.tab.staff": "", "admin.tab.stream": "", "admin.tab.brackets": "", + "admin.tab.audit": "", + "admin.audit.empty": "", + "admin.audit.filter.event": "", + "admin.audit.filter.team": "", + "admin.audit.filter.allEvents": "", + "admin.audit.filter.allTeams": "", + "admin.audit.column.when": "", + "admin.audit.column.event": "", + "admin.audit.column.team": "", + "admin.audit.column.actor": "", + "admin.audit.column.subject": "", + "admin.audit.event.MEMBER_ADDED": "", + "admin.audit.event.MEMBER_REMOVED": "", + "admin.audit.event.TEAM_REGISTERED": "", + "admin.audit.event.TEAM_UNREGISTERED": "", + "admin.audit.event.TEAM_CHECKED_IN": "", + "admin.audit.event.TEAM_CHECKED_OUT": "", + "admin.audit.event.TEAM_DROPPED_OUT": "", + "admin.audit.event.TEAM_DROP_OUT_UNDONE": "", "admin.actions.CHANGE_TEAM_OWNER": "キャプテンを変更", "admin.actions.CHANGE_TEAM_NAME": "チーム名変更", "admin.actions.CHECK_IN": "チェックイン", diff --git a/locales/ko/tournament.json b/locales/ko/tournament.json index c75df3382..c1dfbee95 100644 --- a/locales/ko/tournament.json +++ b/locales/ko/tournament.json @@ -78,6 +78,25 @@ "admin.tab.staff": "", "admin.tab.stream": "", "admin.tab.brackets": "", + "admin.tab.audit": "", + "admin.audit.empty": "", + "admin.audit.filter.event": "", + "admin.audit.filter.team": "", + "admin.audit.filter.allEvents": "", + "admin.audit.filter.allTeams": "", + "admin.audit.column.when": "", + "admin.audit.column.event": "", + "admin.audit.column.team": "", + "admin.audit.column.actor": "", + "admin.audit.column.subject": "", + "admin.audit.event.MEMBER_ADDED": "", + "admin.audit.event.MEMBER_REMOVED": "", + "admin.audit.event.TEAM_REGISTERED": "", + "admin.audit.event.TEAM_UNREGISTERED": "", + "admin.audit.event.TEAM_CHECKED_IN": "", + "admin.audit.event.TEAM_CHECKED_OUT": "", + "admin.audit.event.TEAM_DROPPED_OUT": "", + "admin.audit.event.TEAM_DROP_OUT_UNDONE": "", "admin.actions.CHANGE_TEAM_OWNER": "", "admin.actions.CHANGE_TEAM_NAME": "", "admin.actions.CHECK_IN": "", diff --git a/locales/nl/tournament.json b/locales/nl/tournament.json index 00370fae1..5fdf9fe5f 100644 --- a/locales/nl/tournament.json +++ b/locales/nl/tournament.json @@ -80,6 +80,25 @@ "admin.tab.staff": "", "admin.tab.stream": "", "admin.tab.brackets": "", + "admin.tab.audit": "", + "admin.audit.empty": "", + "admin.audit.filter.event": "", + "admin.audit.filter.team": "", + "admin.audit.filter.allEvents": "", + "admin.audit.filter.allTeams": "", + "admin.audit.column.when": "", + "admin.audit.column.event": "", + "admin.audit.column.team": "", + "admin.audit.column.actor": "", + "admin.audit.column.subject": "", + "admin.audit.event.MEMBER_ADDED": "", + "admin.audit.event.MEMBER_REMOVED": "", + "admin.audit.event.TEAM_REGISTERED": "", + "admin.audit.event.TEAM_UNREGISTERED": "", + "admin.audit.event.TEAM_CHECKED_IN": "", + "admin.audit.event.TEAM_CHECKED_OUT": "", + "admin.audit.event.TEAM_DROPPED_OUT": "", + "admin.audit.event.TEAM_DROP_OUT_UNDONE": "", "admin.actions.CHANGE_TEAM_OWNER": "", "admin.actions.CHANGE_TEAM_NAME": "", "admin.actions.CHECK_IN": "", diff --git a/locales/pl/tournament.json b/locales/pl/tournament.json index 707b5fb52..6c63755d4 100644 --- a/locales/pl/tournament.json +++ b/locales/pl/tournament.json @@ -82,6 +82,25 @@ "admin.tab.staff": "", "admin.tab.stream": "", "admin.tab.brackets": "", + "admin.tab.audit": "", + "admin.audit.empty": "", + "admin.audit.filter.event": "", + "admin.audit.filter.team": "", + "admin.audit.filter.allEvents": "", + "admin.audit.filter.allTeams": "", + "admin.audit.column.when": "", + "admin.audit.column.event": "", + "admin.audit.column.team": "", + "admin.audit.column.actor": "", + "admin.audit.column.subject": "", + "admin.audit.event.MEMBER_ADDED": "", + "admin.audit.event.MEMBER_REMOVED": "", + "admin.audit.event.TEAM_REGISTERED": "", + "admin.audit.event.TEAM_UNREGISTERED": "", + "admin.audit.event.TEAM_CHECKED_IN": "", + "admin.audit.event.TEAM_CHECKED_OUT": "", + "admin.audit.event.TEAM_DROPPED_OUT": "", + "admin.audit.event.TEAM_DROP_OUT_UNDONE": "", "admin.actions.CHANGE_TEAM_OWNER": "", "admin.actions.CHANGE_TEAM_NAME": "", "admin.actions.CHECK_IN": "", diff --git a/locales/pt-BR/tournament.json b/locales/pt-BR/tournament.json index 05f7f3cf7..f56f55e35 100644 --- a/locales/pt-BR/tournament.json +++ b/locales/pt-BR/tournament.json @@ -81,6 +81,25 @@ "admin.tab.staff": "", "admin.tab.stream": "", "admin.tab.brackets": "", + "admin.tab.audit": "", + "admin.audit.empty": "", + "admin.audit.filter.event": "", + "admin.audit.filter.team": "", + "admin.audit.filter.allEvents": "", + "admin.audit.filter.allTeams": "", + "admin.audit.column.when": "", + "admin.audit.column.event": "", + "admin.audit.column.team": "", + "admin.audit.column.actor": "", + "admin.audit.column.subject": "", + "admin.audit.event.MEMBER_ADDED": "", + "admin.audit.event.MEMBER_REMOVED": "", + "admin.audit.event.TEAM_REGISTERED": "", + "admin.audit.event.TEAM_UNREGISTERED": "", + "admin.audit.event.TEAM_CHECKED_IN": "", + "admin.audit.event.TEAM_CHECKED_OUT": "", + "admin.audit.event.TEAM_DROPPED_OUT": "", + "admin.audit.event.TEAM_DROP_OUT_UNDONE": "", "admin.actions.CHANGE_TEAM_OWNER": "Mudar capitão", "admin.actions.CHANGE_TEAM_NAME": "Mudar nome do time", "admin.actions.CHECK_IN": "Check-in", diff --git a/locales/ru/tournament.json b/locales/ru/tournament.json index ec304d7f2..ed5a5c38f 100644 --- a/locales/ru/tournament.json +++ b/locales/ru/tournament.json @@ -82,6 +82,25 @@ "admin.tab.staff": "", "admin.tab.stream": "", "admin.tab.brackets": "", + "admin.tab.audit": "", + "admin.audit.empty": "", + "admin.audit.filter.event": "", + "admin.audit.filter.team": "", + "admin.audit.filter.allEvents": "", + "admin.audit.filter.allTeams": "", + "admin.audit.column.when": "", + "admin.audit.column.event": "", + "admin.audit.column.team": "", + "admin.audit.column.actor": "", + "admin.audit.column.subject": "", + "admin.audit.event.MEMBER_ADDED": "", + "admin.audit.event.MEMBER_REMOVED": "", + "admin.audit.event.TEAM_REGISTERED": "", + "admin.audit.event.TEAM_UNREGISTERED": "", + "admin.audit.event.TEAM_CHECKED_IN": "", + "admin.audit.event.TEAM_CHECKED_OUT": "", + "admin.audit.event.TEAM_DROPPED_OUT": "", + "admin.audit.event.TEAM_DROP_OUT_UNDONE": "", "admin.actions.CHANGE_TEAM_OWNER": "Изменить капитана", "admin.actions.CHANGE_TEAM_NAME": "Изменить имя команды", "admin.actions.CHECK_IN": "Зарегистрировать", diff --git a/locales/zh/tournament.json b/locales/zh/tournament.json index 4fe48653c..92346ecd9 100644 --- a/locales/zh/tournament.json +++ b/locales/zh/tournament.json @@ -78,6 +78,25 @@ "admin.tab.staff": "", "admin.tab.stream": "", "admin.tab.brackets": "", + "admin.tab.audit": "", + "admin.audit.empty": "", + "admin.audit.filter.event": "", + "admin.audit.filter.team": "", + "admin.audit.filter.allEvents": "", + "admin.audit.filter.allTeams": "", + "admin.audit.column.when": "", + "admin.audit.column.event": "", + "admin.audit.column.team": "", + "admin.audit.column.actor": "", + "admin.audit.column.subject": "", + "admin.audit.event.MEMBER_ADDED": "", + "admin.audit.event.MEMBER_REMOVED": "", + "admin.audit.event.TEAM_REGISTERED": "", + "admin.audit.event.TEAM_UNREGISTERED": "", + "admin.audit.event.TEAM_CHECKED_IN": "", + "admin.audit.event.TEAM_CHECKED_OUT": "", + "admin.audit.event.TEAM_DROPPED_OUT": "", + "admin.audit.event.TEAM_DROP_OUT_UNDONE": "", "admin.actions.CHANGE_TEAM_OWNER": "变更队长", "admin.actions.CHANGE_TEAM_NAME": "变更队名", "admin.actions.CHECK_IN": "登入", diff --git a/migrations/145-tournament-audit-log.js b/migrations/145-tournament-audit-log.js new file mode 100644 index 000000000..0d817d8c9 --- /dev/null +++ b/migrations/145-tournament-audit-log.js @@ -0,0 +1,52 @@ +export function up(db) { + db.transaction(() => { + db.prepare( + /* sql */ ` + create table "TournamentTeamHistory" ( + "tournamentTeamId" integer primary key, + "tournamentId" integer not null, + "name" text not null, + foreign key ("tournamentId") references "Tournament"("id") on delete cascade + ) strict + `, + ).run(); + + // xxx: no enum check, we will keep it dynamic + db.prepare( + /* sql */ ` + create table "TournamentAuditLog" ( + "id" integer primary key autoincrement, + "tournamentId" integer not null, + "type" text not null check ( + "type" in ( + 'MEMBER_ADDED', + 'MEMBER_REMOVED', + 'TEAM_REGISTERED', + 'TEAM_UNREGISTERED', + 'TEAM_CHECKED_IN', + 'TEAM_CHECKED_OUT', + 'TEAM_DROPPED_OUT', + 'TEAM_DROP_OUT_UNDONE' + ) + ), + "actorUserId" integer not null, + "subjectUserId" integer, + "tournamentTeamId" integer, + "metadata" text, + "createdAt" integer not null, + foreign key ("tournamentId") references "Tournament"("id") on delete cascade, + foreign key ("actorUserId") references "User"("id"), + foreign key ("subjectUserId") references "User"("id"), + foreign key ("tournamentTeamId") references "TournamentTeamHistory"("tournamentTeamId") + ) strict + `, + ).run(); + + // xxx: which indexes are the best? + db.prepare( + /* sql */ `create index tournament_audit_log_tournament_id_created_at_idx on "TournamentAuditLog"("tournamentId", "createdAt")`, + ).run(); + + db.pragma("foreign_key_check"); + })(); +} diff --git a/scripts/add-leaderboard-teams-to-tournament.ts b/scripts/add-leaderboard-teams-to-tournament.ts index 1e6bb9830..ac8a9435c 100644 --- a/scripts/add-leaderboard-teams-to-tournament.ts +++ b/scripts/add-leaderboard-teams-to-tournament.ts @@ -107,6 +107,7 @@ async function main() { teamId: entry.team?.id ?? null, }, userId: owner.id, + actorUserId: owner.id, tournamentId, }); @@ -114,6 +115,7 @@ async function main() { await TournamentTeamRepository.join({ newTeamId: tournamentTeam.id, userId: member.id, + actorUserId: member.id, }); }