brackt/app/routes/leagues/$leagueId.audit-log.tsx
Chris Parsons 4a875e7628
fix: league settings sports swap and audit log detail improvements (#294)
- Fix sports seasons not updating when saving league settings. The form
  submits intent="update" but sports were only handled under the dead
  intent="update-sports" branch, which nothing called.

- Fix spurious audit log entries (scoring rules, draft settings) firing
  on every settings save. Change detection now compares submitted values
  against current DB values before adding to seasonUpdates.

- Add previousValues to scoring_rules_changed and draft_settings_changed
  audit log entries so the detail formatter can show old → new diffs
  (e.g. "Scoring updated: 1st: 100 → 105").

- Add sports_changed audit action (migration 0076) with sport names
  resolved at log time. Batch-fetch added sports in one query instead
  of N individual queries.

- Improve audit log display for all action types: field-level old→new
  diffs, readable labels, truncated sport lists (+N more).

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 21:41:05 -07:00

309 lines
9.7 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";
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" ||
action === "sports_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>
);
}