feat: add commissioner audit log for league transparency (issue #144)

Adds a complete audit log system so league members can verify that
settings, draft order, picks, and time banks have not been quietly
changed without their awareness.

Changes:
- database/schema.ts: new `audit_action` enum + `commissioner_audit_log`
  table (seasonId, leagueId, actorClerkId, actorDisplayName, action,
  affectedTeamIds[], details jsonb, createdAt)
- drizzle/0075: generated migration for the new table
- app/models/audit-log.ts: createAuditLogEntry, getAuditLogForSeason
  (paginated), logCommissionerAction (resolves display name automatically)
- app/lib/audit-log-display.ts: shared formatAuditDetail() helper used by
  both the league home widget and the full audit log page
- app/routes/leagues/$leagueId.audit-log.tsx: new read-only route at
  /leagues/:id/audit-log, accessible to all league members, with
  action-type filter and pagination
- app/routes.ts: registers the new route
- League home page ($leagueId.server.ts / $leagueId.tsx): "Recent Activity"
  summary card showing the last 5 entries with "View all" link
- Settings page ($leagueId.settings.tsx): "View Full Audit Log" link card;
  audit log calls added for league/draft settings changes, draft order
  set/randomized, and draft reset
- API routes: audit log calls added to draft.start, draft.pause,
  draft.resume, draft.rollback, draft.adjust-time-bank, draft.force-autopick,
  draft.force-manual-pick, draft.replace-pick
- Tests: 11 new unit tests for the audit-log model; mocks added to 3
  existing route test files to account for the new logCommissionerAction call

https://claude.ai/code/session_01NdiwK2fbtKhAD3XuD58fTm
This commit is contained in:
Claude 2026-04-13 17:48:09 +00:00
parent 66b0235678
commit 1d84cd6949
No known key found for this signature in database
24 changed files with 5784 additions and 1 deletions

View file

@ -0,0 +1,106 @@
import type { AuditLogEntry } from "~/models/audit-log";
export const AUDIT_ACTION_LABELS: Record<string, string> = {
league_settings_changed: "League Settings Changed",
draft_settings_changed: "Draft Settings Changed",
scoring_rules_changed: "Scoring Rules Changed",
draft_order_set: "Draft Order Set",
draft_order_randomized: "Draft Order Randomized",
draft_started: "Draft Started",
draft_paused: "Draft Paused",
draft_resumed: "Draft Resumed",
draft_reset: "Draft Reset",
draft_rollback: "Draft Rolled Back",
force_autopick: "Forced Auto-Pick",
force_manual_pick: "Forced Manual Pick",
draft_pick_changed: "Pick Replaced",
time_bank_edited: "Time Bank Edited",
};
function formatSeconds(seconds: number): string {
const m = Math.floor(Math.abs(seconds) / 60);
const s = Math.abs(seconds) % 60;
const sign = seconds < 0 ? "-" : "+";
if (m === 0) return `${sign}${s}s`;
return `${sign}${m}m ${s}s`;
}
function formatTimeRemaining(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
/**
* Returns a concise human-readable summary of an audit log entry.
* Shared between the league home page summary widget and the full audit log page.
*/
export function formatAuditDetail(entry: AuditLogEntry): string {
const d = (entry.details ?? {}) as Record<string, unknown>;
switch (entry.action) {
case "draft_rollback":
return `Rolled back to pick #${d.rolledBackToPickNumber}`;
case "time_bank_edited": {
const adj = Number(d.adjustment ?? 0);
const remaining = Number(d.newTimeRemaining ?? 0);
return `${formatSeconds(adj)} for ${d.teamName ?? "team"} (now ${formatTimeRemaining(remaining)})`;
}
case "draft_pick_changed":
return `Pick #${d.pickNumber}: ${d.oldParticipantName}${d.newParticipantName}`;
case "force_autopick":
return `Auto-picked ${d.participantName} for ${d.teamName} at pick #${d.pickNumber}`;
case "force_manual_pick":
return `Picked ${d.participantName} for ${d.teamName} at pick #${d.pickNumber}`;
case "draft_order_set":
return "Draft order manually set";
case "draft_order_randomized":
return "Draft order randomized";
case "draft_reset": {
const prev = d.previousPickNumber;
return prev != null
? `Draft reset (was at pick #${prev})`
: "Draft reset";
}
case "league_settings_changed": {
const fields = (d.changedFields as string[] | undefined) ?? [];
return fields.length > 0
? `Changed: ${fields.join(", ")}`
: "League settings updated";
}
case "draft_settings_changed": {
const fields = (d.changedFields as string[] | undefined) ?? [];
return fields.length > 0
? `Changed: ${fields.join(", ")}`
: "Draft settings updated";
}
case "scoring_rules_changed":
return "Scoring rules updated";
case "draft_started":
return "Draft started";
case "draft_paused":
return d.pickNumber != null
? `Draft paused at pick #${d.pickNumber}`
: "Draft paused";
case "draft_resumed":
return d.pickNumber != null
? `Draft resumed at pick #${d.pickNumber}`
: "Draft resumed";
default:
return (entry.action as string).replace(/_/g, " ");
}
}

View file

@ -0,0 +1,287 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("~/models/user", () => ({
findUserByClerkId: vi.fn(),
getUserDisplayName: vi.fn(),
}));
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
import {
createAuditLogEntry,
getAuditLogForSeason,
logCommissionerAction,
} from "../audit-log";
import { findUserByClerkId, getUserDisplayName } from "~/models/user";
import { database } from "~/database/context";
const SEASON_ID = "season-1";
const LEAGUE_ID = "league-1";
const ACTOR_CLERK_ID = "user_clerk_1";
const SAMPLE_ENTRY = {
id: "entry-1",
seasonId: SEASON_ID,
leagueId: LEAGUE_ID,
actorClerkId: ACTOR_CLERK_ID,
actorDisplayName: "Alice",
action: "draft_rollback" as const,
affectedTeamIds: ["team-1"],
details: { rolledBackToPickNumber: 5, previousPickNumber: 10 },
createdAt: new Date("2025-01-01T12:00:00Z"),
};
/**
* Builds a mock db for insert operations.
*/
function makeInsertDb(returnValue: object) {
return {
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([returnValue]),
}),
}),
};
}
/**
* Builds a mock db for getAuditLogForSeason which fires two select queries in
* parallel: one for the data rows and one for the count. We use two
* mockResolvedValueOnce calls so the first promise.all resolves the data and
* the second resolves the count.
*
* Both calls share the same chain: select().from().where() after that they
* diverge (data adds .orderBy().limit().offset(), count stops). We build a
* single chainable mock where the terminal call is a jest.fn that returns
* different values on each invocation.
*/
function makeSelectDb(
dataRows: object[],
countRow: { count: number }
): object {
// Count chain: select().from().where() → resolves to [countRow]
const countChain = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue([countRow]),
};
// Data chain: select().from().where().orderBy().limit().offset() → resolves to dataRows
const dataChain = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
offset: vi.fn().mockResolvedValue(dataRows),
};
let selectCallCount = 0;
return {
select: vi.fn().mockImplementation(() => {
// First call is the data query; second call is the count query
selectCallCount++;
return selectCallCount === 1 ? dataChain : countChain;
}),
};
}
beforeEach(() => {
vi.clearAllMocks();
});
// ─── createAuditLogEntry ──────────────────────────────────────────────────────
describe("createAuditLogEntry", () => {
it("inserts the entry and returns the persisted record", async () => {
vi.mocked(database).mockReturnValue(makeInsertDb(SAMPLE_ENTRY) as never);
const result = await createAuditLogEntry({
seasonId: SEASON_ID,
leagueId: LEAGUE_ID,
actorClerkId: ACTOR_CLERK_ID,
actorDisplayName: "Alice",
action: "draft_rollback",
affectedTeamIds: ["team-1"],
details: { rolledBackToPickNumber: 5, previousPickNumber: 10 },
});
expect(result).toEqual(SAMPLE_ENTRY);
});
});
// ─── getAuditLogForSeason ─────────────────────────────────────────────────────
describe("getAuditLogForSeason", () => {
it("returns entries and computes hasMore=false when on the last page", async () => {
vi.mocked(database).mockReturnValue(
makeSelectDb([SAMPLE_ENTRY], { count: 1 }) as never
);
const result = await getAuditLogForSeason(SEASON_ID, {
limit: 50,
offset: 0,
});
expect(result.entries).toHaveLength(1);
expect(result.total).toBe(1);
expect(result.hasMore).toBe(false);
});
it("returns hasMore=true when more entries remain", async () => {
// 51 total, fetching first 50 starting at offset 0 → 50 returned → hasMore
const manyEntries = Array.from({ length: 50 }, (_, i) => ({
...SAMPLE_ENTRY,
id: `entry-${i}`,
}));
vi.mocked(database).mockReturnValue(
makeSelectDb(manyEntries, { count: 51 }) as never
);
const result = await getAuditLogForSeason(SEASON_ID, {
limit: 50,
offset: 0,
});
expect(result.total).toBe(51);
expect(result.hasMore).toBe(true);
});
it("returns hasMore=false on the final page", async () => {
// 51 total, on page 2 (offset=50), 1 entry returned
vi.mocked(database).mockReturnValue(
makeSelectDb([SAMPLE_ENTRY], { count: 51 }) as never
);
const result = await getAuditLogForSeason(SEASON_ID, {
limit: 50,
offset: 50,
});
expect(result.hasMore).toBe(false);
});
it("uses default limit of 50 and offset of 0 when options are omitted", async () => {
const db = makeSelectDb([SAMPLE_ENTRY], { count: 1 });
vi.mocked(database).mockReturnValue(db as never);
await getAuditLogForSeason(SEASON_ID);
// Verify the limit and offset were applied via the data chain
const dataChain = (db as { select: ReturnType<typeof vi.fn> }).select.mock.results[0].value;
expect(dataChain.limit).toHaveBeenCalledWith(50);
expect(dataChain.offset).toHaveBeenCalledWith(0);
});
it("returns an empty page when no entries exist", async () => {
vi.mocked(database).mockReturnValue(
makeSelectDb([], { count: 0 }) as never
);
const result = await getAuditLogForSeason(SEASON_ID);
expect(result.entries).toHaveLength(0);
expect(result.total).toBe(0);
expect(result.hasMore).toBe(false);
});
});
// ─── logCommissionerAction ────────────────────────────────────────────────────
describe("logCommissionerAction", () => {
it("resolves the actor display name from the users table", async () => {
const mockUser = { username: "alice", displayName: "Alice Smith" };
vi.mocked(findUserByClerkId).mockResolvedValue(mockUser as never);
vi.mocked(getUserDisplayName).mockReturnValue("alice");
const insertDb = makeInsertDb(SAMPLE_ENTRY);
vi.mocked(database).mockReturnValue(insertDb as never);
await logCommissionerAction({
seasonId: SEASON_ID,
leagueId: LEAGUE_ID,
actorClerkId: ACTOR_CLERK_ID,
action: "draft_rollback",
});
const insertValues = (insertDb.insert as ReturnType<typeof vi.fn>).mock.results[0].value.values.mock.calls[0][0];
expect(insertValues.actorDisplayName).toBe("alice");
});
it("falls back to actorClerkId if the user is not found", async () => {
vi.mocked(findUserByClerkId).mockResolvedValue(undefined);
vi.mocked(getUserDisplayName).mockReturnValue(null);
const insertDb = makeInsertDb(SAMPLE_ENTRY);
vi.mocked(database).mockReturnValue(insertDb as never);
await logCommissionerAction({
seasonId: SEASON_ID,
leagueId: LEAGUE_ID,
actorClerkId: ACTOR_CLERK_ID,
action: "draft_rollback",
});
const insertValues = (insertDb.insert as ReturnType<typeof vi.fn>).mock.results[0].value.values.mock.calls[0][0];
expect(insertValues.actorDisplayName).toBe(ACTOR_CLERK_ID);
});
it("defaults affectedTeamIds to [] when not provided", async () => {
vi.mocked(findUserByClerkId).mockResolvedValue({ username: "alice", displayName: "Alice" } as never);
vi.mocked(getUserDisplayName).mockReturnValue("alice");
const insertDb = makeInsertDb(SAMPLE_ENTRY);
vi.mocked(database).mockReturnValue(insertDb as never);
await logCommissionerAction({
seasonId: SEASON_ID,
leagueId: LEAGUE_ID,
actorClerkId: ACTOR_CLERK_ID,
action: "draft_started",
});
const insertValues = (insertDb.insert as ReturnType<typeof vi.fn>).mock.results[0].value.values.mock.calls[0][0];
expect(insertValues.affectedTeamIds).toEqual([]);
});
it("defaults details to {} when not provided", async () => {
vi.mocked(findUserByClerkId).mockResolvedValue({ username: "alice", displayName: "Alice" } as never);
vi.mocked(getUserDisplayName).mockReturnValue("alice");
const insertDb = makeInsertDb(SAMPLE_ENTRY);
vi.mocked(database).mockReturnValue(insertDb as never);
await logCommissionerAction({
seasonId: SEASON_ID,
leagueId: LEAGUE_ID,
actorClerkId: ACTOR_CLERK_ID,
action: "draft_started",
});
const insertValues = (insertDb.insert as ReturnType<typeof vi.fn>).mock.results[0].value.values.mock.calls[0][0];
expect(insertValues.details).toEqual({});
});
it("passes through provided affectedTeamIds and details", async () => {
vi.mocked(findUserByClerkId).mockResolvedValue({ username: "alice", displayName: "Alice" } as never);
vi.mocked(getUserDisplayName).mockReturnValue("alice");
const insertDb = makeInsertDb(SAMPLE_ENTRY);
vi.mocked(database).mockReturnValue(insertDb as never);
const details = { pickNumber: 7, teamName: "Team Bravo", participantName: "Max V" };
await logCommissionerAction({
seasonId: SEASON_ID,
leagueId: LEAGUE_ID,
actorClerkId: ACTOR_CLERK_ID,
action: "force_manual_pick",
affectedTeamIds: ["team-7"],
details,
});
const insertValues = (insertDb.insert as ReturnType<typeof vi.fn>).mock.results[0].value.values.mock.calls[0][0];
expect(insertValues.affectedTeamIds).toEqual(["team-7"]);
expect(insertValues.details).toEqual(details);
});
});

87
app/models/audit-log.ts Normal file
View file

@ -0,0 +1,87 @@
import { eq, desc, and, inArray, sql } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { findUserByClerkId, getUserDisplayName } from "~/models/user";
export type AuditLogEntry = typeof schema.commissionerAuditLog.$inferSelect;
export type NewAuditLogEntry = typeof schema.commissionerAuditLog.$inferInsert;
export type AuditAction = typeof schema.auditActionEnum.enumValues[number];
export interface AuditLogPage {
entries: AuditLogEntry[];
total: number;
hasMore: boolean;
}
export async function createAuditLogEntry(
data: NewAuditLogEntry
): Promise<AuditLogEntry> {
const db = database();
const [entry] = await db
.insert(schema.commissionerAuditLog)
.values(data)
.returning();
return entry;
}
export async function getAuditLogForSeason(
seasonId: string,
options?: { limit?: number; offset?: number; actions?: AuditAction[] }
): Promise<AuditLogPage> {
const db = database();
const limit = options?.limit ?? 50;
const offset = options?.offset ?? 0;
const whereClause =
options?.actions && options.actions.length > 0
? and(
eq(schema.commissionerAuditLog.seasonId, seasonId),
inArray(schema.commissionerAuditLog.action, options.actions)
)
: eq(schema.commissionerAuditLog.seasonId, seasonId);
const [entries, countRows] = await Promise.all([
db
.select()
.from(schema.commissionerAuditLog)
.where(whereClause)
.orderBy(desc(schema.commissionerAuditLog.createdAt))
.limit(limit)
.offset(offset),
db
.select({ count: sql<number>`count(*)::int` })
.from(schema.commissionerAuditLog)
.where(whereClause),
]);
const total = countRows[0]?.count ?? 0;
return { entries, total, hasMore: offset + entries.length < total };
}
/**
* Convenience wrapper that resolves the actor display name from the users table
* and writes a single audit log record. Call this after the main action succeeds.
*/
export async function logCommissionerAction(params: {
seasonId: string;
leagueId: string;
actorClerkId: string;
action: AuditAction;
affectedTeamIds?: string[];
details?: Record<string, unknown>;
}): Promise<void> {
const user = await findUserByClerkId(params.actorClerkId);
const actorDisplayName = user
? (getUserDisplayName(user) ?? params.actorClerkId)
: params.actorClerkId;
await createAuditLogEntry({
seasonId: params.seasonId,
leagueId: params.leagueId,
actorClerkId: params.actorClerkId,
actorDisplayName,
action: params.action,
affectedTeamIds: params.affectedTeamIds ?? [],
details: params.details ?? {},
});
}

View file

@ -16,3 +16,4 @@ export * from "./draft-pick";
export * from "./draft-queue";
export * from "./draft-timer";
export * from "./draft-utils";
export * from "./audit-log";

View file

@ -6,6 +6,7 @@ export default [
route("leagues/new", "routes/leagues/new.tsx"),
route("leagues/:leagueId", "routes/leagues/$leagueId.tsx"),
route("leagues/:leagueId/settings", "routes/leagues/$leagueId.settings.tsx"),
route("leagues/:leagueId/audit-log", "routes/leagues/$leagueId.audit-log.tsx"),
route(
"leagues/:leagueId/upcoming-events",
"routes/leagues/$leagueId.upcoming-events.tsx"

View file

@ -31,6 +31,9 @@ vi.mock("~/models/draft-utils", () => ({
vi.mock("~/models/user", () => ({
isUserAdminByClerkId: vi.fn(),
}));
vi.mock("~/models/audit-log", () => ({
logCommissionerAction: vi.fn().mockResolvedValue(undefined),
}));
// ── Fixtures ─────────────────────────────────────────────────────────────────

View file

@ -37,6 +37,9 @@ vi.mock("~/models/draft-utils", () => ({
vi.mock("~/models/user", () => ({
isUserAdminByClerkId: vi.fn(),
}));
vi.mock("~/models/audit-log", () => ({
logCommissionerAction: vi.fn().mockResolvedValue(undefined),
}));
// ── Fixtures ──────────────────────────────────────────────────────────────────

View file

@ -18,6 +18,9 @@ vi.mock("~/models/draft-timer", () => ({
deleteSeasonTimers: vi.fn(),
initializeDraftTimers: vi.fn(),
}));
vi.mock("~/models/audit-log", () => ({
logCommissionerAction: vi.fn().mockResolvedValue(undefined),
}));
// ── Fixtures ─────────────────────────────────────────────────────────────────

View file

@ -3,6 +3,7 @@ import { eq, and } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { isCommissioner } from "~/models/commissioner";
import { logCommissionerAction } from "~/models/audit-log";
import { getSocketIO } from "../../../server/socket";
import { logger } from "~/lib/logger";
@ -76,6 +77,24 @@ export async function action(args: ActionFunctionArgs) {
.where(eq(schema.draftTimers.id, currentTimer.id));
}
const team = await db.query.teams.findFirst({
where: eq(schema.teams.id, teamId),
});
await logCommissionerAction({
seasonId,
leagueId: season.leagueId,
actorClerkId: userId,
action: "time_bank_edited",
affectedTeamIds: [teamId],
details: {
teamId,
teamName: team?.name ?? teamId,
adjustment,
newTimeRemaining: newTime,
},
});
try {
getSocketIO()
.to(`draft-${seasonId}`)

View file

@ -4,6 +4,7 @@ import * as schema from "~/database/schema";
import { eq } from "drizzle-orm";
import { executeAutoPick } from "~/models/draft-utils";
import { isCommissioner } from "~/models/commissioner";
import { logCommissionerAction } from "~/models/audit-log";
import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) {
@ -53,6 +54,25 @@ export async function action(args: ActionFunctionArgs) {
return Response.json({ error: result.error }, { status: 400 });
}
const team = await db.query.teams.findFirst({
where: eq(schema.teams.id, teamId),
});
await logCommissionerAction({
seasonId,
leagueId: season.leagueId,
actorClerkId: userId,
action: "force_autopick",
affectedTeamIds: [teamId],
details: {
pickNumber,
teamId,
teamName: team?.name ?? teamId,
participantId: result.participant?.id,
participantName: result.participant?.name,
},
});
return Response.json({
success: true,
pick: result.pick,

View file

@ -8,6 +8,7 @@ import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/d
import { getParticipantsForSeasonWithSports } from "~/models/participant";
import { getSeasonSportsSimple } from "~/models/season-sport";
import { calculatePickInfo, checkAndTriggerNextAutodraft } from "~/models/draft-utils";
import { logCommissionerAction } from "~/models/audit-log";
import { getSocketIO } from "../../../server/socket";
import { logger } from "~/lib/logger";
import type { ActionFunctionArgs } from "react-router";
@ -257,6 +258,23 @@ export async function action(args: ActionFunctionArgs) {
logger.error("Socket.IO error:", error);
}
const pickedTeam = draftSlots.find((slot) => slot.team.id === teamId)?.team;
await logCommissionerAction({
seasonId,
leagueId: season.leagueId,
actorClerkId: userId,
action: "force_manual_pick",
affectedTeamIds: [teamId],
details: {
pickNumber,
teamId,
teamName: pickedTeam?.name ?? teamId,
participantId,
participantName: participant.name,
},
});
// Check if next team has autodraft enabled and trigger immediately
if (!isDraftComplete) {
const freshSeason = await db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId) });

View file

@ -3,6 +3,7 @@ import { eq } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { isCommissioner } from "~/models/commissioner";
import { logCommissionerAction } from "~/models/audit-log";
import { getSocketIO } from "../../../server/socket";
import { logger } from "~/lib/logger";
@ -55,6 +56,14 @@ export async function action(args: ActionFunctionArgs) {
.set({ draftPaused: true })
.where(eq(schema.seasons.id, seasonId));
await logCommissionerAction({
seasonId,
leagueId: season.leagueId,
actorClerkId: userId,
action: "draft_paused",
details: { pickNumber: season.currentPickNumber },
});
// Emit socket event
try {
getSocketIO().to(`draft-${seasonId}`).emit("draft-paused", {

View file

@ -7,6 +7,7 @@ import { calculateDraftEligibility } from "~/lib/draft-eligibility";
import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick";
import { getParticipantsForSeasonWithSports } from "~/models/participant";
import { getSeasonSportsSimple } from "~/models/season-sport";
import { logCommissionerAction } from "~/models/audit-log";
import { getSocketIO } from "../../../server/socket";
import { logger } from "~/lib/logger";
@ -125,6 +126,11 @@ export async function action(args: ActionFunctionArgs) {
return Response.json({ error: reason }, { status: 400 });
}
// Fetch old participant name for the audit log (before overwriting the pick)
const oldParticipant = await db.query.participants.findFirst({
where: eq(schema.participants.id, oldParticipantId),
});
// Update the pick in-place
const [updatedPick] = await db
.update(schema.draftPicks)
@ -141,6 +147,25 @@ export async function action(args: ActionFunctionArgs) {
)
.returning();
const replacedTeam = draftSlots.find((slot) => slot.team.id === teamId)?.team;
await logCommissionerAction({
seasonId,
leagueId: season.leagueId,
actorClerkId: userId,
action: "draft_pick_changed",
affectedTeamIds: [teamId],
details: {
pickNumber,
teamId,
teamName: replacedTeam?.name ?? teamId,
oldParticipantId,
oldParticipantName: oldParticipant?.name ?? oldParticipantId,
newParticipantId: participantId,
newParticipantName: participant.name,
},
});
// Remove new participant from all team queues
await db
.delete(schema.draftQueue)

View file

@ -3,6 +3,7 @@ import { eq } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { isCommissioner } from "~/models/commissioner";
import { logCommissionerAction } from "~/models/audit-log";
import { getSocketIO } from "../../../server/socket";
import { logger } from "~/lib/logger";
@ -55,6 +56,14 @@ export async function action(args: ActionFunctionArgs) {
.set({ draftPaused: false })
.where(eq(schema.seasons.id, seasonId));
await logCommissionerAction({
seasonId,
leagueId: season.leagueId,
actorClerkId: userId,
action: "draft_resumed",
details: { pickNumber: season.currentPickNumber },
});
// Emit socket event
try {
getSocketIO().to(`draft-${seasonId}`).emit("draft-resumed", {

View file

@ -3,6 +3,7 @@ import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and, gte } from "drizzle-orm";
import { isCommissioner } from "~/models/commissioner";
import { logCommissionerAction } from "~/models/audit-log";
import { getSocketIO } from "../../../server/socket";
import { logger } from "~/lib/logger";
@ -83,6 +84,18 @@ export async function action(args: ActionFunctionArgs) {
})
.where(eq(schema.seasons.id, seasonId));
await logCommissionerAction({
seasonId,
leagueId: season.leagueId,
actorClerkId: userId,
action: "draft_rollback",
affectedTeamIds: rollbackSlot?.teamId ? [rollbackSlot.teamId] : [],
details: {
rolledBackToPickNumber: pickNumber,
previousPickNumber: season.currentPickNumber,
},
});
// Emit socket events
try {
const io = getSocketIO();

View file

@ -4,6 +4,7 @@ import * as schema from "~/database/schema";
import { eq } from "drizzle-orm";
import { deleteSeasonTimers, initializeDraftTimers } from "~/models/draft-timer";
import { isCommissioner } from "~/models/commissioner";
import { logCommissionerAction } from "~/models/audit-log";
import { getSocketIO } from "../../../server/socket";
import { logger } from "~/lib/logger";
@ -77,6 +78,14 @@ export async function action(args: ActionFunctionArgs) {
initialTime
);
await logCommissionerAction({
seasonId,
leagueId: season.leagueId,
actorClerkId: userId,
action: "draft_started",
details: { pickNumber: 1 },
});
// Emit socket event
try {
getSocketIO().to(`draft-${seasonId}`).emit("draft-started", {

View file

@ -0,0 +1,303 @@
import { getAuth } from "@clerk/react-router/server";
import { format } from "date-fns";
import { Link, Form, useSearchParams } from "react-router";
import type { Route } from "./+types/$leagueId.audit-log";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { findLeagueById, isCommissioner, isUserLeagueMember } from "~/models";
import { findCurrentSeasonWithSports } from "~/models/season";
import { findTeamsBySeasonId } from "~/models/team";
import { getAuditLogForSeason, type AuditAction, type AuditLogEntry } from "~/models/audit-log";
import { AUDIT_ACTION_LABELS, formatAuditDetail } from "~/lib/audit-log-display";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Audit Log — ${data?.league?.name ?? "League"} - Brackt` }];
}
const PAGE_SIZE = 50;
export async function loader(args: Route.LoaderArgs) {
const { userId } = await getAuth(args);
const { params, request } = args;
const { leagueId } = params;
if (!userId) {
throw new Response("You must be logged in to view this page", {
status: 401,
});
}
const league = await findLeagueById(leagueId);
if (!league) {
throw new Response("League not found", { status: 404 });
}
const [userIsCommissioner, userIsMember] = await Promise.all([
isCommissioner(leagueId, userId),
isUserLeagueMember(leagueId, userId),
]);
if (!userIsCommissioner && !userIsMember) {
throw new Response("You do not have access to this league", {
status: 403,
});
}
const season = await findCurrentSeasonWithSports(leagueId);
if (!season) {
return {
league,
season: null,
auditLog: { entries: [] as AuditLogEntry[], total: 0, hasMore: false },
teams: [] as Array<{ id: string; name: string }>,
page: 0,
isUserCommissioner: userIsCommissioner,
};
}
const searchParams = new URL(request.url).searchParams;
const page = Math.max(0, parseInt(searchParams.get("page") ?? "0", 10) || 0);
const actionFilter = searchParams.get("action") as AuditAction | null;
const [auditLog, teams] = await Promise.all([
getAuditLogForSeason(season.id, {
limit: PAGE_SIZE,
offset: page * PAGE_SIZE,
actions: actionFilter ? [actionFilter] : undefined,
}),
findTeamsBySeasonId(season.id),
]);
return {
league,
season,
auditLog,
teams: teams.map((t) => ({ id: t.id, name: t.name })),
page,
isUserCommissioner: userIsCommissioner,
};
}
function getActionBadgeVariant(
action: string
): "default" | "secondary" | "destructive" | "outline" {
if (action === "draft_reset" || action === "draft_rollback") {
return "destructive";
}
if (
action === "draft_started" ||
action === "draft_paused" ||
action === "draft_resumed"
) {
return "secondary";
}
if (
action === "league_settings_changed" ||
action === "draft_settings_changed" ||
action === "scoring_rules_changed"
) {
return "outline";
}
return "default";
}
interface AuditLogRowProps {
entry: AuditLogEntry;
teamMap: Map<string, string>;
}
function AuditLogRow({ entry, teamMap }: AuditLogRowProps) {
const affectedTeamNames =
entry.affectedTeamIds && entry.affectedTeamIds.length > 0
? entry.affectedTeamIds
.map((id) => teamMap.get(id) ?? id)
.join(", ")
: "—";
return (
<TableRow>
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
{format(new Date(entry.createdAt), "MMM d, yyyy HH:mm")}
</TableCell>
<TableCell className="text-sm font-medium">
{entry.actorDisplayName ?? entry.actorClerkId}
</TableCell>
<TableCell>
<Badge variant={getActionBadgeVariant(entry.action)}>
{AUDIT_ACTION_LABELS[entry.action] ?? entry.action}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{affectedTeamNames}
</TableCell>
<TableCell className="text-sm text-muted-foreground max-w-xs">
{formatAuditDetail(entry)}
</TableCell>
</TableRow>
);
}
export default function AuditLogPage({ loaderData }: Route.ComponentProps) {
const { league, season, auditLog, teams, page, isUserCommissioner } =
loaderData;
const [searchParams] = useSearchParams();
const actionFilter = searchParams.get("action") ?? "";
const teamMap = new Map(teams.map((t) => [t.id, t.name]));
const totalPages = Math.ceil(auditLog.total / PAGE_SIZE);
function buildPageUrl(newPage: number) {
const p = new URLSearchParams(searchParams);
p.set("page", String(newPage));
return `?${p.toString()}`;
}
return (
<div className="container max-w-5xl mx-auto py-8 px-4">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold">Audit Log</h1>
<p className="text-muted-foreground">{league.name}</p>
</div>
<div className="flex gap-2">
{isUserCommissioner && (
<Button variant="outline" asChild>
<Link to={`/leagues/${league.id}/settings`}>Settings</Link>
</Button>
)}
<Button variant="outline" asChild>
<Link to={`/leagues/${league.id}`}>Back to League</Link>
</Button>
</div>
</div>
{!season ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No season found for this league.
</CardContent>
</Card>
) : (
<>
{/* Filter bar */}
<Card className="mb-4">
<CardContent className="py-4">
<Form method="get" className="flex gap-3 items-center">
<Select name="action" defaultValue={actionFilter || ""}>
<SelectTrigger className="w-[220px]">
<SelectValue placeholder="All actions" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">All actions</SelectItem>
{Object.entries(AUDIT_ACTION_LABELS).map(
([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
)
)}
</SelectContent>
</Select>
<Button type="submit" size="sm">
Filter
</Button>
{actionFilter && (
<Button variant="outline" size="sm" asChild>
<Link to={`/leagues/${league.id}/audit-log`}>
Clear filter
</Link>
</Button>
)}
</Form>
</CardContent>
</Card>
{/* Results table */}
<Card>
<CardHeader>
<CardTitle>Commissioner Actions</CardTitle>
<CardDescription>
{auditLog.total === 0
? "No entries found"
: `${auditLog.total} ${auditLog.total === 1 ? "entry" : "entries"}`}
{totalPages > 1 &&
` — page ${page + 1} of ${totalPages}`}
</CardDescription>
</CardHeader>
<CardContent>
{auditLog.entries.length === 0 ? (
<p className="text-center text-muted-foreground py-8">
No activity recorded yet.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Time</TableHead>
<TableHead>Commissioner</TableHead>
<TableHead>Action</TableHead>
<TableHead>Teams Affected</TableHead>
<TableHead>Details</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{auditLog.entries.map((entry) => (
<AuditLogRow
key={entry.id}
entry={entry}
teamMap={teamMap}
/>
))}
</TableBody>
</Table>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex gap-2 mt-4 justify-center">
{page > 0 && (
<Button variant="outline" size="sm" asChild>
<Link to={buildPageUrl(page - 1)}>Previous</Link>
</Button>
)}
<span className="flex items-center text-sm text-muted-foreground px-2">
Page {page + 1} of {totalPages}
</span>
{auditLog.hasMore && (
<Button variant="outline" size="sm" asChild>
<Link to={buildPageUrl(page + 1)}>Next</Link>
</Button>
)}
</div>
)}
</CardContent>
</Card>
</>
)}
</div>
);
}

View file

@ -16,6 +16,7 @@ import { getSeasonStandings } from "~/models/standings";
import { getUpcomingEventsForDraftedParticipants } from "~/models/scoring-event";
import { getUpcomingGroupStageMatchesForParticipants } from "~/models/group-stage-match";
import { getDraftedParticipantsBySportsSeason } from "~/models/draft-pick";
import { getAuditLogForSeason } from "~/models/audit-log";
import type { Route } from "./+types/$leagueId";
export async function loader(args: Route.LoaderArgs) {
@ -188,6 +189,11 @@ export async function loader(args: Route.LoaderArgs) {
// Extract origin for client use (avoids SSR/client mismatch on invite URLs)
const origin = new URL(args.request.url).origin;
// Fetch recent audit log entries for the "Recent Activity" summary widget
const recentActivity = season
? await getAuditLogForSeason(season.id, { limit: 5 })
: { entries: [], total: 0, hasMore: false };
return {
league,
season,
@ -206,5 +212,6 @@ export async function loader(args: Route.LoaderArgs) {
standings,
origin,
upcomingCalendarEvents,
recentActivity,
};
}

View file

@ -25,6 +25,7 @@ import * as schema from "~/database/schema";
import { deleteAllDraftPicks } from "~/models/draft-pick";
import { clearAllQueuesForSeason } from "~/models/draft-queue";
import { deleteSeasonTimers } from "~/models/draft-timer";
import { logCommissionerAction } from "~/models/audit-log";
import { parseDraftSpeed } from "~/lib/draft-timer";
import type { Route } from "./+types/$leagueId.settings";
import { Button } from "~/components/ui/button";
@ -218,11 +219,41 @@ export async function action(args: Route.ActionArgs) {
}
try {
const changedFields: string[] = [];
if (name.trim() !== league.name) changedFields.push("name");
if (isPublicDraftBoard !== league.isPublicDraftBoard) changedFields.push("isPublicDraftBoard");
if ((webhookUrl || null) !== league.discordWebhookUrl) changedFields.push("discordWebhookUrl");
await updateLeague(leagueId, {
name: name.trim(),
isPublicDraftBoard,
discordWebhookUrl: webhookUrl || null,
});
if (changedFields.length > 0) {
const currentSeason = await findCurrentSeasonWithSports(leagueId);
if (currentSeason) {
await logCommissionerAction({
seasonId: currentSeason.id,
leagueId,
actorClerkId: userId,
action: "league_settings_changed",
details: {
changedFields,
previousValues: {
name: league.name,
isPublicDraftBoard: league.isPublicDraftBoard,
discordWebhookUrl: league.discordWebhookUrl,
},
newValues: {
name: name.trim(),
isPublicDraftBoard,
discordWebhookUrl: webhookUrl || null,
},
},
});
}
}
} catch (error) {
logger.error("Error updating league:", error);
return { error: "Failed to update league. Please try again." };
@ -334,6 +365,22 @@ export async function action(args: Route.ActionArgs) {
}
await setDraftOrder(season.id, teamIds);
await logCommissionerAction({
seasonId: season.id,
leagueId,
actorClerkId: userId,
action: "draft_order_set",
affectedTeamIds: teamIds,
details: {
order: teamIds.map((id, i) => ({
teamId: id,
teamName: teams.find((t) => t.id === id)?.name ?? id,
position: i + 1,
})),
},
});
return { success: true, message: "Draft order updated successfully" };
}
@ -346,6 +393,26 @@ export async function action(args: Route.ActionArgs) {
const teamIds = teams.map(t => t.id);
await randomizeDraftOrder(season.id, teamIds);
// Fetch the new order that was just written so we can log it accurately
const newSlots = await findDraftSlotsBySeasonId(season.id);
const sortedSlots = [...newSlots].sort((a, b) => a.draftOrder - b.draftOrder);
await logCommissionerAction({
seasonId: season.id,
leagueId,
actorClerkId: userId,
action: "draft_order_randomized",
affectedTeamIds: sortedSlots.map((s) => s.teamId),
details: {
order: sortedSlots.map((s) => ({
teamId: s.teamId,
teamName: teams.find((t) => t.id === s.teamId)?.name ?? s.teamId,
position: s.draftOrder,
})),
},
});
return { success: true, message: "Draft order randomized successfully" };
}
@ -427,6 +494,8 @@ export async function action(args: Route.ActionArgs) {
// Delete all draft timers
await deleteSeasonTimers(season.id);
const previousPickNumber = season.currentPickNumber;
// Set season status back to pre_draft and reset draft state (keeps draft order intact)
await updateSeason(season.id, {
status: "pre_draft",
@ -435,6 +504,14 @@ export async function action(args: Route.ActionArgs) {
draftPaused: false,
});
await logCommissionerAction({
seasonId: season.id,
leagueId,
actorClerkId: userId,
action: "draft_reset",
details: { previousPickNumber },
});
return { success: true, message: "Draft has been reset successfully. Draft order preserved." };
} catch (error) {
logger.error("Error resetting draft:", error);
@ -525,6 +602,37 @@ export async function action(args: Route.ActionArgs) {
// Update season if there are changes
if (Object.keys(seasonUpdates).length > 0) {
await updateSeason(season.id, seasonUpdates);
const scoringFields = ["pointsFor1st", "pointsFor2nd", "pointsFor3rd", "pointsFor4th",
"pointsFor5th", "pointsFor6th", "pointsFor7th", "pointsFor8th"];
const draftFields = Object.keys(seasonUpdates).filter((k) => !scoringFields.includes(k));
const scoringChangedFields = Object.keys(seasonUpdates).filter((k) => scoringFields.includes(k));
if (draftFields.length > 0) {
await logCommissionerAction({
seasonId: season.id,
leagueId,
actorClerkId: userId,
action: "draft_settings_changed",
details: {
changedFields: draftFields,
newValues: Object.fromEntries(draftFields.map((k) => [k, (seasonUpdates as Record<string, unknown>)[k]])),
},
});
}
if (scoringChangedFields.length > 0) {
await logCommissionerAction({
seasonId: season.id,
leagueId,
actorClerkId: userId,
action: "scoring_rules_changed",
details: {
changedFields: scoringChangedFields,
newValues: Object.fromEntries(scoringChangedFields.map((k) => [k, (seasonUpdates as Record<string, unknown>)[k]])),
},
});
}
}
// Handle team count changes if provided
@ -1443,6 +1551,23 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
</CardContent>
</Card>
{/* Audit Log */}
<Card>
<CardHeader>
<CardTitle>Audit Log</CardTitle>
<CardDescription>
View the full history of commissioner actions for this season. All league members can see this log.
</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" asChild>
<Link to={`/leagues/${league.id}/audit-log`}>
View Full Audit Log
</Link>
</Button>
</CardContent>
</Card>
{/* Danger Zone */}
<Card className="border-destructive">
<CardHeader>

View file

@ -16,6 +16,7 @@ import { SportSeasonCard } from "~/components/sports/SportSeasonCard";
import { UpcomingCalendarPanel } from "~/components/sport-season/UpcomingCalendarPanel";
import { TeamNameDisplay } from "~/components/ui/team-name-display";
import { buildTiedRankChecker, getDisplayRank } from "~/lib/standings-display";
import { formatAuditDetail } from "~/lib/audit-log-display";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `${data?.league?.name ?? "League"} - Brackt` }];
@ -42,6 +43,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
standings,
origin,
upcomingCalendarEvents,
recentActivity,
} = loaderData;
const myTeam = teams.find((t) => t.ownerId === currentUserId);
@ -533,6 +535,36 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
</div>
</CardContent>
</Card>
{recentActivity.entries.length > 0 && (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div>
<CardTitle>Recent Activity</CardTitle>
<CardDescription>Recent commissioner actions</CardDescription>
</div>
<Button variant="outline" size="sm" asChild>
<Link to={`/leagues/${league.id}/audit-log`}>View all</Link>
</Button>
</CardHeader>
<CardContent>
<ul className="space-y-3">
{recentActivity.entries.map((entry) => (
<li key={entry.id} className="flex items-start gap-3 text-sm">
<span className="text-muted-foreground whitespace-nowrap shrink-0">
{format(new Date(entry.createdAt), "MMM d, HH:mm")}
</span>
<span>
<span className="font-medium">{entry.actorDisplayName ?? entry.actorClerkId}</span>
{" — "}
<span className="text-muted-foreground">{formatAuditDetail(entry)}</span>
</span>
</li>
))}
</ul>
</CardContent>
</Card>
)}
</div>
</div>
</div>

View file

@ -1,4 +1,4 @@
import { integer, pgTable, varchar, uuid, timestamp, pgEnum, boolean, text, date, decimal, uniqueIndex, jsonb } from "drizzle-orm/pg-core";
import { integer, pgTable, varchar, uuid, timestamp, pgEnum, boolean, text, date, decimal, uniqueIndex, index, jsonb } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
// Users table - synced from Clerk
@ -1243,3 +1243,54 @@ export const cs2MajorStageResultsRelations = relations(cs2MajorStageResults, ({
references: [participants.id],
}),
}));
// ─── Commissioner Audit Log ────────────────────────────────────────────────────
// Immutable log of significant commissioner/admin actions per season.
// Readable by all league members for transparency; writable only by the
// server-side route actions that perform the underlying operations.
export const auditActionEnum = pgEnum("audit_action", [
"league_settings_changed",
"draft_settings_changed",
"scoring_rules_changed",
"draft_order_set",
"draft_order_randomized",
"draft_started",
"draft_paused",
"draft_resumed",
"draft_reset",
"draft_rollback",
"force_autopick",
"force_manual_pick",
"draft_pick_changed",
"time_bank_edited",
]);
export const commissionerAuditLog = pgTable("commissioner_audit_log", {
id: uuid("id").primaryKey().defaultRandom(),
seasonId: uuid("season_id")
.notNull()
.references(() => seasons.id, { onDelete: "cascade" }),
leagueId: uuid("league_id")
.notNull()
.references(() => leagues.id, { onDelete: "cascade" }),
actorClerkId: varchar("actor_clerk_id", { length: 255 }).notNull(),
actorDisplayName: varchar("actor_display_name", { length: 255 }),
action: auditActionEnum("action").notNull(),
affectedTeamIds: text("affected_team_ids").array(),
details: jsonb("details"),
createdAt: timestamp("created_at").defaultNow().notNull(),
}, (t) => ({
auditLogSeasonIdx: index("audit_log_season_id_idx").on(t.seasonId, t.createdAt),
}));
export const commissionerAuditLogRelations = relations(commissionerAuditLog, ({ one }) => ({
season: one(seasons, {
fields: [commissionerAuditLog.seasonId],
references: [seasons.id],
}),
league: one(leagues, {
fields: [commissionerAuditLog.leagueId],
references: [leagues.id],
}),
}));

View file

@ -0,0 +1,26 @@
CREATE TYPE "public"."audit_action" AS ENUM('league_settings_changed', 'draft_settings_changed', 'scoring_rules_changed', 'draft_order_set', 'draft_order_randomized', 'draft_started', 'draft_paused', 'draft_resumed', 'draft_reset', 'draft_rollback', 'force_autopick', 'force_manual_pick', 'draft_pick_changed', 'time_bank_edited');--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "commissioner_audit_log" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"season_id" uuid NOT NULL,
"league_id" uuid NOT NULL,
"actor_clerk_id" varchar(255) NOT NULL,
"actor_display_name" varchar(255),
"action" "audit_action" NOT NULL,
"affected_team_ids" text[],
"details" jsonb,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "commissioner_audit_log" ADD CONSTRAINT "commissioner_audit_log_season_id_seasons_id_fk" FOREIGN KEY ("season_id") REFERENCES "public"."seasons"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "commissioner_audit_log" ADD CONSTRAINT "commissioner_audit_log_league_id_leagues_id_fk" FOREIGN KEY ("league_id") REFERENCES "public"."leagues"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "audit_log_season_id_idx" ON "commissioner_audit_log" USING btree ("season_id","created_at");

File diff suppressed because it is too large Load diff

View file

@ -526,6 +526,13 @@
"when": 1775699707816,
"tag": "0074_clammy_tiger_shark",
"breakpoints": true
},
{
"idx": 75,
"version": "7",
"when": 1776101579459,
"tag": "0075_chubby_stephen_strange",
"breakpoints": true
}
]
}