brackt/app/lib/audit-log-display.ts
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

207 lines
7.1 KiB
TypeScript

import { format } from "date-fns";
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",
sports_changed: "Sports 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",
};
const SCORING_FIELD_LABELS: Record<string, string> = {
pointsFor1st: "1st",
pointsFor2nd: "2nd",
pointsFor3rd: "3rd",
pointsFor4th: "4th",
pointsFor5th: "5th",
pointsFor6th: "6th",
pointsFor7th: "7th",
pointsFor8th: "8th",
};
const DRAFT_FIELD_LABELS: Record<string, string> = {
draftRounds: "Rounds",
draftDateTime: "Draft date",
draftInitialTime: "Initial time",
draftIncrementTime: "Increment",
draftTimerMode: "Timer mode",
};
const LEAGUE_FIELD_LABELS: Record<string, string> = {
name: "Name",
isPublicDraftBoard: "Public draft board",
discordWebhookUrl: "Discord webhook",
};
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
if (m === 0) return `${s}s`;
if (s === 0) return `${m}m`;
return `${m}m ${s}s`;
}
function formatDraftField(key: string, value: unknown): string {
if (value === null || value === undefined) return "none";
if (key === "draftInitialTime" || key === "draftIncrementTime") {
return formatDuration(Number(value));
}
if (key === "draftDateTime") {
return format(new Date(value as string), "MMM d, yyyy h:mm a");
}
if (key === "draftTimerMode") {
return value === "chess_clock" ? "Chess clock" : "Standard";
}
// draftRounds and any other numeric fields render as plain numbers
return String(value);
}
function formatLeagueField(key: string, value: unknown): string {
if (value === null || value === undefined || value === "") return "none";
if (key === "isPublicDraftBoard") return value ? "Yes" : "No";
if (key === "discordWebhookUrl") return "set";
return String(value);
}
function diffLines(
fields: string[],
prev: Record<string, unknown>,
next: Record<string, unknown>,
labels: Record<string, string>,
formatter: (key: string, value: unknown) => string
): string {
if (fields.length === 0) return "";
return fields
.map((f) => {
const label = labels[f] ?? f;
const from = prev[f];
const to = next[f];
// `from` is undefined when the log entry predates the addition of previousValues —
// in that case just show the new value without an arrow.
if (from !== undefined) {
return `${label}: ${formatter(f, from)}${formatter(f, to)}`;
}
return `${label}: ${formatter(f, to)}`;
})
.join(", ");
}
function truncateList(names: string[], max = 3): string {
if (names.length <= max) return names.join(", ");
return `${names.slice(0, max).join(", ")} +${names.length - max} more`;
}
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 && prev !== undefined
? `Draft reset (was at pick #${prev})`
: "Draft reset";
}
case "draft_started":
return "Draft started";
case "draft_paused":
return d.pickNumber !== null && d.pickNumber !== undefined
? `Draft paused at pick #${d.pickNumber}`
: "Draft paused";
case "draft_resumed":
return d.pickNumber !== null && d.pickNumber !== undefined
? `Draft resumed at pick #${d.pickNumber}`
: "Draft resumed";
case "league_settings_changed": {
const fields = (d.changedFields as string[] | undefined) ?? [];
const prev = (d.previousValues as Record<string, unknown> | undefined) ?? {};
const next = (d.newValues as Record<string, unknown> | undefined) ?? {};
const detail = diffLines(fields, prev, next, LEAGUE_FIELD_LABELS, formatLeagueField);
return detail || "League settings updated";
}
case "draft_settings_changed": {
const fields = (d.changedFields as string[] | undefined) ?? [];
const prev = (d.previousValues as Record<string, unknown> | undefined) ?? {};
const next = (d.newValues as Record<string, unknown> | undefined) ?? {};
const detail = diffLines(fields, prev, next, DRAFT_FIELD_LABELS, formatDraftField);
return detail || "Draft settings updated";
}
case "scoring_rules_changed": {
const fields = (d.changedFields as string[] | undefined) ?? [];
const prev = (d.previousValues as Record<string, unknown> | undefined) ?? {};
const next = (d.newValues as Record<string, unknown> | undefined) ?? {};
const detail = diffLines(fields, prev, next, SCORING_FIELD_LABELS, (_, v) => String(v ?? ""));
return detail ? `Scoring updated: ${detail}` : "Scoring rules updated";
}
case "sports_changed": {
const added = (d.added as string[] | undefined) ?? [];
const removed = (d.removed as string[] | undefined) ?? [];
const parts: string[] = [];
if (added.length > 0) parts.push(`Added: ${truncateList(added)}`);
if (removed.length > 0) parts.push(`Removed: ${truncateList(removed)}`);
return parts.length > 0 ? parts.join(" | ") : "Sports updated";
}
default:
return (entry.action as string).replace(/_/g, " ");
}
}