From eb7aa42cef7a856f2f854363d7884f18913817e8 Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Fri, 20 Feb 2026 08:45:09 -0800 Subject: [PATCH] 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 --- app/models/commissioner.ts | 10 + app/routes/leagues/$leagueId.settings.tsx | 227 ++++++++++++++++++++-- app/routes/leagues/$leagueId.tsx | 8 + app/routes/leagues/new.tsx | 14 +- plan.md | 66 +++++++ 5 files changed, 303 insertions(+), 22 deletions(-) create mode 100644 plan.md diff --git a/app/models/commissioner.ts b/app/models/commissioner.ts index 6d7a55b..e17a558 100644 --- a/app/models/commissioner.ts +++ b/app/models/commissioner.ts @@ -50,6 +50,16 @@ export async function isCommissioner( return !!commissioner; } +export async function countCommissionersByLeagueId( + leagueId: string +): Promise { + const db = database(); + const commissioners = await db.query.commissioners.findMany({ + where: eq(schema.commissioners.leagueId, leagueId), + }); + return commissioners.length; +} + export async function deleteCommissioner(id: string): Promise { const db = database(); await db.delete(schema.commissioners).where(eq(schema.commissioners.id, id)); diff --git a/app/routes/leagues/$leagueId.settings.tsx b/app/routes/leagues/$leagueId.settings.tsx index 6227078..d7ebfbb 100644 --- a/app/routes/leagues/$leagueId.settings.tsx +++ b/app/routes/leagues/$leagueId.settings.tsx @@ -4,7 +4,13 @@ import { getAuth } from "@clerk/react-router/server"; import { format } from "date-fns"; import { CalendarIcon } from "lucide-react"; import { findLeagueById, updateLeague, deleteLeague } from "~/models/league"; -import { isCommissioner } from "~/models/commissioner"; +import { + isCommissioner, + findCommissionersByLeagueId, + createCommissioner, + countCommissionersByLeagueId, + removeCommissionerByLeagueAndUser, +} from "~/models/commissioner"; import { findCurrentSeasonWithSports, updateSeason } from "~/models/season"; import { findTeamsBySeasonId, createManyTeams, deleteTeam, removeTeamOwner, assignTeamOwner } from "~/models/team"; import { findAllUsers, findUserByClerkId, isUserAdminByClerkId } from "~/models/user"; @@ -92,9 +98,21 @@ export async function loader(args: Route.LoaderArgs) { // Check if user is admin const isAdmin = await isUserAdminByClerkId(userId); - // Get all users if admin (for team assignment dropdown) + // Get all users (only for admins - used in team assignment dropdown) const allUsers = isAdmin ? await findAllUsers() : []; + // Get commissioners for this league with user info + const commissioners = await findCommissionersByLeagueId(leagueId); + const commissionerUserData = await Promise.all( + commissioners.map(async (c) => { + const user = await findUserByClerkId(c.userId); + return { + ...c, + userName: user?.username || user?.displayName || "Unknown User", + }; + }) + ); + // Get owner details for teams const ownerIds = teams .map((t) => t.ownerId) @@ -108,11 +126,15 @@ export async function loader(args: Route.LoaderArgs) { : null; }) ); - const ownerMap = new Map( - owners - .filter((o): o is NonNullable => o !== null) - .map((o) => [o.clerkId, o.name]) - ); + const validOwners = owners.filter((o): o is NonNullable => o !== null); + 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, @@ -124,7 +146,10 @@ export async function loader(args: Route.LoaderArgs) { draftSlots, isAdmin, allUsers, + leagueMembers, ownerMap: Object.fromEntries(ownerMap), + commissioners: commissionerUserData, + currentUserId: userId, }; } @@ -471,20 +496,69 @@ export async function action(args: Route.ActionArgs) { } } + if (intent === "add-commissioner") { + const userClerkId = formData.get("userClerkId") as string; + + if (!userClerkId) { + return { error: "User is required" }; + } + + // Check if user is already a commissioner + const alreadyCommissioner = await isCommissioner(leagueId, userClerkId); + 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) { + console.error("Error adding commissioner:", error); + return { error: "Failed to add commissioner. Please try again." }; + } + } + + if (intent === "remove-commissioner") { + const commissionerUserId = formData.get("commissionerUserId") as string; + + if (!commissionerUserId) { + return { error: "User is required" }; + } + + // 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) { + console.error("Error removing commissioner:", error); + return { error: "Failed to remove commissioner. Please try again." }; + } + } + return { error: "Invalid action" }; } export default function LeagueSettings({ loaderData, actionData }: Route.ComponentProps) { - const { league, season, teams, teamCount, teamsWithOwners, allSportsSeasons, draftSlots, isAdmin, allUsers, ownerMap } = loaderData; + const { league, season, teams, teamCount, teamsWithOwners, allSportsSeasons, draftSlots, isAdmin, allUsers, leagueMembers, ownerMap, commissioners, currentUserId } = loaderData; const navigation = useNavigation(); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [selectedSports, setSelectedSports] = useState>( new Set(season?.seasonSports?.map((s: any) => s.sportsSeason.id) || []) ); const [draftOrderTeams, setDraftOrderTeams] = useState( - draftSlots.length > 0 - ? draftSlots.map((slot: any) => slot.teamId) - : teams.map((team: any) => team.id) + draftSlots.length > 0 + ? draftSlots.map((slot) => slot.teamId) + : teams.map((team) => team.id) ); const [draftRounds, setDraftRounds] = useState(season?.draftRounds || 20); const [draftDate, setDraftDate] = useState( @@ -498,9 +572,9 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone // Update draft order when loader data changes (after randomization) useEffect(() => { - const newOrder = draftSlots.length > 0 - ? draftSlots.map((slot: any) => slot.teamId) - : teams.map((team: any) => team.id); + const newOrder = draftSlots.length > 0 + ? draftSlots.map((slot) => slot.teamId) + : teams.map((team) => team.id); setDraftOrderTeams(newOrder); }, [draftSlots, teams]); @@ -539,7 +613,7 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone }; const getTeamById = (teamId: string) => { - return teams.find((t: any) => t.id === teamId); + return teams.find((t) => t.id === teamId); }; return ( @@ -972,7 +1046,7 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
- {teams.map((team: any) => { + {teams.map((team) => { const ownerName = team.ownerId ? ownerMap[team.ownerId] : null; return (
- {allUsers.map((user: any) => { - // Check if user already owns a team in this league - const userOwnsTeam = teams.some((t: any) => t.ownerId === user.clerkId); + {allUsers.map((user) => { + const userOwnsTeam = teams.some((t) => t.ownerId === user.clerkId); return ( - @@ -1047,6 +1120,118 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone + {/* Commissioner Management */} + + + Commissioner Management + + Commissioners can manage league settings and oversee the draft. They do not need to own a team. + + + + {actionData?.error && !actionData?.success && ( +
+ {actionData.error} +
+ )} + {actionData?.success && actionData?.message && ( +
+ {actionData.message} +
+ )} + +
+ {commissioners.map((commissioner) => ( +
+
+

+ {commissioner.userName} + {commissioner.userId === currentUserId && ( + (you) + )} +

+ {!teams.some((t) => t.ownerId === commissioner.userId) && ( +

No team in current season

+ )} +
+ {commissioners.length > 1 && commissioner.userId !== currentUserId && ( +
+ + + +
+ )} +
+ ))} +
+ +
+

Add Commissioner

+
+ + + +
+
+
+
+ {/* Danger Zone */} diff --git a/app/routes/leagues/$leagueId.tsx b/app/routes/leagues/$leagueId.tsx index 121eb2b..17fe447 100644 --- a/app/routes/leagues/$leagueId.tsx +++ b/app/routes/leagues/$leagueId.tsx @@ -373,6 +373,9 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
{commissioners.map((commissioner) => { const commissionerName = commissionerMap[commissioner.userId]; + const hasTeam = teams.some( + (t) => t.ownerId === commissioner.userId + ); return (
)}

+ {!hasTeam && ( +

+ No team +

+ )}
); })} diff --git a/app/routes/leagues/new.tsx b/app/routes/leagues/new.tsx index e3bb3e5..7ee7110 100644 --- a/app/routes/leagues/new.tsx +++ b/app/routes/leagues/new.tsx @@ -194,11 +194,12 @@ export async function action(args: Route.ActionArgs) { } // Create teams with fun random names + const joinAsPlayer = formData.get("joinAsPlayer") === "on"; const teamNames = generateUniqueTeamNames(teamCountNum); const teams = teamNames.map((name, i) => ({ seasonId: season.id, name, - ownerId: i === 0 ? userId : null, // Assign first team to creator + ownerId: joinAsPlayer && i === 0 ? userId : null, })); await createManyTeams(teams); @@ -485,6 +486,17 @@ export default function NewLeague({ loaderData, actionData }: Route.ComponentPro
+
+ + +
+ {actionData?.error && (
{actionData.error} diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..76c6b86 --- /dev/null +++ b/plan.md @@ -0,0 +1,66 @@ +# Plan: Commissioner Without a Team + +## Current State + +**At the database level**, commissioners and teams are already independent: +- Commissioners are stored in the `commissioners` table (league-level, keyed by `leagueId` + `userId`) +- Teams are stored in the `teams` table (season-level, keyed by `seasonId` + `ownerId`) +- A commissioner does NOT need a team to access the league homepage or draft room — this already works + +**The actual problem** is that there's no way to *become* a commissioner without also getting a team: +1. **League creation** (`app/routes/leagues/new.tsx:198-201`): The creator is always assigned the first team (`ownerId: i === 0 ? userId : null`) +2. **No "Add Commissioner" UI**: There's no way for a league creator to add additional commissioners at all +3. **Invite link flow** (`app/routes/i.$inviteCode.tsx`): Joining always assigns a team — there's no option to join as commissioner-only + +**Also**, the league creation flow has no opt-out for team ownership — the creator always gets Team 1. + +## Changes + +### 1. League creation: Add "Join as player" checkbox +**File**: `app/routes/leagues/new.tsx` + +- Add a checkbox: "I want to play in this league" (checked by default) +- When unchecked, skip assigning the creator to the first team (all teams get `ownerId: null`) +- When checked, behavior stays the same (creator gets first team) + +### 2. Add Commissioner Management UI to League Settings +**File**: `app/routes/leagues/$leagueId.settings.tsx` + +- Add a new "Commissioner Management" card section +- Show current commissioners with a "Remove" button (cannot remove if they're the last commissioner) +- Add an "Add Commissioner" form with a user search/select dropdown + - Only show users who are already members of the league (team owners) OR allow adding by searching all users + - Since only the site admin can currently assign users to teams, let's keep it simple: commissioners can add any registered user as a commissioner +- Backend actions: `add-commissioner` and `remove-commissioner` intents + +### 3. Model changes +**File**: `app/models/commissioner.ts` + +- Already has `createCommissioner`, `deleteCommissioner`, `removeCommissionerByLeagueAndUser` — these are sufficient +- Add `countCommissionersByLeagueId` to prevent removing the last commissioner + +### 4. Settings loader changes +**File**: `app/routes/leagues/$leagueId.settings.tsx` + +- Load commissioners list and user data for display +- Load all users list for the "add commissioner" dropdown (reuse existing `allUsers` pattern but make it available to commissioners, not just admins) + +### 5. League homepage: Show commissioner-only indicator +**File**: `app/routes/leagues/$leagueId.tsx` + +- In the commissioners list, if a commissioner doesn't own a team in the current season, show a "(No team)" indicator next to their name +- This helps distinguish playing commissioners from non-playing commissioners + +## Files to modify + +1. `app/routes/leagues/new.tsx` — Add "play in league" checkbox, conditionally assign first team +2. `app/models/commissioner.ts` — Add `countCommissionersByLeagueId` helper +3. `app/routes/leagues/$leagueId.settings.tsx` — Add commissioner management card (loader + action + UI) +4. `app/routes/leagues/$leagueId.server.ts` — Pass team ownership info for commissioners to the homepage +5. `app/routes/leagues/$leagueId.tsx` — Show "(No team)" indicator for commissioners without teams + +## Out of scope (not changing) + +- The invite link flow — this is for players joining, not commissioners. Commissioners should be added through settings. +- The `createdBy` vs commissioners table inconsistency in draft control endpoints — that's a separate bug +- Adding a way for a commissioner to later claim/release a team mid-season — possible future work but not needed here