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
106 lines
3.3 KiB
TypeScript
106 lines
3.3 KiB
TypeScript
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",
|
|
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",
|
|
};
|
|
|
|
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
|
|
? `Draft reset (was at pick #${prev})`
|
|
: "Draft reset";
|
|
}
|
|
|
|
case "league_settings_changed": {
|
|
const fields = (d.changedFields as string[] | undefined) ?? [];
|
|
return fields.length > 0
|
|
? `Changed: ${fields.join(", ")}`
|
|
: "League settings updated";
|
|
}
|
|
|
|
case "draft_settings_changed": {
|
|
const fields = (d.changedFields as string[] | undefined) ?? [];
|
|
return fields.length > 0
|
|
? `Changed: ${fields.join(", ")}`
|
|
: "Draft settings updated";
|
|
}
|
|
|
|
case "scoring_rules_changed":
|
|
return "Scoring rules updated";
|
|
|
|
case "draft_started":
|
|
return "Draft started";
|
|
|
|
case "draft_paused":
|
|
return d.pickNumber != null
|
|
? `Draft paused at pick #${d.pickNumber}`
|
|
: "Draft paused";
|
|
|
|
case "draft_resumed":
|
|
return d.pickNumber != null
|
|
? `Draft resumed at pick #${d.pickNumber}`
|
|
: "Draft resumed";
|
|
|
|
default:
|
|
return (entry.action as string).replace(/_/g, " ");
|
|
}
|
|
}
|