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:
parent
66b0235678
commit
1d84cd6949
24 changed files with 5784 additions and 1 deletions
106
app/lib/audit-log-display.ts
Normal file
106
app/lib/audit-log-display.ts
Normal 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, " ");
|
||||
}
|
||||
}
|
||||
287
app/models/__tests__/audit-log.test.ts
Normal file
287
app/models/__tests__/audit-log.test.ts
Normal 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
87
app/models/audit-log.ts
Normal 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 ?? {},
|
||||
});
|
||||
}
|
||||
|
|
@ -16,3 +16,4 @@ export * from "./draft-pick";
|
|||
export * from "./draft-queue";
|
||||
export * from "./draft-timer";
|
||||
export * from "./draft-utils";
|
||||
export * from "./audit-log";
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) });
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
303
app/routes/leagues/$leagueId.audit-log.tsx
Normal file
303
app/routes/leagues/$leagueId.audit-log.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
26
drizzle/0075_chubby_stephen_strange.sql
Normal file
26
drizzle/0075_chubby_stephen_strange.sql
Normal 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");
|
||||
4619
drizzle/meta/0075_snapshot.json
Normal file
4619
drizzle/meta/0075_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue