* Refactor league settings into per-section components (#347) Extract all settings sections from the monolithic 1009-line route file into individual components under app/components/league/settings/. Route file drops to ~300 lines. Separates draft-order dirty state from general settings dirty state, deduplicates section-change handling, and fixes several bugs found during review (typo in pointsFor5th, wrong mock in tests, lint violations). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix Stop hook loop: suppress output on typecheck success The Stop hook was producing stdout on every run, causing Claude Code to feed it back as context and rewake the model each turn. Now emits a systemMessage JSON only on failure. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Typescript fix * Remove type asserting --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
770 lines
28 KiB
TypeScript
770 lines
28 KiB
TypeScript
import { redirect } from "react-router";
|
|
import { auth } from "~/lib/auth.server";
|
|
import { logger } from "~/lib/logger";
|
|
import { database } from "~/database/context";
|
|
import * as schema from "~/database/schema";
|
|
import { deleteLeague, findLeagueById, updateLeague } from "~/models/league";
|
|
import {
|
|
countCommissionersByLeagueId,
|
|
createCommissioner,
|
|
findCommissionersByLeagueId,
|
|
hasCommissionerRecord,
|
|
isCommissioner,
|
|
removeCommissionerByLeagueAndUser,
|
|
} from "~/models/commissioner";
|
|
import { clearAllQueuesForSeason } from "~/models/draft-queue";
|
|
import { findDraftSlotsBySeasonId, randomizeDraftOrder, setDraftOrder } from "~/models/draft-slot";
|
|
import { deleteAllDraftPicks } from "~/models/draft-pick";
|
|
import { deleteSeasonTimers } from "~/models/draft-timer";
|
|
import { findCurrentSeasonWithSports, type NewSeason, updateSeason } from "~/models/season";
|
|
import { linkMultipleSportsToSeason, unlinkSportFromSeason } from "~/models/season-sport";
|
|
import { findDraftableSportsSeasons, findSportsSeasonsByIds } from "~/models/sports-season";
|
|
import { claimTeam, deleteTeam, findTeamById, findTeamsBySeasonId, removeTeamOwner, renameTeam } from "~/models/team";
|
|
import { findUserById, findUsersByIds, getUserDisplayName, isUserAdmin } from "~/models/user";
|
|
import { logCommissionerAction } from "~/models/audit-log";
|
|
import { parseDraftSpeed } from "~/lib/draft-timer";
|
|
import { sendStandingsUpdateNotification } from "~/services/discord";
|
|
import {
|
|
applyBracktSportsForSeason,
|
|
seedOrRepairBracktTemplate,
|
|
syncPrivateBracktParticipants,
|
|
} from "~/services/brackt.server";
|
|
import { generateUniqueTeamNames, prependOwnerToTeamName, stripOwnerFromTeamName } from "~/utils/team-names";
|
|
import type { Route } from "./+types/$leagueId.settings";
|
|
|
|
function isValidHHMM(s: string): boolean {
|
|
return /^\d{2}:\d{2}$/.test(s);
|
|
}
|
|
|
|
export async function loader(args: Route.LoaderArgs) {
|
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
|
const userId = session?.user.id ?? null;
|
|
const { params } = args;
|
|
const { leagueId } = params;
|
|
|
|
if (!userId) {
|
|
throw new Response("Unauthorized", { status: 401 });
|
|
}
|
|
|
|
const league = await findLeagueById(leagueId);
|
|
|
|
if (!league) {
|
|
throw new Response("League not found", { status: 404 });
|
|
}
|
|
|
|
// Settings are limited to league commissioners and site admins. isCommissioner()
|
|
// includes admins, so league creators without a commissioner record do not get
|
|
// read-only or edit access here.
|
|
const userIsCommissioner = await isCommissioner(leagueId, userId);
|
|
|
|
if (!userIsCommissioner) {
|
|
throw new Response("Forbidden - You must be a commissioner to access settings", {
|
|
status: 403,
|
|
});
|
|
}
|
|
|
|
await seedOrRepairBracktTemplate();
|
|
|
|
const [season, draftableSportsSeasons] = await Promise.all([
|
|
findCurrentSeasonWithSports(leagueId),
|
|
findDraftableSportsSeasons(),
|
|
]);
|
|
const teams = season ? await findTeamsBySeasonId(season.id) : [];
|
|
// Merge draftable seasons with any already-linked non-draftable seasons so that
|
|
// previously-added seasons remain visible and checked in the UI even after being disabled.
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
const draftableIds = new Set(draftableSportsSeasons.map((s) => s.id));
|
|
const linkedButNotDraftable = (season?.seasonSports ?? [])
|
|
.map((s) => s.sportsSeason)
|
|
.filter((ss) => {
|
|
const fantasySeasonId = (ss as typeof ss & { fantasySeasonId?: string | null }).fantasySeasonId;
|
|
return !draftableIds.has(ss.id) && (fantasySeasonId || ss.draftOff < today || ss.draftOn > today);
|
|
});
|
|
const allSportsSeasons = [...draftableSportsSeasons, ...linkedButNotDraftable];
|
|
const draftSlots = season ? await findDraftSlotsBySeasonId(season.id) : [];
|
|
|
|
const teamsWithOwners = teams.filter((team) => team.ownerId !== null).length;
|
|
const isAdmin = await isUserAdmin(userId);
|
|
const commissioners = await findCommissionersByLeagueId(leagueId);
|
|
|
|
const uniqueOwnerIds = [...new Set(teams.map((t) => t.ownerId).filter((id): id is string => id !== null))];
|
|
const commissionerUserIds = commissioners.map((c) => c.userId);
|
|
const allUserIds = [...new Set([...commissionerUserIds, ...uniqueOwnerIds])];
|
|
const userRows = await findUsersByIds(allUserIds);
|
|
const userById = new Map(userRows.map((u) => [u.id, u]));
|
|
|
|
const commissionerUserData = commissioners.map((c) => {
|
|
const user = userById.get(c.userId);
|
|
return {
|
|
...c,
|
|
userName: user ? (getUserDisplayName(user) ?? "Unknown User") : "Unknown User",
|
|
};
|
|
});
|
|
|
|
const validOwners = uniqueOwnerIds
|
|
.map((ownerId) => {
|
|
const user = userById.get(ownerId);
|
|
return user ? { id: user.id, name: getUserDisplayName(user) } : null;
|
|
})
|
|
.filter((o): o is NonNullable<typeof o> => o !== null);
|
|
const ownerMap = new Map(validOwners.map((o) => [o.id, o.name]));
|
|
|
|
// People currently attached to the league, either through team ownership or
|
|
// commissioner membership. Admin assignment should not expose every user.
|
|
const leagueMembers = allUserIds
|
|
.map((memberUserId) => {
|
|
const user = userById.get(memberUserId);
|
|
return user ? { id: user.id, name: getUserDisplayName(user) } : null;
|
|
})
|
|
.filter((o): o is NonNullable<typeof o> => o !== null);
|
|
|
|
const commishTimezone = (await findUserById(userId))?.timezone ?? null;
|
|
|
|
return {
|
|
league,
|
|
season,
|
|
teams,
|
|
teamCount: teams.length,
|
|
teamsWithOwners,
|
|
allSportsSeasons: allSportsSeasons as Array<typeof allSportsSeasons[0] & {
|
|
fantasySeasonId: string | null;
|
|
sport: { id: string; name: string; type: string; slug: string; iconUrl: string | null };
|
|
}>,
|
|
draftSlots,
|
|
isAdmin,
|
|
leagueMembers,
|
|
ownerMap: Object.fromEntries(ownerMap),
|
|
commissioners: commissionerUserData,
|
|
currentUserId: userId,
|
|
commishTimezone,
|
|
};
|
|
}
|
|
|
|
export async function action(args: Route.ActionArgs) {
|
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
|
const userId = session?.user.id ?? null;
|
|
const { params, request } = args;
|
|
const { leagueId } = params;
|
|
|
|
if (!userId) {
|
|
throw new Response("Unauthorized", { status: 401 });
|
|
}
|
|
|
|
const league = await findLeagueById(leagueId);
|
|
if (!league) {
|
|
throw new Response("League not found", { status: 404 });
|
|
}
|
|
|
|
const userIsCommissioner = await isCommissioner(leagueId, userId);
|
|
|
|
if (!userIsCommissioner) {
|
|
throw new Response("Forbidden", { status: 403 });
|
|
}
|
|
|
|
const formData = await request.formData();
|
|
const intent = formData.get("intent");
|
|
|
|
// For league-level settings (name, public draft board), save immediately
|
|
// before checking season, so they work even if no season exists
|
|
if (intent === "update") {
|
|
const name = formData.get("name");
|
|
const isPublicDraftBoard = formData.get("isPublicDraftBoard") === "on";
|
|
const discordWebhookUrl = formData.get("discordWebhookUrl");
|
|
|
|
if (typeof name !== "string" || !name.trim()) {
|
|
return { error: "League name is required" };
|
|
}
|
|
if (name.trim().length < 3 || name.trim().length > 50) {
|
|
return { error: "League name must be between 3 and 50 characters" };
|
|
}
|
|
|
|
const webhookUrl =
|
|
typeof discordWebhookUrl === "string" ? discordWebhookUrl.trim() : "";
|
|
if (webhookUrl && !webhookUrl.startsWith("https://discord.com/api/webhooks/")) {
|
|
return { error: "Discord webhook URL must start with https://discord.com/api/webhooks/" };
|
|
}
|
|
|
|
try {
|
|
const changedFields: string[] = [];
|
|
if (name.trim() !== league.name) changedFields.push("name");
|
|
if (isPublicDraftBoard !== league.isPublicDraftBoard) changedFields.push("isPublicDraftBoard");
|
|
if ((webhookUrl || null) !== league.discordWebhookUrl) changedFields.push("discordWebhookUrl");
|
|
|
|
await updateLeague(leagueId, {
|
|
name: name.trim(),
|
|
isPublicDraftBoard,
|
|
discordWebhookUrl: webhookUrl || null,
|
|
});
|
|
|
|
if (changedFields.length > 0) {
|
|
const currentSeason = await findCurrentSeasonWithSports(leagueId);
|
|
if (currentSeason) {
|
|
await logCommissionerAction({
|
|
seasonId: currentSeason.id,
|
|
leagueId,
|
|
actorUserId: userId,
|
|
action: "league_settings_changed",
|
|
details: {
|
|
changedFields,
|
|
previousValues: {
|
|
name: league.name,
|
|
isPublicDraftBoard: league.isPublicDraftBoard,
|
|
discordWebhookUrl: league.discordWebhookUrl,
|
|
},
|
|
newValues: {
|
|
name: name.trim(),
|
|
isPublicDraftBoard,
|
|
discordWebhookUrl: webhookUrl || null,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.error("Error updating league:", error);
|
|
return { error: "Failed to update league. Please try again." };
|
|
}
|
|
}
|
|
|
|
if (intent === "test-discord-webhook") {
|
|
const webhookUrl = formData.get("webhookUrl");
|
|
if (typeof webhookUrl !== "string" || !webhookUrl.startsWith("https://discord.com/api/webhooks/")) {
|
|
return { error: "Invalid Discord webhook URL" };
|
|
}
|
|
try {
|
|
const testSeason = await findCurrentSeasonWithSports(leagueId);
|
|
const seasonName = testSeason ? `${league.name} ${testSeason.year}` : league.name;
|
|
await sendStandingsUpdateNotification({
|
|
webhookUrl,
|
|
seasonName,
|
|
standings: [
|
|
{ teamId: "1", teamName: "Team Alpha", totalPoints: 150, rank: 1 },
|
|
{ teamId: "2", teamName: "Team Beta", totalPoints: 125, rank: 2 },
|
|
{ teamId: "3", teamName: "Team Gamma", totalPoints: 100, rank: 3 },
|
|
],
|
|
previousStandings: new Map([
|
|
["1", 125],
|
|
["2", 125],
|
|
["3", 100],
|
|
]),
|
|
previousRanks: new Map([
|
|
["1", 2],
|
|
["2", 1],
|
|
["3", 3],
|
|
]),
|
|
eventName: "Test Notification",
|
|
scoredMatches: [
|
|
{ winnerName: "Team Alpha", loserName: "Team Beta", winnerUsername: "manager1", loserUsername: "manager2" },
|
|
],
|
|
});
|
|
return { testSuccess: true };
|
|
} catch (err) {
|
|
logger.error("Discord test webhook failed:", err);
|
|
return { error: "Failed to send test notification. Check your webhook URL." };
|
|
}
|
|
}
|
|
|
|
const season = await findCurrentSeasonWithSports(leagueId);
|
|
if (!season) {
|
|
if (intent === "update") {
|
|
return redirect(`/leagues/${leagueId}?updated=true`);
|
|
}
|
|
return { error: "No active season found" };
|
|
}
|
|
|
|
if (intent === "set-draft-order") {
|
|
if (season.status !== "pre_draft") {
|
|
return { error: "Cannot modify draft order after draft has started", section: "draft-order" as const };
|
|
}
|
|
|
|
const teams = await findTeamsBySeasonId(season.id);
|
|
const teamIds = formData.getAll("teamOrder") as string[];
|
|
|
|
if (teamIds.length !== teams.length) {
|
|
return { error: "All teams must be included in the draft order", section: "draft-order" as const };
|
|
}
|
|
|
|
const validTeamIds = new Set(teams.map((t) => t.id));
|
|
for (const teamId of teamIds) {
|
|
if (!validTeamIds.has(teamId)) {
|
|
return { error: "Invalid team ID in draft order", section: "draft-order" as const };
|
|
}
|
|
}
|
|
|
|
await setDraftOrder(season.id, teamIds);
|
|
|
|
await logCommissionerAction({
|
|
seasonId: season.id,
|
|
leagueId,
|
|
actorUserId: userId,
|
|
action: "draft_order_set",
|
|
affectedTeamIds: teamIds,
|
|
details: {
|
|
order: teamIds.map((id, i) => ({
|
|
teamId: id,
|
|
teamName: teams.find((t) => t.id === id)?.name ?? id,
|
|
position: i + 1,
|
|
})),
|
|
},
|
|
});
|
|
|
|
return { success: true, message: "Draft order updated successfully", section: "draft-order" as const };
|
|
}
|
|
|
|
if (intent === "randomize-draft-order") {
|
|
if (season.status !== "pre_draft") {
|
|
return { error: "Cannot modify draft order after draft has started", section: "draft-order" as const };
|
|
}
|
|
|
|
const teams = await findTeamsBySeasonId(season.id);
|
|
const teamIds = teams.map((t) => t.id);
|
|
|
|
await randomizeDraftOrder(season.id, teamIds);
|
|
|
|
const newSlots = await findDraftSlotsBySeasonId(season.id);
|
|
const sortedSlots = newSlots.toSorted((a, b) => a.draftOrder - b.draftOrder);
|
|
|
|
await logCommissionerAction({
|
|
seasonId: season.id,
|
|
leagueId,
|
|
actorUserId: userId,
|
|
action: "draft_order_randomized",
|
|
affectedTeamIds: sortedSlots.map((s) => s.teamId),
|
|
details: {
|
|
order: sortedSlots.map((s) => ({
|
|
teamId: s.teamId,
|
|
teamName: teams.find((t) => t.id === s.teamId)?.name ?? s.teamId,
|
|
position: s.draftOrder,
|
|
})),
|
|
},
|
|
});
|
|
|
|
return { success: true, message: "Draft order randomized successfully", section: "draft-order" as const };
|
|
}
|
|
|
|
if (intent === "remove-team-owner") {
|
|
const teamId = formData.get("teamId") as string;
|
|
|
|
if (!teamId) {
|
|
return { error: "Team ID is required" };
|
|
}
|
|
|
|
try {
|
|
const team = await findTeamById(teamId);
|
|
if (team?.ownerId) {
|
|
const owner = await findUserById(team.ownerId);
|
|
const username = owner?.username ?? owner?.displayName;
|
|
if (username) {
|
|
const strippedName = stripOwnerFromTeamName(team.name, username);
|
|
if (strippedName !== team.name) {
|
|
await renameTeam(teamId, strippedName);
|
|
}
|
|
await removeTeamOwner(teamId);
|
|
} else {
|
|
await removeTeamOwner(teamId);
|
|
}
|
|
} else {
|
|
await removeTeamOwner(teamId);
|
|
}
|
|
return { success: true, message: "Owner removed successfully" };
|
|
} catch (error) {
|
|
logger.error("Error removing team owner:", error);
|
|
return { error: "Failed to remove owner. Please try again." };
|
|
}
|
|
}
|
|
|
|
if (intent === "assign-team-owner") {
|
|
const teamId = formData.get("teamId") as string;
|
|
const assignedUserId = formData.get("userId") as string;
|
|
|
|
if (!teamId || !assignedUserId) {
|
|
return { error: "Team ID and User ID are required" };
|
|
}
|
|
|
|
const isAdmin = await isUserAdmin(userId);
|
|
if (!isAdmin) {
|
|
return { error: "Only admins can assign team owners" };
|
|
}
|
|
|
|
const assignedUser = await findUserById(assignedUserId);
|
|
if (!assignedUser) {
|
|
return { error: "User not found. Please try again." };
|
|
}
|
|
|
|
const teams = await findTeamsBySeasonId(season.id);
|
|
const isLeagueMember =
|
|
teams.some((team) => team.ownerId === assignedUserId) ||
|
|
(await hasCommissionerRecord(leagueId, assignedUserId));
|
|
if (!isLeagueMember) {
|
|
return { error: "Only current league members can be assigned to teams" };
|
|
}
|
|
|
|
const userAlreadyHasTeam = teams.some((team) => team.ownerId === assignedUserId);
|
|
|
|
if (userAlreadyHasTeam) {
|
|
return { error: "This user is already assigned to a team in this league" };
|
|
}
|
|
|
|
const targetTeam = teams.find((team) => team.id === teamId);
|
|
if (!targetTeam) {
|
|
return { error: "Team not found" };
|
|
}
|
|
if (targetTeam.ownerId) {
|
|
return { error: "This team already has an owner. Remove the current owner first." };
|
|
}
|
|
|
|
try {
|
|
const teamName = prependOwnerToTeamName(
|
|
targetTeam.name,
|
|
assignedUser.username ?? assignedUser.displayName ?? "Member"
|
|
);
|
|
await claimTeam(teamId, assignedUserId, teamName);
|
|
|
|
return { success: true, message: "Owner assigned successfully" };
|
|
} catch (error) {
|
|
logger.error("Error assigning team owner:", error);
|
|
return { error: "Failed to assign owner. Please try again." };
|
|
}
|
|
}
|
|
|
|
if (intent === "reset-draft") {
|
|
const isAdmin = await isUserAdmin(userId);
|
|
if (!isAdmin) {
|
|
return { error: "Only admins can reset the draft" };
|
|
}
|
|
|
|
try {
|
|
await deleteAllDraftPicks(season.id);
|
|
await clearAllQueuesForSeason(season.id);
|
|
await deleteSeasonTimers(season.id);
|
|
|
|
const previousPickNumber = season.currentPickNumber;
|
|
|
|
await updateSeason(season.id, {
|
|
status: "pre_draft",
|
|
currentPickNumber: null,
|
|
draftStartedAt: null,
|
|
draftPaused: false,
|
|
});
|
|
|
|
await logCommissionerAction({
|
|
seasonId: season.id,
|
|
leagueId,
|
|
actorUserId: userId,
|
|
action: "draft_reset",
|
|
details: { previousPickNumber },
|
|
});
|
|
|
|
return { success: true, message: "Draft has been reset successfully. Draft order preserved." };
|
|
} catch (error) {
|
|
logger.error("Error resetting draft:", error);
|
|
return { error: "Failed to reset draft. Please try again." };
|
|
}
|
|
}
|
|
|
|
if (intent === "delete") {
|
|
await deleteLeague(leagueId);
|
|
return redirect("/?deleted=true");
|
|
}
|
|
|
|
if (intent === "update") {
|
|
const teamCount = formData.get("teamCount");
|
|
const draftDateTime = formData.get("draftDateTime");
|
|
const draftRounds = formData.get("draftRounds");
|
|
const draftSpeed = formData.get("draftSpeed");
|
|
const draftTimerMode = formData.get("draftTimerMode") as "chess_clock" | "standard" | null;
|
|
|
|
try {
|
|
const seasonUpdates: Partial<NewSeason> = {};
|
|
|
|
if (typeof draftRounds === "string") {
|
|
const draftRoundsNum = parseInt(draftRounds, 10);
|
|
if (!isNaN(draftRoundsNum)) {
|
|
const sportsCount = season.seasonSports?.length || 0;
|
|
if (draftRoundsNum < sportsCount) {
|
|
return { error: `Draft rounds must be at least ${sportsCount} (number of sports selected)` };
|
|
}
|
|
if (draftRoundsNum < 1 || draftRoundsNum > 50) {
|
|
return { error: "Draft rounds must be between 1 and 50" };
|
|
}
|
|
if (draftRoundsNum !== season.draftRounds) {
|
|
seasonUpdates.draftRounds = draftRoundsNum;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (typeof draftDateTime === "string") {
|
|
const newDateTime = draftDateTime ? new Date(draftDateTime) : null;
|
|
const currentDateTime = season.draftDateTime ? new Date(season.draftDateTime) : null;
|
|
const changed = newDateTime?.getTime() !== currentDateTime?.getTime();
|
|
if (changed) {
|
|
seasonUpdates.draftDateTime = newDateTime;
|
|
}
|
|
}
|
|
|
|
if (draftSpeed !== null) {
|
|
const { draftInitialTime, draftIncrementTime } = parseDraftSpeed(
|
|
draftSpeed as string | null,
|
|
draftTimerMode ?? "chess_clock"
|
|
);
|
|
if (draftInitialTime !== season.draftInitialTime) {
|
|
seasonUpdates.draftInitialTime = draftInitialTime;
|
|
}
|
|
if (draftIncrementTime !== season.draftIncrementTime) {
|
|
seasonUpdates.draftIncrementTime = draftIncrementTime;
|
|
}
|
|
}
|
|
|
|
if (draftTimerMode !== null && draftTimerMode !== season.draftTimerMode) {
|
|
seasonUpdates.draftTimerMode = draftTimerMode;
|
|
}
|
|
|
|
const overnightPauseMode = formData.get("overnightPauseMode") as "none" | "league" | "per_user" | null;
|
|
if (overnightPauseMode && ["none", "league", "per_user"].includes(overnightPauseMode)) {
|
|
if (overnightPauseMode !== season.overnightPauseMode) {
|
|
seasonUpdates.overnightPauseMode = overnightPauseMode;
|
|
}
|
|
if (overnightPauseMode !== "none") {
|
|
const start = formData.get("overnightPauseStart") as string | null;
|
|
const end = formData.get("overnightPauseEnd") as string | null;
|
|
const tz = formData.get("overnightPauseTimezone") as string | null;
|
|
if (start && isValidHHMM(start) && start !== season.overnightPauseStart) {
|
|
seasonUpdates.overnightPauseStart = start;
|
|
}
|
|
if (end && isValidHHMM(end) && end !== season.overnightPauseEnd) {
|
|
seasonUpdates.overnightPauseEnd = end;
|
|
}
|
|
if (tz && tz.length > 0 && tz !== season.overnightPauseTimezone) {
|
|
seasonUpdates.overnightPauseTimezone = tz;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (season.status === "pre_draft") {
|
|
const scoringFields = [
|
|
"pointsFor1st", "pointsFor2nd", "pointsFor3rd", "pointsFor4th",
|
|
"pointsFor5th", "pointsFor6th", "pointsFor7th", "pointsFor8th",
|
|
];
|
|
|
|
for (const field of scoringFields) {
|
|
const value = formData.get(field);
|
|
if (typeof value === "string") {
|
|
const points = parseInt(value, 10);
|
|
if (!isNaN(points)) {
|
|
if (points < 0 || points > 1000) {
|
|
return { error: `${field} must be between 0 and 1000 points` };
|
|
}
|
|
if (points !== (season as unknown as Record<string, unknown>)[field]) {
|
|
(seasonUpdates as Record<string, unknown>)[field] = points;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (Object.keys(seasonUpdates).length > 0) {
|
|
await updateSeason(season.id, seasonUpdates);
|
|
|
|
const scoringFields = ["pointsFor1st", "pointsFor2nd", "pointsFor3rd", "pointsFor4th",
|
|
"pointsFor5th", "pointsFor6th", "pointsFor7th", "pointsFor8th"];
|
|
const draftFields = Object.keys(seasonUpdates).filter((k) => !scoringFields.includes(k));
|
|
const scoringChangedFields = Object.keys(seasonUpdates).filter((k) => scoringFields.includes(k));
|
|
|
|
const seasonAsMap = season as unknown as Record<string, unknown>;
|
|
const updatesAsMap = seasonUpdates as Record<string, unknown>;
|
|
|
|
if (draftFields.length > 0) {
|
|
await logCommissionerAction({
|
|
seasonId: season.id,
|
|
leagueId,
|
|
actorUserId: userId,
|
|
action: "draft_settings_changed",
|
|
details: {
|
|
changedFields: draftFields,
|
|
previousValues: Object.fromEntries(draftFields.map((k) => [k, seasonAsMap[k]])),
|
|
newValues: Object.fromEntries(draftFields.map((k) => [k, updatesAsMap[k]])),
|
|
},
|
|
});
|
|
}
|
|
|
|
if (scoringChangedFields.length > 0) {
|
|
await logCommissionerAction({
|
|
seasonId: season.id,
|
|
leagueId,
|
|
actorUserId: userId,
|
|
action: "scoring_rules_changed",
|
|
details: {
|
|
changedFields: scoringChangedFields,
|
|
previousValues: Object.fromEntries(scoringChangedFields.map((k) => [k, seasonAsMap[k]])),
|
|
newValues: Object.fromEntries(scoringChangedFields.map((k) => [k, updatesAsMap[k]])),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
if (season.status === "pre_draft") {
|
|
const selectedSports = formData.getAll("sportsSeasons").map(String);
|
|
const currentSportIds = new Set(
|
|
season.seasonSports?.map((s) => s.sportsSeason.id) || []
|
|
);
|
|
const sportsToApply = await applyBracktSportsForSeason(season.id, selectedSports);
|
|
const newSportIds = new Set(sportsToApply);
|
|
|
|
for (const sportId of currentSportIds) {
|
|
if (!newSportIds.has(sportId)) {
|
|
await unlinkSportFromSeason(season.id, sportId);
|
|
}
|
|
}
|
|
|
|
const sportsToAdd = [];
|
|
for (const sportId of newSportIds) {
|
|
if (!currentSportIds.has(sportId)) {
|
|
sportsToAdd.push({
|
|
seasonId: season.id,
|
|
sportsSeasonId: sportId,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (sportsToAdd.length > 0) {
|
|
await linkMultipleSportsToSeason(sportsToAdd);
|
|
}
|
|
|
|
const removedIds = [...currentSportIds].filter((id) => !newSportIds.has(id));
|
|
const addedIds = [...newSportIds].filter((id) => !currentSportIds.has(id));
|
|
if (removedIds.length > 0 || addedIds.length > 0) {
|
|
const nameMap = new Map(
|
|
season.seasonSports?.map((s) => [s.sportsSeason.id, `${s.sportsSeason.sport.name} ${s.sportsSeason.year}`]) ?? []
|
|
);
|
|
const addedSeasons = await findSportsSeasonsByIds(addedIds);
|
|
const addedNameMap = new Map(addedSeasons.map((ss) => [ss.id, `${ss.sport.name} ${ss.year}`]));
|
|
const addedNames = addedIds.map((id) => addedNameMap.get(id) ?? id);
|
|
await logCommissionerAction({
|
|
seasonId: season.id,
|
|
leagueId,
|
|
actorUserId: userId,
|
|
action: "sports_changed",
|
|
details: {
|
|
added: addedNames,
|
|
removed: removedIds.map((id) => nameMap.get(id) ?? id),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
if (typeof teamCount === "string") {
|
|
const newTeamCount = parseInt(teamCount, 10);
|
|
|
|
if (!isNaN(newTeamCount)) {
|
|
const teams = await findTeamsBySeasonId(season.id);
|
|
const currentTeamCount = teams.length;
|
|
const teamsWithOwners = teams.filter((t) => t.ownerId !== null).length;
|
|
|
|
if (newTeamCount < 6 || newTeamCount > 16) {
|
|
return { error: "Number of teams must be between 6 and 16" };
|
|
}
|
|
|
|
if (newTeamCount < teamsWithOwners) {
|
|
return { error: `Cannot reduce team count below ${teamsWithOwners} (number of teams with owners)` };
|
|
}
|
|
|
|
if (newTeamCount > currentTeamCount) {
|
|
const newNames = generateUniqueTeamNames(newTeamCount - currentTeamCount);
|
|
const teamsToAdd = Array.from(
|
|
{ length: newTeamCount - currentTeamCount },
|
|
(_, i) => ({
|
|
seasonId: season.id,
|
|
name: newNames[i],
|
|
ownerId: null as null,
|
|
})
|
|
);
|
|
|
|
const existingSlots = await findDraftSlotsBySeasonId(season.id);
|
|
const maxOrder = existingSlots.reduce((max, s) => Math.max(max, s.draftOrder), 0);
|
|
|
|
const db = database();
|
|
await db.transaction(async (tx) => {
|
|
const newTeams = await tx
|
|
.insert(schema.teams)
|
|
.values(teamsToAdd)
|
|
.returning();
|
|
await tx.insert(schema.draftSlots).values(
|
|
newTeams.map((team, i) => ({
|
|
seasonId: season.id,
|
|
teamId: team.id,
|
|
draftOrder: maxOrder + i + 1,
|
|
}))
|
|
);
|
|
});
|
|
await syncPrivateBracktParticipants(season.id);
|
|
} else if (newTeamCount < currentTeamCount) {
|
|
if (season.status !== "pre_draft") {
|
|
return { error: "Teams cannot be removed after the draft has started" };
|
|
}
|
|
const teamsToRemove = teams
|
|
.filter((t) => t.ownerId === null)
|
|
.slice(-(currentTeamCount - newTeamCount));
|
|
|
|
for (const team of teamsToRemove) {
|
|
await deleteTeam(team.id);
|
|
}
|
|
await syncPrivateBracktParticipants(season.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
return redirect(`/leagues/${leagueId}?updated=true`);
|
|
} catch (error) {
|
|
logger.error("Error updating season settings:", error);
|
|
return { error: "Failed to update season settings. Please try again." };
|
|
}
|
|
}
|
|
|
|
if (intent === "add-commissioner") {
|
|
const newCommissionerUserId = formData.get("userId") as string;
|
|
|
|
if (!newCommissionerUserId) {
|
|
return { error: "User is required" };
|
|
}
|
|
|
|
const alreadyCommissioner = await hasCommissionerRecord(leagueId, newCommissionerUserId);
|
|
if (alreadyCommissioner) {
|
|
return { error: "This user is already a commissioner" };
|
|
}
|
|
|
|
try {
|
|
await createCommissioner({ leagueId, userId: newCommissionerUserId });
|
|
return { success: true, message: "Commissioner added successfully" };
|
|
} catch (error) {
|
|
logger.error("Error adding commissioner:", error);
|
|
return { error: "Failed to add commissioner. Please try again." };
|
|
}
|
|
}
|
|
|
|
if (intent === "remove-commissioner") {
|
|
const commissionerUserId = formData.get("commissionerUserId") as string;
|
|
|
|
if (!commissionerUserId) {
|
|
return { error: "User is required" };
|
|
}
|
|
|
|
if (commissionerUserId === userId) {
|
|
return { error: "You cannot remove yourself as a commissioner" };
|
|
}
|
|
|
|
const count = await countCommissionersByLeagueId(leagueId);
|
|
if (count <= 1) {
|
|
return { error: "Cannot remove the last commissioner" };
|
|
}
|
|
|
|
try {
|
|
await removeCommissionerByLeagueAndUser(leagueId, commissionerUserId);
|
|
return { success: true, message: "Commissioner removed successfully" };
|
|
} catch (error) {
|
|
logger.error("Error removing commissioner:", error);
|
|
return { error: "Failed to remove commissioner. Please try again." };
|
|
}
|
|
}
|
|
|
|
return { error: "Invalid action" };
|
|
}
|