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
303 lines
9.4 KiB
TypeScript
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>
|
|
);
|
|
}
|