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>
This commit is contained in:
parent
03fdba3022
commit
eb7aa42cef
5 changed files with 303 additions and 22 deletions
|
|
@ -50,6 +50,16 @@ export async function isCommissioner(
|
|||
return !!commissioner;
|
||||
}
|
||||
|
||||
export async function countCommissionersByLeagueId(
|
||||
leagueId: string
|
||||
): Promise<number> {
|
||||
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<void> {
|
||||
const db = database();
|
||||
await db.delete(schema.commissioners).where(eq(schema.commissioners.id, id));
|
||||
|
|
|
|||
|
|
@ -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<typeof o> => o !== null)
|
||||
.map((o) => [o.clerkId, o.name])
|
||||
);
|
||||
const validOwners = owners.filter((o): o is NonNullable<typeof o> => 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<Set<string>>(
|
||||
new Set(season?.seasonSports?.map((s: any) => s.sportsSeason.id) || [])
|
||||
);
|
||||
const [draftOrderTeams, setDraftOrderTeams] = useState<string[]>(
|
||||
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<number>(season?.draftRounds || 20);
|
||||
const [draftDate, setDraftDate] = useState<Date | undefined>(
|
||||
|
|
@ -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
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{teams.map((team: any) => {
|
||||
{teams.map((team) => {
|
||||
const ownerName = team.ownerId ? ownerMap[team.ownerId] : null;
|
||||
return (
|
||||
<div
|
||||
|
|
@ -1013,12 +1087,11 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
|
|||
<SelectValue placeholder="Select user" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{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 (
|
||||
<SelectItem
|
||||
key={user.id}
|
||||
<SelectItem
|
||||
key={user.id}
|
||||
value={user.clerkId}
|
||||
disabled={userOwnsTeam}
|
||||
>
|
||||
|
|
@ -1047,6 +1120,118 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 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 && (
|
||||
<div className="bg-green-500/15 text-green-700 dark:text-green-400 px-4 py-3 rounded-md text-sm">
|
||||
{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>
|
||||
{isAdmin ? (
|
||||
allUsers.map((user) => {
|
||||
const alreadyCommissioner = commissioners.some(
|
||||
(c) => c.userId === user.clerkId
|
||||
);
|
||||
return (
|
||||
<SelectItem
|
||||
key={user.id}
|
||||
value={user.clerkId}
|
||||
disabled={alreadyCommissioner}
|
||||
>
|
||||
{user.username || user.displayName || user.email}
|
||||
{alreadyCommissioner && " (already commissioner)"}
|
||||
</SelectItem>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
leagueMembers.map((member) => {
|
||||
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>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={navigation.state === "submitting"}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<Card className="border-destructive">
|
||||
<CardHeader>
|
||||
|
|
|
|||
|
|
@ -373,6 +373,9 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
|||
<div className="space-y-2">
|
||||
{commissioners.map((commissioner) => {
|
||||
const commissionerName = commissionerMap[commissioner.userId];
|
||||
const hasTeam = teams.some(
|
||||
(t) => t.ownerId === commissioner.userId
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={commissioner.id}
|
||||
|
|
@ -386,6 +389,11 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
|||
</span>
|
||||
)}
|
||||
</p>
|
||||
{!hasTeam && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No team
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="joinAsPlayer"
|
||||
name="joinAsPlayer"
|
||||
defaultChecked={true}
|
||||
/>
|
||||
<Label htmlFor="joinAsPlayer" className="font-normal cursor-pointer">
|
||||
I want to play in this league (claim a team)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{actionData?.error && (
|
||||
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
||||
{actionData.error}
|
||||
|
|
|
|||
66
plan.md
Normal file
66
plan.md
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue