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>
217 lines
7.5 KiB
TypeScript
217 lines
7.5 KiB
TypeScript
import { getAuth } from "@clerk/react-router/server";
|
|
import { addDays, subDays } from "date-fns";
|
|
import { toEventSortKey } from "~/lib/date-utils";
|
|
import {
|
|
findLeagueById,
|
|
findTeamsBySeasonId,
|
|
findCommissionersByLeagueId,
|
|
isUserLeagueMember,
|
|
isCommissioner,
|
|
findUsersByClerkIds,
|
|
getUserDisplayName,
|
|
findDraftSlotsBySeasonId,
|
|
} from "~/models";
|
|
import { findCurrentSeasonWithSports } from "~/models/season";
|
|
import { getSeasonStandings } from "~/models/standings";
|
|
import { getUpcomingEventsForDraftedParticipants } from "~/models/scoring-event";
|
|
import { getUpcomingGroupStageMatchesForParticipants } from "~/models/group-stage-match";
|
|
import { getDraftedParticipantsBySportsSeason } from "~/models/draft-pick";
|
|
import { getAuditLogForSeason } from "~/models/audit-log";
|
|
import type { Route } from "./+types/$leagueId";
|
|
|
|
export async function loader(args: Route.LoaderArgs) {
|
|
const { userId } = await getAuth(args);
|
|
const { params } = args;
|
|
const { leagueId } = params;
|
|
|
|
// Fetch league
|
|
const league = await findLeagueById(leagueId);
|
|
|
|
if (!league) {
|
|
throw new Response("League not found", { status: 404 });
|
|
}
|
|
|
|
// Fetch current season with sports
|
|
const seasonWithSports = await findCurrentSeasonWithSports(leagueId);
|
|
const season = seasonWithSports || null;
|
|
|
|
// Fetch commissioners
|
|
const commissioners = await findCommissionersByLeagueId(leagueId);
|
|
|
|
// Check if current user is a commissioner
|
|
const isUserCommissioner = userId
|
|
? await isCommissioner(leagueId, userId)
|
|
: false;
|
|
|
|
// Check if user is a member (has a team in current season)
|
|
const isUserMember = userId
|
|
? await isUserLeagueMember(leagueId, userId)
|
|
: false;
|
|
|
|
// Check access: user must be a commissioner, a member, or an admin
|
|
// If not logged in or not authorized, throw 403
|
|
if (!userId) {
|
|
throw new Response("You must be logged in to view this league", {
|
|
status: 401,
|
|
});
|
|
}
|
|
|
|
if (!isUserCommissioner && !isUserMember) {
|
|
throw new Response("You do not have access to this league", {
|
|
status: 403,
|
|
});
|
|
}
|
|
|
|
// Fetch teams for current season
|
|
const teams = season ? await findTeamsBySeasonId(season.id) : [];
|
|
|
|
// Fetch draft slots if season is in pre_draft or draft status
|
|
const draftSlots =
|
|
season && (season.status === "pre_draft" || season.status === "draft")
|
|
? await findDraftSlotsBySeasonId(season.id)
|
|
: [];
|
|
|
|
// Fetch standings for active/completed seasons
|
|
const standings =
|
|
season && (season.status === "active" || season.status === "completed")
|
|
? await getSeasonStandings(season.id)
|
|
: [];
|
|
|
|
// Batch-fetch all users needed for owner and commissioner maps in one query
|
|
const ownerIds = [...new Set(teams.map((t) => t.ownerId).filter((id): id is string => id !== null))];
|
|
const commissionerIds = commissioners.map((c) => c.userId);
|
|
const allUserIds = [...new Set([...ownerIds, ...commissionerIds])];
|
|
const userRows = await findUsersByClerkIds(allUserIds);
|
|
const userByClerkId = new Map(userRows.map((u) => [u.clerkId, u]));
|
|
|
|
const ownerMap = new Map(
|
|
ownerIds
|
|
.map((id) => [id, userByClerkId.get(id)] as const)
|
|
.filter((entry): entry is [string, NonNullable<typeof entry[1]>] => entry[1] !== undefined)
|
|
.map(([id, user]) => [id, getUserDisplayName(user)])
|
|
);
|
|
|
|
const commissionerMap = new Map(
|
|
commissionerIds
|
|
.map((id) => [id, userByClerkId.get(id)] as const)
|
|
.filter((entry): entry is [string, NonNullable<typeof entry[1]>] => entry[1] !== undefined)
|
|
.map(([id, user]) => [id, getUserDisplayName(user)])
|
|
);
|
|
|
|
// Count available teams
|
|
const availableTeamCount = teams.filter((t) => !t.ownerId).length;
|
|
|
|
// Count teams with owners
|
|
const teamsWithOwners = teams.filter((t) => t.ownerId !== null).length;
|
|
|
|
// Get sports seasons data with upcoming participant events for the current user
|
|
const rawSportsSeasons = seasonWithSports?.seasonSports?.map((ss) => ss.sportsSeason) || [];
|
|
|
|
const myTeam = teams.find((t) => t.ownerId === userId) ?? null;
|
|
const today = new Date();
|
|
const dateFromStr = subDays(today, 1).toISOString().split("T")[0];
|
|
const dateToStr = addDays(today, 30).toISOString().split("T")[0];
|
|
|
|
const participantsBySportsSeason = myTeam && season
|
|
? await getDraftedParticipantsBySportsSeason(myTeam.id, season.id)
|
|
: new Map<string, Array<{ id: string; name: string }>>();
|
|
|
|
const dateFrom = new Date(dateFromStr + "T00:00:00.000Z");
|
|
const dateTo = new Date(dateToStr + "T23:59:59.999Z");
|
|
|
|
const sportsSeasons = await Promise.all(
|
|
rawSportsSeasons.map(async (ss) => {
|
|
const draftedParticipants = participantsBySportsSeason.get(ss.id) ?? [];
|
|
const upcomingParticipantEvents =
|
|
draftedParticipants.length > 0
|
|
? await getUpcomingEventsForDraftedParticipants(
|
|
ss.id,
|
|
ss.scoringPattern ?? "",
|
|
draftedParticipants,
|
|
dateFromStr,
|
|
dateToStr
|
|
)
|
|
: [];
|
|
|
|
// For group-stage bracket sports, also include scheduled group matches
|
|
if (ss.scoringPattern === "playoff_bracket" && draftedParticipants.length > 0) {
|
|
const draftedIds = draftedParticipants.map((p) => p.id);
|
|
const groupMatches = await getUpcomingGroupStageMatchesForParticipants(
|
|
ss.id,
|
|
draftedIds,
|
|
dateFrom,
|
|
dateTo
|
|
);
|
|
for (const gm of groupMatches) {
|
|
const relevant = draftedParticipants.filter(
|
|
(p) => p.id === gm.participant1.id || p.id === gm.participant2.id
|
|
);
|
|
upcomingParticipantEvents.push({
|
|
id: gm.matchId,
|
|
name: `${gm.participant1.name} vs ${gm.participant2.name}`,
|
|
eventDate: gm.scheduledAt ? gm.scheduledAt.split("T")[0] : null,
|
|
earliestGameTime: gm.scheduledAt || null,
|
|
matchLabel: `Group ${gm.groupName} · MD${gm.matchday}`,
|
|
eventType: "group_match",
|
|
sportsSeasonId: ss.id,
|
|
relevantParticipants: relevant,
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: ss.id,
|
|
name: ss.name,
|
|
status: ss.status as "upcoming" | "active" | "completed",
|
|
scoringPattern: ss.scoringPattern,
|
|
sport: ss.sport,
|
|
upcomingParticipantEvents,
|
|
};
|
|
})
|
|
);
|
|
const sportsCount = sportsSeasons.length;
|
|
|
|
// Flatten all events into a panel-ready list, sorted by date
|
|
const upcomingCalendarEvents = sportsSeasons
|
|
.flatMap((ss) =>
|
|
ss.upcomingParticipantEvents.map((e) => ({
|
|
...e,
|
|
sportName: ss.sport.name,
|
|
sportSeasonName: ss.name,
|
|
sportsSeasonPageUrl: `/leagues/${leagueId}/sports-seasons/${ss.id}`,
|
|
}))
|
|
)
|
|
.toSorted((a, b) => toEventSortKey(a).localeCompare(toEventSortKey(b)));
|
|
|
|
// Check if draft order is set
|
|
const isDraftOrderSet = draftSlots.length > 0;
|
|
|
|
// Extract origin for client use (avoids SSR/client mismatch on invite URLs)
|
|
const origin = new URL(args.request.url).origin;
|
|
|
|
// Fetch recent audit log entries for the "Recent Activity" summary widget
|
|
const recentActivity = season
|
|
? await getAuditLogForSeason(season.id, { limit: 5 })
|
|
: { entries: [], total: 0, hasMore: false };
|
|
|
|
return {
|
|
league,
|
|
season,
|
|
teams,
|
|
commissioners,
|
|
currentUserId: userId,
|
|
isUserCommissioner,
|
|
ownerMap: Object.fromEntries(ownerMap),
|
|
commissionerMap: Object.fromEntries(commissionerMap),
|
|
availableTeamCount,
|
|
sportsCount,
|
|
teamsWithOwners,
|
|
isDraftOrderSet,
|
|
draftSlots,
|
|
sportsSeasons,
|
|
standings,
|
|
origin,
|
|
upcomingCalendarEvents,
|
|
recentActivity,
|
|
};
|
|
}
|