brackt/app/routes/leagues/$leagueId.settings.server.ts

850 lines
32 KiB
TypeScript
Raw Normal View History

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, findTeamByNameInSeason, 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 { sendDraftOrderNotification, 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");
Add Discord draft pick announcements (#460) * Add Discord draft pick announcements Posts a message to the league Discord webhook each time a pick is made, announcing the picked participant and pinging the next team on the clock if their owner has opted into Discord notifications. Enabled via a separate toggle in league settings (independent from standings update notifications). https://claude.ai/code/session_01Tvwsv3LfL9JUqxoLct8dTn * Address code review feedback on Discord pick announcements - Fix "on the clock" timing: read currentPickNumber fresh from DB post-autodraft-chain (matches sendOnTheClockEmail guarantee) - Remove outer try/catch from notifyPickMadeOnDiscord so callers' .catch() is not dead code - Add missing draft-discord.server.test.ts with 12 tests covering all early-exit and happy paths - Fix silent empty-string fallback for missing pickedSlot: warn and skip instead - Eliminate sequential season→league DB queries by accepting leagueId as a direct param - Show "save webhook URL to configure options" hint when URL is typed but not yet saved - Remove block-scope braces at both call sites (plain const declarations) - Remove redundant "Round N, Pick M" description line (title already carries this info) - Inline pickInRoundFor helper to avoid circular import with draft-utils https://claude.ai/code/session_01Tvwsv3LfL9JUqxoLct8dTn * Fix lint errors from review fixes - Remove unused logger import (no longer needed after removing try/catch) - Remove unused OWNER_ID constant in test fixture - Use toSorted() instead of sort() in sendDraftOrderNotification https://claude.ai/code/session_01Tvwsv3LfL9JUqxoLct8dTn --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-05-20 19:55:48 -07:00
const discordPicksAnnouncementEnabled = formData.get("discordPicksAnnouncementEnabled") === "on";
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");
Add Discord draft pick announcements (#460) * Add Discord draft pick announcements Posts a message to the league Discord webhook each time a pick is made, announcing the picked participant and pinging the next team on the clock if their owner has opted into Discord notifications. Enabled via a separate toggle in league settings (independent from standings update notifications). https://claude.ai/code/session_01Tvwsv3LfL9JUqxoLct8dTn * Address code review feedback on Discord pick announcements - Fix "on the clock" timing: read currentPickNumber fresh from DB post-autodraft-chain (matches sendOnTheClockEmail guarantee) - Remove outer try/catch from notifyPickMadeOnDiscord so callers' .catch() is not dead code - Add missing draft-discord.server.test.ts with 12 tests covering all early-exit and happy paths - Fix silent empty-string fallback for missing pickedSlot: warn and skip instead - Eliminate sequential season→league DB queries by accepting leagueId as a direct param - Show "save webhook URL to configure options" hint when URL is typed but not yet saved - Remove block-scope braces at both call sites (plain const declarations) - Remove redundant "Round N, Pick M" description line (title already carries this info) - Inline pickInRoundFor helper to avoid circular import with draft-utils https://claude.ai/code/session_01Tvwsv3LfL9JUqxoLct8dTn * Fix lint errors from review fixes - Remove unused logger import (no longer needed after removing try/catch) - Remove unused OWNER_ID constant in test fixture - Use toSorted() instead of sort() in sendDraftOrderNotification https://claude.ai/code/session_01Tvwsv3LfL9JUqxoLct8dTn --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-05-20 19:55:48 -07:00
if (discordPicksAnnouncementEnabled !== league.discordPicksAnnouncementEnabled) changedFields.push("discordPicksAnnouncementEnabled");
await updateLeague(leagueId, {
name: name.trim(),
isPublicDraftBoard,
discordWebhookUrl: webhookUrl || null,
Add Discord draft pick announcements (#460) * Add Discord draft pick announcements Posts a message to the league Discord webhook each time a pick is made, announcing the picked participant and pinging the next team on the clock if their owner has opted into Discord notifications. Enabled via a separate toggle in league settings (independent from standings update notifications). https://claude.ai/code/session_01Tvwsv3LfL9JUqxoLct8dTn * Address code review feedback on Discord pick announcements - Fix "on the clock" timing: read currentPickNumber fresh from DB post-autodraft-chain (matches sendOnTheClockEmail guarantee) - Remove outer try/catch from notifyPickMadeOnDiscord so callers' .catch() is not dead code - Add missing draft-discord.server.test.ts with 12 tests covering all early-exit and happy paths - Fix silent empty-string fallback for missing pickedSlot: warn and skip instead - Eliminate sequential season→league DB queries by accepting leagueId as a direct param - Show "save webhook URL to configure options" hint when URL is typed but not yet saved - Remove block-scope braces at both call sites (plain const declarations) - Remove redundant "Round N, Pick M" description line (title already carries this info) - Inline pickInRoundFor helper to avoid circular import with draft-utils https://claude.ai/code/session_01Tvwsv3LfL9JUqxoLct8dTn * Fix lint errors from review fixes - Remove unused logger import (no longer needed after removing try/catch) - Remove unused OWNER_ID constant in test fixture - Use toSorted() instead of sort() in sendDraftOrderNotification https://claude.ai/code/session_01Tvwsv3LfL9JUqxoLct8dTn --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-05-20 19:55:48 -07:00
discordPicksAnnouncementEnabled,
});
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,
Add Discord draft pick announcements (#460) * Add Discord draft pick announcements Posts a message to the league Discord webhook each time a pick is made, announcing the picked participant and pinging the next team on the clock if their owner has opted into Discord notifications. Enabled via a separate toggle in league settings (independent from standings update notifications). https://claude.ai/code/session_01Tvwsv3LfL9JUqxoLct8dTn * Address code review feedback on Discord pick announcements - Fix "on the clock" timing: read currentPickNumber fresh from DB post-autodraft-chain (matches sendOnTheClockEmail guarantee) - Remove outer try/catch from notifyPickMadeOnDiscord so callers' .catch() is not dead code - Add missing draft-discord.server.test.ts with 12 tests covering all early-exit and happy paths - Fix silent empty-string fallback for missing pickedSlot: warn and skip instead - Eliminate sequential season→league DB queries by accepting leagueId as a direct param - Show "save webhook URL to configure options" hint when URL is typed but not yet saved - Remove block-scope braces at both call sites (plain const declarations) - Remove redundant "Round N, Pick M" description line (title already carries this info) - Inline pickInRoundFor helper to avoid circular import with draft-utils https://claude.ai/code/session_01Tvwsv3LfL9JUqxoLct8dTn * Fix lint errors from review fixes - Remove unused logger import (no longer needed after removing try/catch) - Remove unused OWNER_ID constant in test fixture - Use toSorted() instead of sort() in sendDraftOrderNotification https://claude.ai/code/session_01Tvwsv3LfL9JUqxoLct8dTn --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-05-20 19:55:48 -07:00
discordPicksAnnouncementEnabled: league.discordPicksAnnouncementEnabled,
},
newValues: {
name: name.trim(),
isPublicDraftBoard,
discordWebhookUrl: webhookUrl || null,
Add Discord draft pick announcements (#460) * Add Discord draft pick announcements Posts a message to the league Discord webhook each time a pick is made, announcing the picked participant and pinging the next team on the clock if their owner has opted into Discord notifications. Enabled via a separate toggle in league settings (independent from standings update notifications). https://claude.ai/code/session_01Tvwsv3LfL9JUqxoLct8dTn * Address code review feedback on Discord pick announcements - Fix "on the clock" timing: read currentPickNumber fresh from DB post-autodraft-chain (matches sendOnTheClockEmail guarantee) - Remove outer try/catch from notifyPickMadeOnDiscord so callers' .catch() is not dead code - Add missing draft-discord.server.test.ts with 12 tests covering all early-exit and happy paths - Fix silent empty-string fallback for missing pickedSlot: warn and skip instead - Eliminate sequential season→league DB queries by accepting leagueId as a direct param - Show "save webhook URL to configure options" hint when URL is typed but not yet saved - Remove block-scope braces at both call sites (plain const declarations) - Remove redundant "Round N, Pick M" description line (title already carries this info) - Inline pickInRoundFor helper to avoid circular import with draft-utils https://claude.ai/code/session_01Tvwsv3LfL9JUqxoLct8dTn * Fix lint errors from review fixes - Remove unused logger import (no longer needed after removing try/catch) - Remove unused OWNER_ID constant in test fixture - Use toSorted() instead of sort() in sendDraftOrderNotification https://claude.ai/code/session_01Tvwsv3LfL9JUqxoLct8dTn --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-05-20 19:55:48 -07:00
discordPicksAnnouncementEnabled,
},
},
});
}
}
} 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: [
Add opt-in Discord pings to standings notifications (#429) * Add opt-in Discord ping to standings notifications Users who have linked their Discord account can enable a ping preference in Settings > Notifications. When enabled, their bracket username is replaced with a Discord @mention (<@userId>) in league standings update messages, sending them a push notification and showing their Discord display name. Adds discordPingEnabled column to users, a findDiscordIdsByUserIds model helper, allowed_mentions support to the Discord webhook payload, and a NotificationsSection settings component. https://claude.ai/code/session_01NUv93WRrufHhpZSMyY4bjs * Remove Display Name field from user profile settings Username is the only user-facing name field. The display_name column remains in the database since BetterAuth writes the OAuth provider name there, and it serves as a programmatic fallback via getUserDisplayName. https://claude.ai/code/session_01NUv93WRrufHhpZSMyY4bjs * Address code review feedback on Discord ping feature - Fix broken Account settings link in NotificationsSection: replace the non-functional ?section=account href with an onNavigateToAccount callback that drives the parent's useState-based section switcher - Replace hand-rolled toggle button with the project's Switch component (Radix UI) for consistent sizing, accessibility, and dark-mode support - Guard update-discord-ping action: return an error if the user attempts to enable pings without a Discord account linked - Surface action error in NotificationsSection UI - Cap allowed_mentions.users at 100 to avoid Discord rejecting the payload in large leagues - Remove the showWinner alias in scoring-calculator; inline winnerScoreChanged - Add comment to league settings test notification explaining why discordUserId is omitted - Clarify database schema comment: displayName is write-only from BetterAuth https://claude.ai/code/session_01NUv93WRrufHhpZSMyY4bjs --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-05-15 10:06:54 -07:00
// discordUserId intentionally omitted — we don't know the commissioner's Discord ID at test time
{ 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 { success: true, message: "Settings saved successfully.", intent: "update" as const };
}
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);
const teamMap = new Map(teams.map((t) => [t.id, t]));
await logCommissionerAction({
seasonId: season.id,
leagueId,
actorUserId: userId,
action: "draft_order_set",
affectedTeamIds: teamIds,
details: {
order: teamIds.map((id, i) => ({
teamId: id,
teamName: teamMap.get(id)?.name ?? id,
position: i + 1,
})),
},
});
if (league.discordWebhookUrl) {
try {
const ownerIds = teams.map((t) => t.ownerId).filter(Boolean) as string[];
const owners = ownerIds.length > 0 ? await findUsersByIds(ownerIds) : [];
const ownerMap = new Map(owners.map((u) => [u.id, getUserDisplayName(u) ?? undefined]));
const appUrl = process.env.APP_URL ?? "https://brackt.com";
await sendDraftOrderNotification({
webhookUrl: league.discordWebhookUrl,
leagueName: league.name,
leagueUrl: `${appUrl}/leagues/${leagueId}`,
method: "manual",
teams: teamIds.map((id, i) => {
const team = teamMap.get(id);
return {
name: team?.name ?? id,
position: i + 1,
username: team?.ownerId ? ownerMap.get(team.ownerId) : undefined,
};
}),
});
} catch (err) {
logger.error("Discord draft order notification failed:", err);
}
}
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);
const teamMap = new Map(teams.map((t) => [t.id, t]));
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: teamMap.get(s.teamId)?.name ?? s.teamId,
position: s.draftOrder,
})),
},
});
if (league.discordWebhookUrl) {
try {
const ownerIds = teams.map((t) => t.ownerId).filter(Boolean) as string[];
const owners = ownerIds.length > 0 ? await findUsersByIds(ownerIds) : [];
const ownerMap = new Map(owners.map((u) => [u.id, getUserDisplayName(u) ?? undefined]));
const appUrl = process.env.APP_URL ?? "https://brackt.com";
await sendDraftOrderNotification({
webhookUrl: league.discordWebhookUrl,
leagueName: league.name,
leagueUrl: `${appUrl}/leagues/${leagueId}`,
method: "randomized",
teams: sortedSlots.map((s) => {
const team = teamMap.get(s.teamId);
return {
name: team?.name ?? s.teamId,
position: s.draftOrder,
username: team?.ownerId ? ownerMap.get(team.ownerId) : undefined,
};
}),
});
} catch (err) {
logger.error("Discord draft order notification failed:", err);
}
}
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) {
const nameConflict = await findTeamByNameInSeason(team.seasonId, strippedName, teamId);
if (nameConflict) {
return { error: "Cannot remove owner: the resulting team name is already taken in this season." };
}
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"
);
const nameConflict = await findTeamByNameInSeason(season.id, teamName, teamId);
if (nameConflict) {
return { error: "Cannot assign owner: the resulting team name is already taken in this season." };
}
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;
}
// Determine effective draftDateTime after this save so we can validate autoStartDraft
const effectiveDateTime =
"draftDateTime" in seasonUpdates ? seasonUpdates.draftDateTime : season.draftDateTime;
const autoStartDraft = formData.get("autoStartDraft") === "on" && effectiveDateTime !== null;
if (autoStartDraft !== season.autoStartDraft) {
seasonUpdates.autoStartDraft = autoStartDraft;
}
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();
Fix draft order initialization for teams added mid-season (#449) * Fix draft order showing only new team when league size increased before order was set When a commissioner increased the team count on a league that had teams but no draft order set yet, the server created draft slots only for the newly-added teams. This left the DB with N+K teams but only K slots, causing the drag-and-drop list to display only the K new teams. Two fixes: 1. Server: only append new draft slots when an order was already set (existingSlots.length > 0). If no order exists yet, skip slot creation so the page correctly treats the order as unset for all teams. 2. Frontend: buildDraftOrderTeams() appends any unslotted teams after the slotted ones, so a partially-corrupt DB state still shows all teams. https://claude.ai/code/session_01M3H55gnMxRztJK9KMXqqZo * Address code review feedback on draft order bug fix - Move buildDraftOrderTeams to app/lib/draft-order.ts so it is importable and testable; remove the local copy from the settings component - Add unit tests for buildDraftOrderTeams covering the empty, full, and partial-slot cases - Tighten the draft slot guard from existingSlots.length > 0 to existingSlots.length === currentTeamCount so partial legacy states are treated the same as "order not set" - Condense the 3-line server comment to a single line per project style - Rename getNumTeamsInSeason → getNumDraftSlotsBySeasonId to reflect what the function actually counts, and update its one call site - Add tests for the server-side slot-creation guard logic https://claude.ai/code/session_01M3H55gnMxRztJK9KMXqqZo * Fix lint: move shouldAppendDraftSlots to outer scope oxlint (consistent-function-scoping) requires functions that don't close over any variables to be defined at the outer scope rather than inside a describe block. https://claude.ai/code/session_01M3H55gnMxRztJK9KMXqqZo --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-05-19 18:22:47 -07:00
// Only append slots when all existing teams already have one (order fully set).
if (existingSlots.length === currentTeamCount) {
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 { success: true, message: "Settings saved successfully.", intent: "update" as const };
} 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" };
}