## Summary
- Removes the last in-process \`setInterval\` (\`server/snapshots.ts\` 24h loop) and replaces it with an external HTTP cron job via Forgejo Actions
- Adds automated standings sync + conditional simulation: syncs every 2h, only simulates when standings actually changed (detected by comparing \`gamesPlayed\`/\`leagueRank\` before upsert)
- Adds \`GET /healthz\` for Docker healthcheck (Phase B prerequisite)
## What's new
| Endpoint | Triggered by | What it does |
|---|---|---|
| \`POST /admin/jobs/run-daily-snapshots\` | Forgejo schedule \`5 0 * * *\` | Creates daily fantasy standings snapshots for all active/draft seasons |
| \`POST /admin/jobs/sync-and-simulate\` | Forgejo schedule \`0 */2 * * *\` | Syncs standings from external APIs; runs simulation only if standings changed |
| \`GET /healthz\` | Docker / Traefik | Returns 200 \`{ok:true}\` when DB reachable, 503 otherwise |
Both cron endpoints are protected by \`X-Cron-Secret\` header (set \`CRON_SECRET\` in Forgejo repo secrets + production env).
## Schema changes (migration 0118)
Two new nullable columns on \`sports_seasons\`:
- \`standings_last_changed_at\` — written by \`syncStandings()\` when data actually changes
- \`last_simulated_at\` — written by the cron job after a successful simulation run
## Deployment notes
1. Add \`CRON_SECRET\` to Forgejo repo secrets (generate with \`openssl rand -hex 32\`)
2. Add same value to production environment
3. Migration runs automatically via the \`migrate\` container on deploy
## Test plan
- [ ] \`curl -X POST https://brackt.com/admin/jobs/run-daily-snapshots -H "X-Cron-Secret: ..."\` → 200 \`{total, succeeded, errors}\`
- [ ] \`curl -X POST https://brackt.com/admin/jobs/sync-and-simulate -H "X-Cron-Secret: ..."\` → 200 with \`synced\`/\`unchanged\`/\`simulated\` breakdown
- [ ] \`curl https://brackt.com/healthz\` → 200 \`{ok:true}\`
- [ ] Verify Forgejo workflow runs appear in Actions tab after merge
- [ ] Kill web process mid-day; confirm external cron still fires (no in-process dependency)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #79
* 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>
Disables prepared statements when PGBOUNCER=true (required for PgBouncer
transaction mode). Adds DATABASE_DIRECT_URL so migrations always use a
direct connection, bypassing the pool.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- New /support route with Discord join button and contact form
- Contact form sends via Resend to support@brackt.com (email stays hidden from users)
- Spam protection: honeypot field + Cloudflare Turnstile verification
- Server-side input length limits (subject: 200, message: 5000 chars)
- Added Support nav link to navbar (desktop + mobile)
Fixes#233
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Monte Carlo simulation of the 2026 AFL season and 10-team finals series.
Elo ratings are backsolved from Squiggle's projected season win totals using
the inverse formula: elo = 1500 - 450×log₁₀((1−wins/23)/(wins/23)).
- New `afl_bracket` simulator type (schema + migration)
- `afl-simulator.ts`: projects remaining regular season via Elo, seeds the
AFL_10 finals bracket (Wildcard → QF/EF → SF → PF → Grand Final), and
correctly tracks P5/P6 and P7/P8 as separate scoring tiers
- `afl.ts` standings sync adapter pulling from Squiggle API (no auth required)
- Finals line on standings display set to 10 for AFL seasons
- Substring name matching with longest-key-wins to prevent "Port Adelaide"
colliding with "Adelaide" when matching "Port Adelaide Power"
- 27 unit tests covering bracket logic, column-sum guarantees, and name matching
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat: add Sentry error monitoring (#77)
Installs and configures @sentry/react-router with server and client
instrumentation. Disabled in development to avoid noise; only active
in production.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: add VSCode Sentry MCP server config
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: pass vite env to sentryReactRouter plugin
sentryReactRouter requires the ConfigEnv as a second argument to read
the vite `command` property.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
When a participant wins a bracket round, they immediately earn provisional
"floor" points (the averaged minimum they'd receive if eliminated next round).
These update as they advance and are replaced by finalized scores on elimination.
Key changes:
- Add `is_partial_score` column to `participant_results` (migration 0038)
- `processPlayoffEvent`: assign provisional position 5 to non-scoring round
winners; assign round-appropriate floors to scoring round winners via
`getGuaranteedMinimumPosition`; add catch-all for unrecognized round names
- `upsertParticipantResult`: guard against un-finalizing rows (never overwrite
isPartialScore=false with true)
- `calculateBracketPoints`: new function averaging tied bracket tiers
(5-8 → 20 pts, 3-4 → avg, 1-2 solo); used in `calculateTeamScore` for
playoff_bracket pattern (pattern-aware, doesn't affect F1/golf scoring)
- `PlayoffBracket`: "In Contention" table for still-active participants;
AFL double-chance fix (participants who won a later match excluded from
earlier round's loser list); correct `nextRank` starting position
- Server loader: batch owner DB queries (one query vs N+1); deduplicate
participantPoints; use calculateBracketPoints for bracket point display
- Clean up Phase/Q-number tracking comments throughout scoring-calculator.ts
- 3 new tests for non-scoring round provisional floor behavior
Also includes a dev admin bypass via DEV_ADMIN_CLERK_ID env var (separate
change on this branch, not part of floor scoring feature).
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>