brackt/app/routes/leagues/$leagueId.settings.tsx

1654 lines
64 KiB
TypeScript
Raw Normal View History

import { useState, useEffect } from "react";
import { Form, Link, redirect, useNavigation } from "react-router";
import { getAuth } from "@clerk/react-router/server";
import { format } from "date-fns";
import { CalendarIcon } from "lucide-react";
import { logger } from "~/lib/logger";
import { findLeagueById, updateLeague, deleteLeague } from "~/models/league";
import { sendStandingsUpdateNotification } from "~/services/discord";
Add implementation plan for commissioner-without-team feature (#6) * Add implementation plan for commissioner-without-team feature https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to exist without owning a team - Add opt-out checkbox on league creation ("I want to play in this league") so the creator can be commissioner-only without claiming a team - Add Commissioner Management card to league settings with add/remove commissioner UI; guards against removing the last commissioner - Add countCommissionersByLeagueId model helper for the last-commissioner guard - Show "No team" indicator on the league homepage next to commissioners who don't own a team in the current season https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Fix code review issues in commissioner management - Add success/error feedback banners to commissioner add/remove actions - Gate allUsers query to admin-only (prevents exposing all users to non-admin commissioners) - Prevent commissioner self-removal in both action (server) and UI (client) - Add "(you)" label next to current user in commissioner list - Remove all unnecessary `any` type casts in favor of inferred types https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to add league members as co-commissioners Non-admin commissioners can now add users who already own a team in the league as co-commissioners. Admins still see the full user list. Previously the add-commissioner form was admin-only. https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:45:09 -08:00
import {
isCommissioner,
hasCommissionerRecord,
Add implementation plan for commissioner-without-team feature (#6) * Add implementation plan for commissioner-without-team feature https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to exist without owning a team - Add opt-out checkbox on league creation ("I want to play in this league") so the creator can be commissioner-only without claiming a team - Add Commissioner Management card to league settings with add/remove commissioner UI; guards against removing the last commissioner - Add countCommissionersByLeagueId model helper for the last-commissioner guard - Show "No team" indicator on the league homepage next to commissioners who don't own a team in the current season https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Fix code review issues in commissioner management - Add success/error feedback banners to commissioner add/remove actions - Gate allUsers query to admin-only (prevents exposing all users to non-admin commissioners) - Prevent commissioner self-removal in both action (server) and UI (client) - Add "(you)" label next to current user in commissioner list - Remove all unnecessary `any` type casts in favor of inferred types https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to add league members as co-commissioners Non-admin commissioners can now add users who already own a team in the league as co-commissioners. Admins still see the full user list. Previously the add-commissioner form was admin-only. https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:45:09 -08:00
findCommissionersByLeagueId,
createCommissioner,
countCommissionersByLeagueId,
removeCommissionerByLeagueAndUser,
} from "~/models/commissioner";
Add oxlint linting setup with zero errors (#194) * Add oxlint and fix all lint errors - Install oxlint, add .oxlintrc.json with rules for TypeScript/React - Add npm run lint / lint:fix scripts - Add Claude PostToolUse hook to run oxlint on every edited file - Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array - Fix no-array-index-key (use stable keys or suppress positional cases) - Fix exhaustive-deps missing dependency in useEffect - Promote exhaustive-deps and no-array-index-key to errors - Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-explicit-any warnings and upgrade tsconfig to ES2023 - Replace all `any` types with proper types or `unknown` across ~20 files - Add typed socket payload interfaces in draft route and useDraftSocket - Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch) - Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed - Fix cascading type errors uncovered by removing any: Map.get narrowing, participant relation types, ChartDataPoint, Partial<NewSeason> indexing - Add ParticipantResultWithParticipant type to participant-result model - Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult) - Fix duplicate getQPStandings import in sportsSeasonId.server.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Promote no-explicit-any to error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:05 -07:00
import { findCurrentSeasonWithSports, updateSeason, type NewSeason } from "~/models/season";
import { findTeamsBySeasonId, deleteTeam, removeTeamOwner, claimTeam } from "~/models/team";
Optimize user data fetching with batch queries and centralize display name logic (#176) * Fall back to displayName when username is null for Discord webhook Users who sign up via OAuth (Google, GitHub, etc.) without setting a Clerk username have a null `username` field but always have a `displayName` (computed from firstName+lastName or email). Previously, `usernameByClerkId` was filtered to only include users with a non-null username, causing those owners to appear without any identifier in Discord standings messages (e.g. "Liverpool def. Galatasaray" instead of "Liverpool def. Galatasaray (Madmike)"). https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH * Extract getUserDisplayName helper and use consistently throughout Add a single getUserDisplayName(user) function to app/models/user.ts that encapsulates the username → displayName fallback logic. Replace 9 scattered inline expressions across the codebase (owner-map, scoring-calculator, league routes, settings, invite flow, draft API, Clerk webhook) with calls to the shared helper. No behaviour change — all existing logic preserved, just centralised. https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH * Fix N+1 user queries in league loader and settings loader Add findUsersByClerkIds() batch function to the user model and replace two separate Promise.all+findUserByClerkId loops (one for owners, one for commissioners) with a single inArray query in both $leagueId.server.ts and $leagueId.settings.tsx. The merged query covers both owner and commissioner IDs in one round-trip. https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH * Fix N+1 user queries in buildOwnerMap Replace the Promise.all+findUserByClerkId loop with a single findUsersByClerkIds batch query, consistent with the league loader and settings loader fixes. https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-03-18 16:46:07 -07:00
import { findAllUsers, findUserByClerkId, findUsersByClerkIds, getUserDisplayName, isUserAdminByClerkId } from "~/models/user";
import { unlinkSportFromSeason, linkMultipleSportsToSeason } from "~/models/season-sport";
import { findDraftableSportsSeasons } from "~/models/sports-season";
import { findDraftSlotsBySeasonId, setDraftOrder, randomizeDraftOrder } from "~/models/draft-slot";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { deleteAllDraftPicks } from "~/models/draft-pick";
import { clearAllQueuesForSeason } from "~/models/draft-queue";
import { deleteSeasonTimers } from "~/models/draft-timer";
Add audit logging for commissioner actions (#293) 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>
2026-04-13 18:45:39 -04:00
import { logCommissionerAction } from "~/models/audit-log";
import { parseDraftSpeed } from "~/lib/draft-timer";
import type { Route } from "./+types/$leagueId.settings";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Calendar } from "~/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import { cn } from "~/lib/utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Badge } from "~/components/ui/badge";
import { Checkbox } from "~/components/ui/checkbox";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/components/ui/alert-dialog";
import { ScoringRulesEditor } from "~/components/scoring/ScoringRulesEditor";
2026-03-05 10:07:15 -08:00
import { toast } from "sonner";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Settings — ${data?.league?.name ?? "League"} - Brackt` }];
}
export async function loader(args: Route.LoaderArgs) {
const { userId } = await getAuth(args);
const { params } = args;
const { leagueId } = params;
if (!userId) {
throw new Response("Unauthorized", { status: 401 });
}
// Fetch league
const league = await findLeagueById(leagueId);
if (!league) {
throw new Response("League not found", { status: 404 });
}
// Check if user is authorized (creator or commissioner)
const userIsCommissioner = await isCommissioner(leagueId, userId);
const isCreator = league.createdBy === userId;
if (!isCreator && !userIsCommissioner) {
throw new Response("Forbidden - You must be a commissioner to access settings", {
status: 403,
});
}
// Get current season with sports and draftable seasons in parallel
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) => !draftableIds.has(ss.id) && (ss.draftOff < today || ss.draftOn > today));
const allSportsSeasons = [...draftableSportsSeasons, ...linkedButNotDraftable];
const draftSlots = season ? await findDraftSlotsBySeasonId(season.id) : [];
// Count teams with owners
const teamsWithOwners = teams.filter(team => team.ownerId !== null).length;
// Check if user is admin
const isAdmin = await isUserAdminByClerkId(userId);
Add implementation plan for commissioner-without-team feature (#6) * Add implementation plan for commissioner-without-team feature https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to exist without owning a team - Add opt-out checkbox on league creation ("I want to play in this league") so the creator can be commissioner-only without claiming a team - Add Commissioner Management card to league settings with add/remove commissioner UI; guards against removing the last commissioner - Add countCommissionersByLeagueId model helper for the last-commissioner guard - Show "No team" indicator on the league homepage next to commissioners who don't own a team in the current season https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Fix code review issues in commissioner management - Add success/error feedback banners to commissioner add/remove actions - Gate allUsers query to admin-only (prevents exposing all users to non-admin commissioners) - Prevent commissioner self-removal in both action (server) and UI (client) - Add "(you)" label next to current user in commissioner list - Remove all unnecessary `any` type casts in favor of inferred types https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to add league members as co-commissioners Non-admin commissioners can now add users who already own a team in the league as co-commissioners. Admins still see the full user list. Previously the add-commissioner form was admin-only. https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:45:09 -08:00
// Get all users (only for admins - used in team assignment dropdown)
const allUsers = isAdmin ? await findAllUsers() : [];
Add implementation plan for commissioner-without-team feature (#6) * Add implementation plan for commissioner-without-team feature https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to exist without owning a team - Add opt-out checkbox on league creation ("I want to play in this league") so the creator can be commissioner-only without claiming a team - Add Commissioner Management card to league settings with add/remove commissioner UI; guards against removing the last commissioner - Add countCommissionersByLeagueId model helper for the last-commissioner guard - Show "No team" indicator on the league homepage next to commissioners who don't own a team in the current season https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Fix code review issues in commissioner management - Add success/error feedback banners to commissioner add/remove actions - Gate allUsers query to admin-only (prevents exposing all users to non-admin commissioners) - Prevent commissioner self-removal in both action (server) and UI (client) - Add "(you)" label next to current user in commissioner list - Remove all unnecessary `any` type casts in favor of inferred types https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to add league members as co-commissioners Non-admin commissioners can now add users who already own a team in the league as co-commissioners. Admins still see the full user list. Previously the add-commissioner form was admin-only. https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:45:09 -08:00
// Get commissioners for this league with user info
const commissioners = await findCommissionersByLeagueId(leagueId);
Optimize user data fetching with batch queries and centralize display name logic (#176) * Fall back to displayName when username is null for Discord webhook Users who sign up via OAuth (Google, GitHub, etc.) without setting a Clerk username have a null `username` field but always have a `displayName` (computed from firstName+lastName or email). Previously, `usernameByClerkId` was filtered to only include users with a non-null username, causing those owners to appear without any identifier in Discord standings messages (e.g. "Liverpool def. Galatasaray" instead of "Liverpool def. Galatasaray (Madmike)"). https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH * Extract getUserDisplayName helper and use consistently throughout Add a single getUserDisplayName(user) function to app/models/user.ts that encapsulates the username → displayName fallback logic. Replace 9 scattered inline expressions across the codebase (owner-map, scoring-calculator, league routes, settings, invite flow, draft API, Clerk webhook) with calls to the shared helper. No behaviour change — all existing logic preserved, just centralised. https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH * Fix N+1 user queries in league loader and settings loader Add findUsersByClerkIds() batch function to the user model and replace two separate Promise.all+findUserByClerkId loops (one for owners, one for commissioners) with a single inArray query in both $leagueId.server.ts and $leagueId.settings.tsx. The merged query covers both owner and commissioner IDs in one round-trip. https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH * Fix N+1 user queries in buildOwnerMap Replace the Promise.all+findUserByClerkId loop with a single findUsersByClerkIds batch query, consistent with the league loader and settings loader fixes. https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-03-18 16:46:07 -07:00
// Batch-fetch all users needed for commissioner and owner lookups in one query
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 findUsersByClerkIds(allUserIds);
const userByClerkId = new Map(userRows.map((u) => [u.clerkId, u]));
const commissionerUserData = commissioners.map((c) => {
const user = userByClerkId.get(c.userId);
return {
...c,
userName: user ? (getUserDisplayName(user) ?? "Unknown User") : "Unknown User",
};
});
const validOwners = uniqueOwnerIds
.map((ownerId) => {
const user = userByClerkId.get(ownerId);
return user ? { clerkId: ownerId, name: getUserDisplayName(user), id: user.id } : null;
})
Optimize user data fetching with batch queries and centralize display name logic (#176) * Fall back to displayName when username is null for Discord webhook Users who sign up via OAuth (Google, GitHub, etc.) without setting a Clerk username have a null `username` field but always have a `displayName` (computed from firstName+lastName or email). Previously, `usernameByClerkId` was filtered to only include users with a non-null username, causing those owners to appear without any identifier in Discord standings messages (e.g. "Liverpool def. Galatasaray" instead of "Liverpool def. Galatasaray (Madmike)"). https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH * Extract getUserDisplayName helper and use consistently throughout Add a single getUserDisplayName(user) function to app/models/user.ts that encapsulates the username → displayName fallback logic. Replace 9 scattered inline expressions across the codebase (owner-map, scoring-calculator, league routes, settings, invite flow, draft API, Clerk webhook) with calls to the shared helper. No behaviour change — all existing logic preserved, just centralised. https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH * Fix N+1 user queries in league loader and settings loader Add findUsersByClerkIds() batch function to the user model and replace two separate Promise.all+findUserByClerkId loops (one for owners, one for commissioners) with a single inArray query in both $leagueId.server.ts and $leagueId.settings.tsx. The merged query covers both owner and commissioner IDs in one round-trip. https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH * Fix N+1 user queries in buildOwnerMap Replace the Promise.all+findUserByClerkId loop with a single findUsersByClerkIds batch query, consistent with the league loader and settings loader fixes. https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-03-18 16:46:07 -07:00
.filter((o): o is NonNullable<typeof o> => o !== null);
Add implementation plan for commissioner-without-team feature (#6) * Add implementation plan for commissioner-without-team feature https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to exist without owning a team - Add opt-out checkbox on league creation ("I want to play in this league") so the creator can be commissioner-only without claiming a team - Add Commissioner Management card to league settings with add/remove commissioner UI; guards against removing the last commissioner - Add countCommissionersByLeagueId model helper for the last-commissioner guard - Show "No team" indicator on the league homepage next to commissioners who don't own a team in the current season https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Fix code review issues in commissioner management - Add success/error feedback banners to commissioner add/remove actions - Gate allUsers query to admin-only (prevents exposing all users to non-admin commissioners) - Prevent commissioner self-removal in both action (server) and UI (client) - Add "(you)" label next to current user in commissioner list - Remove all unnecessary `any` type casts in favor of inferred types https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to add league members as co-commissioners Non-admin commissioners can now add users who already own a team in the league as co-commissioners. Admins still see the full user list. Previously the add-commissioner form was admin-only. https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:45:09 -08:00
const ownerMap = new Map(validOwners.map((o) => [o.clerkId, o.name]));
// League members (team owners) - available to all commissioners for adding co-commissioners
const leagueMembers = validOwners.map((o) => ({
id: o.id,
clerkId: o.clerkId,
name: o.name,
}));
return {
league,
season,
teams,
teamCount: teams.length,
teamsWithOwners,
allSportsSeasons: allSportsSeasons as Array<typeof allSportsSeasons[0] & { sport: { id: string; name: string; type: string; slug: string } }>,
draftSlots,
isAdmin,
allUsers,
Add implementation plan for commissioner-without-team feature (#6) * Add implementation plan for commissioner-without-team feature https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to exist without owning a team - Add opt-out checkbox on league creation ("I want to play in this league") so the creator can be commissioner-only without claiming a team - Add Commissioner Management card to league settings with add/remove commissioner UI; guards against removing the last commissioner - Add countCommissionersByLeagueId model helper for the last-commissioner guard - Show "No team" indicator on the league homepage next to commissioners who don't own a team in the current season https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Fix code review issues in commissioner management - Add success/error feedback banners to commissioner add/remove actions - Gate allUsers query to admin-only (prevents exposing all users to non-admin commissioners) - Prevent commissioner self-removal in both action (server) and UI (client) - Add "(you)" label next to current user in commissioner list - Remove all unnecessary `any` type casts in favor of inferred types https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to add league members as co-commissioners Non-admin commissioners can now add users who already own a team in the league as co-commissioners. Admins still see the full user list. Previously the add-commissioner form was admin-only. https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:45:09 -08:00
leagueMembers,
ownerMap: Object.fromEntries(ownerMap),
Add implementation plan for commissioner-without-team feature (#6) * Add implementation plan for commissioner-without-team feature https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to exist without owning a team - Add opt-out checkbox on league creation ("I want to play in this league") so the creator can be commissioner-only without claiming a team - Add Commissioner Management card to league settings with add/remove commissioner UI; guards against removing the last commissioner - Add countCommissionersByLeagueId model helper for the last-commissioner guard - Show "No team" indicator on the league homepage next to commissioners who don't own a team in the current season https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Fix code review issues in commissioner management - Add success/error feedback banners to commissioner add/remove actions - Gate allUsers query to admin-only (prevents exposing all users to non-admin commissioners) - Prevent commissioner self-removal in both action (server) and UI (client) - Add "(you)" label next to current user in commissioner list - Remove all unnecessary `any` type casts in favor of inferred types https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to add league members as co-commissioners Non-admin commissioners can now add users who already own a team in the league as co-commissioners. Admins still see the full user list. Previously the add-commissioner form was admin-only. https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:45:09 -08:00
commissioners: commissionerUserData,
currentUserId: userId,
};
}
export async function action(args: Route.ActionArgs) {
const { userId } = await getAuth(args);
const { params, request } = args;
const { leagueId } = params;
if (!userId) {
throw new Response("Unauthorized", { status: 401 });
}
// Verify authorization
const league = await findLeagueById(leagueId);
if (!league) {
throw new Response("League not found", { status: 404 });
}
const userIsCommissioner = await isCommissioner(leagueId, userId);
const isCreator = league.createdBy === userId;
if (!isCreator && !userIsCommissioner) {
throw new Response("Forbidden", { status: 403 });
}
const formData = await request.formData();
const intent = formData.get("intent");
Fix public draft board access not working when setting is enabled (#23) * Fix public draft board access not working when setting is enabled Two bugs were causing the isPublicDraftBoard setting to fail: 1. Draft board loader called getAuth() before checking isPublicDraftBoard. Restructured to skip auth entirely for public boards - only call getAuth when the board is private and we need to verify member/commissioner access. 2. Settings action saved the league (name + isPublicDraftBoard) AFTER fetching the current season. If the season fetch had any issue, updateLeague was never reached. Moved the league-level save to happen unconditionally before the season fetch, so isPublicDraftBoard is always persisted on form submit. https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy * Fix bugs in draft board access and settings update action - Wrap updateLeague call in try-catch so DB errors return a user-friendly message instead of an unhandled 500 - Fix season-update catch block to say "season settings" not "league" since the league was already saved successfully at that point - Validate leagueId URL param against season.leagueId in draft board loader to prevent accessing a season via the wrong league's URL - Change 403 message for authenticated non-members from "not public" to "you don't have access" to accurately reflect their logged-in state - Add settings-update.test.ts covering name validation, league save, error handling, draft speed mapping, and season-specific validation - Add draft-board-access.test.ts covering public/private access rules, leagueId mismatch detection, commissioner/owner/unauthenticated cases, and the new per-role 403 message distinction https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy * Fix TypeScript errors in settings-update tests TS was narrowing const draftSpeed = 'fast' to the literal type 'fast', making comparisons with 'slow'/'very-slow' etc. flagged as impossible. Widen all draftSpeed locals and the season status locals to string so the if-chains compile cleanly under strict mode. https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-22 16:31:24 -08:00
// 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");
Fix public draft board access not working when setting is enabled (#23) * Fix public draft board access not working when setting is enabled Two bugs were causing the isPublicDraftBoard setting to fail: 1. Draft board loader called getAuth() before checking isPublicDraftBoard. Restructured to skip auth entirely for public boards - only call getAuth when the board is private and we need to verify member/commissioner access. 2. Settings action saved the league (name + isPublicDraftBoard) AFTER fetching the current season. If the season fetch had any issue, updateLeague was never reached. Moved the league-level save to happen unconditionally before the season fetch, so isPublicDraftBoard is always persisted on form submit. https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy * Fix bugs in draft board access and settings update action - Wrap updateLeague call in try-catch so DB errors return a user-friendly message instead of an unhandled 500 - Fix season-update catch block to say "season settings" not "league" since the league was already saved successfully at that point - Validate leagueId URL param against season.leagueId in draft board loader to prevent accessing a season via the wrong league's URL - Change 403 message for authenticated non-members from "not public" to "you don't have access" to accurately reflect their logged-in state - Add settings-update.test.ts covering name validation, league save, error handling, draft speed mapping, and season-specific validation - Add draft-board-access.test.ts covering public/private access rules, leagueId mismatch detection, commissioner/owner/unauthenticated cases, and the new per-role 403 message distinction https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy * Fix TypeScript errors in settings-update tests TS was narrowing const draftSpeed = 'fast' to the literal type 'fast', making comparisons with 'slow'/'very-slow' etc. flagged as impossible. Widen all draftSpeed locals and the season status locals to string so the if-chains compile cleanly under strict mode. https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-22 16:31:24 -08:00
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/" };
}
Fix public draft board access not working when setting is enabled (#23) * Fix public draft board access not working when setting is enabled Two bugs were causing the isPublicDraftBoard setting to fail: 1. Draft board loader called getAuth() before checking isPublicDraftBoard. Restructured to skip auth entirely for public boards - only call getAuth when the board is private and we need to verify member/commissioner access. 2. Settings action saved the league (name + isPublicDraftBoard) AFTER fetching the current season. If the season fetch had any issue, updateLeague was never reached. Moved the league-level save to happen unconditionally before the season fetch, so isPublicDraftBoard is always persisted on form submit. https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy * Fix bugs in draft board access and settings update action - Wrap updateLeague call in try-catch so DB errors return a user-friendly message instead of an unhandled 500 - Fix season-update catch block to say "season settings" not "league" since the league was already saved successfully at that point - Validate leagueId URL param against season.leagueId in draft board loader to prevent accessing a season via the wrong league's URL - Change 403 message for authenticated non-members from "not public" to "you don't have access" to accurately reflect their logged-in state - Add settings-update.test.ts covering name validation, league save, error handling, draft speed mapping, and season-specific validation - Add draft-board-access.test.ts covering public/private access rules, leagueId mismatch detection, commissioner/owner/unauthenticated cases, and the new per-role 403 message distinction https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy * Fix TypeScript errors in settings-update tests TS was narrowing const draftSpeed = 'fast' to the literal type 'fast', making comparisons with 'slow'/'very-slow' etc. flagged as impossible. Widen all draftSpeed locals and the season status locals to string so the if-chains compile cleanly under strict mode. https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-22 16:31:24 -08:00
try {
Add audit logging for commissioner actions (#293) 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>
2026-04-13 18:45:39 -04:00
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");
Fix public draft board access not working when setting is enabled (#23) * Fix public draft board access not working when setting is enabled Two bugs were causing the isPublicDraftBoard setting to fail: 1. Draft board loader called getAuth() before checking isPublicDraftBoard. Restructured to skip auth entirely for public boards - only call getAuth when the board is private and we need to verify member/commissioner access. 2. Settings action saved the league (name + isPublicDraftBoard) AFTER fetching the current season. If the season fetch had any issue, updateLeague was never reached. Moved the league-level save to happen unconditionally before the season fetch, so isPublicDraftBoard is always persisted on form submit. https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy * Fix bugs in draft board access and settings update action - Wrap updateLeague call in try-catch so DB errors return a user-friendly message instead of an unhandled 500 - Fix season-update catch block to say "season settings" not "league" since the league was already saved successfully at that point - Validate leagueId URL param against season.leagueId in draft board loader to prevent accessing a season via the wrong league's URL - Change 403 message for authenticated non-members from "not public" to "you don't have access" to accurately reflect their logged-in state - Add settings-update.test.ts covering name validation, league save, error handling, draft speed mapping, and season-specific validation - Add draft-board-access.test.ts covering public/private access rules, leagueId mismatch detection, commissioner/owner/unauthenticated cases, and the new per-role 403 message distinction https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy * Fix TypeScript errors in settings-update tests TS was narrowing const draftSpeed = 'fast' to the literal type 'fast', making comparisons with 'slow'/'very-slow' etc. flagged as impossible. Widen all draftSpeed locals and the season status locals to string so the if-chains compile cleanly under strict mode. https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-22 16:31:24 -08:00
await updateLeague(leagueId, {
name: name.trim(),
isPublicDraftBoard,
discordWebhookUrl: webhookUrl || null,
Fix public draft board access not working when setting is enabled (#23) * Fix public draft board access not working when setting is enabled Two bugs were causing the isPublicDraftBoard setting to fail: 1. Draft board loader called getAuth() before checking isPublicDraftBoard. Restructured to skip auth entirely for public boards - only call getAuth when the board is private and we need to verify member/commissioner access. 2. Settings action saved the league (name + isPublicDraftBoard) AFTER fetching the current season. If the season fetch had any issue, updateLeague was never reached. Moved the league-level save to happen unconditionally before the season fetch, so isPublicDraftBoard is always persisted on form submit. https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy * Fix bugs in draft board access and settings update action - Wrap updateLeague call in try-catch so DB errors return a user-friendly message instead of an unhandled 500 - Fix season-update catch block to say "season settings" not "league" since the league was already saved successfully at that point - Validate leagueId URL param against season.leagueId in draft board loader to prevent accessing a season via the wrong league's URL - Change 403 message for authenticated non-members from "not public" to "you don't have access" to accurately reflect their logged-in state - Add settings-update.test.ts covering name validation, league save, error handling, draft speed mapping, and season-specific validation - Add draft-board-access.test.ts covering public/private access rules, leagueId mismatch detection, commissioner/owner/unauthenticated cases, and the new per-role 403 message distinction https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy * Fix TypeScript errors in settings-update tests TS was narrowing const draftSpeed = 'fast' to the literal type 'fast', making comparisons with 'slow'/'very-slow' etc. flagged as impossible. Widen all draftSpeed locals and the season status locals to string so the if-chains compile cleanly under strict mode. https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-22 16:31:24 -08:00
});
Add audit logging for commissioner actions (#293) 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>
2026-04-13 18:45:39 -04:00
if (changedFields.length > 0) {
const currentSeason = await findCurrentSeasonWithSports(leagueId);
if (currentSeason) {
await logCommissionerAction({
seasonId: currentSeason.id,
leagueId,
actorClerkId: 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,
},
},
});
}
}
Fix public draft board access not working when setting is enabled (#23) * Fix public draft board access not working when setting is enabled Two bugs were causing the isPublicDraftBoard setting to fail: 1. Draft board loader called getAuth() before checking isPublicDraftBoard. Restructured to skip auth entirely for public boards - only call getAuth when the board is private and we need to verify member/commissioner access. 2. Settings action saved the league (name + isPublicDraftBoard) AFTER fetching the current season. If the season fetch had any issue, updateLeague was never reached. Moved the league-level save to happen unconditionally before the season fetch, so isPublicDraftBoard is always persisted on form submit. https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy * Fix bugs in draft board access and settings update action - Wrap updateLeague call in try-catch so DB errors return a user-friendly message instead of an unhandled 500 - Fix season-update catch block to say "season settings" not "league" since the league was already saved successfully at that point - Validate leagueId URL param against season.leagueId in draft board loader to prevent accessing a season via the wrong league's URL - Change 403 message for authenticated non-members from "not public" to "you don't have access" to accurately reflect their logged-in state - Add settings-update.test.ts covering name validation, league save, error handling, draft speed mapping, and season-specific validation - Add draft-board-access.test.ts covering public/private access rules, leagueId mismatch detection, commissioner/owner/unauthenticated cases, and the new per-role 403 message distinction https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy * Fix TypeScript errors in settings-update tests TS was narrowing const draftSpeed = 'fast' to the literal type 'fast', making comparisons with 'slow'/'very-slow' etc. flagged as impossible. Widen all draftSpeed locals and the season status locals to string so the if-chains compile cleanly under strict mode. https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-22 16:31:24 -08:00
} catch (error) {
logger.error("Error updating league:", error);
Fix public draft board access not working when setting is enabled (#23) * Fix public draft board access not working when setting is enabled Two bugs were causing the isPublicDraftBoard setting to fail: 1. Draft board loader called getAuth() before checking isPublicDraftBoard. Restructured to skip auth entirely for public boards - only call getAuth when the board is private and we need to verify member/commissioner access. 2. Settings action saved the league (name + isPublicDraftBoard) AFTER fetching the current season. If the season fetch had any issue, updateLeague was never reached. Moved the league-level save to happen unconditionally before the season fetch, so isPublicDraftBoard is always persisted on form submit. https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy * Fix bugs in draft board access and settings update action - Wrap updateLeague call in try-catch so DB errors return a user-friendly message instead of an unhandled 500 - Fix season-update catch block to say "season settings" not "league" since the league was already saved successfully at that point - Validate leagueId URL param against season.leagueId in draft board loader to prevent accessing a season via the wrong league's URL - Change 403 message for authenticated non-members from "not public" to "you don't have access" to accurately reflect their logged-in state - Add settings-update.test.ts covering name validation, league save, error handling, draft speed mapping, and season-specific validation - Add draft-board-access.test.ts covering public/private access rules, leagueId mismatch detection, commissioner/owner/unauthenticated cases, and the new per-role 403 message distinction https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy * Fix TypeScript errors in settings-update tests TS was narrowing const draftSpeed = 'fast' to the literal type 'fast', making comparisons with 'slow'/'very-slow' etc. flagged as impossible. Widen all draftSpeed locals and the season status locals to string so the if-chains compile cleanly under strict mode. https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-22 16:31:24 -08:00
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],
]),
Refactor standings notifications to show only changed teams (#182) * Refine Discord webhook scoring notifications (fixes #180, #181) - Filter scored matches to only show matchups where a manager scores Brackt points (winner drafted) or has a team eliminated (loser drafted); matches with no manager involvement are suppressed entirely. - Escape Discord markdown characters (_*~`|\) in team names and usernames to prevent formatting issues (e.g. double-underscore names causing unintended underlines). - Replace full standings with a "Standings Changes" section that shows only teams whose points changed, plus any teams whose rank shifted as a result, with ↑N / ↓N indicators for rank movement. - Pass previousRanks map from scoring-calculator to the notification so rank-displaced teams (who didn't score points themselves) are included. - Update test webhook in league settings to demonstrate rank changes and a sample scored match. - Update all discord service tests to cover the new filtering, escaping, and standings-change behaviour. https://claude.ai/code/session_01FciwfdG9Sfr5ZrXUHPeB18 * fix: only set winnerUsername when winning team's score actually changed Previously, winnerUsername was set for any drafted winner regardless of whether points were actually scored. This caused R64 wins (where the scoring system may not award points until later rounds) to appear in Discord's Scored Matches section even when no Brackt points were earned. Now winnerUsername is only set when the winner's team's totalPoints changed after recalculation. loserUsername (eliminations) is always set when the loser is drafted, since being knocked out is always notable. https://claude.ai/code/session_01FciwfdG9Sfr5ZrXUHPeB18 * Refactor Discord notification logic in scoring calculator - Compute changedTeamIds once up front; derive hasChanges from it instead of duplicating the same iteration - Merge usernameForParticipant and winnerUsernameForParticipant into a single function with a requireScoreChange flag - Replace hasDraftedParticipantMatches with hasScoredMatchesToShow, which checks that at least one scoredMatch has a displayable username — prevents sending a contentless Discord embed when a drafted winner doesn't score any points and the loser isn't drafted - Update inline comment to be sport-agnostic https://claude.ai/code/session_01FciwfdG9Sfr5ZrXUHPeB18 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-03-19 15:52:57 -07:00
previousRanks: new Map([
["1", 2],
["2", 1],
["3", 3],
]),
eventName: "Test Notification",
Refactor standings notifications to show only changed teams (#182) * Refine Discord webhook scoring notifications (fixes #180, #181) - Filter scored matches to only show matchups where a manager scores Brackt points (winner drafted) or has a team eliminated (loser drafted); matches with no manager involvement are suppressed entirely. - Escape Discord markdown characters (_*~`|\) in team names and usernames to prevent formatting issues (e.g. double-underscore names causing unintended underlines). - Replace full standings with a "Standings Changes" section that shows only teams whose points changed, plus any teams whose rank shifted as a result, with ↑N / ↓N indicators for rank movement. - Pass previousRanks map from scoring-calculator to the notification so rank-displaced teams (who didn't score points themselves) are included. - Update test webhook in league settings to demonstrate rank changes and a sample scored match. - Update all discord service tests to cover the new filtering, escaping, and standings-change behaviour. https://claude.ai/code/session_01FciwfdG9Sfr5ZrXUHPeB18 * fix: only set winnerUsername when winning team's score actually changed Previously, winnerUsername was set for any drafted winner regardless of whether points were actually scored. This caused R64 wins (where the scoring system may not award points until later rounds) to appear in Discord's Scored Matches section even when no Brackt points were earned. Now winnerUsername is only set when the winner's team's totalPoints changed after recalculation. loserUsername (eliminations) is always set when the loser is drafted, since being knocked out is always notable. https://claude.ai/code/session_01FciwfdG9Sfr5ZrXUHPeB18 * Refactor Discord notification logic in scoring calculator - Compute changedTeamIds once up front; derive hasChanges from it instead of duplicating the same iteration - Merge usernameForParticipant and winnerUsernameForParticipant into a single function with a requireScoreChange flag - Replace hasDraftedParticipantMatches with hasScoredMatchesToShow, which checks that at least one scoredMatch has a displayable username — prevents sending a contentless Discord embed when a drafted winner doesn't score any points and the loser isn't drafted - Update inline comment to be sport-agnostic https://claude.ai/code/session_01FciwfdG9Sfr5ZrXUHPeB18 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-03-19 15:52:57 -07:00
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." };
}
}
// Get current season to check status
const season = await findCurrentSeasonWithSports(leagueId);
if (!season) {
Fix public draft board access not working when setting is enabled (#23) * Fix public draft board access not working when setting is enabled Two bugs were causing the isPublicDraftBoard setting to fail: 1. Draft board loader called getAuth() before checking isPublicDraftBoard. Restructured to skip auth entirely for public boards - only call getAuth when the board is private and we need to verify member/commissioner access. 2. Settings action saved the league (name + isPublicDraftBoard) AFTER fetching the current season. If the season fetch had any issue, updateLeague was never reached. Moved the league-level save to happen unconditionally before the season fetch, so isPublicDraftBoard is always persisted on form submit. https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy * Fix bugs in draft board access and settings update action - Wrap updateLeague call in try-catch so DB errors return a user-friendly message instead of an unhandled 500 - Fix season-update catch block to say "season settings" not "league" since the league was already saved successfully at that point - Validate leagueId URL param against season.leagueId in draft board loader to prevent accessing a season via the wrong league's URL - Change 403 message for authenticated non-members from "not public" to "you don't have access" to accurately reflect their logged-in state - Add settings-update.test.ts covering name validation, league save, error handling, draft speed mapping, and season-specific validation - Add draft-board-access.test.ts covering public/private access rules, leagueId mismatch detection, commissioner/owner/unauthenticated cases, and the new per-role 403 message distinction https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy * Fix TypeScript errors in settings-update tests TS was narrowing const draftSpeed = 'fast' to the literal type 'fast', making comparisons with 'slow'/'very-slow' etc. flagged as impossible. Widen all draftSpeed locals and the season status locals to string so the if-chains compile cleanly under strict mode. https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-22 16:31:24 -08:00
if (intent === "update") {
return redirect(`/leagues/${leagueId}?updated=true`);
}
return { error: "No active season found" };
}
if (intent === "update-sports") {
if (season.status !== "pre_draft") {
return { error: "Cannot modify sports after draft has started" };
}
const selectedSports = formData.getAll("sportsSeasons");
const currentSportIds = new Set(
Add oxlint linting setup with zero errors (#194) * Add oxlint and fix all lint errors - Install oxlint, add .oxlintrc.json with rules for TypeScript/React - Add npm run lint / lint:fix scripts - Add Claude PostToolUse hook to run oxlint on every edited file - Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array - Fix no-array-index-key (use stable keys or suppress positional cases) - Fix exhaustive-deps missing dependency in useEffect - Promote exhaustive-deps and no-array-index-key to errors - Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-explicit-any warnings and upgrade tsconfig to ES2023 - Replace all `any` types with proper types or `unknown` across ~20 files - Add typed socket payload interfaces in draft route and useDraftSocket - Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch) - Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed - Fix cascading type errors uncovered by removing any: Map.get narrowing, participant relation types, ChartDataPoint, Partial<NewSeason> indexing - Add ParticipantResultWithParticipant type to participant-result model - Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult) - Fix duplicate getQPStandings import in sportsSeasonId.server.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Promote no-explicit-any to error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:05 -07:00
season.seasonSports?.map((s) => s.sportsSeason.id) || []
);
const newSportIds = new Set(selectedSports as string[]);
// Remove sports that are no longer selected
for (const sportId of currentSportIds) {
if (!newSportIds.has(sportId)) {
await unlinkSportFromSeason(season.id, sportId);
}
}
// Add newly selected sports
const sportsToAdd = [];
for (const sportId of newSportIds) {
if (!currentSportIds.has(sportId)) {
sportsToAdd.push({
seasonId: season.id,
sportsSeasonId: sportId as string,
});
}
}
if (sportsToAdd.length > 0) {
await linkMultipleSportsToSeason(sportsToAdd);
}
return { success: true };
}
if (intent === "set-draft-order") {
if (season.status !== "pre_draft") {
return { error: "Cannot modify draft order after draft has started" };
}
const teams = await findTeamsBySeasonId(season.id);
const teamIds = formData.getAll("teamOrder") as string[];
// Validate that all teams are included
if (teamIds.length !== teams.length) {
return { error: "All teams must be included in the draft order" };
}
// Validate that all team IDs are valid
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" };
}
}
await setDraftOrder(season.id, teamIds);
Add audit logging for commissioner actions (#293) 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>
2026-04-13 18:45:39 -04:00
await logCommissionerAction({
seasonId: season.id,
leagueId,
actorClerkId: 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" };
}
if (intent === "randomize-draft-order") {
if (season.status !== "pre_draft") {
return { error: "Cannot modify draft order after draft has started" };
}
const teams = await findTeamsBySeasonId(season.id);
const teamIds = teams.map(t => t.id);
await randomizeDraftOrder(season.id, teamIds);
Add audit logging for commissioner actions (#293) 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>
2026-04-13 18:45:39 -04:00
// Fetch the new order that was just written so we can log it accurately
const newSlots = await findDraftSlotsBySeasonId(season.id);
const sortedSlots = newSlots.toSorted((a, b) => a.draftOrder - b.draftOrder);
await logCommissionerAction({
seasonId: season.id,
leagueId,
actorClerkId: 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" };
}
if (intent === "remove-team-owner") {
const teamId = formData.get("teamId") as string;
if (!teamId) {
return { error: "Team ID is required" };
}
try {
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 userClerkId = formData.get("userClerkId") as string;
if (!teamId || !userClerkId) {
return { error: "Team ID and User ID are required" };
}
// Check if user is admin (only admins can assign owners)
const isAdmin = await isUserAdminByClerkId(userId);
if (!isAdmin) {
return { error: "Only admins can assign team owners" };
}
// Look up user before assigning to fail fast and get their name
const assignedUser = await findUserByClerkId(userClerkId);
if (!assignedUser) {
return { error: "User not found. Please try again." };
}
// Check if user is already assigned to a team in this season
const teams = await findTeamsBySeasonId(season.id);
const userAlreadyHasTeam = teams.some(team => team.ownerId === userClerkId);
if (userAlreadyHasTeam) {
return { error: "This user is already assigned to a team in this league" };
}
// Check if the target team already has an owner
const targetTeam = teams.find(team => team.id === teamId);
if (targetTeam?.ownerId) {
return { error: "This team already has an owner. Remove the current owner first." };
}
try {
Optimize user data fetching with batch queries and centralize display name logic (#176) * Fall back to displayName when username is null for Discord webhook Users who sign up via OAuth (Google, GitHub, etc.) without setting a Clerk username have a null `username` field but always have a `displayName` (computed from firstName+lastName or email). Previously, `usernameByClerkId` was filtered to only include users with a non-null username, causing those owners to appear without any identifier in Discord standings messages (e.g. "Liverpool def. Galatasaray" instead of "Liverpool def. Galatasaray (Madmike)"). https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH * Extract getUserDisplayName helper and use consistently throughout Add a single getUserDisplayName(user) function to app/models/user.ts that encapsulates the username → displayName fallback logic. Replace 9 scattered inline expressions across the codebase (owner-map, scoring-calculator, league routes, settings, invite flow, draft API, Clerk webhook) with calls to the shared helper. No behaviour change — all existing logic preserved, just centralised. https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH * Fix N+1 user queries in league loader and settings loader Add findUsersByClerkIds() batch function to the user model and replace two separate Promise.all+findUserByClerkId loops (one for owners, one for commissioners) with a single inArray query in both $leagueId.server.ts and $leagueId.settings.tsx. The merged query covers both owner and commissioner IDs in one round-trip. https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH * Fix N+1 user queries in buildOwnerMap Replace the Promise.all+findUserByClerkId loop with a single findUsersByClerkIds batch query, consistent with the league loader and settings loader fixes. https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-03-18 16:46:07 -07:00
const teamName = `Team ${getUserDisplayName(assignedUser) ?? "Member"}`;
await claimTeam(teamId, userClerkId, 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") {
// Check if user is admin (only admins can reset draft)
const isAdmin = await isUserAdminByClerkId(userId);
if (!isAdmin) {
return { error: "Only admins can reset the draft" };
}
try {
// Delete all draft picks
await deleteAllDraftPicks(season.id);
// Clear all draft queues
await clearAllQueuesForSeason(season.id);
// Delete all draft timers
await deleteSeasonTimers(season.id);
Add audit logging for commissioner actions (#293) 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>
2026-04-13 18:45:39 -04:00
const previousPickNumber = season.currentPickNumber;
// Set season status back to pre_draft and reset draft state (keeps draft order intact)
await updateSeason(season.id, {
status: "pre_draft",
currentPickNumber: null,
draftStartedAt: null,
draftPaused: false,
});
Add audit logging for commissioner actions (#293) 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>
2026-04-13 18:45:39 -04:00
await logCommissionerAction({
seasonId: season.id,
leagueId,
actorClerkId: 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") {
// Delete the league
await deleteLeague(leagueId);
// Redirect to home with success message
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;
Fix public draft board access not working when setting is enabled (#23) * Fix public draft board access not working when setting is enabled Two bugs were causing the isPublicDraftBoard setting to fail: 1. Draft board loader called getAuth() before checking isPublicDraftBoard. Restructured to skip auth entirely for public boards - only call getAuth when the board is private and we need to verify member/commissioner access. 2. Settings action saved the league (name + isPublicDraftBoard) AFTER fetching the current season. If the season fetch had any issue, updateLeague was never reached. Moved the league-level save to happen unconditionally before the season fetch, so isPublicDraftBoard is always persisted on form submit. https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy * Fix bugs in draft board access and settings update action - Wrap updateLeague call in try-catch so DB errors return a user-friendly message instead of an unhandled 500 - Fix season-update catch block to say "season settings" not "league" since the league was already saved successfully at that point - Validate leagueId URL param against season.leagueId in draft board loader to prevent accessing a season via the wrong league's URL - Change 403 message for authenticated non-members from "not public" to "you don't have access" to accurately reflect their logged-in state - Add settings-update.test.ts covering name validation, league save, error handling, draft speed mapping, and season-specific validation - Add draft-board-access.test.ts covering public/private access rules, leagueId mismatch detection, commissioner/owner/unauthenticated cases, and the new per-role 403 message distinction https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy * Fix TypeScript errors in settings-update tests TS was narrowing const draftSpeed = 'fast' to the literal type 'fast', making comparisons with 'slow'/'very-slow' etc. flagged as impossible. Widen all draftSpeed locals and the season status locals to string so the if-chains compile cleanly under strict mode. https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-22 16:31:24 -08:00
// Map draft speed to time values
const { draftInitialTime, draftIncrementTime } = parseDraftSpeed(
draftSpeed as string | null,
draftTimerMode ?? "chess_clock"
);
Fix public draft board access not working when setting is enabled (#23) * Fix public draft board access not working when setting is enabled Two bugs were causing the isPublicDraftBoard setting to fail: 1. Draft board loader called getAuth() before checking isPublicDraftBoard. Restructured to skip auth entirely for public boards - only call getAuth when the board is private and we need to verify member/commissioner access. 2. Settings action saved the league (name + isPublicDraftBoard) AFTER fetching the current season. If the season fetch had any issue, updateLeague was never reached. Moved the league-level save to happen unconditionally before the season fetch, so isPublicDraftBoard is always persisted on form submit. https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy * Fix bugs in draft board access and settings update action - Wrap updateLeague call in try-catch so DB errors return a user-friendly message instead of an unhandled 500 - Fix season-update catch block to say "season settings" not "league" since the league was already saved successfully at that point - Validate leagueId URL param against season.leagueId in draft board loader to prevent accessing a season via the wrong league's URL - Change 403 message for authenticated non-members from "not public" to "you don't have access" to accurately reflect their logged-in state - Add settings-update.test.ts covering name validation, league save, error handling, draft speed mapping, and season-specific validation - Add draft-board-access.test.ts covering public/private access rules, leagueId mismatch detection, commissioner/owner/unauthenticated cases, and the new per-role 403 message distinction https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy * Fix TypeScript errors in settings-update tests TS was narrowing const draftSpeed = 'fast' to the literal type 'fast', making comparisons with 'slow'/'very-slow' etc. flagged as impossible. Widen all draftSpeed locals and the season status locals to string so the if-chains compile cleanly under strict mode. https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-22 16:31:24 -08:00
// League-level fields (name, isPublicDraftBoard) are already saved above.
// Now handle season-specific updates.
try {
// Update season settings
Add oxlint linting setup with zero errors (#194) * Add oxlint and fix all lint errors - Install oxlint, add .oxlintrc.json with rules for TypeScript/React - Add npm run lint / lint:fix scripts - Add Claude PostToolUse hook to run oxlint on every edited file - Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array - Fix no-array-index-key (use stable keys or suppress positional cases) - Fix exhaustive-deps missing dependency in useEffect - Promote exhaustive-deps and no-array-index-key to errors - Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-explicit-any warnings and upgrade tsconfig to ES2023 - Replace all `any` types with proper types or `unknown` across ~20 files - Add typed socket payload interfaces in draft route and useDraftSocket - Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch) - Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed - Fix cascading type errors uncovered by removing any: Map.get narrowing, participant relation types, ChartDataPoint, Partial<NewSeason> indexing - Add ParticipantResultWithParticipant type to participant-result model - Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult) - Fix duplicate getQPStandings import in sportsSeasonId.server.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Promote no-explicit-any to error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:05 -07:00
const seasonUpdates: Partial<NewSeason> = {};
// Handle draft rounds
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" };
}
seasonUpdates.draftRounds = draftRoundsNum;
}
}
// Handle draft date/time
if (typeof draftDateTime === "string") {
seasonUpdates.draftDateTime = draftDateTime ? new Date(draftDateTime) : null;
}
fix: harden draft timer system with race-condition safety and DRY refactor (#34) - Fix settings action silently resetting timer values when draft speed select is disabled (add null guard before overwriting draftInitialTime/ draftIncrementTime) - Make timer decrement and pick increment atomic using SQL expressions to prevent read-modify-write races between the timer loop and HTTP handlers - Add UNIQUE INDEX on (season_id, pick_number) in draft_picks to prevent duplicate picks from concurrent requests (TOCTOU guard) - Add socket join-draft team ownership validation via DB query - Add iteration cap to autodraft chain while loop (max = totalTeams) - Add draftPaused re-check before firing autodraft chain in make-pick and force-manual-pick - Consolidate all snake draft calculations into calculatePickInfo (DRY); remove duplicated logic from timer.ts, make-pick, force-manual-pick, executeAutoPick, and checkAndTriggerNextAutodraft - Fix calculatePickInfo to return snake-adjusted pickInRound matching draftOrder values instead of raw pre-snake value - Fix timer-update socket events to emit nextPickNumber after a pick instead of the already-completed currentPickNumber - Fix force-manual-pick to validate submitted teamId matches the team whose turn it actually is at the given pick number - Replace inline timer init in draft.start with deleteSeasonTimers + initializeDraftTimers model functions (dead code fix) - Fix draft loader to not crash when getTeamQueue fails (returns []) - Fix misleading 403 message for commissioner picks - Remove dead hidden inputs from league settings form - Document connectedTeams single-instance limitation in socket.ts Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 23:23:24 -08:00
// Set draft times from speed preset (only if draftSpeed was submitted;
// the select is disabled during an active draft so it won't be present)
if (draftSpeed !== null) {
seasonUpdates.draftInitialTime = draftInitialTime;
seasonUpdates.draftIncrementTime = draftIncrementTime;
}
// Timer mode (only if submitted; disabled during active draft)
if (draftTimerMode !== null) {
seasonUpdates.draftTimerMode = draftTimerMode;
}
// Handle scoring rules (only if in pre_draft status)
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` };
}
Add oxlint linting setup with zero errors (#194) * Add oxlint and fix all lint errors - Install oxlint, add .oxlintrc.json with rules for TypeScript/React - Add npm run lint / lint:fix scripts - Add Claude PostToolUse hook to run oxlint on every edited file - Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array - Fix no-array-index-key (use stable keys or suppress positional cases) - Fix exhaustive-deps missing dependency in useEffect - Promote exhaustive-deps and no-array-index-key to errors - Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-explicit-any warnings and upgrade tsconfig to ES2023 - Replace all `any` types with proper types or `unknown` across ~20 files - Add typed socket payload interfaces in draft route and useDraftSocket - Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch) - Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed - Fix cascading type errors uncovered by removing any: Map.get narrowing, participant relation types, ChartDataPoint, Partial<NewSeason> indexing - Add ParticipantResultWithParticipant type to participant-result model - Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult) - Fix duplicate getQPStandings import in sportsSeasonId.server.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Promote no-explicit-any to error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:05 -07:00
(seasonUpdates as Record<string, unknown>)[field] = points;
}
}
}
}
// Update season if there are changes
if (Object.keys(seasonUpdates).length > 0) {
await updateSeason(season.id, seasonUpdates);
Add audit logging for commissioner actions (#293) 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>
2026-04-13 18:45:39 -04:00
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));
if (draftFields.length > 0) {
await logCommissionerAction({
seasonId: season.id,
leagueId,
actorClerkId: userId,
action: "draft_settings_changed",
details: {
changedFields: draftFields,
newValues: Object.fromEntries(draftFields.map((k) => [k, (seasonUpdates as Record<string, unknown>)[k]])),
},
});
}
if (scoringChangedFields.length > 0) {
await logCommissionerAction({
seasonId: season.id,
leagueId,
actorClerkId: userId,
action: "scoring_rules_changed",
details: {
changedFields: scoringChangedFields,
newValues: Object.fromEntries(scoringChangedFields.map((k) => [k, (seasonUpdates as Record<string, unknown>)[k]])),
},
});
}
}
// Handle team count changes if provided
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;
// Validate team count
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)` };
}
// Add or remove teams as needed
if (newTeamCount > currentTeamCount) {
const teamsToAdd = Array.from(
{ length: newTeamCount - currentTeamCount },
(_, i) => ({
seasonId: season.id,
name: `Team ${currentTeamCount + i + 1}`,
ownerId: null as null,
})
);
// Fetch existing slots before the transaction to determine append position
const existingSlots = await findDraftSlotsBySeasonId(season.id);
const maxOrder = existingSlots.reduce((max, s) => Math.max(max, s.draftOrder), 0);
// Create teams and their draft slots atomically
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,
}))
);
});
} else if (newTeamCount < currentTeamCount) {
// Remove teams without owners (from the end)
const teamsToRemove = teams
.filter(t => t.ownerId === null)
.slice(-(currentTeamCount - newTeamCount));
for (const team of teamsToRemove) {
await deleteTeam(team.id);
}
}
}
}
return redirect(`/leagues/${leagueId}?updated=true`);
} catch (error) {
logger.error("Error updating season settings:", error);
Fix public draft board access not working when setting is enabled (#23) * Fix public draft board access not working when setting is enabled Two bugs were causing the isPublicDraftBoard setting to fail: 1. Draft board loader called getAuth() before checking isPublicDraftBoard. Restructured to skip auth entirely for public boards - only call getAuth when the board is private and we need to verify member/commissioner access. 2. Settings action saved the league (name + isPublicDraftBoard) AFTER fetching the current season. If the season fetch had any issue, updateLeague was never reached. Moved the league-level save to happen unconditionally before the season fetch, so isPublicDraftBoard is always persisted on form submit. https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy * Fix bugs in draft board access and settings update action - Wrap updateLeague call in try-catch so DB errors return a user-friendly message instead of an unhandled 500 - Fix season-update catch block to say "season settings" not "league" since the league was already saved successfully at that point - Validate leagueId URL param against season.leagueId in draft board loader to prevent accessing a season via the wrong league's URL - Change 403 message for authenticated non-members from "not public" to "you don't have access" to accurately reflect their logged-in state - Add settings-update.test.ts covering name validation, league save, error handling, draft speed mapping, and season-specific validation - Add draft-board-access.test.ts covering public/private access rules, leagueId mismatch detection, commissioner/owner/unauthenticated cases, and the new per-role 403 message distinction https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy * Fix TypeScript errors in settings-update tests TS was narrowing const draftSpeed = 'fast' to the literal type 'fast', making comparisons with 'slow'/'very-slow' etc. flagged as impossible. Widen all draftSpeed locals and the season status locals to string so the if-chains compile cleanly under strict mode. https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-22 16:31:24 -08:00
return { error: "Failed to update season settings. Please try again." };
}
}
Add implementation plan for commissioner-without-team feature (#6) * Add implementation plan for commissioner-without-team feature https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to exist without owning a team - Add opt-out checkbox on league creation ("I want to play in this league") so the creator can be commissioner-only without claiming a team - Add Commissioner Management card to league settings with add/remove commissioner UI; guards against removing the last commissioner - Add countCommissionersByLeagueId model helper for the last-commissioner guard - Show "No team" indicator on the league homepage next to commissioners who don't own a team in the current season https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Fix code review issues in commissioner management - Add success/error feedback banners to commissioner add/remove actions - Gate allUsers query to admin-only (prevents exposing all users to non-admin commissioners) - Prevent commissioner self-removal in both action (server) and UI (client) - Add "(you)" label next to current user in commissioner list - Remove all unnecessary `any` type casts in favor of inferred types https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to add league members as co-commissioners Non-admin commissioners can now add users who already own a team in the league as co-commissioners. Admins still see the full user list. Previously the add-commissioner form was admin-only. https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:45:09 -08:00
if (intent === "add-commissioner") {
const userClerkId = formData.get("userClerkId") as string;
if (!userClerkId) {
return { error: "User is required" };
}
// Check if user already has a commissioner record (don't use isCommissioner — it
// returns true for site admins, causing a false "already a commissioner" error)
const alreadyCommissioner = await hasCommissionerRecord(leagueId, userClerkId);
Add implementation plan for commissioner-without-team feature (#6) * Add implementation plan for commissioner-without-team feature https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to exist without owning a team - Add opt-out checkbox on league creation ("I want to play in this league") so the creator can be commissioner-only without claiming a team - Add Commissioner Management card to league settings with add/remove commissioner UI; guards against removing the last commissioner - Add countCommissionersByLeagueId model helper for the last-commissioner guard - Show "No team" indicator on the league homepage next to commissioners who don't own a team in the current season https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Fix code review issues in commissioner management - Add success/error feedback banners to commissioner add/remove actions - Gate allUsers query to admin-only (prevents exposing all users to non-admin commissioners) - Prevent commissioner self-removal in both action (server) and UI (client) - Add "(you)" label next to current user in commissioner list - Remove all unnecessary `any` type casts in favor of inferred types https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to add league members as co-commissioners Non-admin commissioners can now add users who already own a team in the league as co-commissioners. Admins still see the full user list. Previously the add-commissioner form was admin-only. https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:45:09 -08:00
if (alreadyCommissioner) {
return { error: "This user is already a commissioner" };
}
try {
await createCommissioner({ leagueId, userId: userClerkId });
return { success: true, message: "Commissioner added successfully" };
} catch (error) {
logger.error("Error adding commissioner:", error);
Add implementation plan for commissioner-without-team feature (#6) * Add implementation plan for commissioner-without-team feature https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to exist without owning a team - Add opt-out checkbox on league creation ("I want to play in this league") so the creator can be commissioner-only without claiming a team - Add Commissioner Management card to league settings with add/remove commissioner UI; guards against removing the last commissioner - Add countCommissionersByLeagueId model helper for the last-commissioner guard - Show "No team" indicator on the league homepage next to commissioners who don't own a team in the current season https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Fix code review issues in commissioner management - Add success/error feedback banners to commissioner add/remove actions - Gate allUsers query to admin-only (prevents exposing all users to non-admin commissioners) - Prevent commissioner self-removal in both action (server) and UI (client) - Add "(you)" label next to current user in commissioner list - Remove all unnecessary `any` type casts in favor of inferred types https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to add league members as co-commissioners Non-admin commissioners can now add users who already own a team in the league as co-commissioners. Admins still see the full user list. Previously the add-commissioner form was admin-only. https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:45:09 -08:00
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" };
}
// Prevent self-removal
if (commissionerUserId === userId) {
return { error: "You cannot remove yourself as a commissioner" };
}
// Prevent removing the last 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);
Add implementation plan for commissioner-without-team feature (#6) * Add implementation plan for commissioner-without-team feature https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to exist without owning a team - Add opt-out checkbox on league creation ("I want to play in this league") so the creator can be commissioner-only without claiming a team - Add Commissioner Management card to league settings with add/remove commissioner UI; guards against removing the last commissioner - Add countCommissionersByLeagueId model helper for the last-commissioner guard - Show "No team" indicator on the league homepage next to commissioners who don't own a team in the current season https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Fix code review issues in commissioner management - Add success/error feedback banners to commissioner add/remove actions - Gate allUsers query to admin-only (prevents exposing all users to non-admin commissioners) - Prevent commissioner self-removal in both action (server) and UI (client) - Add "(you)" label next to current user in commissioner list - Remove all unnecessary `any` type casts in favor of inferred types https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to add league members as co-commissioners Non-admin commissioners can now add users who already own a team in the league as co-commissioners. Admins still see the full user list. Previously the add-commissioner form was admin-only. https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:45:09 -08:00
return { error: "Failed to remove commissioner. Please try again." };
}
}
return { error: "Invalid action" };
}
export default function LeagueSettings({ loaderData, actionData }: Route.ComponentProps) {
Add implementation plan for commissioner-without-team feature (#6) * Add implementation plan for commissioner-without-team feature https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to exist without owning a team - Add opt-out checkbox on league creation ("I want to play in this league") so the creator can be commissioner-only without claiming a team - Add Commissioner Management card to league settings with add/remove commissioner UI; guards against removing the last commissioner - Add countCommissionersByLeagueId model helper for the last-commissioner guard - Show "No team" indicator on the league homepage next to commissioners who don't own a team in the current season https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Fix code review issues in commissioner management - Add success/error feedback banners to commissioner add/remove actions - Gate allUsers query to admin-only (prevents exposing all users to non-admin commissioners) - Prevent commissioner self-removal in both action (server) and UI (client) - Add "(you)" label next to current user in commissioner list - Remove all unnecessary `any` type casts in favor of inferred types https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to add league members as co-commissioners Non-admin commissioners can now add users who already own a team in the league as co-commissioners. Admins still see the full user list. Previously the add-commissioner form was admin-only. https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:45:09 -08:00
const { league, season, teams, teamCount, teamsWithOwners, allSportsSeasons, draftSlots, isAdmin, allUsers, leagueMembers, ownerMap, commissioners, currentUserId } = loaderData;
const navigation = useNavigation();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [timerMode, setTimerMode] = useState<"chess_clock" | "standard">(season?.draftTimerMode ?? "chess_clock");
const [selectedSports, setSelectedSports] = useState<Set<string>>(
Add oxlint linting setup with zero errors (#194) * Add oxlint and fix all lint errors - Install oxlint, add .oxlintrc.json with rules for TypeScript/React - Add npm run lint / lint:fix scripts - Add Claude PostToolUse hook to run oxlint on every edited file - Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array - Fix no-array-index-key (use stable keys or suppress positional cases) - Fix exhaustive-deps missing dependency in useEffect - Promote exhaustive-deps and no-array-index-key to errors - Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-explicit-any warnings and upgrade tsconfig to ES2023 - Replace all `any` types with proper types or `unknown` across ~20 files - Add typed socket payload interfaces in draft route and useDraftSocket - Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch) - Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed - Fix cascading type errors uncovered by removing any: Map.get narrowing, participant relation types, ChartDataPoint, Partial<NewSeason> indexing - Add ParticipantResultWithParticipant type to participant-result model - Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult) - Fix duplicate getQPStandings import in sportsSeasonId.server.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Promote no-explicit-any to error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:05 -07:00
new Set(season?.seasonSports?.map((s) => s.sportsSeason.id) || [])
);
const [draftOrderTeams, setDraftOrderTeams] = useState<string[]>(
Add implementation plan for commissioner-without-team feature (#6) * Add implementation plan for commissioner-without-team feature https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to exist without owning a team - Add opt-out checkbox on league creation ("I want to play in this league") so the creator can be commissioner-only without claiming a team - Add Commissioner Management card to league settings with add/remove commissioner UI; guards against removing the last commissioner - Add countCommissionersByLeagueId model helper for the last-commissioner guard - Show "No team" indicator on the league homepage next to commissioners who don't own a team in the current season https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Fix code review issues in commissioner management - Add success/error feedback banners to commissioner add/remove actions - Gate allUsers query to admin-only (prevents exposing all users to non-admin commissioners) - Prevent commissioner self-removal in both action (server) and UI (client) - Add "(you)" label next to current user in commissioner list - Remove all unnecessary `any` type casts in favor of inferred types https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to add league members as co-commissioners Non-admin commissioners can now add users who already own a team in the league as co-commissioners. Admins still see the full user list. Previously the add-commissioner form was admin-only. https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:45:09 -08:00
draftSlots.length > 0
? draftSlots.map((slot) => slot.teamId)
: teams.map((team) => team.id)
);
const [draftRounds, setDraftRounds] = useState<number>(season?.draftRounds || 20);
const [draftDate, setDraftDate] = useState<Date | undefined>(
season?.draftDateTime ? new Date(season.draftDateTime) : undefined
);
const [draftTime, setDraftTime] = useState<string>(
season?.draftDateTime
? format(new Date(season.draftDateTime), "HH:mm")
: ""
);
// Update draft order when loader data changes (after randomization)
useEffect(() => {
Add implementation plan for commissioner-without-team feature (#6) * Add implementation plan for commissioner-without-team feature https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to exist without owning a team - Add opt-out checkbox on league creation ("I want to play in this league") so the creator can be commissioner-only without claiming a team - Add Commissioner Management card to league settings with add/remove commissioner UI; guards against removing the last commissioner - Add countCommissionersByLeagueId model helper for the last-commissioner guard - Show "No team" indicator on the league homepage next to commissioners who don't own a team in the current season https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Fix code review issues in commissioner management - Add success/error feedback banners to commissioner add/remove actions - Gate allUsers query to admin-only (prevents exposing all users to non-admin commissioners) - Prevent commissioner self-removal in both action (server) and UI (client) - Add "(you)" label next to current user in commissioner list - Remove all unnecessary `any` type casts in favor of inferred types https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to add league members as co-commissioners Non-admin commissioners can now add users who already own a team in the league as co-commissioners. Admins still see the full user list. Previously the add-commissioner form was admin-only. https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:45:09 -08:00
const newOrder = draftSlots.length > 0
? draftSlots.map((slot) => slot.teamId)
: teams.map((team) => team.id);
setDraftOrderTeams(newOrder);
}, [draftSlots, teams]);
const canEditSports = season && season.status === "pre_draft";
const canEditTeamCount = season && season.status === "pre_draft";
const canEditDraftOrder = season && season.status === "pre_draft";
const canEditDraftRounds = season && season.status === "pre_draft";
const minTeamCount = Math.max(6, teamsWithOwners);
// Calculate flex spots and minimum rounds
const sportsCount = selectedSports.size;
const minRounds = sportsCount;
const recommendedRounds = Math.ceil(sportsCount * 1.25);
const flexSpots = Math.max(0, draftRounds - sportsCount);
const handleSportToggle = (sportId: string) => {
const newSelected = new Set(selectedSports);
if (newSelected.has(sportId)) {
newSelected.delete(sportId);
} else {
newSelected.add(sportId);
}
setSelectedSports(newSelected);
};
const handleDelete = () => {
setIsDeleteDialogOpen(false);
// The form submission will handle the actual deletion
};
const moveDraftSlot = (fromIndex: number, toIndex: number) => {
const newOrder = [...draftOrderTeams];
const [movedTeam] = newOrder.splice(fromIndex, 1);
newOrder.splice(toIndex, 0, movedTeam);
setDraftOrderTeams(newOrder);
};
const getTeamById = (teamId: string) => {
Add implementation plan for commissioner-without-team feature (#6) * Add implementation plan for commissioner-without-team feature https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to exist without owning a team - Add opt-out checkbox on league creation ("I want to play in this league") so the creator can be commissioner-only without claiming a team - Add Commissioner Management card to league settings with add/remove commissioner UI; guards against removing the last commissioner - Add countCommissionersByLeagueId model helper for the last-commissioner guard - Show "No team" indicator on the league homepage next to commissioners who don't own a team in the current season https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Fix code review issues in commissioner management - Add success/error feedback banners to commissioner add/remove actions - Gate allUsers query to admin-only (prevents exposing all users to non-admin commissioners) - Prevent commissioner self-removal in both action (server) and UI (client) - Add "(you)" label next to current user in commissioner list - Remove all unnecessary `any` type casts in favor of inferred types https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to add league members as co-commissioners Non-admin commissioners can now add users who already own a team in the league as co-commissioners. Admins still see the full user list. Previously the add-commissioner form was admin-only. https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:45:09 -08:00
return teams.find((t) => t.id === teamId);
};
2026-03-05 10:07:15 -08:00
const [copied, setCopied] = useState(false);
const handleCopyInviteLink = async () => {
if (!season?.inviteCode) return;
const inviteUrl = `${window.location.origin}/i/${season.inviteCode}`;
try {
await navigator.clipboard.writeText(inviteUrl);
setCopied(true);
toast.success("Invite link copied to clipboard!");
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error("Failed to copy invite link");
}
};
return (
<div className="container max-w-2xl mx-auto py-8 px-4">
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
<h1 className="text-4xl font-bold">League Settings</h1>
<Button variant="outline" asChild>
<Link to={`/leagues/${league.id}`}>Back to League</Link>
</Button>
</div>
<p className="text-muted-foreground">{league.name}</p>
</div>
2026-03-05 10:07:15 -08:00
{season?.inviteCode && (
<Card className="mb-6">
<CardHeader>
<CardTitle>Invite Link</CardTitle>
<CardDescription>
Share this link to invite people to join your league
{teamCount - teamsWithOwners > 0 && ` (${teamCount - teamsWithOwners} spot${teamCount - teamsWithOwners !== 1 ? "s" : ""} available)`}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-2">
<input
type="text"
readOnly
value={`${typeof window !== "undefined" ? window.location.origin : ""}/i/${season.inviteCode}`}
className="flex-1 px-3 py-2 text-sm border rounded-md bg-muted"
onClick={(e) => e.currentTarget.select()}
/>
<Button
onClick={handleCopyInviteLink}
variant={copied ? "default" : "outline"}
size="sm"
>
{copied ? "Copied!" : "Copy"}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Anyone with this link can join your league
</p>
</CardContent>
</Card>
)}
<Form method="post" className="space-y-6">
<input type="hidden" name="intent" value="update" />
2026-03-05 10:07:15 -08:00
{/* General Settings */}
<Card>
<CardHeader>
<CardTitle>General Settings</CardTitle>
<CardDescription>
Update your league's basic information
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">League Name</Label>
<Input
id="name"
name="name"
type="text"
defaultValue={league.name}
placeholder="Enter league name"
required
minLength={3}
maxLength={50}
/>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="isPublicDraftBoard"
name="isPublicDraftBoard"
defaultChecked={league.isPublicDraftBoard}
Redesign to dark-mode-only with navy palette and accent colors (#13) Removes light mode entirely in favour of a permanent dark theme with a navy-tinted background and three signature accents (electric blue, amber/gold, coral) exposed as CSS custom properties and Tailwind utilities (bg-electric, text-amber-accent, text-coral-accent). - Set class="dark" on <html> and apply Clerk dark base theme - Rewrite app.css: single :root palette (oklch navy values), custom --electric / --amber-accent / --coral-accent variables, remove duplicate .dark block and light-mode bg-white/bg-gray-950 rule - Install @clerk/themes for Clerk dark modal support - Replace hardcoded Tailwind colors across 30+ files: - Draft grid cells: blue-50/blue-950 → electric/15, green-50/950 → emerald/10 - Timer: green-600/yellow-600/red-600 → emerald-400/amber-accent/coral-accent - Status badges: blue-50/green-50/gray-50 → electric/emerald/muted variants - Success messages: green-500/15 text-green-700 dark:text-green-400 → emerald-500/15 text-emerald-400 - Info cards: blue-50 dark:bg-blue-950 → electric/10 - Warning cards: yellow-500 → amber-accent variants - Medal/placement badges: yellow-500/orange-600 → amber-accent/coral-accent - Movement indicators: green-600/red-600 → emerald-400/coral-accent - Connection dots: green-500/red-500 → emerald-500/coral-accent - Remove dark:hidden/dark:block logo toggle in welcome.tsx (always dark) - Update DraftGrid test assertions to match new class names Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 19:26:11 -08:00
className="h-4 w-4 rounded border-border"
/>
<Label htmlFor="isPublicDraftBoard" className="font-normal cursor-pointer">
Make draft board publicly accessible (no login required)
</Label>
</div>
<div className="space-y-2">
<Label htmlFor="teamCount">Number of Teams</Label>
<Select
name="teamCount"
defaultValue={teamCount.toString()}
disabled={!canEditTeamCount}
>
<SelectTrigger id="teamCount">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 11 }, (_, i) => i + 6).map((num) => (
<SelectItem
key={num}
value={num.toString()}
disabled={num < minTeamCount}
>
{num} Teams
{num < minTeamCount && ` (${teamsWithOwners} teams have owners)`}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
{canEditTeamCount
? `Can be changed before draft starts (minimum ${minTeamCount} based on registered teams)`
: "Team count cannot be changed after draft has started"}
</p>
</div>
</CardContent>
</Card>
{/* Notifications */}
<Card>
<CardHeader>
<CardTitle>Notifications</CardTitle>
<CardDescription>
Get Discord alerts when standings change after scoring events
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="discordWebhookUrl">Discord Webhook URL</Label>
<Input
id="discordWebhookUrl"
name="discordWebhookUrl"
type="url"
defaultValue={league.discordWebhookUrl ?? ""}
placeholder="https://discord.com/api/webhooks/..."
/>
<p className="text-sm text-muted-foreground">
Create a webhook in your Discord server under Server Settings Integrations Webhooks.
</p>
</div>
{league.discordWebhookUrl && (
<Form method="post" className="pt-2">
<input type="hidden" name="intent" value="test-discord-webhook" />
<input type="hidden" name="webhookUrl" value={league.discordWebhookUrl} />
<Button type="submit" variant="outline" size="sm">
Send Test Notification
</Button>
</Form>
)}
{actionData && "testSuccess" in actionData && actionData.testSuccess && (
<p className="text-sm text-emerald-600">Test notification sent to Discord!</p>
)}
</CardContent>
</Card>
{/* Draft Rounds */}
<Card>
<CardHeader>
<CardTitle>Draft Rounds</CardTitle>
<CardDescription>
{canEditDraftRounds
? "Set the number of draft rounds for your league"
: "Draft rounds cannot be modified after the draft has started"}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="draftRounds">Number of Draft Rounds</Label>
<Select
name="draftRounds"
value={draftRounds.toString()}
onValueChange={(value) => setDraftRounds(parseInt(value))}
disabled={!canEditDraftRounds}
required
>
<SelectTrigger id="draftRounds">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 50 }, (_, i) => i + 1)
.filter(num => num >= minRounds)
.map((num) => (
<SelectItem
key={num}
value={num.toString()}
>
{num} Round{num !== 1 ? 's' : ''}
{num === recommendedRounds && ' (recommended)'}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">
Minimum: {minRounds} (number of sports selected)
{recommendedRounds > minRounds && ` • Recommended: ${recommendedRounds}`}
</p>
{sportsCount > 0 && draftRounds < sportsCount && (
<p className="text-sm font-medium text-destructive">
You need at least {sportsCount} rounds for the {sportsCount} sports selected
</p>
)}
{sportsCount > 0 && draftRounds >= sportsCount && (
<p className="text-sm font-medium text-primary">
Flex Spots: {flexSpots}
</p>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="draftDate">Draft Date & Time</Label>
<div className="grid gap-2 sm:grid-cols-2">
<Popover>
<PopoverTrigger asChild>
<Button
variant={"outline"}
className={cn(
"justify-start text-left font-normal",
!draftDate && "text-muted-foreground"
)}
disabled={!canEditDraftRounds}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{draftDate ? format(draftDate, "PPP") : <span>Pick a date</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={draftDate}
onSelect={setDraftDate}
disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))}
/>
</PopoverContent>
</Popover>
<Input
type="time"
value={draftTime}
onChange={(e) => setDraftTime(e.target.value)}
placeholder="Select time"
disabled={!canEditDraftRounds}
/>
</div>
{draftDate && draftTime && (
<input
type="hidden"
name="draftDateTime"
value={new Date(`${format(draftDate, "yyyy-MM-dd")}T${draftTime}`).toISOString()}
/>
)}
{!draftDate && !draftTime && (
<input type="hidden" name="draftDateTime" value="" />
)}
<p className="text-sm text-muted-foreground">
{canEditDraftRounds
? "You must set a draft date and time before starting the draft"
: "Draft date cannot be changed after draft has started"}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="draftTimerMode">Timer Mode</Label>
<select
id="draftTimerMode"
name="draftTimerMode"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={timerMode}
onChange={(e) => setTimerMode(e.target.value as "chess_clock" | "standard")}
disabled={!canEditDraftRounds}
required
>
<option value="chess_clock">Chess Clock (accumulating bank)</option>
<option value="standard">Standard (per-pick countdown)</option>
</select>
<p className="text-sm text-muted-foreground">
Chess Clock: unused time carries over between picks. Standard: timer resets each pick.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="draftSpeed">Draft Speed</Label>
<select
id="draftSpeed"
name="draftSpeed"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
defaultValue={
timerMode === "standard"
? (season?.draftIncrementTime?.toString() ?? "90")
: season?.draftInitialTime === 60 && season?.draftIncrementTime === 10
? "fast"
: season?.draftInitialTime === 120 && season?.draftIncrementTime === 15
? "standard"
: season?.draftInitialTime === 28800 && season?.draftIncrementTime === 3600
? "slow"
: season?.draftInitialTime === 43200 && season?.draftIncrementTime === 3600
? "very-slow"
: "standard"
}
key={timerMode}
disabled={!canEditDraftRounds}
required
>
{timerMode === "standard" ? (
<>
<option value="15">15 seconds</option>
<option value="30">30 seconds</option>
<option value="60">1 minute</option>
<option value="90">90 seconds (default)</option>
<option value="120">2 minutes</option>
<option value="900">15 minutes</option>
<option value="3600">1 hour</option>
<option value="7200">2 hours</option>
<option value="14400">4 hours</option>
<option value="28800">8 hours</option>
<option value="43200">12 hours</option>
</>
) : (
<>
<option value="fast">Fast (1 min + 10 sec)</option>
<option value="standard">Standard (2 min + 15 sec)</option>
<option value="slow">Slow (8 hours + 1 hour)</option>
<option value="very-slow">Very Slow (12 hours + 1 hour)</option>
</>
)}
</select>
<p className="text-sm text-muted-foreground">
{timerMode === "standard"
? "Time per pick — resets to this value after each selection"
: "Initial time bank + increment added after each pick"}
</p>
fix: harden draft timer system with race-condition safety and DRY refactor (#34) - Fix settings action silently resetting timer values when draft speed select is disabled (add null guard before overwriting draftInitialTime/ draftIncrementTime) - Make timer decrement and pick increment atomic using SQL expressions to prevent read-modify-write races between the timer loop and HTTP handlers - Add UNIQUE INDEX on (season_id, pick_number) in draft_picks to prevent duplicate picks from concurrent requests (TOCTOU guard) - Add socket join-draft team ownership validation via DB query - Add iteration cap to autodraft chain while loop (max = totalTeams) - Add draftPaused re-check before firing autodraft chain in make-pick and force-manual-pick - Consolidate all snake draft calculations into calculatePickInfo (DRY); remove duplicated logic from timer.ts, make-pick, force-manual-pick, executeAutoPick, and checkAndTriggerNextAutodraft - Fix calculatePickInfo to return snake-adjusted pickInRound matching draftOrder values instead of raw pre-snake value - Fix timer-update socket events to emit nextPickNumber after a pick instead of the already-completed currentPickNumber - Fix force-manual-pick to validate submitted teamId matches the team whose turn it actually is at the given pick number - Replace inline timer init in draft.start with deleteSeasonTimers + initializeDraftTimers model functions (dead code fix) - Fix draft loader to not crash when getTeamQueue fails (returns []) - Fix misleading 403 message for commissioner picks - Remove dead hidden inputs from league settings form - Document connectedTeams single-instance limitation in socket.ts Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 23:23:24 -08:00
</div>
</CardContent>
</Card>
{/* Scoring Rules */}
<ScoringRulesEditor
scoringRules={season ? {
pointsFor1st: season.pointsFor1st,
pointsFor2nd: season.pointsFor2nd,
pointsFor3rd: season.pointsFor3rd,
pointsFor4th: season.pointsFor4th,
pointsFor5th: season.pointsFor5th,
pointsFor6th: season.pointsFor6th,
pointsFor7th: season.pointsFor7th,
pointsFor8th: season.pointsFor8th,
} : undefined}
disabled={!season || season.status !== "pre_draft"}
/>
{/* Sports Seasons */}
<Card>
<CardHeader>
<CardTitle>Sports Seasons</CardTitle>
<CardDescription>
{canEditSports
? "Select the sports seasons that teams can draft from"
: "Sports cannot be modified after the draft has started"}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<div className="bg-muted/50 border border-muted-foreground/20 rounded-md p-3">
<p className="text-sm text-muted-foreground">
<strong>Recommendation:</strong> Select 16-20 sports seasons for optimal league balance and variety.
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">
Sports Seasons ({selectedSports.size} selected)
</Label>
<div className="max-h-96 overflow-y-auto border rounded-md p-3 space-y-2">
{allSportsSeasons.length > 0 ? (
Fix oxlint warnings: no-shadow, consistent-function-scoping, no-non-null-assertion, and others (#196) * Fix no-shadow and consistent-function-scoping lint violations Resolves all 11 no-shadow and 16 consistent-function-scoping oxlint warnings and promotes both rules to errors in .oxlintrc.json. no-shadow: renamed Drizzle callback params (sports→s, matches→m, seasons→s) to avoid shadowing outer imports; removed shadowed destructures (eq, inArray) from where callbacks; renamed inner template→bracketTemplate, prev→currentTimers, season→ss, name→teamName (with name: teamName fix to preserve semantics). consistent-function-scoping: moved formatDate, getRankBadge, getMovementIndicator, getPositionBadge, getStatusBadge, toDateStr, elo (×2), weightedPick, sortByMatchNumber (×2) to module scope; moved formatTime (×2), isValidLeagueName, getDraftTimes, makeSeasonQueues to file scope in test files. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-non-null-assertion lint violations and promote to error Eliminates all 208 no-non-null-assertion warnings across 38 files. Promotes typescript/no-non-null-assertion from warn to error in .oxlintrc.json. Fix patterns applied: - Map.get(key)! after .has() check → extract with get() + null guard - Map.get(key)! on pre-populated count maps → ?? 0 default - .set(id, map.get(id)! + 1) increment → ?? 0 before adding - participant1Id!/participant2Id! on DB matches → ?? "" fallback - array.find()! in tests → guard + throw or expect().toBeDefined() - bracketTemplateCache.get(id)! → null guard extract - Various nullable field accesses → optional chain or ?? default Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix prefer-add-event-listener, no-unassigned-import, require-module-specifiers Resolves all 9 remaining non-console lint warnings and promotes all three rules to errors in .oxlintrc.json. - prefer-add-event-listener: converted onchange/onclick/onload assignments to addEventListener in useDraftNotifications.ts and admin.data-sync.tsx; stored changeHandler ref for proper cleanup with removeEventListener - no-unassigned-import: configured rule with allow list for legitimate side-effect imports (*.css, @testing-library/jest-dom, @testing-library/cypress/add-commands) - require-module-specifiers: removed redundant `export {}` from cypress/support/e2e.ts (file already has an import) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix TypeScript errors from no-non-null-assertion fixes Two fixes introduced by the non-null assertion cleanup produced type errors: - scoring-event.ts: `?? ""` was wrong type for a participant object map; restructured to explicit null guards so TypeScript can narrow correctly - standings-sync/index.ts: `?? null` after name-match lookup lost the truthy guarantee, causing TS18047 on the write-back block; added `participant &&` guard before accessing its properties Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add npm run typecheck as Stop hook in Claude settings Runs a full project typecheck at the end of each Claude turn so type errors surface as feedback before the next message. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 10:59:51 -07:00
allSportsSeasons.map((ss) => (
<div key={ss.id} className="flex items-center space-x-2">
<Checkbox
Fix oxlint warnings: no-shadow, consistent-function-scoping, no-non-null-assertion, and others (#196) * Fix no-shadow and consistent-function-scoping lint violations Resolves all 11 no-shadow and 16 consistent-function-scoping oxlint warnings and promotes both rules to errors in .oxlintrc.json. no-shadow: renamed Drizzle callback params (sports→s, matches→m, seasons→s) to avoid shadowing outer imports; removed shadowed destructures (eq, inArray) from where callbacks; renamed inner template→bracketTemplate, prev→currentTimers, season→ss, name→teamName (with name: teamName fix to preserve semantics). consistent-function-scoping: moved formatDate, getRankBadge, getMovementIndicator, getPositionBadge, getStatusBadge, toDateStr, elo (×2), weightedPick, sortByMatchNumber (×2) to module scope; moved formatTime (×2), isValidLeagueName, getDraftTimes, makeSeasonQueues to file scope in test files. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-non-null-assertion lint violations and promote to error Eliminates all 208 no-non-null-assertion warnings across 38 files. Promotes typescript/no-non-null-assertion from warn to error in .oxlintrc.json. Fix patterns applied: - Map.get(key)! after .has() check → extract with get() + null guard - Map.get(key)! on pre-populated count maps → ?? 0 default - .set(id, map.get(id)! + 1) increment → ?? 0 before adding - participant1Id!/participant2Id! on DB matches → ?? "" fallback - array.find()! in tests → guard + throw or expect().toBeDefined() - bracketTemplateCache.get(id)! → null guard extract - Various nullable field accesses → optional chain or ?? default Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix prefer-add-event-listener, no-unassigned-import, require-module-specifiers Resolves all 9 remaining non-console lint warnings and promotes all three rules to errors in .oxlintrc.json. - prefer-add-event-listener: converted onchange/onclick/onload assignments to addEventListener in useDraftNotifications.ts and admin.data-sync.tsx; stored changeHandler ref for proper cleanup with removeEventListener - no-unassigned-import: configured rule with allow list for legitimate side-effect imports (*.css, @testing-library/jest-dom, @testing-library/cypress/add-commands) - require-module-specifiers: removed redundant `export {}` from cypress/support/e2e.ts (file already has an import) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix TypeScript errors from no-non-null-assertion fixes Two fixes introduced by the non-null assertion cleanup produced type errors: - scoring-event.ts: `?? ""` was wrong type for a participant object map; restructured to explicit null guards so TypeScript can narrow correctly - standings-sync/index.ts: `?? null` after name-match lookup lost the truthy guarantee, causing TS18047 on the write-back block; added `participant &&` guard before accessing its properties Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add npm run typecheck as Stop hook in Claude settings Runs a full project typecheck at the end of each Claude turn so type errors surface as feedback before the next message. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 10:59:51 -07:00
id={ss.id}
name="sportsSeasons"
Fix oxlint warnings: no-shadow, consistent-function-scoping, no-non-null-assertion, and others (#196) * Fix no-shadow and consistent-function-scoping lint violations Resolves all 11 no-shadow and 16 consistent-function-scoping oxlint warnings and promotes both rules to errors in .oxlintrc.json. no-shadow: renamed Drizzle callback params (sports→s, matches→m, seasons→s) to avoid shadowing outer imports; removed shadowed destructures (eq, inArray) from where callbacks; renamed inner template→bracketTemplate, prev→currentTimers, season→ss, name→teamName (with name: teamName fix to preserve semantics). consistent-function-scoping: moved formatDate, getRankBadge, getMovementIndicator, getPositionBadge, getStatusBadge, toDateStr, elo (×2), weightedPick, sortByMatchNumber (×2) to module scope; moved formatTime (×2), isValidLeagueName, getDraftTimes, makeSeasonQueues to file scope in test files. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-non-null-assertion lint violations and promote to error Eliminates all 208 no-non-null-assertion warnings across 38 files. Promotes typescript/no-non-null-assertion from warn to error in .oxlintrc.json. Fix patterns applied: - Map.get(key)! after .has() check → extract with get() + null guard - Map.get(key)! on pre-populated count maps → ?? 0 default - .set(id, map.get(id)! + 1) increment → ?? 0 before adding - participant1Id!/participant2Id! on DB matches → ?? "" fallback - array.find()! in tests → guard + throw or expect().toBeDefined() - bracketTemplateCache.get(id)! → null guard extract - Various nullable field accesses → optional chain or ?? default Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix prefer-add-event-listener, no-unassigned-import, require-module-specifiers Resolves all 9 remaining non-console lint warnings and promotes all three rules to errors in .oxlintrc.json. - prefer-add-event-listener: converted onchange/onclick/onload assignments to addEventListener in useDraftNotifications.ts and admin.data-sync.tsx; stored changeHandler ref for proper cleanup with removeEventListener - no-unassigned-import: configured rule with allow list for legitimate side-effect imports (*.css, @testing-library/jest-dom, @testing-library/cypress/add-commands) - require-module-specifiers: removed redundant `export {}` from cypress/support/e2e.ts (file already has an import) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix TypeScript errors from no-non-null-assertion fixes Two fixes introduced by the non-null assertion cleanup produced type errors: - scoring-event.ts: `?? ""` was wrong type for a participant object map; restructured to explicit null guards so TypeScript can narrow correctly - standings-sync/index.ts: `?? null` after name-match lookup lost the truthy guarantee, causing TS18047 on the write-back block; added `participant &&` guard before accessing its properties Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add npm run typecheck as Stop hook in Claude settings Runs a full project typecheck at the end of each Claude turn so type errors surface as feedback before the next message. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 10:59:51 -07:00
value={ss.id}
checked={selectedSports.has(ss.id)}
onCheckedChange={() => handleSportToggle(ss.id)}
disabled={!canEditSports}
/>
<Label
Fix oxlint warnings: no-shadow, consistent-function-scoping, no-non-null-assertion, and others (#196) * Fix no-shadow and consistent-function-scoping lint violations Resolves all 11 no-shadow and 16 consistent-function-scoping oxlint warnings and promotes both rules to errors in .oxlintrc.json. no-shadow: renamed Drizzle callback params (sports→s, matches→m, seasons→s) to avoid shadowing outer imports; removed shadowed destructures (eq, inArray) from where callbacks; renamed inner template→bracketTemplate, prev→currentTimers, season→ss, name→teamName (with name: teamName fix to preserve semantics). consistent-function-scoping: moved formatDate, getRankBadge, getMovementIndicator, getPositionBadge, getStatusBadge, toDateStr, elo (×2), weightedPick, sortByMatchNumber (×2) to module scope; moved formatTime (×2), isValidLeagueName, getDraftTimes, makeSeasonQueues to file scope in test files. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-non-null-assertion lint violations and promote to error Eliminates all 208 no-non-null-assertion warnings across 38 files. Promotes typescript/no-non-null-assertion from warn to error in .oxlintrc.json. Fix patterns applied: - Map.get(key)! after .has() check → extract with get() + null guard - Map.get(key)! on pre-populated count maps → ?? 0 default - .set(id, map.get(id)! + 1) increment → ?? 0 before adding - participant1Id!/participant2Id! on DB matches → ?? "" fallback - array.find()! in tests → guard + throw or expect().toBeDefined() - bracketTemplateCache.get(id)! → null guard extract - Various nullable field accesses → optional chain or ?? default Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix prefer-add-event-listener, no-unassigned-import, require-module-specifiers Resolves all 9 remaining non-console lint warnings and promotes all three rules to errors in .oxlintrc.json. - prefer-add-event-listener: converted onchange/onclick/onload assignments to addEventListener in useDraftNotifications.ts and admin.data-sync.tsx; stored changeHandler ref for proper cleanup with removeEventListener - no-unassigned-import: configured rule with allow list for legitimate side-effect imports (*.css, @testing-library/jest-dom, @testing-library/cypress/add-commands) - require-module-specifiers: removed redundant `export {}` from cypress/support/e2e.ts (file already has an import) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix TypeScript errors from no-non-null-assertion fixes Two fixes introduced by the non-null assertion cleanup produced type errors: - scoring-event.ts: `?? ""` was wrong type for a participant object map; restructured to explicit null guards so TypeScript can narrow correctly - standings-sync/index.ts: `?? null` after name-match lookup lost the truthy guarantee, causing TS18047 on the write-back block; added `participant &&` guard before accessing its properties Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add npm run typecheck as Stop hook in Claude settings Runs a full project typecheck at the end of each Claude turn so type errors surface as feedback before the next message. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 10:59:51 -07:00
htmlFor={ss.id}
className={`font-normal flex-1 ${canEditSports ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'}`}
>
Fix oxlint warnings: no-shadow, consistent-function-scoping, no-non-null-assertion, and others (#196) * Fix no-shadow and consistent-function-scoping lint violations Resolves all 11 no-shadow and 16 consistent-function-scoping oxlint warnings and promotes both rules to errors in .oxlintrc.json. no-shadow: renamed Drizzle callback params (sports→s, matches→m, seasons→s) to avoid shadowing outer imports; removed shadowed destructures (eq, inArray) from where callbacks; renamed inner template→bracketTemplate, prev→currentTimers, season→ss, name→teamName (with name: teamName fix to preserve semantics). consistent-function-scoping: moved formatDate, getRankBadge, getMovementIndicator, getPositionBadge, getStatusBadge, toDateStr, elo (×2), weightedPick, sortByMatchNumber (×2) to module scope; moved formatTime (×2), isValidLeagueName, getDraftTimes, makeSeasonQueues to file scope in test files. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-non-null-assertion lint violations and promote to error Eliminates all 208 no-non-null-assertion warnings across 38 files. Promotes typescript/no-non-null-assertion from warn to error in .oxlintrc.json. Fix patterns applied: - Map.get(key)! after .has() check → extract with get() + null guard - Map.get(key)! on pre-populated count maps → ?? 0 default - .set(id, map.get(id)! + 1) increment → ?? 0 before adding - participant1Id!/participant2Id! on DB matches → ?? "" fallback - array.find()! in tests → guard + throw or expect().toBeDefined() - bracketTemplateCache.get(id)! → null guard extract - Various nullable field accesses → optional chain or ?? default Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix prefer-add-event-listener, no-unassigned-import, require-module-specifiers Resolves all 9 remaining non-console lint warnings and promotes all three rules to errors in .oxlintrc.json. - prefer-add-event-listener: converted onchange/onclick/onload assignments to addEventListener in useDraftNotifications.ts and admin.data-sync.tsx; stored changeHandler ref for proper cleanup with removeEventListener - no-unassigned-import: configured rule with allow list for legitimate side-effect imports (*.css, @testing-library/jest-dom, @testing-library/cypress/add-commands) - require-module-specifiers: removed redundant `export {}` from cypress/support/e2e.ts (file already has an import) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix TypeScript errors from no-non-null-assertion fixes Two fixes introduced by the non-null assertion cleanup produced type errors: - scoring-event.ts: `?? ""` was wrong type for a participant object map; restructured to explicit null guards so TypeScript can narrow correctly - standings-sync/index.ts: `?? null` after name-match lookup lost the truthy guarantee, causing TS18047 on the write-back block; added `participant &&` guard before accessing its properties Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add npm run typecheck as Stop hook in Claude settings Runs a full project typecheck at the end of each Claude turn so type errors surface as feedback before the next message. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 10:59:51 -07:00
<span className="font-medium">{ss.sport.name}</span> - {ss.name} ({ss.year})
<Badge variant="outline" className="ml-2 text-xs">
Fix oxlint warnings: no-shadow, consistent-function-scoping, no-non-null-assertion, and others (#196) * Fix no-shadow and consistent-function-scoping lint violations Resolves all 11 no-shadow and 16 consistent-function-scoping oxlint warnings and promotes both rules to errors in .oxlintrc.json. no-shadow: renamed Drizzle callback params (sports→s, matches→m, seasons→s) to avoid shadowing outer imports; removed shadowed destructures (eq, inArray) from where callbacks; renamed inner template→bracketTemplate, prev→currentTimers, season→ss, name→teamName (with name: teamName fix to preserve semantics). consistent-function-scoping: moved formatDate, getRankBadge, getMovementIndicator, getPositionBadge, getStatusBadge, toDateStr, elo (×2), weightedPick, sortByMatchNumber (×2) to module scope; moved formatTime (×2), isValidLeagueName, getDraftTimes, makeSeasonQueues to file scope in test files. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-non-null-assertion lint violations and promote to error Eliminates all 208 no-non-null-assertion warnings across 38 files. Promotes typescript/no-non-null-assertion from warn to error in .oxlintrc.json. Fix patterns applied: - Map.get(key)! after .has() check → extract with get() + null guard - Map.get(key)! on pre-populated count maps → ?? 0 default - .set(id, map.get(id)! + 1) increment → ?? 0 before adding - participant1Id!/participant2Id! on DB matches → ?? "" fallback - array.find()! in tests → guard + throw or expect().toBeDefined() - bracketTemplateCache.get(id)! → null guard extract - Various nullable field accesses → optional chain or ?? default Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix prefer-add-event-listener, no-unassigned-import, require-module-specifiers Resolves all 9 remaining non-console lint warnings and promotes all three rules to errors in .oxlintrc.json. - prefer-add-event-listener: converted onchange/onclick/onload assignments to addEventListener in useDraftNotifications.ts and admin.data-sync.tsx; stored changeHandler ref for proper cleanup with removeEventListener - no-unassigned-import: configured rule with allow list for legitimate side-effect imports (*.css, @testing-library/jest-dom, @testing-library/cypress/add-commands) - require-module-specifiers: removed redundant `export {}` from cypress/support/e2e.ts (file already has an import) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix TypeScript errors from no-non-null-assertion fixes Two fixes introduced by the non-null assertion cleanup produced type errors: - scoring-event.ts: `?? ""` was wrong type for a participant object map; restructured to explicit null guards so TypeScript can narrow correctly - standings-sync/index.ts: `?? null` after name-match lookup lost the truthy guarantee, causing TS18047 on the write-back block; added `participant &&` guard before accessing its properties Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add npm run typecheck as Stop hook in Claude settings Runs a full project typecheck at the end of each Claude turn so type errors surface as feedback before the next message. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 10:59:51 -07:00
{ss.scoringType.replace("_", " ")}
</Badge>
</Label>
</div>
))
) : (
<p className="text-sm text-muted-foreground">
No sports seasons available
</p>
)}
</div>
</div>
</div>
</CardContent>
</Card>
{/* Save Button */}
{actionData?.error && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
{actionData.error}
</div>
)}
{actionData?.success && (
Redesign to dark-mode-only with navy palette and accent colors (#13) Removes light mode entirely in favour of a permanent dark theme with a navy-tinted background and three signature accents (electric blue, amber/gold, coral) exposed as CSS custom properties and Tailwind utilities (bg-electric, text-amber-accent, text-coral-accent). - Set class="dark" on <html> and apply Clerk dark base theme - Rewrite app.css: single :root palette (oklch navy values), custom --electric / --amber-accent / --coral-accent variables, remove duplicate .dark block and light-mode bg-white/bg-gray-950 rule - Install @clerk/themes for Clerk dark modal support - Replace hardcoded Tailwind colors across 30+ files: - Draft grid cells: blue-50/blue-950 → electric/15, green-50/950 → emerald/10 - Timer: green-600/yellow-600/red-600 → emerald-400/amber-accent/coral-accent - Status badges: blue-50/green-50/gray-50 → electric/emerald/muted variants - Success messages: green-500/15 text-green-700 dark:text-green-400 → emerald-500/15 text-emerald-400 - Info cards: blue-50 dark:bg-blue-950 → electric/10 - Warning cards: yellow-500 → amber-accent variants - Medal/placement badges: yellow-500/orange-600 → amber-accent/coral-accent - Movement indicators: green-600/red-600 → emerald-400/coral-accent - Connection dots: green-500/red-500 → emerald-500/coral-accent - Remove dark:hidden/dark:block logo toggle in welcome.tsx (always dark) - Update DraftGrid test assertions to match new class names Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 19:26:11 -08:00
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
Settings updated successfully!
</div>
)}
<Button type="submit" size="lg" className="w-full">
Save All Settings
</Button>
</Form>
<div className="space-y-6">
{/* Draft Order Management */}
<Card id="draft-order">
<CardHeader>
<CardTitle>Draft Order</CardTitle>
<CardDescription>
{canEditDraftOrder
? "Set the draft order for your league. Drag teams to reorder or randomize."
: "Draft order cannot be changed after the draft has started"}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{draftSlots.length === 0 && canEditDraftOrder && (
<div className="bg-muted/50 border border-muted-foreground/20 rounded-md p-3">
<p className="text-sm text-muted-foreground">
<strong>Note:</strong> Draft order has not been set yet. Use the form below to set the order manually or click "Randomize" to generate a random order.
</p>
</div>
)}
<Form method="post" className="space-y-4">
<input type="hidden" name="intent" value="set-draft-order" />
<div className="space-y-2">
<div className="border rounded-md divide-y">
{draftOrderTeams.map((teamId, index) => {
const team = getTeamById(teamId);
if (!team) return null;
return (
<div key={teamId} className="flex items-center gap-3 p-3">
<input type="hidden" name="teamOrder" value={teamId} />
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary text-primary-foreground font-bold text-sm">
{index + 1}
</div>
<div className="flex-1">
<p className="font-medium">{team.name}</p>
{team.ownerId && (
<p className="text-xs text-muted-foreground">{ownerMap[team.ownerId] ?? "Unknown"}</p>
)}
</div>
{canEditDraftOrder && (
<div className="flex gap-1">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => moveDraftSlot(index, Math.max(0, index - 1))}
disabled={index === 0}
>
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => moveDraftSlot(index, Math.min(draftOrderTeams.length - 1, index + 1))}
disabled={index === draftOrderTeams.length - 1}
>
</Button>
</div>
)}
</div>
);
})}
</div>
</div>
{canEditDraftOrder && (
<div className="flex gap-2">
<Button type="submit" className="flex-1">
Save Draft Order
</Button>
</div>
)}
{actionData?.success && actionData?.message && (
Redesign to dark-mode-only with navy palette and accent colors (#13) Removes light mode entirely in favour of a permanent dark theme with a navy-tinted background and three signature accents (electric blue, amber/gold, coral) exposed as CSS custom properties and Tailwind utilities (bg-electric, text-amber-accent, text-coral-accent). - Set class="dark" on <html> and apply Clerk dark base theme - Rewrite app.css: single :root palette (oklch navy values), custom --electric / --amber-accent / --coral-accent variables, remove duplicate .dark block and light-mode bg-white/bg-gray-950 rule - Install @clerk/themes for Clerk dark modal support - Replace hardcoded Tailwind colors across 30+ files: - Draft grid cells: blue-50/blue-950 → electric/15, green-50/950 → emerald/10 - Timer: green-600/yellow-600/red-600 → emerald-400/amber-accent/coral-accent - Status badges: blue-50/green-50/gray-50 → electric/emerald/muted variants - Success messages: green-500/15 text-green-700 dark:text-green-400 → emerald-500/15 text-emerald-400 - Info cards: blue-50 dark:bg-blue-950 → electric/10 - Warning cards: yellow-500 → amber-accent variants - Medal/placement badges: yellow-500/orange-600 → amber-accent/coral-accent - Movement indicators: green-600/red-600 → emerald-400/coral-accent - Connection dots: green-500/red-500 → emerald-500/coral-accent - Remove dark:hidden/dark:block logo toggle in welcome.tsx (always dark) - Update DraftGrid test assertions to match new class names Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 19:26:11 -08:00
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
{actionData.message}
</div>
)}
{actionData?.error && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
{actionData.error}
</div>
)}
</Form>
{canEditDraftOrder && (
<Form method="post">
<input type="hidden" name="intent" value="randomize-draft-order" />
<Button
type="submit"
variant="outline"
className="w-full"
disabled={navigation.state === "submitting"}
>
{navigation.state === "submitting" && navigation.formData?.get("intent") === "randomize-draft-order"
? "Randomizing..."
: "🎲 Randomize Draft Order"}
</Button>
</Form>
)}
</div>
</CardContent>
</Card>
{/* Team Management */}
<Card>
<CardHeader>
<CardTitle>Team Management</CardTitle>
<CardDescription>
Manage team ownership for this league
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
Add implementation plan for commissioner-without-team feature (#6) * Add implementation plan for commissioner-without-team feature https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to exist without owning a team - Add opt-out checkbox on league creation ("I want to play in this league") so the creator can be commissioner-only without claiming a team - Add Commissioner Management card to league settings with add/remove commissioner UI; guards against removing the last commissioner - Add countCommissionersByLeagueId model helper for the last-commissioner guard - Show "No team" indicator on the league homepage next to commissioners who don't own a team in the current season https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Fix code review issues in commissioner management - Add success/error feedback banners to commissioner add/remove actions - Gate allUsers query to admin-only (prevents exposing all users to non-admin commissioners) - Prevent commissioner self-removal in both action (server) and UI (client) - Add "(you)" label next to current user in commissioner list - Remove all unnecessary `any` type casts in favor of inferred types https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to add league members as co-commissioners Non-admin commissioners can now add users who already own a team in the league as co-commissioners. Admins still see the full user list. Previously the add-commissioner form was admin-only. https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:45:09 -08:00
{teams.map((team) => {
const ownerName = team.ownerId ? ownerMap[team.ownerId] : null;
return (
<div
key={team.id}
className="flex items-center justify-between p-3 border rounded-md"
>
<div className="flex-1">
<p className="font-medium">{team.name}</p>
<p className="text-sm text-muted-foreground">
{team.ownerId ? (
<span>Owner: {ownerName || "Unknown"}</span>
) : (
<span>No owner</span>
)}
</p>
</div>
<div className="flex gap-2">
{team.ownerId && (
<Form method="post">
<input type="hidden" name="intent" value="remove-team-owner" />
<input type="hidden" name="teamId" value={team.id} />
<Button
type="submit"
variant="outline"
size="sm"
disabled={navigation.state === "submitting"}
>
Remove Owner
</Button>
</Form>
)}
{isAdmin && (
<Form method="post" className="flex gap-2">
<input type="hidden" name="intent" value="assign-team-owner" />
<input type="hidden" name="teamId" value={team.id} />
<Select name="userClerkId" required>
<SelectTrigger className="w-[180px] h-9">
<SelectValue placeholder="Select user" />
</SelectTrigger>
<SelectContent>
Add implementation plan for commissioner-without-team feature (#6) * Add implementation plan for commissioner-without-team feature https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to exist without owning a team - Add opt-out checkbox on league creation ("I want to play in this league") so the creator can be commissioner-only without claiming a team - Add Commissioner Management card to league settings with add/remove commissioner UI; guards against removing the last commissioner - Add countCommissionersByLeagueId model helper for the last-commissioner guard - Show "No team" indicator on the league homepage next to commissioners who don't own a team in the current season https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Fix code review issues in commissioner management - Add success/error feedback banners to commissioner add/remove actions - Gate allUsers query to admin-only (prevents exposing all users to non-admin commissioners) - Prevent commissioner self-removal in both action (server) and UI (client) - Add "(you)" label next to current user in commissioner list - Remove all unnecessary `any` type casts in favor of inferred types https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to add league members as co-commissioners Non-admin commissioners can now add users who already own a team in the league as co-commissioners. Admins still see the full user list. Previously the add-commissioner form was admin-only. https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:45:09 -08:00
{allUsers.map((user) => {
const userOwnsTeam = teams.some((t) => t.ownerId === user.clerkId);
return (
Add implementation plan for commissioner-without-team feature (#6) * Add implementation plan for commissioner-without-team feature https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to exist without owning a team - Add opt-out checkbox on league creation ("I want to play in this league") so the creator can be commissioner-only without claiming a team - Add Commissioner Management card to league settings with add/remove commissioner UI; guards against removing the last commissioner - Add countCommissionersByLeagueId model helper for the last-commissioner guard - Show "No team" indicator on the league homepage next to commissioners who don't own a team in the current season https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Fix code review issues in commissioner management - Add success/error feedback banners to commissioner add/remove actions - Gate allUsers query to admin-only (prevents exposing all users to non-admin commissioners) - Prevent commissioner self-removal in both action (server) and UI (client) - Add "(you)" label next to current user in commissioner list - Remove all unnecessary `any` type casts in favor of inferred types https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to add league members as co-commissioners Non-admin commissioners can now add users who already own a team in the league as co-commissioners. Admins still see the full user list. Previously the add-commissioner form was admin-only. https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:45:09 -08:00
<SelectItem
key={user.id}
value={user.clerkId}
disabled={userOwnsTeam}
>
{user.username || user.email}
{userOwnsTeam && " (already in league)"}
</SelectItem>
);
})}
</SelectContent>
</Select>
<Button
type="submit"
variant="outline"
size="sm"
disabled={navigation.state === "submitting"}
>
Assign
</Button>
</Form>
)}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
Add implementation plan for commissioner-without-team feature (#6) * Add implementation plan for commissioner-without-team feature https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to exist without owning a team - Add opt-out checkbox on league creation ("I want to play in this league") so the creator can be commissioner-only without claiming a team - Add Commissioner Management card to league settings with add/remove commissioner UI; guards against removing the last commissioner - Add countCommissionersByLeagueId model helper for the last-commissioner guard - Show "No team" indicator on the league homepage next to commissioners who don't own a team in the current season https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Fix code review issues in commissioner management - Add success/error feedback banners to commissioner add/remove actions - Gate allUsers query to admin-only (prevents exposing all users to non-admin commissioners) - Prevent commissioner self-removal in both action (server) and UI (client) - Add "(you)" label next to current user in commissioner list - Remove all unnecessary `any` type casts in favor of inferred types https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to add league members as co-commissioners Non-admin commissioners can now add users who already own a team in the league as co-commissioners. Admins still see the full user list. Previously the add-commissioner form was admin-only. https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:45:09 -08:00
{/* Commissioner Management */}
<Card>
<CardHeader>
<CardTitle>Commissioner Management</CardTitle>
<CardDescription>
Commissioners can manage league settings and oversee the draft. They do not need to own a team.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{actionData?.error && !actionData?.success && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
{actionData.error}
</div>
)}
{actionData?.success && actionData?.message && (
Redesign to dark-mode-only with navy palette and accent colors (#13) Removes light mode entirely in favour of a permanent dark theme with a navy-tinted background and three signature accents (electric blue, amber/gold, coral) exposed as CSS custom properties and Tailwind utilities (bg-electric, text-amber-accent, text-coral-accent). - Set class="dark" on <html> and apply Clerk dark base theme - Rewrite app.css: single :root palette (oklch navy values), custom --electric / --amber-accent / --coral-accent variables, remove duplicate .dark block and light-mode bg-white/bg-gray-950 rule - Install @clerk/themes for Clerk dark modal support - Replace hardcoded Tailwind colors across 30+ files: - Draft grid cells: blue-50/blue-950 → electric/15, green-50/950 → emerald/10 - Timer: green-600/yellow-600/red-600 → emerald-400/amber-accent/coral-accent - Status badges: blue-50/green-50/gray-50 → electric/emerald/muted variants - Success messages: green-500/15 text-green-700 dark:text-green-400 → emerald-500/15 text-emerald-400 - Info cards: blue-50 dark:bg-blue-950 → electric/10 - Warning cards: yellow-500 → amber-accent variants - Medal/placement badges: yellow-500/orange-600 → amber-accent/coral-accent - Movement indicators: green-600/red-600 → emerald-400/coral-accent - Connection dots: green-500/red-500 → emerald-500/coral-accent - Remove dark:hidden/dark:block logo toggle in welcome.tsx (always dark) - Update DraftGrid test assertions to match new class names Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 19:26:11 -08:00
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
Add implementation plan for commissioner-without-team feature (#6) * Add implementation plan for commissioner-without-team feature https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to exist without owning a team - Add opt-out checkbox on league creation ("I want to play in this league") so the creator can be commissioner-only without claiming a team - Add Commissioner Management card to league settings with add/remove commissioner UI; guards against removing the last commissioner - Add countCommissionersByLeagueId model helper for the last-commissioner guard - Show "No team" indicator on the league homepage next to commissioners who don't own a team in the current season https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Fix code review issues in commissioner management - Add success/error feedback banners to commissioner add/remove actions - Gate allUsers query to admin-only (prevents exposing all users to non-admin commissioners) - Prevent commissioner self-removal in both action (server) and UI (client) - Add "(you)" label next to current user in commissioner list - Remove all unnecessary `any` type casts in favor of inferred types https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to add league members as co-commissioners Non-admin commissioners can now add users who already own a team in the league as co-commissioners. Admins still see the full user list. Previously the add-commissioner form was admin-only. https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:45:09 -08:00
{actionData.message}
</div>
)}
<div className="space-y-3">
{commissioners.map((commissioner) => (
<div
key={commissioner.id}
className="flex items-center justify-between p-3 border rounded-md"
>
<div className="flex-1">
<p className="font-medium">
{commissioner.userName}
{commissioner.userId === currentUserId && (
<span className="text-xs text-muted-foreground ml-2">(you)</span>
)}
</p>
{!teams.some((t) => t.ownerId === commissioner.userId) && (
<p className="text-xs text-muted-foreground">No team in current season</p>
)}
</div>
{commissioners.length > 1 && commissioner.userId !== currentUserId && (
<Form method="post">
<input type="hidden" name="intent" value="remove-commissioner" />
<input type="hidden" name="commissionerUserId" value={commissioner.userId} />
<Button
type="submit"
variant="outline"
size="sm"
disabled={navigation.state === "submitting"}
>
Remove
</Button>
</Form>
)}
</div>
))}
</div>
<div className="border-t pt-4">
<p className="text-sm font-medium mb-3">Add Commissioner</p>
<Form method="post" className="flex gap-2">
<input type="hidden" name="intent" value="add-commissioner" />
<Select name="userClerkId" required>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Select a user" />
</SelectTrigger>
<SelectContent>
{leagueMembers.map((member) => {
Add implementation plan for commissioner-without-team feature (#6) * Add implementation plan for commissioner-without-team feature https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to exist without owning a team - Add opt-out checkbox on league creation ("I want to play in this league") so the creator can be commissioner-only without claiming a team - Add Commissioner Management card to league settings with add/remove commissioner UI; guards against removing the last commissioner - Add countCommissionersByLeagueId model helper for the last-commissioner guard - Show "No team" indicator on the league homepage next to commissioners who don't own a team in the current season https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Fix code review issues in commissioner management - Add success/error feedback banners to commissioner add/remove actions - Gate allUsers query to admin-only (prevents exposing all users to non-admin commissioners) - Prevent commissioner self-removal in both action (server) and UI (client) - Add "(you)" label next to current user in commissioner list - Remove all unnecessary `any` type casts in favor of inferred types https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to add league members as co-commissioners Non-admin commissioners can now add users who already own a team in the league as co-commissioners. Admins still see the full user list. Previously the add-commissioner form was admin-only. https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:45:09 -08:00
const alreadyCommissioner = commissioners.some(
(c) => c.userId === member.clerkId
);
return (
<SelectItem
key={member.id}
value={member.clerkId}
disabled={alreadyCommissioner}
>
{member.name || "Unknown"}
{alreadyCommissioner && " (already commissioner)"}
</SelectItem>
);
})}
Add implementation plan for commissioner-without-team feature (#6) * Add implementation plan for commissioner-without-team feature https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to exist without owning a team - Add opt-out checkbox on league creation ("I want to play in this league") so the creator can be commissioner-only without claiming a team - Add Commissioner Management card to league settings with add/remove commissioner UI; guards against removing the last commissioner - Add countCommissionersByLeagueId model helper for the last-commissioner guard - Show "No team" indicator on the league homepage next to commissioners who don't own a team in the current season https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Fix code review issues in commissioner management - Add success/error feedback banners to commissioner add/remove actions - Gate allUsers query to admin-only (prevents exposing all users to non-admin commissioners) - Prevent commissioner self-removal in both action (server) and UI (client) - Add "(you)" label next to current user in commissioner list - Remove all unnecessary `any` type casts in favor of inferred types https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to add league members as co-commissioners Non-admin commissioners can now add users who already own a team in the league as co-commissioners. Admins still see the full user list. Previously the add-commissioner form was admin-only. https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:45:09 -08:00
</SelectContent>
</Select>
<Button
type="submit"
variant="outline"
size="sm"
disabled={navigation.state === "submitting"}
>
Add
</Button>
</Form>
</div>
</CardContent>
</Card>
Add audit logging for commissioner actions (#293) 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>
2026-04-13 18:45:39 -04:00
{/* Audit Log */}
<Card>
<CardHeader>
<CardTitle>Audit Log</CardTitle>
<CardDescription>
View the full history of commissioner actions for this season. All league members can see this log.
</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" asChild>
<Link to={`/leagues/${league.id}/audit-log`}>
View Full Audit Log
</Link>
</Button>
</CardContent>
</Card>
{/* Danger Zone */}
<Card className="border-destructive">
<CardHeader>
<CardTitle className="text-destructive">Danger Zone</CardTitle>
<CardDescription>
Irreversible and destructive actions
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Reset Draft - Admin Only */}
{isAdmin && season && (
<div className="space-y-2">
<h3 className="font-medium">Reset Draft</h3>
<p className="text-sm text-muted-foreground">
Delete all draft picks, queues, and timers, and reset the season to pre-draft status. The draft order will be preserved.
</p>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="w-full">
Reset Draft
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reset the draft?</AlertDialogTitle>
<AlertDialogDescription>
This will delete all draft picks, draft queues, and draft timers, and set the season back to pre-draft status.
The draft order will remain intact. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Form method="post">
<input type="hidden" name="intent" value="reset-draft" />
<AlertDialogAction type="submit" className="bg-destructive hover:bg-destructive/90">
Reset Draft
</AlertDialogAction>
</Form>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
{/* Delete League */}
<div className="space-y-2">
<h3 className="font-medium">Delete League</h3>
<p className="text-sm text-muted-foreground">
Permanently delete this league and all associated data.
</p>
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="w-full">
Delete League
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete <strong>{league.name}</strong> and all
associated data including seasons, teams, and commissioners. This
action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Form method="post" onSubmit={handleDelete}>
<input type="hidden" name="intent" value="delete" />
<AlertDialogAction type="submit" className="bg-destructive hover:bg-destructive/90">
Delete League
</AlertDialogAction>
</Form>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
</div>
</div>
);
}