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 => 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 => o !== null); const commishTimezone = (await findUserById(userId))?.timezone ?? null; return { league, season, teams, teamCount: teams.length, teamsWithOwners, allSportsSeasons: allSportsSeasons as Array, 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 = {}; 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)[field]) { (seasonUpdates as Record)[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; const updatesAsMap = seasonUpdates as Record; 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" }; }