brackt/app/routes/leagues/$leagueId.audit-log.tsx
Claude 1d84cd6949
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
2026-04-13 17:48:09 +00:00

303 lines
9.4 KiB
TypeScript

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>
);
}