Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ npm run db:generate --prefix server # generate a migration into server/drizzle/
npm run db:studio --prefix server # Drizzle Studio
npm run db:migrate --prefix server # apply pending migrations to the dev DB (local .env)
npm run db:migrate:prod --prefix server # apply pending migrations to prod (server/.env.production, gitignored)
npm run db:backfill-users --prefix server # mirror Clerk's user list into the `users` table (idempotent)
npm run db:backfill-users:prod --prefix server # same, against prod
```

There is no test runner configured (`client/src/setupTests.ts` is a leftover). Verify changes by running the app.
Expand Down Expand Up @@ -55,7 +57,9 @@ Algorithms in `server/src/algorithms/` self-register via `registerAlgorithm()` (

### Auth

Clerk everywhere. Server: `clerkMiddleware()` globally + `requireAuth` per route (`getAuth(req).userId`). Client: `AuthGuard` gates the app shell and hydrates Clerk `unsafeMetadata` into the Redux `auth` slice. After mutating metadata server-side, call `await user.reload()` client-side or Redux stays stale.
Clerk owns **sessions only**. Server: `clerkMiddleware()` globally + `requireAuth` per route (`getAuth(req).userId`). Client: `AuthGuard` gates the app shell and hydrates the Redux `auth` slice from `GET /api/user/me`.

User profile data (email/username cache, Sleeper link, synced leagues) lives in our own `users` table, keyed by the Clerk user id — see PLAYBOOK's "Users and auth ownership" for the three rules that keep a future auth migration cheap. Two things that used to be true and no longer are: nothing reads Clerk `unsafeMetadata`, and `await user.reload()` is not how you refresh a profile (use `useInvalidateCurrentUser()`).

### State split

Expand Down
45 changes: 40 additions & 5 deletions PLAYBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -460,13 +460,31 @@ The provider pattern lives in `server/src/providers/`. To add a new platform:

## Auth & user state

User metadata flows: **Clerk `unsafeMetadata` → `AuthGuard` → Redux `auth` slice**
User profile flows: **`users` table → `GET /api/user/me` → `AuthGuard` → Redux `auth` slice**

- `AuthGuard` calls `setUser()` once on load and again whenever Clerk's `user` object updates (e.g. after `user.reload()`)
- After mutating metadata on the server, always call `await user.reload()` on the client to keep Clerk's cache fresh
- The `auth` slice holds: `user` (id, sleeperUsername, sleeperUserId, syncedLeagueIds), `selectedLeagueId`, `selectedYear`
- `AuthGuard` blocks render on `useCurrentUser()` (`client/src/hooks/useCurrentUser.ts`), then calls `setUser()`. It re-runs whenever that query is invalidated.
- After mutating the profile server-side, call `useInvalidateCurrentUser()` on the client. **Do not** use `await user.reload()` — that was the old Clerk-metadata cache-bust and no longer affects anything we read.
- The `auth` slice holds: `user` (id, username, email, sleeperUsername, sleeperUserId, syncedLeagueIds), `selectedLeagueId`, `selectedYear`
- `selectedLeagueId` and `selectedYear` are persisted to `localStorage` (`huddle:selection`) by the store subscriber in `client/src/store/index.ts`, so the user's chosen league/season survives a refresh.

### Users and auth ownership

Clerk owns **sessions only**. Everything else about a user is ours, in the `users` table.

| Data | Owner | Notes |
|---|---|---|
| Session / JWT / sign-in UI | Clerk | `clerkMiddleware()` + `requireAuth`, `getToken()` client-side |
| `email`, `username` | Clerk, **cached** by us | Refreshed by `usersService.ensureUser` on a 24h TTL |
| `sleeperUsername`, `sleeperUserId`, `syncedLeagueIds` | **Us** | Formerly Clerk `unsafeMetadata`; migrated in `0012` |

Three rules keep a future auth migration cheap — they exist because the alternative is a rewrite:

1. **`users.id` is the Clerk user id, and stays that way.** All 18 user-referencing columns across 11 tables store that same string. Swapping providers later means issuing our own JWTs with the same `sub`, not renumbering the database. Never migrate these to fresh uuids.
2. **Never call Clerk for display names.** Use `usersService.getUserSummaries(ids)` — one SQL round-trip. `huddleRoutes` used to make a `clerkClient.users.getUserList()` HTTP call inside five request handlers; that's what this replaced.
3. **Never `DELETE` a `users` row.** Every FK is `ON DELETE RESTRICT` on purpose — forum posts, votes and survey answers are league history that must outlive an account. To delete a user, blank the identity fields and set `is_placeholder`.

`scripts/backfill-users.mjs` (`npm run db:backfill-users --prefix server`) mirrors Clerk's user list into `users`. It's idempotent and safe to re-run on a schedule — identity fields are always refreshed, but Sleeper fields are only filled when ours are empty, so it can never roll back a user's later edits. Run it after any migration that adds a new user-referencing column, and add that column to the `USER_ID_COLUMNS` list at the top of the script.

---

## Database changes
Expand Down Expand Up @@ -502,9 +520,26 @@ This replaces the old `psql -f ...` workflow — no `psql` install required, and
| Renaming a column | Drizzle sees drop + add and generates `DROP`/`ADD`, silently discarding data. Resolve as a rename, or hand-edit to `ALTER ... RENAME`. |
| Adding `NOT NULL` | Fails on a populated table unless you give it a default. |
| Removing an enum value | Postgres can add values but not drop them; requires recreating the type. |
| Changing a column's type | Drizzle emits a bare `SET DATA TYPE`, which Postgres rejects whenever no implicit cast exists (e.g. `integer` → `boolean` in `0013`). Hand-edit to `DROP DEFAULT` → `SET DATA TYPE ... USING (...)` → `SET DEFAULT`. |
| Renaming a table | Needs a TTY — `drizzle-kit generate` prompts "created or renamed?" per table, and answering "create" produces a `DROP`/`CREATE` that discards the data. For a bulk rename it's safer to answer "create" deliberately (the *snapshot* is correct either way, since it records end state) and then replace the generated SQL with `ALTER TABLE ... RENAME`. Constraint and index names don't follow the rename; see "Table naming". |
| Deploys | Never auto-migrate on boot — Vercel serverless would race on every cold start. Migrations are always run deliberately. |

Legacy artifact: the primary key on `huddles` is still named `groups_pkey`, because `0001` renamed the table but Postgres keeps constraint names. Harmless; rename if you're ever touching that table.
### Table naming

> **Tables are named for what they hold. No product-name prefix.**

`commissioners`, `polls`, `survey_responses` — not `huddle_commissioners`, `huddle_polls`. The `huddle_` prefix that 19 tables used to carry said nothing: every table in this database belongs to Huddle. It cost real ergonomics — `\dt` and editor autocomplete on `hud` returned almost the whole schema — and it implied a scoping rule it didn't keep (`team_claims` and `side_bets` carried `huddle_id` without the prefix; the poll/survey child tables carried the prefix without a `huddle_id`). `0015` removed it from all 21 remaining tables.

The one structural distinction worth knowing isn't in the names: **`users` is global; everything else is reachable from a huddle**, directly via `huddle_id` or transitively through a parent (`poll_options` → `polls` → huddle). If you add auth tables later (`sessions`, `accounts`), they join `users` on the global side.

The same rule applies to TypeScript types (`Award`, `Poll`, `SurveyResponse`, not `HuddleAward`), on both sides of the wire — the client had already settled on the unprefixed names, so `0015`'s follow-up brought the server in line rather than the other way round. Keep the `Huddle` prefix only where it's doing real work: `Huddle` itself, `HuddleClaim`, `HuddleDetailResponse`, `HuddleMemberStatus`. Watch for collisions with lucide-react icons when you strip a prefix — `CommissionerPage` imports `Award as LucideAward` for exactly this reason.

**Renaming a table is a three-part job, and Postgres only does the first part.** `ALTER TABLE ... RENAME TO` leaves every constraint and index still carrying the old name, and Drizzle doesn't track those names, so they will never show up in a generated diff — they just rot until something collides. `0015` renamed 115 objects for 21 tables. Generate the statements from the live schema (`pg_constraint` + `pg_index`) rather than by hand.

Two traps that cost a follow-up migration (`0016`) the first time:

- **The 63-byte identifier limit.** Postgres silently truncates longer constraint names, so `huddle_survey_answer_options_option_id_huddle_survey_options_id_fk` lost its `_fk` suffix on creation. Renaming carried the truncation forward, leaving a name Drizzle's snapshot didn't expect.
- **Verify against the snapshot afterwards.** `db:generate` reporting "no schema changes" only proves `schema.ts` matches the snapshot — not that the *database* does. Diff the snapshot's `foreignKeys`/`indexes` names against `pg_constraint`/`pg_indexes` directly; that's what caught the truncation.

---

Expand Down
13 changes: 7 additions & 6 deletions client/src/components/AccountModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
type ReactNode,
} from "react";
import { Link } from "react-router-dom";
import { UserProfile, useAuth, useUser } from "@clerk/clerk-react";
import { UserProfile, useAuth } from "@clerk/clerk-react";
import { ChevronRight } from "lucide-react";
import { Plug, X } from "lucide-react";
import axios from "axios";
Expand All @@ -36,6 +36,7 @@ import {
setSyncedLeagueIds,
setSelectedLeague,
} from "../store/slices/authSlice";
import { useInvalidateCurrentUser } from "../hooks/useCurrentUser";
import { Button } from "./ui/button";

interface AccountModalContextValue {
Expand Down Expand Up @@ -148,9 +149,9 @@ function AccountModal({ onClose, initialTab }: { onClose: () => void; initialTab

function IntegrationsPage() {
const { getToken } = useAuth();
const { user } = useUser();
const dispatch = useAppDispatch();
const queryClient = useQueryClient();
const invalidateCurrentUser = useInvalidateCurrentUser();
const sleeperUsername = useAppSelector(
(state) => state.auth.user?.sleeperUsername,
);
Expand Down Expand Up @@ -179,8 +180,8 @@ function IntegrationsPage() {
setErrorMsg("");
try {
await patchSleeperUsername(input.trim());
// Reload Clerk user — AuthGuard's useEffect will re-hydrate Redux with fresh metadata
await user?.reload();
// Refetch /api/user/me — AuthGuard's useEffect re-hydrates Redux from it.
await invalidateCurrentUser();
setInput("");
setStatus("success");
} catch (err: unknown) {
Expand All @@ -202,8 +203,8 @@ function IntegrationsPage() {
dispatch(setSyncedLeagueIds([]));
dispatch(setSelectedLeague(null));
queryClient.removeQueries({ queryKey: ["sleeper-leagues-all"] });
// Sync Clerk cache so AuthGuard re-hydrates with cleared metadata
await user?.reload();
// Refetch /api/user/me so AuthGuard re-hydrates with the cleared link.
await invalidateCurrentUser();
setStatus("idle");
} catch (err: unknown) {
setStatus("error");
Expand Down
81 changes: 51 additions & 30 deletions client/src/components/auth/AuthGuard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,72 +4,93 @@
* Responsibilities:
* 1. Wait for Clerk to finish loading the session (spinner while loading).
* 2. Redirect to /sign-in if the user isn't signed in.
* 3. Mirror Clerk's user (including the Sleeper metadata we tuck into
* `unsafeMetadata`) into the Redux `auth` slice so the rest of the
* app can read it synchronously via `useAppSelector`.
* 3. Fetch the user's Huddle profile from `GET /api/user/me` and mirror it
* into the Redux `auth` slice so the rest of the app can read it
* synchronously via `useAppSelector`.
* 4. Hold render until Redux is hydrated — otherwise the first paint
* would fire queries with `sleeperUserId = null` and waste a
* no-op TanStack Query round-trip.
*
* Why `unsafeMetadata` and not a separate DB?
* Clerk lets us stash arbitrary JSON on the user record and read it back
* without an extra fetch. We use it for the Sleeper handle and the
* user's list of synced leagueIds — the data is small, user-owned, and
* non-sensitive. Anything bigger lives in our Postgres tables.
* Why our API and not Clerk's `unsafeMetadata`?
* It used to be Clerk metadata — small, user-owned JSON readable without an
* extra fetch. The cost was that the Sleeper link and synced-league list
* were the only product state we couldn't recover without a third-party
* export. They now live in our `users` table. Clerk still owns the session;
* it no longer owns any product data.
*
* IMPORTANT: After mutating `unsafeMetadata` on the server, always call
* `await user.reload()` on the client to invalidate Clerk's cache —
* otherwise the next `useUser()` read returns stale data and this guard
* dispatches the old values into Redux.
* This is also the request that *creates* the user's row (see the route's
* comment), so it deliberately blocks render: everything downstream assumes a
* `users` row exists, and the foreign keys enforce it.
*/
import { type ReactNode, useEffect } from "react";
import { useUser, useAuth, RedirectToSignIn } from "@clerk/clerk-react";
import { useAppDispatch, useAppSelector } from "../../store/hooks";
import { setUser, clearUser } from "../../store/slices/authSlice";
import { useCurrentUser } from "../../hooks/useCurrentUser";

interface AuthGuardProps {
children: ReactNode;
}

export function AuthGuard({ children }: AuthGuardProps) {
const { isLoaded, isSignedIn, user } = useUser();
const { isLoaded, isSignedIn } = useUser();
const { isLoaded: authLoaded } = useAuth();
const dispatch = useAppDispatch();
const reduxUser = useAppSelector((state) => state.auth.user);
const { data: profile, isError, error } = useCurrentUser();

// Keep Redux in lockstep with Clerk's user object. Fires on initial load
// and again any time Clerk publishes an updated `user` (e.g. after the
// app calls `user.reload()` post-metadata write).
// Keep Redux in lockstep with our profile endpoint. Fires on initial load
// and again whenever the query is invalidated (e.g. after linking Sleeper).
useEffect(() => {
if (!isLoaded || !authLoaded) return;

if (isSignedIn && user) {
const meta = user.unsafeMetadata ?? {};
if (isSignedIn && profile) {
dispatch(
setUser({
id: user.id,
username: user.username,
email: user.primaryEmailAddress?.emailAddress ?? "",
sleeperUsername: (meta.sleeperUsername as string) ?? null,
sleeperUserId: (meta.sleeperUserId as string) ?? null,
syncedLeagueIds: (meta.syncedLeagueIds as string[]) ?? [],
id: profile.id,
username: profile.username,
email: profile.email ?? "",
sleeperUsername: profile.sleeperUsername,
sleeperUserId: profile.sleeperUserId,
syncedLeagueIds: profile.syncedLeagueIds,
}),
);
} else {
} else if (!isSignedIn) {
dispatch(clearUser());
}
}, [isLoaded, authLoaded, isSignedIn, user, dispatch]);
}, [isLoaded, authLoaded, isSignedIn, profile, dispatch]);

if (!isLoaded || !authLoaded) {
return <Spinner />;
}

if (!isSignedIn) return <RedirectToSignIn />;

// Hold render until Redux is hydrated — prevents queries firing with null sleeperUserId.
// Without this we'd briefly mount children with `useAppSelector(...)` returning the
// initial state, which would cause every Sleeper hook to fire its `enabled: !!id` guard
// twice (once with null, once with the real id once Redux catches up).
// The profile request failing means we have no user row and no Sleeper link,
// so rendering the app would just produce a wall of broken widgets. Surface
// it instead of spinning forever.
if (isError) {
return (
<div className="flex flex-col items-center justify-center min-h-screen gap-3 px-6 text-center">
<p className="text-ink font-serif text-lg">Couldn't load your account</p>
<p className="text-sm text-gray-500">
{error instanceof Error ? error.message : "Please try again."}
</p>
<button
onClick={() => window.location.reload()}
className="text-sm underline"
>
Reload
</button>
</div>
);
}

// Hold render until Redux is hydrated — prevents queries firing with null
// sleeperUserId. Without this we'd briefly mount children with
// `useAppSelector(...)` returning the initial state, which would cause every
// Sleeper hook to fire its `enabled: !!id` guard twice (once with null, once
// with the real id once Redux catches up).
if (!reduxUser) return <Spinner />;

return <>{children}</>;
Expand Down
59 changes: 59 additions & 0 deletions client/src/hooks/useCurrentUser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* The signed-in user's Huddle profile, from our own API.
*
* This replaces reading Clerk's `unsafeMetadata` on the client. Clerk still
* owns the *session* (`useAuth`/`useUser` for tokens and sign-in state), but
* the Sleeper link and synced-league list now come from our `users` table via
* `GET /api/user/me`.
*
* That endpoint is also what creates the user's row, so it must succeed before
* anything else calls the API — AuthGuard blocks render on it for exactly that
* reason. Don't add a competing fetch of this data elsewhere; read the Redux
* `auth.user` mirror instead, or reuse this hook's cache.
*
* Stale time is Infinity: this only changes when *we* change it, so it's
* refreshed by explicit invalidation (see `useInvalidateCurrentUser`) rather
* than by polling. This is the same reasoning as the 24h player-dictionary
* tier in `useSleeper.ts`.
*/
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@clerk/clerk-react";
import axios from "axios";

export interface CurrentUser {
id: string;
email: string | null;
username: string | null;
sleeperUsername: string | null;
sleeperUserId: string | null;
syncedLeagueIds: string[];
}

export const currentUserKey = ["current-user"] as const;

export function useCurrentUser() {
const { getToken, isSignedIn } = useAuth();

return useQuery({
queryKey: currentUserKey,
enabled: !!isSignedIn,
staleTime: Infinity,
queryFn: async (): Promise<CurrentUser> => {
const token = await getToken();
const res = await axios.get<{ user: CurrentUser }>("/api/user/me", {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
return res.data.user;
},
});
}

/**
* Call after any mutation that changes the user's profile. Replaces the old
* `await user.reload()` dance, which existed only to bust Clerk's metadata
* cache.
*/
export function useInvalidateCurrentUser() {
const queryClient = useQueryClient();
return () => queryClient.invalidateQueries({ queryKey: currentUserKey });
}
Loading