* 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>
184 lines
6.4 KiB
TypeScript
184 lines
6.4 KiB
TypeScript
import { useLoaderData, Link } from "react-router";
|
|
import { database } from "~/database/context";
|
|
import { eq } from "drizzle-orm";
|
|
import * as schema from "~/database/schema";
|
|
import { requireLeagueAccess } from "~/lib/auth";
|
|
import { Button } from "~/components/ui/button";
|
|
import { logger } from "~/lib/logger";
|
|
import { getSevenDayStandingsChange, getSeasonPointProgression } from "~/models/standings";
|
|
import { isSeasonComplete, getSeasonCompletionPercentage } from "~/lib/season-helpers.server";
|
|
import { getRecentTeamScoreEvents } from "~/models/team-score-events";
|
|
import { PointProgressionChart } from "~/components/standings/PointProgressionChart";
|
|
import { findUsersByIds, getUserDisplayName } from "~/models/user";
|
|
import { StandingsPreview } from "~/components/league/StandingsPreview";
|
|
import { buildTiedRankChecker, getDisplayRank } from "~/lib/standings-display";
|
|
import { RecentScoresCard } from "~/components/standings/RecentScoresCard";
|
|
import type { Route } from "./+types/$leagueId.standings.$seasonId";
|
|
|
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
|
return [{ title: `Standings — ${data?.league?.name ?? "League"} - Brackt` }];
|
|
}
|
|
|
|
export async function loader(args: Route.LoaderArgs) {
|
|
try {
|
|
const { params } = args;
|
|
const { leagueId, seasonId } = params;
|
|
|
|
const db = database();
|
|
|
|
// Fetch league
|
|
const league = await db.query.leagues.findFirst({
|
|
where: eq(schema.leagues.id, leagueId),
|
|
});
|
|
|
|
if (!league) {
|
|
throw new Response("League not found", { status: 404 });
|
|
}
|
|
|
|
// Fetch season
|
|
const season = await db.query.seasons.findFirst({
|
|
where: eq(schema.seasons.id, seasonId),
|
|
});
|
|
|
|
if (!season || season.leagueId !== leagueId) {
|
|
throw new Response("Season not found", { status: 404 });
|
|
}
|
|
|
|
// Check access
|
|
await requireLeagueAccess(args, {
|
|
leagueId,
|
|
seasonId,
|
|
db,
|
|
unauthMessage: "You must be logged in to view standings",
|
|
unauthorizedMessage: "You do not have access to this league",
|
|
});
|
|
|
|
// Fetch standings, teams (for owner lookup), and independent data in parallel
|
|
const [standingsWithComparison, teams, progressionData, seasonComplete, completionPercentage, recentScoreEvents] =
|
|
await Promise.all([
|
|
getSevenDayStandingsChange(seasonId, db),
|
|
db.query.teams.findMany({
|
|
where: eq(schema.teams.seasonId, seasonId),
|
|
columns: { id: true, ownerId: true },
|
|
}),
|
|
getSeasonPointProgression(seasonId, db),
|
|
isSeasonComplete(seasonId, db),
|
|
getSeasonCompletionPercentage(seasonId, db),
|
|
getRecentTeamScoreEvents(seasonId, 10, db),
|
|
]);
|
|
|
|
// Build teamId -> ownerName map
|
|
const ownerIds = [...new Set(teams.map((t) => t.ownerId).filter((id): id is string => id !== null))];
|
|
const userRows = await findUsersByIds(ownerIds);
|
|
const userById = new Map(userRows.map((u) => [u.id, u]));
|
|
const ownerNameByTeamId = new Map(
|
|
teams
|
|
.filter((t): t is typeof t & { ownerId: string } => t.ownerId !== null)
|
|
.map((t) => {
|
|
const user = userById.get(t.ownerId);
|
|
return [t.id, user ? getUserDisplayName(user) : null] as const;
|
|
})
|
|
);
|
|
|
|
// Map to format expected by component (use sevenDayRankChange instead of previousRank)
|
|
const formattedStandings = standingsWithComparison.map((standing) => ({
|
|
...standing,
|
|
rankChange: standing.sevenDayRankChange,
|
|
ownerName: ownerNameByTeamId.get(standing.teamId) ?? null,
|
|
}));
|
|
|
|
// Enrich recentScoreEvents with owner names
|
|
const enrichedScoreEvents = recentScoreEvents.map((event) => ({
|
|
...event,
|
|
ownerName: ownerNameByTeamId.get(event.teamId) ?? null,
|
|
}));
|
|
|
|
return {
|
|
league,
|
|
season,
|
|
standings: formattedStandings,
|
|
progressionData,
|
|
seasonComplete,
|
|
completionPercentage,
|
|
recentScoreEvents: enrichedScoreEvents,
|
|
};
|
|
} catch (error) {
|
|
logger.error("Error loading standings:", error);
|
|
logger.error("Error details:", {
|
|
message: error instanceof Error ? error.message : String(error),
|
|
stack: error instanceof Error ? error.stack : undefined,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export default function LeagueStandings() {
|
|
const { league, season, standings, progressionData, seasonComplete, completionPercentage, recentScoreEvents } = useLoaderData<typeof loader>();
|
|
|
|
const isTied = buildTiedRankChecker(standings.map((s) => s.currentRank));
|
|
const previewEntries = standings.map((s) => ({
|
|
teamId: s.teamId,
|
|
teamName: s.teamName,
|
|
ownerName: s.ownerName,
|
|
displayRank: getDisplayRank(s, standings.length, isTied(s.currentRank)),
|
|
currentRank: s.currentRank,
|
|
points: s.totalPoints,
|
|
rankChange: s.rankChange,
|
|
pointChange: s.sevenDayPointChange,
|
|
href: `/leagues/${league.id}/standings/${season.id}/teams/${s.teamId}`,
|
|
}));
|
|
|
|
return (
|
|
<div className="container mx-auto py-8 px-4">
|
|
{/* Header */}
|
|
<div className="mb-8">
|
|
<Button variant="ghost" className="mb-4" asChild>
|
|
<Link to={`/leagues/${league.id}`}>
|
|
← Back to League
|
|
</Link>
|
|
</Button>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-4xl font-bold mb-2">{league.name}</h1>
|
|
<p className="text-muted-foreground text-lg">
|
|
{season.year} Season Standings
|
|
</p>
|
|
</div>
|
|
<div className="text-right">
|
|
{seasonComplete ? (
|
|
<div className="inline-flex items-center px-3 py-1 rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100 text-sm font-medium">
|
|
✓ Season Complete
|
|
</div>
|
|
) : (
|
|
<div className="text-sm text-muted-foreground">
|
|
{completionPercentage}% Complete
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Two-column layout */}
|
|
<div className="grid gap-6 md:grid-cols-3">
|
|
{/* Left: Standings + Point Progression (2/3 width) */}
|
|
<div className="md:col-span-2 space-y-6">
|
|
<StandingsPreview
|
|
entries={previewEntries}
|
|
description={seasonComplete ? "Final Standings" : "Current Standings"}
|
|
/>
|
|
|
|
{progressionData.chartData.length > 0 && (
|
|
<PointProgressionChart
|
|
chartData={progressionData.chartData}
|
|
teams={progressionData.teams}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right: Recent Scores (1/3 width) */}
|
|
<RecentScoresCard events={recentScoreEvents} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|