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>
112 lines
3.1 KiB
TypeScript
112 lines
3.1 KiB
TypeScript
import { getAuth } from "@clerk/react-router/server";
|
|
import { eq, and } from "drizzle-orm";
|
|
import { database } from "~/database/context";
|
|
import * as schema from "~/database/schema";
|
|
import { isCommissioner } from "~/models/commissioner";
|
|
import { logCommissionerAction } from "~/models/audit-log";
|
|
import { getSocketIO } from "../../../server/socket";
|
|
import { logger } from "~/lib/logger";
|
|
|
|
import type { ActionFunctionArgs } from "react-router";
|
|
export async function action(args: ActionFunctionArgs) {
|
|
const { request } = args;
|
|
const { userId } = await getAuth(args);
|
|
|
|
if (!userId) {
|
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
const formData = await request.formData();
|
|
const seasonId = formData.get("seasonId") as string;
|
|
const teamId = formData.get("teamId") as string;
|
|
const adjustmentRaw = formData.get("adjustment");
|
|
const adjustment = adjustmentRaw !== null ? parseInt(adjustmentRaw as string, 10) : NaN;
|
|
|
|
if (!seasonId || !teamId || isNaN(adjustment)) {
|
|
return Response.json({ error: "seasonId, teamId, and adjustment are required" }, { status: 400 });
|
|
}
|
|
|
|
const db = database();
|
|
|
|
const season = await db.query.seasons.findFirst({
|
|
where: eq(schema.seasons.id, seasonId),
|
|
});
|
|
|
|
if (!season) {
|
|
return Response.json({ error: "Season not found" }, { status: 404 });
|
|
}
|
|
|
|
if (season.status !== "draft") {
|
|
return Response.json({ error: "Draft is not currently active" }, { status: 409 });
|
|
}
|
|
|
|
if (!(await isCommissioner(season.leagueId, userId))) {
|
|
return Response.json(
|
|
{ error: "Only commissioners can adjust time banks" },
|
|
{ status: 403 }
|
|
);
|
|
}
|
|
|
|
const [currentTimer] = await db
|
|
.select()
|
|
.from(schema.draftTimers)
|
|
.where(
|
|
and(
|
|
eq(schema.draftTimers.seasonId, seasonId),
|
|
eq(schema.draftTimers.teamId, teamId)
|
|
)
|
|
);
|
|
|
|
let newTime: number;
|
|
|
|
if (!currentTimer) {
|
|
if (adjustment <= 0) {
|
|
return Response.json({ error: "Timer not found for this team" }, { status: 404 });
|
|
}
|
|
newTime = adjustment;
|
|
await db.insert(schema.draftTimers).values({
|
|
seasonId,
|
|
teamId,
|
|
timeRemaining: newTime,
|
|
});
|
|
} else {
|
|
newTime = Math.max(0, currentTimer.timeRemaining + adjustment);
|
|
await db
|
|
.update(schema.draftTimers)
|
|
.set({ timeRemaining: newTime, updatedAt: new Date() })
|
|
.where(eq(schema.draftTimers.id, currentTimer.id));
|
|
}
|
|
|
|
const team = await db.query.teams.findFirst({
|
|
where: eq(schema.teams.id, teamId),
|
|
});
|
|
|
|
await logCommissionerAction({
|
|
seasonId,
|
|
leagueId: season.leagueId,
|
|
actorClerkId: userId,
|
|
action: "time_bank_edited",
|
|
affectedTeamIds: [teamId],
|
|
details: {
|
|
teamId,
|
|
teamName: team?.name ?? teamId,
|
|
adjustment,
|
|
newTimeRemaining: newTime,
|
|
},
|
|
});
|
|
|
|
try {
|
|
getSocketIO()
|
|
.to(`draft-${seasonId}`)
|
|
.emit("timer-update", {
|
|
seasonId,
|
|
teamId,
|
|
timeRemaining: newTime,
|
|
currentPickNumber: season.currentPickNumber ?? 1,
|
|
});
|
|
} catch (error) {
|
|
logger.error("Socket.IO error:", error);
|
|
}
|
|
|
|
return Response.json({ success: true, timeRemaining: newTime });
|
|
}
|