Add audit logging for commissioner actions (#293)

Closes #144

* 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

* fix: validate action filter URL param against known enum values

The action filter on the audit log route was cast directly from the URL
search param to AuditAction without validation. An invalid value would
be passed into the Drizzle inArray() call, potentially throwing a
PostgreSQL enum type error. Now validates against the actual enum values
before using the filter.

https://claude.ai/code/session_01NdiwK2fbtKhAD3XuD58fTm

* Fix lint errors: use !== instead of != and toSorted instead of sort

https://claude.ai/code/session_01NdiwK2fbtKhAD3XuD58fTm

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-04-13 18:45:39 -04:00 committed by GitHub
parent 66b0235678
commit 442b392461
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 5789 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 && prev !== undefined
? `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 && d.pickNumber !== undefined
? `Draft paused at pick #${d.pickNumber}`
: "Draft paused";
case "draft_resumed":
return d.pickNumber !== null && d.pickNumber !== undefined
? `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-queue";
export * from "./draft-timer"; export * from "./draft-timer";
export * from "./draft-utils"; 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/new", "routes/leagues/new.tsx"),
route("leagues/:leagueId", "routes/leagues/$leagueId.tsx"), route("leagues/:leagueId", "routes/leagues/$leagueId.tsx"),
route("leagues/:leagueId/settings", "routes/leagues/$leagueId.settings.tsx"), route("leagues/:leagueId/settings", "routes/leagues/$leagueId.settings.tsx"),
route("leagues/:leagueId/audit-log", "routes/leagues/$leagueId.audit-log.tsx"),
route( route(
"leagues/:leagueId/upcoming-events", "leagues/:leagueId/upcoming-events",
"routes/leagues/$leagueId.upcoming-events.tsx" "routes/leagues/$leagueId.upcoming-events.tsx"

View file

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

View file

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

View file

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

View file

@ -3,6 +3,7 @@ import { eq, and } from "drizzle-orm";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { isCommissioner } from "~/models/commissioner"; import { isCommissioner } from "~/models/commissioner";
import { logCommissionerAction } from "~/models/audit-log";
import { getSocketIO } from "../../../server/socket"; import { getSocketIO } from "../../../server/socket";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
@ -76,6 +77,24 @@ export async function action(args: ActionFunctionArgs) {
.where(eq(schema.draftTimers.id, currentTimer.id)); .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 { try {
getSocketIO() getSocketIO()
.to(`draft-${seasonId}`) .to(`draft-${seasonId}`)

View file

@ -4,6 +4,7 @@ import * as schema from "~/database/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { executeAutoPick } from "~/models/draft-utils"; import { executeAutoPick } from "~/models/draft-utils";
import { isCommissioner } from "~/models/commissioner"; import { isCommissioner } from "~/models/commissioner";
import { logCommissionerAction } from "~/models/audit-log";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) { export async function action(args: ActionFunctionArgs) {
@ -53,6 +54,25 @@ export async function action(args: ActionFunctionArgs) {
return Response.json({ error: result.error }, { status: 400 }); 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({ return Response.json({
success: true, success: true,
pick: result.pick, pick: result.pick,

View file

@ -8,6 +8,7 @@ import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/d
import { getParticipantsForSeasonWithSports } from "~/models/participant"; import { getParticipantsForSeasonWithSports } from "~/models/participant";
import { getSeasonSportsSimple } from "~/models/season-sport"; import { getSeasonSportsSimple } from "~/models/season-sport";
import { calculatePickInfo, checkAndTriggerNextAutodraft } from "~/models/draft-utils"; import { calculatePickInfo, checkAndTriggerNextAutodraft } from "~/models/draft-utils";
import { logCommissionerAction } from "~/models/audit-log";
import { getSocketIO } from "../../../server/socket"; import { getSocketIO } from "../../../server/socket";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
@ -257,6 +258,23 @@ export async function action(args: ActionFunctionArgs) {
logger.error("Socket.IO error:", error); 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 // Check if next team has autodraft enabled and trigger immediately
if (!isDraftComplete) { if (!isDraftComplete) {
const freshSeason = await db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId) }); 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 { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { isCommissioner } from "~/models/commissioner"; import { isCommissioner } from "~/models/commissioner";
import { logCommissionerAction } from "~/models/audit-log";
import { getSocketIO } from "../../../server/socket"; import { getSocketIO } from "../../../server/socket";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
@ -55,6 +56,14 @@ export async function action(args: ActionFunctionArgs) {
.set({ draftPaused: true }) .set({ draftPaused: true })
.where(eq(schema.seasons.id, seasonId)); .where(eq(schema.seasons.id, seasonId));
await logCommissionerAction({
seasonId,
leagueId: season.leagueId,
actorClerkId: userId,
action: "draft_paused",
details: { pickNumber: season.currentPickNumber },
});
// Emit socket event // Emit socket event
try { try {
getSocketIO().to(`draft-${seasonId}`).emit("draft-paused", { 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 { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick";
import { getParticipantsForSeasonWithSports } from "~/models/participant"; import { getParticipantsForSeasonWithSports } from "~/models/participant";
import { getSeasonSportsSimple } from "~/models/season-sport"; import { getSeasonSportsSimple } from "~/models/season-sport";
import { logCommissionerAction } from "~/models/audit-log";
import { getSocketIO } from "../../../server/socket"; import { getSocketIO } from "../../../server/socket";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
@ -125,6 +126,11 @@ export async function action(args: ActionFunctionArgs) {
return Response.json({ error: reason }, { status: 400 }); 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 // Update the pick in-place
const [updatedPick] = await db const [updatedPick] = await db
.update(schema.draftPicks) .update(schema.draftPicks)
@ -141,6 +147,25 @@ export async function action(args: ActionFunctionArgs) {
) )
.returning(); .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 // Remove new participant from all team queues
await db await db
.delete(schema.draftQueue) .delete(schema.draftQueue)

View file

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

View file

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

View file

@ -0,0 +1,308 @@
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";
import * as schema from "~/database/schema";
const VALID_AUDIT_ACTIONS = new Set<string>(schema.auditActionEnum.enumValues);
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 rawAction = searchParams.get("action");
const actionFilter: AuditAction | null =
rawAction && VALID_AUDIT_ACTIONS.has(rawAction) ? (rawAction 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 { getUpcomingEventsForDraftedParticipants } from "~/models/scoring-event";
import { getUpcomingGroupStageMatchesForParticipants } from "~/models/group-stage-match"; import { getUpcomingGroupStageMatchesForParticipants } from "~/models/group-stage-match";
import { getDraftedParticipantsBySportsSeason } from "~/models/draft-pick"; import { getDraftedParticipantsBySportsSeason } from "~/models/draft-pick";
import { getAuditLogForSeason } from "~/models/audit-log";
import type { Route } from "./+types/$leagueId"; import type { Route } from "./+types/$leagueId";
export async function loader(args: Route.LoaderArgs) { 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) // Extract origin for client use (avoids SSR/client mismatch on invite URLs)
const origin = new URL(args.request.url).origin; 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 { return {
league, league,
season, season,
@ -206,5 +212,6 @@ export async function loader(args: Route.LoaderArgs) {
standings, standings,
origin, origin,
upcomingCalendarEvents, upcomingCalendarEvents,
recentActivity,
}; };
} }

View file

@ -25,6 +25,7 @@ import * as schema from "~/database/schema";
import { deleteAllDraftPicks } from "~/models/draft-pick"; import { deleteAllDraftPicks } from "~/models/draft-pick";
import { clearAllQueuesForSeason } from "~/models/draft-queue"; import { clearAllQueuesForSeason } from "~/models/draft-queue";
import { deleteSeasonTimers } from "~/models/draft-timer"; import { deleteSeasonTimers } from "~/models/draft-timer";
import { logCommissionerAction } from "~/models/audit-log";
import { parseDraftSpeed } from "~/lib/draft-timer"; import { parseDraftSpeed } from "~/lib/draft-timer";
import type { Route } from "./+types/$leagueId.settings"; import type { Route } from "./+types/$leagueId.settings";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
@ -218,11 +219,41 @@ export async function action(args: Route.ActionArgs) {
} }
try { 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, { await updateLeague(leagueId, {
name: name.trim(), name: name.trim(),
isPublicDraftBoard, isPublicDraftBoard,
discordWebhookUrl: webhookUrl || null, 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) { } catch (error) {
logger.error("Error updating league:", error); logger.error("Error updating league:", error);
return { error: "Failed to update league. Please try again." }; 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 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" }; 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); const teamIds = teams.map(t => t.id);
await randomizeDraftOrder(season.id, teamIds); 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.toSorted((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" }; return { success: true, message: "Draft order randomized successfully" };
} }
@ -427,6 +494,8 @@ export async function action(args: Route.ActionArgs) {
// Delete all draft timers // Delete all draft timers
await deleteSeasonTimers(season.id); await deleteSeasonTimers(season.id);
const previousPickNumber = season.currentPickNumber;
// Set season status back to pre_draft and reset draft state (keeps draft order intact) // Set season status back to pre_draft and reset draft state (keeps draft order intact)
await updateSeason(season.id, { await updateSeason(season.id, {
status: "pre_draft", status: "pre_draft",
@ -435,6 +504,14 @@ export async function action(args: Route.ActionArgs) {
draftPaused: false, 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." }; return { success: true, message: "Draft has been reset successfully. Draft order preserved." };
} catch (error) { } catch (error) {
logger.error("Error resetting draft:", error); logger.error("Error resetting draft:", error);
@ -525,6 +602,37 @@ export async function action(args: Route.ActionArgs) {
// Update season if there are changes // Update season if there are changes
if (Object.keys(seasonUpdates).length > 0) { if (Object.keys(seasonUpdates).length > 0) {
await updateSeason(season.id, seasonUpdates); 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 // Handle team count changes if provided
@ -1443,6 +1551,23 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
</CardContent> </CardContent>
</Card> </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 */} {/* Danger Zone */}
<Card className="border-destructive"> <Card className="border-destructive">
<CardHeader> <CardHeader>

View file

@ -16,6 +16,7 @@ import { SportSeasonCard } from "~/components/sports/SportSeasonCard";
import { UpcomingCalendarPanel } from "~/components/sport-season/UpcomingCalendarPanel"; import { UpcomingCalendarPanel } from "~/components/sport-season/UpcomingCalendarPanel";
import { TeamNameDisplay } from "~/components/ui/team-name-display"; import { TeamNameDisplay } from "~/components/ui/team-name-display";
import { buildTiedRankChecker, getDisplayRank } from "~/lib/standings-display"; import { buildTiedRankChecker, getDisplayRank } from "~/lib/standings-display";
import { formatAuditDetail } from "~/lib/audit-log-display";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors { export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `${data?.league?.name ?? "League"} - Brackt` }]; return [{ title: `${data?.league?.name ?? "League"} - Brackt` }];
@ -42,6 +43,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
standings, standings,
origin, origin,
upcomingCalendarEvents, upcomingCalendarEvents,
recentActivity,
} = loaderData; } = loaderData;
const myTeam = teams.find((t) => t.ownerId === currentUserId); const myTeam = teams.find((t) => t.ownerId === currentUserId);
@ -533,6 +535,36 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
</div> </div>
</CardContent> </CardContent>
</Card> </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> </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"; import { relations } from "drizzle-orm";
// Users table - synced from Clerk // Users table - synced from Clerk
@ -1243,3 +1243,54 @@ export const cs2MajorStageResultsRelations = relations(cs2MajorStageResults, ({
references: [participants.id], 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, "when": 1775699707816,
"tag": "0074_clammy_tiger_shark", "tag": "0074_clammy_tiger_shark",
"breakpoints": true "breakpoints": true
},
{
"idx": 75,
"version": "7",
"when": 1776101579459,
"tag": "0075_chubby_stephen_strange",
"breakpoints": true
} }
] ]
} }