brackt/app/routes/leagues/new.tsx
Chris Parsons ba9bf64e37
Migrate authentication from Clerk to BetterAuth (#324)
* Migrate authentication from Clerk to BetterAuth (#322)

Replaces @clerk/react-router with self-hosted better-auth to eliminate
the external Clerk dependency and keep all user/session data in our own
PostgreSQL database.

**What changed**
- New: auth.server.ts (BetterAuth config w/ Drizzle adapter, bcrypt, Resend), auth-client.ts, api.auth.$.ts handler
- New: /login and /register pages with email+password and Google/Discord OAuth; open-redirect guard on redirectTo param
- New: UserMenu component replacing Clerk's UserButton
- Schema: sessions, accounts, verifications tables; emailVerified column; clerkId made nullable
- Migrations 0081 (BetterAuth tables) and 0082 (accounts extra columns for v1.6.9)
- All ~30 route files: getAuth → auth.api.getSession, isUserAdminByClerkId → isUserAdmin
- root.tsx: isAdmin read directly from session.user.isAdmin (no extra DB query)
- useDraftAuthRecovery: removed Clerk JWT refresh logic; replaced with cookie-session check
- models/user.ts: removed findUserByClerkId, findOrCreateUser, updateUserByClerkId (webhook pattern)
- Deleted: app/routes/api/webhooks/clerk.ts; uninstalled @clerk/react-router, @clerk/themes, svix
- scripts/migrate.mjs: extended with idempotent Clerk → BetterAuth data migration (FK conversion, email_verified, OAuth accounts)
- scripts/migrate-clerk-passwords.mjs: one-time script to import bcrypt hashes from Clerk CSV export
- BETTERAUTH_MIGRATION.md: dev and production runbooks
- All test mocks updated: vi.mock('~/lib/auth.server') instead of @clerk/react-router/server
- Test fixtures: added emailVerified field

**Follow-up (post-stable)**
- Rename actor_clerk_id column → actor_user_id in commissioner_audit_log
- Drop clerk_id column from users once migration confirmed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add .npmrc with legacy-peer-deps for better-auth/drizzle peer dep conflict

better-auth@1.6.9 declares peerOptional deps on drizzle-orm ^0.45.2 and
drizzle-kit >=0.31.4, but we run drizzle-orm ~0.36.3 / drizzle-kit ~0.28.1.
The adapter works correctly at runtime with our versions — the peer dep is
only for stricter type checking. This unblocks npm ci in CI without a risky
drizzle major-version upgrade.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 22:00:49 -07:00

547 lines
22 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState } from "react";
import { Form, redirect, Link } from "react-router";
import { auth } from "~/lib/auth.server";
import type { Route } from "./+types/new";
import { logger } from "~/lib/logger";
import { createLeague, setCurrentSeason } from "~/models/league";
import { createCommissioner } from "~/models/commissioner";
import { createSeason } from "~/models/season";
import { linkMultipleSportsToSeason } from "~/models/season-sport";
import { createManyTeams } from "~/models/team";
import { findActiveSeasonTemplates, findSeasonTemplateWithSportsSeasons } from "~/models/season-template";
import { findDraftableSportsSeasons } from "~/models/sports-season";
import { generateUniqueTeamNames } from "~/utils/team-names";
import { format } from "date-fns";
import { CalendarIcon, Check } from "lucide-react";
import { Button } from "~/components/ui/button";
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 { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Checkbox } from "~/components/ui/checkbox";
import { Calendar } from "~/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import { cn } from "~/lib/utils";
import { parseDraftSpeed } from "~/lib/draft-timer";
import { ScoringRulesEditor } from "~/components/scoring/ScoringRulesEditor";
export function meta(): Route.MetaDescriptors {
return [{ title: "New League - Brackt" }];
}
export async function loader() {
const templates = await findActiveSeasonTemplates();
const allSportsSeasons = await findDraftableSportsSeasons();
// Fetch templates with their sports seasons for mapping
const templatesWithSports = await Promise.all(
templates.map(async (template) => {
const templateWithSports = await findSeasonTemplateWithSportsSeasons(template.id);
return {
...template,
sportsSeasonIds: templateWithSports?.seasonTemplateSports?.map(
(ts: { sportsSeason: { id: string } }) => ts.sportsSeason.id
) || []
};
})
);
return {
templates: templatesWithSports,
allSportsSeasons: allSportsSeasons as Array<typeof allSportsSeasons[0] & { sport: { id: string; name: string; type: string; slug: string } }>
};
}
export async function action(args: Route.ActionArgs) {
const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
const { request } = args;
if (!userId) {
throw new Response("Unauthorized", { status: 401 });
}
const formData = await request.formData();
const name = formData.get("name");
const teamCount = formData.get("teamCount");
const templateId = formData.get("templateId");
const draftRounds = formData.get("draftRounds");
const draftDateTime = formData.get("draftDateTime");
const draftSpeed = formData.get("draftSpeed");
const draftTimerMode = (formData.get("draftTimerMode") as "chess_clock" | "standard") || "chess_clock";
// Map draft speed to time values
const { draftInitialTime: draftInitialTimeNum, draftIncrementTime: draftIncrementTimeNum } =
parseDraftSpeed(draftSpeed as string | null, draftTimerMode);
// Validation
if (typeof name !== "string" || !name.trim()) {
return { error: "League name is required" };
}
if (typeof teamCount !== "string") {
return { error: "Number of teams is required" };
}
const teamCountNum = parseInt(teamCount, 10);
if (isNaN(teamCountNum) || teamCountNum < 6 || teamCountNum > 16) {
return { error: "Number of teams must be between 6 and 16" };
}
const draftRoundsNum = typeof draftRounds === "string" ? parseInt(draftRounds, 10) : 20;
if (isNaN(draftRoundsNum) || draftRoundsNum < 1 || draftRoundsNum > 50) {
return { error: "Draft rounds must be between 1 and 50" };
}
// Extract and validate scoring rules
const scoringFields = [
"pointsFor1st", "pointsFor2nd", "pointsFor3rd", "pointsFor4th",
"pointsFor5th", "pointsFor6th", "pointsFor7th", "pointsFor8th"
];
const scoringRules: Record<string, number> = {};
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` };
}
scoringRules[field] = points;
}
}
}
try {
// Create league
const league = await createLeague({
name: name.trim(),
createdBy: userId,
});
// Make creator a commissioner
await createCommissioner({
leagueId: league.id,
userId: userId,
});
// Create first season
const currentYear = new Date().getFullYear();
const season = await createSeason({
leagueId: league.id,
year: currentYear,
status: "pre_draft",
templateId: typeof templateId === "string" && templateId !== "" ? templateId : null,
draftRounds: draftRoundsNum,
draftDateTime: typeof draftDateTime === "string" && draftDateTime ? new Date(draftDateTime) : null,
draftInitialTime: draftInitialTimeNum,
draftIncrementTime: draftIncrementTimeNum,
draftTimerMode,
...scoringRules,
});
// Set this as the current season for the league
await setCurrentSeason(league.id, season.id);
// Add selected sports to the season
const selectedSports = formData.getAll("sportsSeasons");
// Validate that draft rounds is at least the number of sports
if (selectedSports.length > draftRoundsNum) {
return {
error: `You need at least ${selectedSports.length} draft rounds for the ${selectedSports.length} sports selected. Currently set to ${draftRoundsNum} rounds.`
};
}
if (selectedSports.length > 0) {
await linkMultipleSportsToSeason(
selectedSports.map((id) => ({
seasonId: season.id,
sportsSeasonId: id as string,
}))
);
}
// Create teams with fun random names
const joinAsPlayer = formData.get("joinAsPlayer") === "on";
const teamNames = generateUniqueTeamNames(teamCountNum);
const teams = teamNames.map((teamName, i) => ({
seasonId: season.id,
name: teamName,
ownerId: joinAsPlayer && i === 0 ? userId : null,
}));
await createManyTeams(teams);
// Redirect to league homepage
return redirect(`/leagues/${league.id}`);
} catch (error) {
logger.error("Error creating league:", error);
return { error: "Failed to create league. Please try again." };
}
}
export default function NewLeague({ loaderData, actionData }: Route.ComponentProps) {
const { templates, allSportsSeasons } = loaderData;
const [selectedTemplate, setSelectedTemplate] = useState<string>("");
const [selectedSports, setSelectedSports] = useState<Set<string>>(new Set());
const [draftRounds, setDraftRounds] = useState<number>(20);
const [draftDate, setDraftDate] = useState<Date>();
const [draftTime, setDraftTime] = useState<string>("");
const [timerMode, setTimerMode] = useState<"chess_clock" | "standard">("chess_clock");
const handleSportToggle = (sportId: string) => {
const newSelected = new Set(selectedSports);
if (newSelected.has(sportId)) {
newSelected.delete(sportId);
} else {
newSelected.add(sportId);
}
setSelectedSports(newSelected);
};
const handleTemplateClick = (templateId: string, sportsSeasonIds: string[]) => {
setSelectedTemplate(templateId);
setSelectedSports(new Set(sportsSeasonIds));
};
// 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);
return (
<div className="container max-w-2xl mx-auto py-8 px-4">
<Card>
<CardHeader>
<CardTitle className="text-3xl">Start a New League</CardTitle>
<CardDescription>
Create your fantasy league and invite your friends to compete.
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-6">
<div className="space-y-2">
<Label htmlFor="name">League Name</Label>
<Input
id="name"
name="name"
type="text"
placeholder="Enter league name"
required
minLength={3}
maxLength={50}
/>
</div>
<div className="space-y-2">
<Label htmlFor="teamCount">Number of Teams</Label>
<Select name="teamCount" defaultValue="12" required>
<SelectTrigger id="teamCount">
<SelectValue placeholder="Select number of teams" />
</SelectTrigger>
<SelectContent>
<SelectItem value="6">6 Teams</SelectItem>
<SelectItem value="7">7 Teams</SelectItem>
<SelectItem value="8">8 Teams</SelectItem>
<SelectItem value="9">9 Teams</SelectItem>
<SelectItem value="10">10 Teams</SelectItem>
<SelectItem value="11">11 Teams</SelectItem>
<SelectItem value="12">12 Teams</SelectItem>
<SelectItem value="13">13 Teams</SelectItem>
<SelectItem value="14">14 Teams</SelectItem>
<SelectItem value="15">15 Teams</SelectItem>
<SelectItem value="16">16 Teams</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
Choose between 6 and 16 teams
</p>
</div>
<div className="space-y-2">
<Label htmlFor="draftRounds">Draft Rounds</Label>
<Select
name="draftRounds"
value={draftRounds.toString()}
onValueChange={(value) => setDraftRounds(parseInt(value))}
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"
)}
>
<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}
initialFocus
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"
/>
</div>
{draftDate && draftTime && (
<input
type="hidden"
name="draftDateTime"
value={new Date(`${format(draftDate, "yyyy-MM-dd")}T${draftTime}`).toISOString()}
/>
)}
<p className="text-sm text-muted-foreground">
You must set a draft date and time before starting the draft
</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")}
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" ? "90" : "standard"}
key={timerMode}
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>
</div>
<ScoringRulesEditor disabled={false} />
<div className="space-y-4">
<Label>Sports Selection</Label>
<p className="text-sm text-muted-foreground">
Choose a template to auto-select sports, or manually select your own
</p>
<input type="hidden" name="templateId" value={selectedTemplate} />
{/* Template Cards */}
{templates.length > 0 && (
<div className="grid gap-3">
{templates.map((template) => (
<Card
key={template.id}
className={`cursor-pointer transition-all ${
selectedTemplate === template.id
? "ring-2 ring-primary"
: "hover:border-primary/50"
}`}
onClick={() => handleTemplateClick(template.id, template.sportsSeasonIds)}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="text-lg">{template.name}</CardTitle>
<CardDescription>{template.year}</CardDescription>
</div>
{selectedTemplate === template.id && (
<div className="rounded-full bg-primary text-primary-foreground p-1">
<Check className="h-4 w-4" />
</div>
)}
</div>
</CardHeader>
{template.description && (
<CardContent className="pt-0">
<p className="text-sm text-muted-foreground">{template.description}</p>
</CardContent>
)}
</Card>
))}
</div>
)}
{/* Sports Checkboxes */}
<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-64 overflow-y-auto border rounded-md p-3 space-y-2">
{allSportsSeasons.length > 0 ? (
allSportsSeasons.map((season) => (
<div key={season.id} className="flex items-center space-x-2">
<Checkbox
id={season.id}
name="sportsSeasons"
value={season.id}
checked={selectedSports.has(season.id)}
onCheckedChange={() => handleSportToggle(season.id)}
/>
<Label
htmlFor={season.id}
className="font-normal cursor-pointer flex-1"
>
<span className="font-medium">{season.sport.name}</span> - {season.name} ({season.year})
<Badge variant="outline" className="ml-2 text-xs">
{season.scoringType.replace("_", " ")}
</Badge>
</Label>
</div>
))
) : (
<p className="text-sm text-muted-foreground">
No sports seasons available
</p>
)}
</div>
</div>
</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}
</div>
)}
<div className="flex gap-4">
<Button type="submit" className="flex-1">
Create League
</Button>
<Button type="button" variant="outline" asChild>
<Link to="/">Cancel</Link>
</Button>
</div>
</Form>
</CardContent>
</Card>
</div>
);
}