- 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>
309 lines
9.7 KiB
TypeScript
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>
|
|
);
|
|
}
|