2025-10-15 08:58:35 -07:00
|
|
|
|
import { useState, useEffect } from "react";
|
|
|
|
|
|
import { Form, Link, redirect, useNavigation } from "react-router";
|
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
|
|
|
|
import { auth } from "~/lib/auth.server";
|
2025-10-15 22:02:21 -07:00
|
|
|
|
import { format } from "date-fns";
|
|
|
|
|
|
import { CalendarIcon } from "lucide-react";
|
2026-03-21 13:41:39 -07:00
|
|
|
|
import { logger } from "~/lib/logger";
|
2025-10-11 00:29:04 -07:00
|
|
|
|
import { findLeagueById, updateLeague, deleteLeague } from "~/models/league";
|
2026-03-17 11:16:36 -07:00
|
|
|
|
import { sendStandingsUpdateNotification } from "~/services/discord";
|
2026-02-20 08:45:09 -08:00
|
|
|
|
import {
|
|
|
|
|
|
isCommissioner,
|
Grant sitewide admins commissioner-level access in leagues (#162)
* Grant sitewide admins commissioner-level access in leagues
- `isCommissioner()` now returns true for site admins, covering all
commissioner-gated loaders (league home, settings, sport season detail)
and draft API routes (start, pause, resume, rollback, replace-pick,
force-autopick, force-manual-pick, adjust-time-bank, make-pick)
- Added `hasCommissionerRecord()` (DB-only, no admin bypass) for the
"already a commissioner" duplicate-entry check in the settings action,
preventing a false positive when adding a site admin as commissioner
- `isCommissioner()` now runs the admin check and DB query in parallel
via Promise.all to avoid a serial roundtrip on every check
- Added "admin" to the `picked_by_type` enum (migration 0049) so picks
forced by a site admin are recorded accurately in the audit log rather
than as "commissioner"
- 8 unit tests covering both isCommissioner and hasCommissionerRecord
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix draft.force-manual-pick tests broken by isUserAdminByClerkId
The route now calls isUserAdminByClerkId which hits database().query.users,
but the test's mock DB had no query.users entry. Add a vi.mock for
~/models/user and default isUserAdminByClerkId to false in beforeEach.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 00:41:56 -07:00
|
|
|
|
hasCommissionerRecord,
|
2026-02-20 08:45:09 -08:00
|
|
|
|
findCommissionersByLeagueId,
|
|
|
|
|
|
createCommissioner,
|
|
|
|
|
|
countCommissionersByLeagueId,
|
|
|
|
|
|
removeCommissionerByLeagueAndUser,
|
|
|
|
|
|
} from "~/models/commissioner";
|
2026-03-21 09:44:05 -07:00
|
|
|
|
import { findCurrentSeasonWithSports, updateSeason, type NewSeason } from "~/models/season";
|
2026-03-18 00:53:40 -07:00
|
|
|
|
import { findTeamsBySeasonId, deleteTeam, removeTeamOwner, claimTeam } from "~/models/team";
|
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
|
|
|
|
import { findAllUsers, findUsersByIds, findUserById, getUserDisplayName, isUserAdmin } from "~/models/user";
|
2025-10-13 19:59:34 -07:00
|
|
|
|
import { unlinkSportFromSeason, linkMultipleSportsToSeason } from "~/models/season-sport";
|
2026-04-14 21:41:05 -07:00
|
|
|
|
import { findDraftableSportsSeasons, findSportsSeasonsByIds } from "~/models/sports-season";
|
2025-10-15 08:58:35 -07:00
|
|
|
|
import { findDraftSlotsBySeasonId, setDraftOrder, randomizeDraftOrder } from "~/models/draft-slot";
|
2026-02-24 10:01:27 -08:00
|
|
|
|
import { database } from "~/database/context";
|
|
|
|
|
|
import * as schema from "~/database/schema";
|
2025-10-21 22:34:26 -07:00
|
|
|
|
import { deleteAllDraftPicks } from "~/models/draft-pick";
|
|
|
|
|
|
import { clearAllQueuesForSeason } from "~/models/draft-queue";
|
2025-10-24 21:41:30 -07:00
|
|
|
|
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";
|
2026-03-20 21:36:39 -07:00
|
|
|
|
import { parseDraftSpeed } from "~/lib/draft-timer";
|
2025-10-11 00:29:04 -07:00
|
|
|
|
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";
|
2025-10-15 22:02:21 -07:00
|
|
|
|
import { Calendar } from "~/components/ui/calendar";
|
|
|
|
|
|
import {
|
|
|
|
|
|
Popover,
|
|
|
|
|
|
PopoverContent,
|
|
|
|
|
|
PopoverTrigger,
|
|
|
|
|
|
} from "~/components/ui/popover";
|
|
|
|
|
|
import { cn } from "~/lib/utils";
|
2025-10-11 00:29:04 -07:00
|
|
|
|
import {
|
|
|
|
|
|
Card,
|
|
|
|
|
|
CardContent,
|
|
|
|
|
|
CardDescription,
|
|
|
|
|
|
CardHeader,
|
|
|
|
|
|
CardTitle,
|
|
|
|
|
|
} from "~/components/ui/card";
|
|
|
|
|
|
import {
|
|
|
|
|
|
Select,
|
|
|
|
|
|
SelectContent,
|
|
|
|
|
|
SelectItem,
|
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
|
SelectValue,
|
|
|
|
|
|
} from "~/components/ui/select";
|
2025-10-13 19:59:34 -07:00
|
|
|
|
import { Badge } from "~/components/ui/badge";
|
|
|
|
|
|
import { Checkbox } from "~/components/ui/checkbox";
|
2025-10-11 00:29:04 -07:00
|
|
|
|
import {
|
|
|
|
|
|
AlertDialog,
|
|
|
|
|
|
AlertDialogAction,
|
|
|
|
|
|
AlertDialogCancel,
|
|
|
|
|
|
AlertDialogContent,
|
|
|
|
|
|
AlertDialogDescription,
|
|
|
|
|
|
AlertDialogFooter,
|
|
|
|
|
|
AlertDialogHeader,
|
|
|
|
|
|
AlertDialogTitle,
|
|
|
|
|
|
AlertDialogTrigger,
|
|
|
|
|
|
} from "~/components/ui/alert-dialog";
|
2025-10-29 00:04:27 -07:00
|
|
|
|
import { ScoringRulesEditor } from "~/components/scoring/ScoringRulesEditor";
|
2026-03-05 10:07:15 -08:00
|
|
|
|
import { toast } from "sonner";
|
2025-10-11 00:29:04 -07:00
|
|
|
|
|
2026-03-10 12:10:52 -07:00
|
|
|
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
|
|
|
|
|
return [{ title: `Settings — ${data?.league?.name ?? "League"} - Brackt` }];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-11 00:29:04 -07:00
|
|
|
|
export async function loader(args: Route.LoaderArgs) {
|
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
|
|
|
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
|
|
|
|
|
const userId = session?.user.id ?? null;
|
2025-10-11 00:29:04 -07:00
|
|
|
|
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,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 22:40:19 -07:00
|
|
|
|
// Get current season with sports and draftable seasons in parallel
|
|
|
|
|
|
const [season, draftableSportsSeasons] = await Promise.all([
|
|
|
|
|
|
findCurrentSeasonWithSports(leagueId),
|
|
|
|
|
|
findDraftableSportsSeasons(),
|
|
|
|
|
|
]);
|
2025-10-11 00:29:04 -07:00
|
|
|
|
const teams = season ? await findTeamsBySeasonId(season.id) : [];
|
2026-03-18 22:40:19 -07:00
|
|
|
|
// 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.
|
2026-04-05 19:09:52 -07:00
|
|
|
|
const today = new Date().toISOString().slice(0, 10);
|
2026-03-18 22:40:19 -07:00
|
|
|
|
const draftableIds = new Set(draftableSportsSeasons.map((s) => s.id));
|
|
|
|
|
|
const linkedButNotDraftable = (season?.seasonSports ?? [])
|
|
|
|
|
|
.map((s) => s.sportsSeason)
|
2026-04-05 19:09:52 -07:00
|
|
|
|
.filter((ss) => !draftableIds.has(ss.id) && (ss.draftOff < today || ss.draftOn > today));
|
2026-03-18 22:40:19 -07:00
|
|
|
|
const allSportsSeasons = [...draftableSportsSeasons, ...linkedButNotDraftable];
|
2025-10-15 08:58:35 -07:00
|
|
|
|
const draftSlots = season ? await findDraftSlotsBySeasonId(season.id) : [];
|
2025-10-13 19:59:34 -07:00
|
|
|
|
|
|
|
|
|
|
// Count teams with owners
|
|
|
|
|
|
const teamsWithOwners = teams.filter(team => team.ownerId !== null).length;
|
2025-10-11 00:29:04 -07:00
|
|
|
|
|
2025-10-21 22:15:15 -07:00
|
|
|
|
// Check if user is admin
|
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
|
|
|
|
const isAdmin = await isUserAdmin(userId);
|
2025-10-21 22:15:15 -07:00
|
|
|
|
|
2026-02-20 08:45:09 -08:00
|
|
|
|
// Get all users (only for admins - used in team assignment dropdown)
|
2025-10-21 22:15:15 -07:00
|
|
|
|
const allUsers = isAdmin ? await findAllUsers() : [];
|
|
|
|
|
|
|
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])];
|
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
|
|
|
|
const userRows = await findUsersByIds(allUserIds);
|
|
|
|
|
|
const userById = new Map(userRows.map((u) => [u.id, u]));
|
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 commissionerUserData = commissioners.map((c) => {
|
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
|
|
|
|
const user = userById.get(c.userId);
|
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
|
|
|
|
return {
|
|
|
|
|
|
...c,
|
|
|
|
|
|
userName: user ? (getUserDisplayName(user) ?? "Unknown User") : "Unknown User",
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const validOwners = uniqueOwnerIds
|
|
|
|
|
|
.map((ownerId) => {
|
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
|
|
|
|
const user = userById.get(ownerId);
|
|
|
|
|
|
return user ? { id: user.id, name: getUserDisplayName(user) } : null;
|
2025-10-21 22:15:15 -07:00
|
|
|
|
})
|
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);
|
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
|
|
|
|
const ownerMap = new Map(validOwners.map((o) => [o.id, o.name]));
|
2026-02-20 08:45:09 -08:00
|
|
|
|
|
|
|
|
|
|
// League members (team owners) - available to all commissioners for adding co-commissioners
|
|
|
|
|
|
const leagueMembers = validOwners.map((o) => ({
|
|
|
|
|
|
id: o.id,
|
|
|
|
|
|
name: o.name,
|
|
|
|
|
|
}));
|
2025-10-21 22:15:15 -07:00
|
|
|
|
|
2025-10-11 00:29:04 -07:00
|
|
|
|
return {
|
|
|
|
|
|
league,
|
2025-10-13 19:59:34 -07:00
|
|
|
|
season,
|
|
|
|
|
|
teams,
|
2025-10-11 00:29:04 -07:00
|
|
|
|
teamCount: teams.length,
|
2025-10-13 19:59:34 -07:00
|
|
|
|
teamsWithOwners,
|
|
|
|
|
|
allSportsSeasons: allSportsSeasons as Array<typeof allSportsSeasons[0] & { sport: { id: string; name: string; type: string; slug: string } }>,
|
2025-10-15 08:58:35 -07:00
|
|
|
|
draftSlots,
|
2025-10-21 22:15:15 -07:00
|
|
|
|
isAdmin,
|
|
|
|
|
|
allUsers,
|
2026-02-20 08:45:09 -08:00
|
|
|
|
leagueMembers,
|
2025-10-21 22:15:15 -07:00
|
|
|
|
ownerMap: Object.fromEntries(ownerMap),
|
2026-02-20 08:45:09 -08:00
|
|
|
|
commissioners: commissionerUserData,
|
|
|
|
|
|
currentUserId: userId,
|
2025-10-11 00:29:04 -07:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function action(args: Route.ActionArgs) {
|
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
|
|
|
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
|
|
|
|
|
const userId = session?.user.id ?? null;
|
2025-10-11 00:29:04 -07:00
|
|
|
|
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";
|
2026-03-17 11:16:36 -07:00
|
|
|
|
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" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 11:16:36 -07:00
|
|
|
|
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,
|
2026-03-17 11:16:36 -07:00
|
|
|
|
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,
|
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
|
|
|
|
actorUserId: userId,
|
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
|
|
|
|
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) {
|
2026-03-21 13:41:39 -07:00
|
|
|
|
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." };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 11:16:36 -07:00
|
|
|
|
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],
|
|
|
|
|
|
]),
|
2026-03-19 15:52:57 -07:00
|
|
|
|
previousRanks: new Map([
|
|
|
|
|
|
["1", 2],
|
|
|
|
|
|
["2", 1],
|
|
|
|
|
|
["3", 3],
|
|
|
|
|
|
]),
|
2026-03-17 11:16:36 -07:00
|
|
|
|
eventName: "Test Notification",
|
2026-03-19 15:52:57 -07:00
|
|
|
|
scoredMatches: [
|
|
|
|
|
|
{ winnerName: "Team Alpha", loserName: "Team Beta", winnerUsername: "manager1", loserUsername: "manager2" },
|
|
|
|
|
|
],
|
2026-03-17 11:16:36 -07:00
|
|
|
|
});
|
|
|
|
|
|
return { testSuccess: true };
|
|
|
|
|
|
} catch (err) {
|
2026-03-21 13:41:39 -07:00
|
|
|
|
logger.error("Discord test webhook failed:", err);
|
2026-03-17 11:16:36 -07:00
|
|
|
|
return { error: "Failed to send test notification. Check your webhook URL." };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-13 19:59:34 -07:00
|
|
|
|
// 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`);
|
|
|
|
|
|
}
|
2025-10-13 19:59:34 -07:00
|
|
|
|
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(
|
2026-03-21 09:44:05 -07:00
|
|
|
|
season.seasonSports?.map((s) => s.sportsSeason.id) || []
|
2025-10-13 19:59:34 -07:00
|
|
|
|
);
|
|
|
|
|
|
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 };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-15 08:58:35 -07:00
|
|
|
|
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,
|
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
|
|
|
|
actorUserId: userId,
|
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
|
|
|
|
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,
|
|
|
|
|
|
})),
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-10-15 08:58:35 -07:00
|
|
|
|
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,
|
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
|
|
|
|
actorUserId: userId,
|
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
|
|
|
|
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,
|
|
|
|
|
|
})),
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-10-15 08:58:35 -07:00
|
|
|
|
return { success: true, message: "Draft order randomized successfully" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-21 22:15:15 -07:00
|
|
|
|
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) {
|
2026-03-21 13:41:39 -07:00
|
|
|
|
logger.error("Error removing team owner:", error);
|
2025-10-21 22:15:15 -07:00
|
|
|
|
return { error: "Failed to remove owner. Please try again." };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (intent === "assign-team-owner") {
|
|
|
|
|
|
const teamId = formData.get("teamId") as string;
|
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
|
|
|
|
const assignedUserId = formData.get("userId") as string;
|
2025-10-21 22:15:15 -07:00
|
|
|
|
|
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
|
|
|
|
if (!teamId || !assignedUserId) {
|
2025-10-21 22:15:15 -07:00
|
|
|
|
return { error: "Team ID and User ID are required" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Check if user is admin (only admins can assign owners)
|
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
|
|
|
|
const isAdmin = await isUserAdmin(userId);
|
2025-10-21 22:15:15 -07:00
|
|
|
|
if (!isAdmin) {
|
|
|
|
|
|
return { error: "Only admins can assign team owners" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 00:53:40 -07:00
|
|
|
|
// Look up user before assigning to fail fast and get their name
|
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
|
|
|
|
const assignedUser = await findUserById(assignedUserId);
|
2026-03-18 00:53:40 -07:00
|
|
|
|
if (!assignedUser) {
|
|
|
|
|
|
return { error: "User not found. Please try again." };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-21 22:27:14 -07:00
|
|
|
|
// Check if user is already assigned to a team in this season
|
|
|
|
|
|
const teams = await findTeamsBySeasonId(season.id);
|
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
|
|
|
|
const userAlreadyHasTeam = teams.some(team => team.ownerId === assignedUserId);
|
2026-03-18 00:53:40 -07:00
|
|
|
|
|
2025-10-21 22:27:14 -07:00
|
|
|
|
if (userAlreadyHasTeam) {
|
|
|
|
|
|
return { error: "This user is already assigned to a team in this league" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 00:53:40 -07:00
|
|
|
|
// 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." };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-21 22:15:15 -07:00
|
|
|
|
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"}`;
|
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
|
|
|
|
await claimTeam(teamId, assignedUserId, teamName);
|
2026-03-18 00:53:40 -07:00
|
|
|
|
|
2025-10-21 22:15:15 -07:00
|
|
|
|
return { success: true, message: "Owner assigned successfully" };
|
|
|
|
|
|
} catch (error) {
|
2026-03-21 13:41:39 -07:00
|
|
|
|
logger.error("Error assigning team owner:", error);
|
2025-10-21 22:15:15 -07:00
|
|
|
|
return { error: "Failed to assign owner. Please try again." };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-21 22:34:26 -07:00
|
|
|
|
if (intent === "reset-draft") {
|
|
|
|
|
|
// Check if user is admin (only admins can reset draft)
|
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
|
|
|
|
const isAdmin = await isUserAdmin(userId);
|
2025-10-21 22:34:26 -07:00
|
|
|
|
if (!isAdmin) {
|
|
|
|
|
|
return { error: "Only admins can reset the draft" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// Delete all draft picks
|
|
|
|
|
|
await deleteAllDraftPicks(season.id);
|
2025-10-24 21:41:30 -07:00
|
|
|
|
|
2025-10-21 22:34:26 -07:00
|
|
|
|
// Clear all draft queues
|
|
|
|
|
|
await clearAllQueuesForSeason(season.id);
|
2025-10-24 21:41:30 -07:00
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
|
2025-10-24 21:41:30 -07:00
|
|
|
|
// 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,
|
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
|
|
|
|
actorUserId: userId,
|
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
|
|
|
|
action: "draft_reset",
|
|
|
|
|
|
details: { previousPickNumber },
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-10-21 22:34:26 -07:00
|
|
|
|
return { success: true, message: "Draft has been reset successfully. Draft order preserved." };
|
|
|
|
|
|
} catch (error) {
|
2026-03-21 13:41:39 -07:00
|
|
|
|
logger.error("Error resetting draft:", error);
|
2025-10-21 22:34:26 -07:00
|
|
|
|
return { error: "Failed to reset draft. Please try again." };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-11 00:29:04 -07:00
|
|
|
|
if (intent === "delete") {
|
|
|
|
|
|
// Delete the league
|
|
|
|
|
|
await deleteLeague(leagueId);
|
|
|
|
|
|
|
|
|
|
|
|
// Redirect to home with success message
|
|
|
|
|
|
return redirect("/?deleted=true");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (intent === "update") {
|
2025-10-13 19:59:34 -07:00
|
|
|
|
const teamCount = formData.get("teamCount");
|
2025-10-15 22:02:21 -07:00
|
|
|
|
const draftDateTime = formData.get("draftDateTime");
|
|
|
|
|
|
const draftRounds = formData.get("draftRounds");
|
2025-10-20 15:03:11 -07:00
|
|
|
|
const draftSpeed = formData.get("draftSpeed");
|
2026-03-20 21:36:39 -07:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
// League-level fields (name, isPublicDraftBoard) are already saved above.
|
|
|
|
|
|
// Now handle season-specific updates.
|
2025-10-11 00:29:04 -07:00
|
|
|
|
try {
|
2025-10-15 22:02:21 -07:00
|
|
|
|
// Update season settings
|
2026-03-21 09:44:05 -07:00
|
|
|
|
const seasonUpdates: Partial<NewSeason> = {};
|
2025-10-29 00:04:27 -07:00
|
|
|
|
|
2026-04-14 21:41:05 -07:00
|
|
|
|
// Handle draft rounds (only if value actually changed)
|
2025-10-15 22:02:21 -07:00
|
|
|
|
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" };
|
|
|
|
|
|
}
|
2026-04-14 21:41:05 -07:00
|
|
|
|
if (draftRoundsNum !== season.draftRounds) {
|
|
|
|
|
|
seasonUpdates.draftRounds = draftRoundsNum;
|
|
|
|
|
|
}
|
2025-10-15 22:02:21 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 21:41:05 -07:00
|
|
|
|
// Handle draft date/time (only if value actually changed)
|
2025-10-15 22:02:21 -07:00
|
|
|
|
if (typeof draftDateTime === "string") {
|
2026-04-14 21:41:05 -07:00
|
|
|
|
const newDateTime = draftDateTime ? new Date(draftDateTime) : null;
|
|
|
|
|
|
const currentDateTime = season.draftDateTime ? new Date(season.draftDateTime) : null;
|
|
|
|
|
|
const changed = newDateTime?.getTime() !== currentDateTime?.getTime();
|
|
|
|
|
|
if (changed) {
|
|
|
|
|
|
seasonUpdates.draftDateTime = newDateTime;
|
|
|
|
|
|
}
|
2025-10-15 22:02:21 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 21:41:05 -07:00
|
|
|
|
// Set draft times from speed preset (only if draftSpeed was submitted and values changed)
|
2026-02-23 23:23:24 -08:00
|
|
|
|
if (draftSpeed !== null) {
|
2026-04-14 21:41:05 -07:00
|
|
|
|
const { draftInitialTime, draftIncrementTime } = parseDraftSpeed(
|
|
|
|
|
|
draftSpeed as string | null,
|
|
|
|
|
|
draftTimerMode ?? "chess_clock"
|
|
|
|
|
|
);
|
|
|
|
|
|
if (draftInitialTime !== season.draftInitialTime) {
|
|
|
|
|
|
seasonUpdates.draftInitialTime = draftInitialTime;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (draftIncrementTime !== season.draftIncrementTime) {
|
|
|
|
|
|
seasonUpdates.draftIncrementTime = draftIncrementTime;
|
|
|
|
|
|
}
|
2026-02-23 23:23:24 -08:00
|
|
|
|
}
|
2025-10-16 00:37:51 -07:00
|
|
|
|
|
2026-04-14 21:41:05 -07:00
|
|
|
|
// Timer mode (only if submitted and changed)
|
|
|
|
|
|
if (draftTimerMode !== null && draftTimerMode !== season.draftTimerMode) {
|
2026-03-20 21:36:39 -07:00
|
|
|
|
seasonUpdates.draftTimerMode = draftTimerMode;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 21:41:05 -07:00
|
|
|
|
// Handle scoring rules (only if in pre_draft status and values changed)
|
2025-10-29 00:04:27 -07:00
|
|
|
|
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` };
|
|
|
|
|
|
}
|
2026-04-14 21:41:05 -07:00
|
|
|
|
if (points !== (season as unknown as Record<string, unknown>)[field]) {
|
|
|
|
|
|
(seasonUpdates as Record<string, unknown>)[field] = points;
|
|
|
|
|
|
}
|
2025-10-29 00:04:27 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-15 22:02:21 -07:00
|
|
|
|
// 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));
|
|
|
|
|
|
|
2026-04-14 21:41:05 -07:00
|
|
|
|
const seasonAsMap = season as unknown as Record<string, unknown>;
|
|
|
|
|
|
const updatesAsMap = seasonUpdates as Record<string, unknown>;
|
|
|
|
|
|
|
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 (draftFields.length > 0) {
|
|
|
|
|
|
await logCommissionerAction({
|
|
|
|
|
|
seasonId: season.id,
|
|
|
|
|
|
leagueId,
|
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
|
|
|
|
actorUserId: userId,
|
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
|
|
|
|
action: "draft_settings_changed",
|
|
|
|
|
|
details: {
|
|
|
|
|
|
changedFields: draftFields,
|
2026-04-14 21:41:05 -07:00
|
|
|
|
previousValues: Object.fromEntries(draftFields.map((k) => [k, seasonAsMap[k]])),
|
|
|
|
|
|
newValues: Object.fromEntries(draftFields.map((k) => [k, updatesAsMap[k]])),
|
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 (scoringChangedFields.length > 0) {
|
|
|
|
|
|
await logCommissionerAction({
|
|
|
|
|
|
seasonId: season.id,
|
|
|
|
|
|
leagueId,
|
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
|
|
|
|
actorUserId: userId,
|
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
|
|
|
|
action: "scoring_rules_changed",
|
|
|
|
|
|
details: {
|
|
|
|
|
|
changedFields: scoringChangedFields,
|
2026-04-14 21:41:05 -07:00
|
|
|
|
previousValues: Object.fromEntries(scoringChangedFields.map((k) => [k, seasonAsMap[k]])),
|
|
|
|
|
|
newValues: Object.fromEntries(scoringChangedFields.map((k) => [k, updatesAsMap[k]])),
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Handle sports changes (only valid in pre_draft)
|
|
|
|
|
|
if (season.status === "pre_draft") {
|
|
|
|
|
|
const selectedSports = formData.getAll("sportsSeasons");
|
|
|
|
|
|
const currentSportIds = new Set(
|
|
|
|
|
|
season.seasonSports?.map((s) => s.sportsSeason.id) || []
|
|
|
|
|
|
);
|
|
|
|
|
|
const newSportIds = new Set(selectedSports as string[]);
|
|
|
|
|
|
|
|
|
|
|
|
for (const sportId of currentSportIds) {
|
|
|
|
|
|
if (!newSportIds.has(sportId)) {
|
|
|
|
|
|
await unlinkSportFromSeason(season.id, sportId);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const removedIds = [...currentSportIds].filter((id) => !newSportIds.has(id));
|
|
|
|
|
|
const addedIds = [...newSportIds].filter((id) => !currentSportIds.has(id));
|
|
|
|
|
|
if (removedIds.length > 0 || addedIds.length > 0) {
|
|
|
|
|
|
const nameMap = new Map(
|
|
|
|
|
|
season.seasonSports?.map((s) => [s.sportsSeason.id, `${s.sportsSeason.sport.name} ${s.sportsSeason.year}`]) ?? []
|
|
|
|
|
|
);
|
|
|
|
|
|
const addedSeasons = await findSportsSeasonsByIds(addedIds);
|
|
|
|
|
|
const addedNameMap = new Map(addedSeasons.map((ss) => [ss.id, `${ss.sport.name} ${ss.year}`]));
|
|
|
|
|
|
const addedNames = addedIds.map((id) => addedNameMap.get(id) ?? id);
|
|
|
|
|
|
await logCommissionerAction({
|
|
|
|
|
|
seasonId: season.id,
|
|
|
|
|
|
leagueId,
|
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
|
|
|
|
actorUserId: userId,
|
2026-04-14 21:41:05 -07:00
|
|
|
|
action: "sports_changed",
|
|
|
|
|
|
details: {
|
|
|
|
|
|
added: addedNames,
|
|
|
|
|
|
removed: removedIds.map((id) => nameMap.get(id) ?? 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
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2025-10-15 22:02:21 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-13 19:59:34 -07:00
|
|
|
|
// 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}`,
|
2026-02-24 10:01:27 -08:00
|
|
|
|
ownerId: null as null,
|
2025-10-13 19:59:34 -07:00
|
|
|
|
})
|
|
|
|
|
|
);
|
2026-02-24 10:01:27 -08:00
|
|
|
|
|
|
|
|
|
|
// 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,
|
|
|
|
|
|
}))
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
2025-10-13 19:59:34 -07:00
|
|
|
|
} 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-11 01:03:31 -07:00
|
|
|
|
return redirect(`/leagues/${leagueId}?updated=true`);
|
2025-10-11 00:29:04 -07:00
|
|
|
|
} catch (error) {
|
2026-03-21 13:41:39 -07:00
|
|
|
|
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." };
|
2025-10-11 00:29:04 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-20 08:45:09 -08:00
|
|
|
|
if (intent === "add-commissioner") {
|
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
|
|
|
|
const newCommissionerUserId = formData.get("userId") as string;
|
2026-02-20 08:45:09 -08:00
|
|
|
|
|
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
|
|
|
|
if (!newCommissionerUserId) {
|
2026-02-20 08:45:09 -08:00
|
|
|
|
return { error: "User is required" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Grant sitewide admins commissioner-level access in leagues (#162)
* Grant sitewide admins commissioner-level access in leagues
- `isCommissioner()` now returns true for site admins, covering all
commissioner-gated loaders (league home, settings, sport season detail)
and draft API routes (start, pause, resume, rollback, replace-pick,
force-autopick, force-manual-pick, adjust-time-bank, make-pick)
- Added `hasCommissionerRecord()` (DB-only, no admin bypass) for the
"already a commissioner" duplicate-entry check in the settings action,
preventing a false positive when adding a site admin as commissioner
- `isCommissioner()` now runs the admin check and DB query in parallel
via Promise.all to avoid a serial roundtrip on every check
- Added "admin" to the `picked_by_type` enum (migration 0049) so picks
forced by a site admin are recorded accurately in the audit log rather
than as "commissioner"
- 8 unit tests covering both isCommissioner and hasCommissionerRecord
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix draft.force-manual-pick tests broken by isUserAdminByClerkId
The route now calls isUserAdminByClerkId which hits database().query.users,
but the test's mock DB had no query.users entry. Add a vi.mock for
~/models/user and default isUserAdminByClerkId to false in beforeEach.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 00:41:56 -07:00
|
|
|
|
// 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)
|
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
|
|
|
|
const alreadyCommissioner = await hasCommissionerRecord(leagueId, newCommissionerUserId);
|
2026-02-20 08:45:09 -08:00
|
|
|
|
if (alreadyCommissioner) {
|
|
|
|
|
|
return { error: "This user is already a commissioner" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
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
|
|
|
|
await createCommissioner({ leagueId, userId: newCommissionerUserId });
|
2026-02-20 08:45:09 -08:00
|
|
|
|
return { success: true, message: "Commissioner added successfully" };
|
|
|
|
|
|
} catch (error) {
|
2026-03-21 13:41:39 -07:00
|
|
|
|
logger.error("Error adding commissioner:", error);
|
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) {
|
2026-03-21 13:41:39 -07:00
|
|
|
|
logger.error("Error removing commissioner:", error);
|
2026-02-20 08:45:09 -08:00
|
|
|
|
return { error: "Failed to remove commissioner. Please try again." };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-11 00:29:04 -07:00
|
|
|
|
return { error: "Invalid action" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default function LeagueSettings({ loaderData, actionData }: Route.ComponentProps) {
|
2026-02-20 08:45:09 -08:00
|
|
|
|
const { league, season, teams, teamCount, teamsWithOwners, allSportsSeasons, draftSlots, isAdmin, allUsers, leagueMembers, ownerMap, commissioners, currentUserId } = loaderData;
|
2025-10-15 08:58:35 -07:00
|
|
|
|
const navigation = useNavigation();
|
2025-10-11 00:29:04 -07:00
|
|
|
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
2026-03-20 21:36:39 -07:00
|
|
|
|
const [timerMode, setTimerMode] = useState<"chess_clock" | "standard">(season?.draftTimerMode ?? "chess_clock");
|
2025-10-13 19:59:34 -07:00
|
|
|
|
const [selectedSports, setSelectedSports] = useState<Set<string>>(
|
2026-03-21 09:44:05 -07:00
|
|
|
|
new Set(season?.seasonSports?.map((s) => s.sportsSeason.id) || [])
|
2025-10-13 19:59:34 -07:00
|
|
|
|
);
|
2025-10-15 08:58:35 -07:00
|
|
|
|
const [draftOrderTeams, setDraftOrderTeams] = useState<string[]>(
|
2026-02-20 08:45:09 -08:00
|
|
|
|
draftSlots.length > 0
|
|
|
|
|
|
? draftSlots.map((slot) => slot.teamId)
|
|
|
|
|
|
: teams.map((team) => team.id)
|
2025-10-15 08:58:35 -07:00
|
|
|
|
);
|
2025-10-15 21:50:02 -07:00
|
|
|
|
const [draftRounds, setDraftRounds] = useState<number>(season?.draftRounds || 20);
|
2025-10-15 22:02:21 -07:00
|
|
|
|
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")
|
|
|
|
|
|
: ""
|
|
|
|
|
|
);
|
2025-10-15 08:58:35 -07:00
|
|
|
|
|
|
|
|
|
|
// Update draft order when loader data changes (after randomization)
|
|
|
|
|
|
useEffect(() => {
|
2026-02-20 08:45:09 -08:00
|
|
|
|
const newOrder = draftSlots.length > 0
|
|
|
|
|
|
? draftSlots.map((slot) => slot.teamId)
|
|
|
|
|
|
: teams.map((team) => team.id);
|
2025-10-15 08:58:35 -07:00
|
|
|
|
setDraftOrderTeams(newOrder);
|
|
|
|
|
|
}, [draftSlots, teams]);
|
2025-10-13 19:59:34 -07:00
|
|
|
|
|
|
|
|
|
|
const canEditSports = season && season.status === "pre_draft";
|
|
|
|
|
|
const canEditTeamCount = season && season.status === "pre_draft";
|
2025-10-15 08:58:35 -07:00
|
|
|
|
const canEditDraftOrder = season && season.status === "pre_draft";
|
2025-10-15 21:50:02 -07:00
|
|
|
|
const canEditDraftRounds = season && season.status === "pre_draft";
|
2025-10-13 19:59:34 -07:00
|
|
|
|
const minTeamCount = Math.max(6, teamsWithOwners);
|
|
|
|
|
|
|
2025-10-15 21:50:02 -07:00
|
|
|
|
// 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);
|
|
|
|
|
|
|
2025-10-13 19:59:34 -07:00
|
|
|
|
const handleSportToggle = (sportId: string) => {
|
|
|
|
|
|
const newSelected = new Set(selectedSports);
|
|
|
|
|
|
if (newSelected.has(sportId)) {
|
|
|
|
|
|
newSelected.delete(sportId);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
newSelected.add(sportId);
|
|
|
|
|
|
}
|
|
|
|
|
|
setSelectedSports(newSelected);
|
|
|
|
|
|
};
|
2025-10-11 00:29:04 -07:00
|
|
|
|
|
|
|
|
|
|
const handleDelete = () => {
|
|
|
|
|
|
setIsDeleteDialogOpen(false);
|
|
|
|
|
|
// The form submission will handle the actual deletion
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-15 08:58:35 -07:00
|
|
|
|
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) => {
|
2026-02-20 08:45:09 -08:00
|
|
|
|
return teams.find((t) => t.id === teamId);
|
2025-10-15 08:58:35 -07:00
|
|
|
|
};
|
|
|
|
|
|
|
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");
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-11 00:29:04 -07:00
|
|
|
|
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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2025-10-15 21:50:02 -07:00
|
|
|
|
<Form method="post" className="space-y-6">
|
|
|
|
|
|
<input type="hidden" name="intent" value="update" />
|
2026-03-05 10:07:15 -08:00
|
|
|
|
|
2025-10-15 21:50:02 -07:00
|
|
|
|
{/* General Settings */}
|
2025-10-11 00:29:04 -07:00
|
|
|
|
<Card>
|
|
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<CardTitle>General Settings</CardTitle>
|
|
|
|
|
|
<CardDescription>
|
|
|
|
|
|
Update your league's basic information
|
|
|
|
|
|
</CardDescription>
|
|
|
|
|
|
</CardHeader>
|
2025-10-15 21:50:02 -07:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2025-10-20 15:03:11 -07:00
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
id="isPublicDraftBoard"
|
|
|
|
|
|
name="isPublicDraftBoard"
|
|
|
|
|
|
defaultChecked={league.isPublicDraftBoard}
|
2026-02-20 19:26:11 -08:00
|
|
|
|
className="h-4 w-4 rounded border-border"
|
2025-10-20 15:03:11 -07:00
|
|
|
|
/>
|
|
|
|
|
|
<Label htmlFor="isPublicDraftBoard" className="font-normal cursor-pointer">
|
|
|
|
|
|
Make draft board publicly accessible (no login required)
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-10-15 21:50:02 -07:00
|
|
|
|
<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>
|
2025-10-11 00:29:04 -07:00
|
|
|
|
|
2026-03-17 11:16:36 -07:00
|
|
|
|
{/* 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>
|
|
|
|
|
|
|
2025-10-15 21:50:02 -07:00
|
|
|
|
{/* 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) => (
|
2025-10-13 19:59:34 -07:00
|
|
|
|
<SelectItem
|
|
|
|
|
|
key={num}
|
|
|
|
|
|
value={num.toString()}
|
|
|
|
|
|
>
|
2025-10-15 21:50:02 -07:00
|
|
|
|
{num} Round{num !== 1 ? 's' : ''}
|
|
|
|
|
|
{num === recommendedRounds && ' (recommended)'}
|
2025-10-11 00:29:04 -07:00
|
|
|
|
</SelectItem>
|
|
|
|
|
|
))}
|
2025-10-15 21:50:02 -07:00
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
<div className="space-y-1">
|
2025-10-11 00:29:04 -07:00
|
|
|
|
<p className="text-sm text-muted-foreground">
|
2025-10-15 21:50:02 -07:00
|
|
|
|
Minimum: {minRounds} (number of sports selected)
|
|
|
|
|
|
{recommendedRounds > minRounds && ` • Recommended: ${recommendedRounds}`}
|
2025-10-11 00:29:04 -07:00
|
|
|
|
</p>
|
2025-10-15 21:50:02 -07:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2025-10-11 00:29:04 -07:00
|
|
|
|
</div>
|
2025-10-15 21:50:02 -07:00
|
|
|
|
</div>
|
2025-10-15 22:02:21 -07:00
|
|
|
|
|
|
|
|
|
|
<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>
|
2025-10-16 00:37:51 -07:00
|
|
|
|
|
2026-03-20 21:36:39 -07:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2025-10-16 00:37:51 -07:00
|
|
|
|
<div className="space-y-2">
|
2025-10-20 15:03:11 -07:00
|
|
|
|
<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={
|
2026-03-20 21:36:39 -07:00
|
|
|
|
timerMode === "standard"
|
|
|
|
|
|
? (season?.draftIncrementTime?.toString() ?? "90")
|
|
|
|
|
|
: season?.draftInitialTime === 60 && season?.draftIncrementTime === 10
|
2025-10-20 15:03:11 -07:00
|
|
|
|
? "fast"
|
|
|
|
|
|
: season?.draftInitialTime === 120 && season?.draftIncrementTime === 15
|
|
|
|
|
|
? "standard"
|
|
|
|
|
|
: season?.draftInitialTime === 28800 && season?.draftIncrementTime === 3600
|
|
|
|
|
|
? "slow"
|
|
|
|
|
|
: season?.draftInitialTime === 43200 && season?.draftIncrementTime === 3600
|
|
|
|
|
|
? "very-slow"
|
|
|
|
|
|
: "standard"
|
|
|
|
|
|
}
|
2026-03-20 21:36:39 -07:00
|
|
|
|
key={timerMode}
|
2025-10-16 00:37:51 -07:00
|
|
|
|
disabled={!canEditDraftRounds}
|
|
|
|
|
|
required
|
2025-10-20 15:03:11 -07:00
|
|
|
|
>
|
2026-03-20 21:36:39 -07:00
|
|
|
|
{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>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2025-10-20 15:03:11 -07:00
|
|
|
|
</select>
|
2025-10-16 00:37:51 -07:00
|
|
|
|
<p className="text-sm text-muted-foreground">
|
2026-03-20 21:36:39 -07:00
|
|
|
|
{timerMode === "standard"
|
|
|
|
|
|
? "Time per pick — resets to this value after each selection"
|
|
|
|
|
|
: "Initial time bank + increment added after each pick"}
|
2025-10-16 00:37:51 -07:00
|
|
|
|
</p>
|
2026-02-23 23:23:24 -08:00
|
|
|
|
|
2025-10-16 00:37:51 -07:00
|
|
|
|
</div>
|
2025-10-11 00:29:04 -07:00
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
2025-10-29 00:04:27 -07:00
|
|
|
|
{/* 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"}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
2025-10-15 21:50:02 -07:00
|
|
|
|
{/* Sports Seasons */}
|
2025-10-13 19:59:34 -07:00
|
|
|
|
<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>
|
2025-10-15 21:50:02 -07:00
|
|
|
|
<CardContent className="space-y-4">
|
2025-10-13 19:59:34 -07:00
|
|
|
|
|
2025-10-15 21:50:02 -07:00
|
|
|
|
<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">
|
2025-10-15 21:50:02 -07:00
|
|
|
|
<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}
|
2025-10-15 21:50:02 -07:00
|
|
|
|
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)}
|
2025-10-15 21:50:02 -07:00
|
|
|
|
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}
|
2025-10-15 21:50:02 -07:00
|
|
|
|
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})
|
2025-10-15 21:50:02 -07:00
|
|
|
|
<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("_", " ")}
|
2025-10-15 21:50:02 -07:00
|
|
|
|
</Badge>
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
|
No sports seasons available
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
2025-10-13 19:59:34 -07:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-10-15 21:50:02 -07:00
|
|
|
|
</div>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
2025-10-13 19:59:34 -07:00
|
|
|
|
|
2025-10-15 21:50:02 -07:00
|
|
|
|
{/* Save Button */}
|
|
|
|
|
|
{actionData?.error && (
|
|
|
|
|
|
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
|
|
|
|
|
{actionData.error}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-10-13 19:59:34 -07:00
|
|
|
|
|
2025-10-15 21:50:02 -07:00
|
|
|
|
{actionData?.success && (
|
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">
|
2025-10-15 21:50:02 -07:00
|
|
|
|
Settings updated successfully!
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-10-13 19:59:34 -07:00
|
|
|
|
|
2025-10-15 21:50:02 -07:00
|
|
|
|
<Button type="submit" size="lg" className="w-full">
|
|
|
|
|
|
Save All Settings
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Form>
|
2025-10-13 19:59:34 -07:00
|
|
|
|
|
2025-10-15 21:50:02 -07:00
|
|
|
|
<div className="space-y-6">
|
2025-10-15 08:58:35 -07:00
|
|
|
|
{/* Draft Order Management */}
|
2025-10-21 21:36:42 -07:00
|
|
|
|
<Card id="draft-order">
|
2025-10-15 08:58:35 -07:00
|
|
|
|
<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>
|
2026-03-18 00:53:40 -07:00
|
|
|
|
{team.ownerId && (
|
|
|
|
|
|
<p className="text-xs text-muted-foreground">{ownerMap[team.ownerId] ?? "Unknown"}</p>
|
|
|
|
|
|
)}
|
2025-10-15 08:58:35 -07:00
|
|
|
|
</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 && (
|
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">
|
2025-10-15 08:58:35 -07:00
|
|
|
|
{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>
|
|
|
|
|
|
|
2025-10-21 22:15:15 -07:00
|
|
|
|
{/* Team Management */}
|
|
|
|
|
|
<Card>
|
|
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<CardTitle>Team Management</CardTitle>
|
|
|
|
|
|
<CardDescription>
|
|
|
|
|
|
Manage team ownership for this league
|
|
|
|
|
|
</CardDescription>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent>
|
|
|
|
|
|
<div className="space-y-3">
|
2026-02-20 08:45:09 -08:00
|
|
|
|
{teams.map((team) => {
|
2025-10-21 22:15:15 -07:00
|
|
|
|
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} />
|
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
|
|
|
|
<Select name="userId" required>
|
2025-10-21 22:15:15 -07:00
|
|
|
|
<SelectTrigger className="w-[180px] h-9">
|
|
|
|
|
|
<SelectValue placeholder="Select user" />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
2026-02-20 08:45:09 -08:00
|
|
|
|
{allUsers.map((user) => {
|
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
|
|
|
|
const userOwnsTeam = teams.some((t) => t.ownerId === user.id);
|
2025-10-21 22:27:14 -07:00
|
|
|
|
return (
|
2026-02-20 08:45:09 -08:00
|
|
|
|
<SelectItem
|
|
|
|
|
|
key={user.id}
|
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
|
|
|
|
value={user.id}
|
2025-10-21 22:27:14 -07:00
|
|
|
|
disabled={userOwnsTeam}
|
|
|
|
|
|
>
|
|
|
|
|
|
{user.username || user.email}
|
|
|
|
|
|
{userOwnsTeam && " (already in league)"}
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
2025-10-21 22:15:15 -07:00
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="submit"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
disabled={navigation.state === "submitting"}
|
|
|
|
|
|
>
|
|
|
|
|
|
Assign
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
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 && (
|
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">
|
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" />
|
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
|
|
|
|
<Select name="userId" required>
|
2026-02-20 08:45:09 -08:00
|
|
|
|
<SelectTrigger className="flex-1">
|
|
|
|
|
|
<SelectValue placeholder="Select a user" />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
2026-03-18 00:53:40 -07:00
|
|
|
|
{leagueMembers.map((member) => {
|
2026-02-20 08:45:09 -08:00
|
|
|
|
const alreadyCommissioner = commissioners.some(
|
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
|
|
|
|
(c) => c.userId === member.id
|
2026-02-20 08:45:09 -08:00
|
|
|
|
);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<SelectItem
|
|
|
|
|
|
key={member.id}
|
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
|
|
|
|
value={member.id}
|
2026-02-20 08:45:09 -08:00
|
|
|
|
disabled={alreadyCommissioner}
|
|
|
|
|
|
>
|
|
|
|
|
|
{member.name || "Unknown"}
|
|
|
|
|
|
{alreadyCommissioner && " (already commissioner)"}
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
);
|
2026-03-18 00:53:40 -07:00
|
|
|
|
})}
|
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>
|
|
|
|
|
|
|
2025-10-11 00:29:04 -07:00
|
|
|
|
{/* Danger Zone */}
|
|
|
|
|
|
<Card className="border-destructive">
|
|
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<CardTitle className="text-destructive">Danger Zone</CardTitle>
|
|
|
|
|
|
<CardDescription>
|
|
|
|
|
|
Irreversible and destructive actions
|
|
|
|
|
|
</CardDescription>
|
|
|
|
|
|
</CardHeader>
|
2025-10-21 22:34:26 -07:00
|
|
|
|
<CardContent className="space-y-4">
|
|
|
|
|
|
{/* Reset Draft - Admin Only */}
|
2025-10-24 21:41:30 -07:00
|
|
|
|
{isAdmin && season && (
|
2025-10-21 22:34:26 -07:00
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<h3 className="font-medium">Reset Draft</h3>
|
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
2025-10-24 21:41:30 -07:00
|
|
|
|
Delete all draft picks, queues, and timers, and reset the season to pre-draft status. The draft order will be preserved.
|
2025-10-21 22:34:26 -07:00
|
|
|
|
</p>
|
|
|
|
|
|
<AlertDialog>
|
|
|
|
|
|
<AlertDialogTrigger asChild>
|
|
|
|
|
|
<Button variant="destructive" className="w-full">
|
|
|
|
|
|
Reset Draft
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</AlertDialogTrigger>
|
|
|
|
|
|
<AlertDialogContent>
|
|
|
|
|
|
<AlertDialogHeader>
|
|
|
|
|
|
<AlertDialogTitle>Reset the draft?</AlertDialogTitle>
|
|
|
|
|
|
<AlertDialogDescription>
|
2025-10-24 21:41:30 -07:00
|
|
|
|
This will delete all draft picks, draft queues, and draft timers, and set the season back to pre-draft status.
|
2025-10-21 22:34:26 -07:00
|
|
|
|
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}>
|
2025-10-11 00:29:04 -07:00
|
|
|
|
<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>
|
2025-10-21 22:34:26 -07:00
|
|
|
|
</div>
|
2025-10-11 00:29:04 -07:00
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|