2026-04-14 21:41:05 -07:00
|
|
|
import { format } from "date-fns";
|
Add audit logging for commissioner actions (#293)
Closes #144
* feat: add commissioner audit log for league transparency (issue #144)
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
* fix: validate action filter URL param against known enum values
The action filter on the audit log route was cast directly from the URL
search param to AuditAction without validation. An invalid value would
be passed into the Drizzle inArray() call, potentially throwing a
PostgreSQL enum type error. Now validates against the actual enum values
before using the filter.
https://claude.ai/code/session_01NdiwK2fbtKhAD3XuD58fTm
* Fix lint errors: use !== instead of != and toSorted instead of sort
https://claude.ai/code/session_01NdiwK2fbtKhAD3XuD58fTm
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-13 18:45:39 -04:00
|
|
|
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",
|
2026-04-14 21:41:05 -07:00
|
|
|
sports_changed: "Sports Changed",
|
Add audit logging for commissioner actions (#293)
Closes #144
* feat: add commissioner audit log for league transparency (issue #144)
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
* fix: validate action filter URL param against known enum values
The action filter on the audit log route was cast directly from the URL
search param to AuditAction without validation. An invalid value would
be passed into the Drizzle inArray() call, potentially throwing a
PostgreSQL enum type error. Now validates against the actual enum values
before using the filter.
https://claude.ai/code/session_01NdiwK2fbtKhAD3XuD58fTm
* Fix lint errors: use !== instead of != and toSorted instead of sort
https://claude.ai/code/session_01NdiwK2fbtKhAD3XuD58fTm
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-13 18:45:39 -04:00
|
|
|
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",
|
2026-05-04 20:31:44 -07:00
|
|
|
brackt_resolved: "Brackt Resolved",
|
Add audit logging for commissioner actions (#293)
Closes #144
* feat: add commissioner audit log for league transparency (issue #144)
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
* fix: validate action filter URL param against known enum values
The action filter on the audit log route was cast directly from the URL
search param to AuditAction without validation. An invalid value would
be passed into the Drizzle inArray() call, potentially throwing a
PostgreSQL enum type error. Now validates against the actual enum values
before using the filter.
https://claude.ai/code/session_01NdiwK2fbtKhAD3XuD58fTm
* Fix lint errors: use !== instead of != and toSorted instead of sort
https://claude.ai/code/session_01NdiwK2fbtKhAD3XuD58fTm
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-13 18:45:39 -04:00
|
|
|
};
|
|
|
|
|
|
2026-04-14 21:41:05 -07:00
|
|
|
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`;
|
|
|
|
|
}
|
|
|
|
|
|
Add audit logging for commissioner actions (#293)
Closes #144
* feat: add commissioner audit log for league transparency (issue #144)
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
* fix: validate action filter URL param against known enum values
The action filter on the audit log route was cast directly from the URL
search param to AuditAction without validation. An invalid value would
be passed into the Drizzle inArray() call, potentially throwing a
PostgreSQL enum type error. Now validates against the actual enum values
before using the filter.
https://claude.ai/code/session_01NdiwK2fbtKhAD3XuD58fTm
* Fix lint errors: use !== instead of != and toSorted instead of sort
https://claude.ai/code/session_01NdiwK2fbtKhAD3XuD58fTm
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-13 18:45:39 -04:00
|
|
|
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";
|
|
|
|
|
|
2026-04-14 21:41:05 -07:00
|
|
|
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";
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-04 20:31:44 -07:00
|
|
|
case "brackt_resolved":
|
|
|
|
|
return d.trigger === "auto" ? "Brackt resolved automatically" : "Brackt resolution retried";
|
|
|
|
|
|
Add audit logging for commissioner actions (#293)
Closes #144
* feat: add commissioner audit log for league transparency (issue #144)
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
* fix: validate action filter URL param against known enum values
The action filter on the audit log route was cast directly from the URL
search param to AuditAction without validation. An invalid value would
be passed into the Drizzle inArray() call, potentially throwing a
PostgreSQL enum type error. Now validates against the actual enum values
before using the filter.
https://claude.ai/code/session_01NdiwK2fbtKhAD3XuD58fTm
* Fix lint errors: use !== instead of != and toSorted instead of sort
https://claude.ai/code/session_01NdiwK2fbtKhAD3XuD58fTm
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-13 18:45:39 -04:00
|
|
|
default:
|
|
|
|
|
return (entry.action as string).replace(/_/g, " ");
|
|
|
|
|
}
|
|
|
|
|
}
|