From b98258424419d4f5a79ece4a0d70bc293feb5caa Mon Sep 17 00:00:00 2001 From: Rolf Date: Wed, 18 Mar 2026 13:34:41 +0100 Subject: [PATCH] Initial commit v1.1 --- CLAUDE.md | 92 +- Dockerfile | 1 + app/(app)/courses/new/page.tsx | 10 +- app/(app)/dashboard/page.tsx | 121 +- app/(app)/layout.tsx | 20 +- app/(app)/rounds/[id]/join/page.tsx | 72 +- app/(app)/rounds/[id]/page.tsx | 113 +- app/(app)/rounds/[id]/scorecard/page.tsx | 150 +- app/(app)/rounds/new/page.tsx | 53 +- app/(auth)/confirm/route.ts | 19 - app/(auth)/login/page.tsx | 19 +- app/actions/courses.ts | 81 +- app/actions/invites.ts | 54 +- app/actions/rounds.ts | 288 ++- app/actions/scores.ts | 66 +- app/api/auth/[...all]/route.ts | 4 + app/api/rounds/[id]/stream/route.ts | 75 + components/round/join-round.tsx | 89 +- components/round/round-lobby.tsx | 30 +- components/scorecard/full-grid.tsx | 28 +- components/scorecard/hole-view.tsx | 22 +- components/scorecard/leaderboard-view.tsx | 18 +- components/scorecard/score-entry-sheet.tsx | 6 +- components/scorecard/scorecard-view.tsx | 56 +- docker-compose.yml | 209 +- drizzle.config.ts | 10 + instrumentation.ts | 7 + lib/auth-client.ts | 8 + lib/auth.ts | 60 + lib/db/index.ts | 6 + lib/db/schema.ts | 110 + lib/email.ts | 28 + lib/supabase/client.ts | 9 - lib/supabase/server.ts | 51 - lib/supabase/types.ts | 10 - middleware.ts | 54 +- package-lock.json | 2242 ++++++++++++++++++-- package.json | 8 +- 38 files changed, 3196 insertions(+), 1103 deletions(-) delete mode 100644 app/(auth)/confirm/route.ts create mode 100644 app/api/auth/[...all]/route.ts create mode 100644 app/api/rounds/[id]/stream/route.ts create mode 100644 drizzle.config.ts create mode 100644 instrumentation.ts create mode 100644 lib/auth-client.ts create mode 100644 lib/auth.ts create mode 100644 lib/db/index.ts create mode 100644 lib/db/schema.ts create mode 100644 lib/email.ts delete mode 100644 lib/supabase/client.ts delete mode 100644 lib/supabase/server.ts delete mode 100644 lib/supabase/types.ts diff --git a/CLAUDE.md b/CLAUDE.md index 6d8f857..8835356 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,14 +4,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What This Is -MulliganMates is a social golf score tracking PWA. Multiple players in a group track scores per hole in real-time. Anyone in a group can enter or edit scores for any player. Built as a self-hosted Next.js + Supabase app running in Docker Compose. +MulliganMates is a social golf score tracking PWA. Multiple players in a group track scores per hole in real-time. Anyone in a group can enter or edit scores for any player. Built as a self-hosted Next.js app running in Docker Compose with a single PostgreSQL database. ## Commands All commands run inside Docker (Node.js is not installed on the host): ```bash -# Development (with Studio + Mailpit) +# Development (with Mailpit for email) docker compose --profile dev up # Production @@ -23,70 +23,72 @@ docker run --rm -v $(pwd):/app -w /app node:22-alpine npm install # Add a shadcn/ui component docker run --rm -v $(pwd):/app -w /app node:22-alpine npx shadcn@latest add -# Type-check +# Type-check / build docker run --rm -v $(pwd):/app -w /app node:22-alpine npm run build + +# Generate Drizzle migrations (after schema changes) +docker run --rm -v $(pwd):/app -w /app -e DATABASE_URL=... node:22-alpine npx drizzle-kit generate + +# Apply migrations manually +docker run --rm -v $(pwd):/app -w /app -e DATABASE_URL=... node:22-alpine npx drizzle-kit migrate ``` ## Architecture -**Stack:** Next.js 15 (App Router) · TypeScript · Tailwind CSS v4 · shadcn/ui · Supabase (self-hosted) · TanStack Query v5 · Zustand · Serwist (PWA) +**Stack:** Next.js 15 (App Router) · TypeScript · Tailwind CSS v4 · shadcn/ui v4 · better-auth · Drizzle ORM · SSE · nodemailer · Serwist (PWA) **Routing groups:** -- `app/(auth)/` — unauthenticated pages (login, confirm) +- `app/(auth)/` — unauthenticated pages (login only) - `app/(app)/` — authenticated app shell with bottom navigation -**Supabase clients:** -- `lib/supabase/client.ts` — browser client (use in `'use client'` components) -- `lib/supabase/server.ts` — server client + admin client (Server Components, Server Actions, Route Handlers) -- Always use `supabase.auth.getUser()` server-side, never `getSession()` — getUser() cryptographically verifies the JWT +**Auth (`lib/auth.ts`):** +- better-auth with `magicLink` plugin (`disableSignUp: true` for invite-only) +- `drizzleAdapter` pointing to `lib/db/schema.ts` tables: `user`, `session`, `verification` +- Additional field: `handicapIndex` on user +- Server-side session: `auth.api.getSession({ headers: await headers() })` +- Magic link to invite new users: create user with `db.insert(user)` first, then `auth.api.signInMagicLink()` -**Auth flow:** -- Invite-only. New users are invited via `supabase.auth.admin.inviteUserByEmail()` (uses service role key). -- Magic link → `/auth/confirm` route handler exchanges the PKCE code for a session. -- `middleware.ts` guards all `/(app)` routes; redirects unauthenticated users to `/login`. -- Admin email is set via `ADMIN_EMAIL` env var; `lib/admin.ts` exposes `isAdminEmail()`. +**Database (`lib/db/`):** +- `index.ts` — `drizzle(postgres(DATABASE_URL))` export +- `schema.ts` — all table definitions (camelCase column names) +- `migrations/` — generated SQL files applied via `instrumentation.ts` on startup +- **All Drizzle field names are camelCase** (`holesCount`, `courseHandicap`, `holeNumber`, `strokeIndex`) **Real-time scores:** -- Supabase Postgres Changes subscriptions on the `scores` table, filtered by `round_id`. -- TanStack Query cache is invalidated on change events. -- Score entry uses UPSERT — `unique(round_id, hole_id, player_id)` constraint. +- `app/api/rounds/[id]/stream/route.ts` — SSE Route Handler polling `scores` table every 3s +- Client connects via `new EventSource('/api/rounds/{id}/stream')` +- Payload: `ScoreRow[]` with `{ holeId, playerId, strokes }` **Round lifecycle:** `lobby → active → completed` -- Lobby: players can be invited and join; no scoring yet. -- Active: started by round creator; scoring enabled; no new players. -- Completed: closed by round creator; read-only. +- Lobby: players join/are invited; no scoring +- Active: started by round creator; scoring enabled +- Completed: closed by creator; read-only -## Database +## Database Schema Key Points -Migrations live in `supabase/migrations/` and are applied on DB container startup via `/docker-entrypoint-initdb.d/`. +- better-auth tables use `text` PKs (nanoid); app tables use `uuid` PKs (`gen_random_uuid()`) +- `scores` has unique constraint on `(roundId, holeId, playerId)` — upsert pattern +- `roundPlayers` snapshots `handicapIndex` and `courseHandicap` at join time +- Migrations run automatically on app startup via `instrumentation.ts` -- `00001_schema.sql` — tables, triggers (auto-create profile, enforce course par sum, update scores.updated_at) -- `00002_rls.sql` — Row Level Security policies for all tables +## UI Conventions -**Key RLS rules:** -- Scores can be inserted/updated by any round member, but only when `round.status = 'active'` -- `is_round_member(round_id)` helper function used throughout RLS policies -- `service_role` key bypasses RLS — only use server-side, never expose to client - -**Course par validation:** A DB trigger fires on `holes` INSERT/UPDATE; once 9 or 18 holes exist for a course, it asserts `SUM(holes.par) = courses.par`. +- shadcn/ui v4 uses `@base-ui/react` — **no `asChild` prop on Button**. Use `buttonVariants()` + `` instead +- Net score vs par (WHS handicap-adjusted) is always **primary** display, bold +- Raw strokes vs course par is secondary +- Score entry defaults to hole par; +/− buttons; tap number for direct input +- Bottom navigation: Rounds · History · Profile (+ Admin tab for admin user) +- Mobile-first: minimum 44×44px tap targets, swipe between holes **Handicap calculation (WHS):** `course_handicap = handicap_index × (slope_rating ÷ 113) + (course_rating − par)` -Stored as a snapshot on `round_players` at join time. +See `lib/handicap.ts` for `strokesReceived()`, `netStrokes()`, `formatVsPar()`, `scoreColorClass()` ## Infrastructure -- **Docker Compose** — all services defined in `docker-compose.yml` -- **Traefik** — external reverse proxy; services expose themselves via labels only (no port mappings) -- `studio` and `mailpit` services are in the `dev` profile — not started in production -- **Email dev:** Mailpit catches all outgoing email (`SMTP_HOST=mailpit`) -- **Email prod:** Set `SMTP_HOST`, `SMTP_USER`, `SMTP_PASS` to Resend/Mailgun/etc. in `.env` -- Copy `.env.example` → `.env` and fill all values before first run - -## Key Conventions - -- Net score vs par (WHS handicap-adjusted) is always the **primary** display, in bold -- Raw strokes vs course par is secondary -- Score entry defaults to hole par; +/− buttons; tap number for picker -- Bottom navigation: Rounds · History · Profile (+ Admin tab for admin user only) -- Mobile-first: minimum 44×44px tap targets, swipe between holes, high-contrast for sunlight +- **Docker Compose** — `app` + `db` (postgres:17-alpine) + `mailpit` (dev profile) +- **Traefik** — external reverse proxy; services expose via labels only +- **Email dev:** Mailpit (`docker compose --profile dev up`) +- **Email prod:** Set `SMTP_HOST`, `SMTP_USER`, `SMTP_PASS` in `.env` +- Copy `.env.example` → `.env` before first run +- `BETTER_AUTH_URL` must equal the app's public URL for magic links to work diff --git a/Dockerfile b/Dockerfile index aa72b4e..22fdac4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,6 +28,7 @@ RUN adduser --system --uid 1001 nextjs COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/lib/db/migrations ./lib/db/migrations USER nextjs EXPOSE 3000 diff --git a/app/(app)/courses/new/page.tsx b/app/(app)/courses/new/page.tsx index 53a3700..7d19a13 100644 --- a/app/(app)/courses/new/page.tsx +++ b/app/(app)/courses/new/page.tsx @@ -83,13 +83,13 @@ export default function NewCoursePage() { tees: tees.map((t) => ({ name: t.name, color: t.color, - course_rating: parseFloat(t.course_rating), - slope_rating: parseInt(t.slope_rating), + courseRating: parseFloat(t.course_rating), + slopeRating: parseInt(t.slope_rating), })), holes: holes.slice(0, holesCount).map((h) => ({ - hole_number: h.hole_number, - par: h.par, - stroke_index: h.stroke_index, + holeNumber: h.hole_number, + par: h.par, + strokeIndex: h.stroke_index, })), }) diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx index 2b3dad1..fdd9b4b 100644 --- a/app/(app)/dashboard/page.tsx +++ b/app/(app)/dashboard/page.tsx @@ -1,45 +1,45 @@ -import { createClient } from '@/lib/supabase/server' import { redirect } from 'next/navigation' import Link from 'next/link' +import { auth } from '@/lib/auth' +import { db } from '@/lib/db' +import { rounds, roundPlayers, courses, tees } from '@/lib/db/schema' +import { eq, inArray } from 'drizzle-orm' import { buttonVariants } from '@/components/ui/button' import { cn } from '@/lib/utils' +import { headers } from 'next/headers' export default async function DashboardPage() { - const supabase = await createClient() - const { - data: { user }, - } = await supabase.auth.getUser() + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) redirect('/login') - if (!user) redirect('/login') + const memberships = await db + .select({ roundId: roundPlayers.roundId }) + .from(roundPlayers) + .where(eq(roundPlayers.userId, session.user.id)) - type RoundRow = { - round: { - id: string - name: string | null - date: string - status: string - holes_count: number - course: { name: string } | null - tee: { name: string; color: string } | null - } | null - } + const roundIds = memberships.map((m) => m.roundId) - // Fetch rounds the user is participating in - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { data: rounds } = (await (supabase as any) - .from('round_players') - .select( - ` - round:rounds ( - id, name, date, status, holes_count, - course:courses (name), - tee:tees (name, color) - ) - `, - ) - .eq('user_id', user.id) - .in('round.status', ['lobby', 'active']) - .order('round(date)', { ascending: false })) as { data: RoundRow[] | null } + const activeRounds = + roundIds.length > 0 + ? await db + .select({ + id: rounds.id, + name: rounds.name, + date: rounds.date, + status: rounds.status, + holesCount: rounds.holesCount, + courseName: courses.name, + teeName: tees.name, + teeColor: tees.color, + }) + .from(rounds) + .innerJoin(courses, eq(rounds.courseId, courses.id)) + .innerJoin(tees, eq(rounds.teeId, tees.id)) + .where(inArray(rounds.id, roundIds)) + .orderBy(rounds.date) + : [] + + const visibleRounds = activeRounds.filter((r) => r.status !== 'completed') return (
@@ -50,48 +50,47 @@ export default async function DashboardPage() {
- {!rounds?.length && ( -

+ {visibleRounds.length === 0 && ( +

No active rounds. Start one!

)}
- {rounds?.map(({ round }) => { - if (!round) return null - return ( - -
- {round.course?.name} - -
-
- {round.name && {round.name} · } - {round.tee?.name} tees · {round.holes_count} holes ·{' '} - {new Date(round.date).toLocaleDateString()} -
- - ) - })} + {visibleRounds.map((round) => ( + +
+ {round.courseName} + +
+
+ {round.name && {round.name} · } + + {round.teeName} · {round.holesCount} holes ·{' '} + {new Date(round.date).toLocaleDateString()} +
+ + ))}
) } function StatusBadge({ status }: { status: string }) { - const styles = { - lobby: 'bg-yellow-100 text-yellow-800', - active: 'bg-green-100 text-green-800', + const styles: Record = { + lobby: 'bg-yellow-100 text-yellow-800', + active: 'bg-green-100 text-green-800', completed: 'bg-gray-100 text-gray-600', } return ( - + {status} ) diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 58f4086..68f96ea 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -1,23 +1,19 @@ import { redirect } from 'next/navigation' -import { createClient } from '@/lib/supabase/server' +import { auth } from '@/lib/auth' +import { headers } from 'next/headers' import { BottomNav } from '@/components/bottom-nav' +import { isAdminEmail } from '@/lib/admin' -export default async function AppLayout({ - children, -}: { - children: React.ReactNode -}) { - const supabase = await createClient() - const { - data: { user }, - } = await supabase.auth.getUser() +export default async function AppLayout({ children }: { children: React.ReactNode }) { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) redirect('/login') - if (!user) redirect('/login') + const isAdmin = isAdminEmail(session.user.email) return (
{children}
- +
) } diff --git a/app/(app)/rounds/[id]/join/page.tsx b/app/(app)/rounds/[id]/join/page.tsx index 7d2878e..0ae3c8b 100644 --- a/app/(app)/rounds/[id]/join/page.tsx +++ b/app/(app)/rounds/[id]/join/page.tsx @@ -1,43 +1,57 @@ -import { createClient } from '@/lib/supabase/server' +import { auth } from '@/lib/auth' +import { db } from '@/lib/db' +import { rounds, roundPlayers, courses, tees } from '@/lib/db/schema' +import { eq } from 'drizzle-orm' import { redirect, notFound } from 'next/navigation' +import { headers } from 'next/headers' import { JoinRound } from '@/components/round/join-round' export default async function JoinRoundPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params - const supabase = await createClient() - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const db = supabase as any + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) redirect(`/login`) - const { - data: { user }, - } = await supabase.auth.getUser() - if (!user) redirect(`/login`) - - const { data: round } = await db - .from('rounds') - .select( - ` - id, name, date, holes_count, status, created_by, - course:courses (name), - tee:tees (name, color), - round_players (user_id) - `, - ) - .eq('id', id) - .single() + const [round] = await db + .select({ + id: rounds.id, + name: rounds.name, + date: rounds.date, + holesCount: rounds.holesCount, + status: rounds.status, + courseName: courses.name, + teeName: tees.name, + teeColor: tees.color, + }) + .from(rounds) + .innerJoin(courses, eq(rounds.courseId, courses.id)) + .innerJoin(tees, eq(rounds.teeId, tees.id)) + .where(eq(rounds.id, id)) + .limit(1) if (!round) notFound() // Already a member — go straight to the round - const alreadyJoined = round.round_players?.some( - (p: { user_id: string }) => p.user_id === user.id, - ) - if (alreadyJoined) redirect(`/rounds/${id}`) + const [membership] = await db + .select({ id: roundPlayers.id }) + .from(roundPlayers) + .where(eq(roundPlayers.roundId, id)) + .limit(1) + + if (membership) redirect(`/rounds/${id}`) // Round must be in lobby to join - if (round.status !== 'lobby') { - redirect(`/dashboard`) - } + if (round.status !== 'lobby') redirect('/dashboard') - return + return ( + + ) } diff --git a/app/(app)/rounds/[id]/page.tsx b/app/(app)/rounds/[id]/page.tsx index 6b34fd4..96ee05b 100644 --- a/app/(app)/rounds/[id]/page.tsx +++ b/app/(app)/rounds/[id]/page.tsx @@ -1,63 +1,84 @@ -import { createClient } from '@/lib/supabase/server' +import { auth } from '@/lib/auth' +import { db } from '@/lib/db' +import { rounds, roundPlayers, roundInvites, courses, tees, user } from '@/lib/db/schema' +import { eq } from 'drizzle-orm' import { redirect, notFound } from 'next/navigation' +import { headers } from 'next/headers' import { RoundLobby } from '@/components/round/round-lobby' export default async function RoundPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params - const supabase = await createClient() - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const db = supabase as any + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) redirect('/login') - const { - data: { user }, - } = await supabase.auth.getUser() - if (!user) redirect('/login') - - const { data: round } = await db - .from('rounds') - .select( - ` - id, name, date, status, holes_count, created_by, - course:courses (id, name, par), - tee:tees (id, name, color, course_rating, slope_rating) - `, - ) - .eq('id', id) - .single() + const [round] = await db + .select({ + id: rounds.id, + name: rounds.name, + date: rounds.date, + status: rounds.status, + holesCount: rounds.holesCount, + createdBy: rounds.createdBy, + courseName: courses.name, + teeName: tees.name, + teeColor: tees.color, + courseRating: tees.courseRating, + slopeRating: tees.slopeRating, + }) + .from(rounds) + .innerJoin(courses, eq(rounds.courseId, courses.id)) + .innerJoin(tees, eq(rounds.teeId, tees.id)) + .where(eq(rounds.id, id)) + .limit(1) if (!round) notFound() - - const { data: players } = await db - .from('round_players') - .select( - ` - id, role, handicap_index, course_handicap, joined_at, - profile:profiles (id, display_name, avatar_url) - `, - ) - .eq('round_id', id) - .order('joined_at') - - const { data: invites } = await db - .from('round_invites') - .select('id, email, accepted_at, created_at') - .eq('round_id', id) - .is('accepted_at', null) - .order('created_at') - - const isCreator = round.created_by === user.id - if (round.status === 'active' || round.status === 'completed') { redirect(`/rounds/${id}/scorecard`) } + const players = await db + .select({ + id: roundPlayers.id, + userId: roundPlayers.userId, + role: roundPlayers.role, + handicapIndex: roundPlayers.handicapIndex, + courseHandicap: roundPlayers.courseHandicap, + joinedAt: roundPlayers.joinedAt, + displayName: user.name, + avatarUrl: user.image, + }) + .from(roundPlayers) + .innerJoin(user, eq(roundPlayers.userId, user.id)) + .where(eq(roundPlayers.roundId, id)) + .orderBy(roundPlayers.joinedAt) + + const pendingInvites = await db + .select({ id: roundInvites.id, email: roundInvites.email, createdAt: roundInvites.createdAt }) + .from(roundInvites) + .where(eq(roundInvites.roundId, id)) + return ( ({ + id: p.id, + role: p.role, + handicapIndex: p.handicapIndex, + courseHandicap: p.courseHandicap, + profile: { id: p.userId, displayName: p.displayName, avatarUrl: p.avatarUrl }, + }))} + pendingInvites={pendingInvites.map((i) => ({ id: i.id, email: i.email, createdAt: i.createdAt, acceptedAt: null }))} + currentUserId={session.user.id} + isCreator={round.createdBy === session.user.id} /> ) } diff --git a/app/(app)/rounds/[id]/scorecard/page.tsx b/app/(app)/rounds/[id]/scorecard/page.tsx index 7c98f6c..77eff27 100644 --- a/app/(app)/rounds/[id]/scorecard/page.tsx +++ b/app/(app)/rounds/[id]/scorecard/page.tsx @@ -1,24 +1,28 @@ -import { createClient } from '@/lib/supabase/server' +import { auth } from '@/lib/auth' +import { db } from '@/lib/db' +import { rounds, roundPlayers, holes, scores, courses, tees, user } from '@/lib/db/schema' +import { eq, asc } from 'drizzle-orm' import { redirect, notFound } from 'next/navigation' +import { headers } from 'next/headers' import { ScorecardView } from '@/components/scorecard/scorecard-view' export type Hole = { id: string - hole_number: number + holeNumber: number par: number - stroke_index: number + strokeIndex: number } export type Player = { - user_id: string - course_handicap: number | null - handicap_index: number | null - profile: { id: string; display_name: string; avatar_url: string | null } | null + userId: string + courseHandicap: number | null + handicapIndex: string | null + profile: { id: string; displayName: string; avatarUrl: string | null } | null } export type ScoreRow = { - hole_id: string - player_id: string + holeId: string + playerId: string strokes: number } @@ -27,8 +31,8 @@ export type RoundInfo = { name: string | null date: string status: string - holes_count: number - created_by: string + holesCount: number + createdBy: string course: { name: string } | null tee: { name: string; color: string } | null } @@ -39,66 +43,90 @@ export default async function ScorecardPage({ params: Promise<{ id: string }> }) { const { id } = await params - const supabase = await createClient() - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const db = supabase as any + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) redirect('/login') - const { - data: { user }, - } = await supabase.auth.getUser() - if (!user) redirect('/login') - - const { data: round } = await db - .from('rounds') - .select( - `id, name, date, status, holes_count, created_by, - course:courses (name), - tee:tees (name, color)`, - ) - .eq('id', id) - .single() + const [round] = await db + .select({ + id: rounds.id, + name: rounds.name, + date: rounds.date, + status: rounds.status, + holesCount: rounds.holesCount, + createdBy: rounds.createdBy, + courseId: rounds.courseId, + courseName: courses.name, + teeName: tees.name, + teeColor: tees.color, + }) + .from(rounds) + .innerJoin(courses, eq(rounds.courseId, courses.id)) + .innerJoin(tees, eq(rounds.teeId, tees.id)) + .where(eq(rounds.id, id)) + .limit(1) if (!round) notFound() if (round.status === 'lobby') redirect(`/rounds/${id}`) - // Holes ordered by hole_number, limited to holes_count - const { data: holes } = await db - .from('holes') - .select('id, hole_number, par, stroke_index') - .eq('course_id', await getCourseId(db, id)) - .order('hole_number') - .limit(round.holes_count) + const holeRows = await db + .select({ + id: holes.id, + holeNumber: holes.holeNumber, + par: holes.par, + strokeIndex: holes.strokeIndex, + }) + .from(holes) + .where(eq(holes.courseId, round.courseId)) + .orderBy(asc(holes.holeNumber)) + .limit(round.holesCount) - const { data: players } = await db - .from('round_players') - .select( - `user_id, course_handicap, handicap_index, - profile:profiles (id, display_name, avatar_url)`, - ) - .eq('round_id', id) - .order('joined_at') + const playerRows = await db + .select({ + userId: roundPlayers.userId, + courseHandicap: roundPlayers.courseHandicap, + handicapIndex: roundPlayers.handicapIndex, + displayName: user.name, + avatarUrl: user.image, + }) + .from(roundPlayers) + .innerJoin(user, eq(roundPlayers.userId, user.id)) + .where(eq(roundPlayers.roundId, id)) + .orderBy(roundPlayers.joinedAt) - const { data: scores } = await db - .from('scores') - .select('hole_id, player_id, strokes') - .eq('round_id', id) + const scoreRows = await db + .select({ holeId: scores.holeId, playerId: scores.playerId, strokes: scores.strokes }) + .from(scores) + .where(eq(scores.roundId, id)) + + const roundInfo: RoundInfo = { + id: round.id, + name: round.name, + date: round.date, + status: round.status, + holesCount: round.holesCount, + createdBy: round.createdBy, + course: { name: round.courseName }, + tee: { name: round.teeName, color: round.teeColor }, + } + + const players: Player[] = playerRows.map((p) => ({ + userId: p.userId, + courseHandicap: p.courseHandicap, + handicapIndex: p.handicapIndex, + profile: { + id: p.userId, + displayName: p.displayName, + avatarUrl: p.avatarUrl, + }, + })) return ( ) } - -async function getCourseId(db: any, roundId: string): Promise { - const { data } = await db - .from('rounds') - .select('course_id') - .eq('id', roundId) - .single() - return data?.course_id ?? '' -} diff --git a/app/(app)/rounds/new/page.tsx b/app/(app)/rounds/new/page.tsx index afc5168..99499b3 100644 --- a/app/(app)/rounds/new/page.tsx +++ b/app/(app)/rounds/new/page.tsx @@ -1,28 +1,35 @@ -import { createClient } from '@/lib/supabase/server' +import { auth } from '@/lib/auth' +import { db } from '@/lib/db' +import { courses, tees } from '@/lib/db/schema' +import { eq } from 'drizzle-orm' +import { headers } from 'next/headers' +import { redirect } from 'next/navigation' import { NewRoundWizard } from '@/components/round/new-round-wizard' -type Tee = { - id: string - name: string - color: string - course_rating: number - slope_rating: number -} - -type Course = { - id: string - name: string - par: number - tees: Tee[] -} - export default async function NewRoundPage() { - const supabase = await createClient() - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { data: courses } = await (supabase as any) - .from('courses') - .select('id, name, par, tees(id, name, color, course_rating, slope_rating)') - .order('name') + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) redirect('/login') - return + const courseList = await db.select().from(courses).orderBy(courses.name) + + const teesData = await db.select().from(tees) + const teesById = teesData.reduce>((acc, tee) => { + ;(acc[tee.courseId] ??= []).push(tee) + return acc + }, {}) + + const coursesWithTees = courseList.map((c) => ({ + id: c.id, + name: c.name, + par: c.par, + tees: (teesById[c.id] ?? []).map((t) => ({ + id: t.id, + name: t.name, + color: t.color, + course_rating: parseFloat(t.courseRating), + slope_rating: t.slopeRating, + })), + })) + + return } diff --git a/app/(auth)/confirm/route.ts b/app/(auth)/confirm/route.ts deleted file mode 100644 index 4ea43ad..0000000 --- a/app/(auth)/confirm/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { createClient } from '@/lib/supabase/server' -import { NextResponse, type NextRequest } from 'next/server' - -export async function GET(request: NextRequest) { - const { searchParams, origin } = new URL(request.url) - const code = searchParams.get('code') - const next = searchParams.get('next') ?? '/dashboard' - - if (code) { - const supabase = await createClient() - const { error } = await supabase.auth.exchangeCodeForSession(code) - - if (!error) { - return NextResponse.redirect(`${origin}${next}`) - } - } - - return NextResponse.redirect(`${origin}/login?error=auth_failed`) -} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index e856f27..ff363c5 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -1,7 +1,7 @@ 'use client' import { useState } from 'react' -import { createClient } from '@/lib/supabase/client' +import { authClient } from '@/lib/auth-client' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -17,17 +17,13 @@ export default function LoginPage() { setLoading(true) setError(null) - const supabase = createClient() - const { error } = await supabase.auth.signInWithOtp({ + const { error } = await authClient.signIn.magicLink({ email, - options: { - emailRedirectTo: `${window.location.origin}/auth/confirm`, - shouldCreateUser: false, // invite-only: only existing users can log in - }, + callbackURL: '/dashboard', }) if (error) { - setError(error.message) + setError(error.message ?? 'Could not send link. Have you been invited?') } else { setSubmitted(true) } @@ -41,8 +37,7 @@ export default function LoginPage() {

Check your email

- We sent a magic link to {email}. Tap the link to - sign in. + We sent a magic link to {email}. Tap it to sign in.

@@ -72,9 +67,7 @@ export default function LoginPage() { autoFocus /> - - {error &&

{error}

} - + {error &&

{error}

} diff --git a/app/actions/courses.ts b/app/actions/courses.ts index 824b999..3851933 100644 --- a/app/actions/courses.ts +++ b/app/actions/courses.ts @@ -1,26 +1,29 @@ 'use server' import { z } from 'zod' -import { createClient } from '@/lib/supabase/server' +import { db } from '@/lib/db' +import { courses, tees, holes } from '@/lib/db/schema' +import { auth } from '@/lib/auth' +import { headers } from 'next/headers' import { revalidatePath } from 'next/cache' const TeeInput = z.object({ - name: z.string().min(1, 'Tee name required'), - color: z.string().regex(/^#[0-9a-fA-F]{6}$/), - course_rating: z.coerce.number().min(50).max(80), - slope_rating: z.coerce.number().int().min(55).max(155), + name: z.string().min(1, 'Tee name required'), + color: z.string().regex(/^#[0-9a-fA-F]{6}$/), + courseRating: z.coerce.number().min(50).max(80), + slopeRating: z.coerce.number().int().min(55).max(155), }) const HoleInput = z.object({ - hole_number: z.number().int().min(1).max(18), - par: z.number().int().min(3).max(5), - stroke_index: z.number().int().min(1).max(18), + holeNumber: z.number().int().min(1).max(18), + par: z.number().int().min(3).max(5), + strokeIndex: z.number().int().min(1).max(18), }) export const CreateCourseSchema = z.object({ - name: z.string().min(1, 'Course name required').max(100), - par: z.number().int().min(27).max(90), - tees: z.array(TeeInput).min(1, 'At least one tee required'), + name: z.string().min(1, 'Course name required').max(100), + par: z.number().int().min(27).max(90), + tees: z.array(TeeInput).min(1, 'At least one tee required'), holes: z.array(HoleInput).min(9), }) @@ -28,35 +31,41 @@ export type CreateCourseInput = z.infer export async function createCourse(data: CreateCourseInput) { const parsed = CreateCourseSchema.safeParse(data) - if (!parsed.success) { - return { error: parsed.error.issues[0]?.message ?? 'Invalid data' } + if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? 'Invalid data' } + + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) return { error: 'Not authenticated' } + + // Validate par sum equals declared course par + const parSum = parsed.data.holes.reduce((s, h) => s + h.par, 0) + if (parSum !== parsed.data.par) { + return { error: `Sum of hole pars (${parSum}) does not equal course par (${parsed.data.par})` } } - const supabase = await createClient() - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const db = supabase as any - const { - data: { user }, - } = await supabase.auth.getUser() - if (!user) return { error: 'Not authenticated' } + const [course] = await db + .insert(courses) + .values({ name: parsed.data.name, par: parsed.data.par, createdBy: session.user.id }) + .returning({ id: courses.id }) - const { data: course, error: courseError } = await db - .from('courses') - .insert({ name: parsed.data.name, par: parsed.data.par, created_by: user.id }) - .select('id') - .single() - if (courseError) return { error: courseError.message } + await db.insert(tees).values( + parsed.data.tees.map((t) => ({ + courseId: course.id, + name: t.name, + color: t.color, + courseRating: String(t.courseRating), + slopeRating: t.slopeRating, + })), + ) - const { error: teesError } = await db - .from('tees') - .insert(parsed.data.tees.map((t) => ({ ...t, course_id: course.id }))) - if (teesError) return { error: teesError.message } - - const { error: holesError } = await db - .from('holes') - .insert(parsed.data.holes.map((h) => ({ ...h, course_id: course.id }))) - if (holesError) return { error: holesError.message } + await db.insert(holes).values( + parsed.data.holes.map((h) => ({ + courseId: course.id, + holeNumber: h.holeNumber, + par: h.par, + strokeIndex: h.strokeIndex, + })), + ) revalidatePath('/rounds/new') - return { courseId: course.id as string } + return { courseId: course.id } } diff --git a/app/actions/invites.ts b/app/actions/invites.ts index 162a731..829c2cf 100644 --- a/app/actions/invites.ts +++ b/app/actions/invites.ts @@ -1,50 +1,30 @@ 'use server' import { z } from 'zod' -import { createClient, createAdminClient } from '@/lib/supabase/server' - -const InviteSchema = z.object({ - roundId: z.string().uuid(), - email: z.string().email(), -}) +import { db } from '@/lib/db' +import { rounds } from '@/lib/db/schema' +import { eq } from 'drizzle-orm' +import { auth } from '@/lib/auth' +import { headers } from 'next/headers' +import { inviteToRound } from './rounds' export async function inviteFromLobby(roundId: string, email: string) { - const parsed = InviteSchema.safeParse({ roundId, email }) + const parsed = z.object({ roundId: z.string().uuid(), email: z.string().email() }) + .safeParse({ roundId, email }) if (!parsed.success) return { error: 'Invalid email address' } - const supabase = await createClient() - const adminClient = await createAdminClient() - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const db = supabase as any + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) return { error: 'Not authenticated' } - const { - data: { user }, - } = await supabase.auth.getUser() - if (!user) return { error: 'Not authenticated' } - - const { data: round } = await db - .from('rounds') - .select('status, created_by') - .eq('id', parsed.data.roundId) - .single() + const [round] = await db + .select({ status: rounds.status, createdBy: rounds.createdBy }) + .from(rounds) + .where(eq(rounds.id, roundId)) + .limit(1) if (!round) return { error: 'Round not found' } - if (round.created_by !== user.id) return { error: 'Only the round creator can invite players' } + if (round.createdBy !== session.user.id) return { error: 'Only the round creator can invite players' } if (round.status !== 'lobby') return { error: 'Cannot invite after the round has started' } - const redirectTo = `${process.env.SITE_URL}/auth/confirm?next=${encodeURIComponent(`/rounds/${parsed.data.roundId}/join`)}` - - const { error: inviteError } = await (adminClient as any).auth.admin.inviteUserByEmail( - parsed.data.email, - { redirectTo }, - ) - if (inviteError) return { error: inviteError.message } - - await db.from('round_invites').insert({ - round_id: parsed.data.roundId, - email: parsed.data.email, - invited_by: user.id, - }) - - return { success: true } + return inviteToRound(roundId, email, session.user.id) } diff --git a/app/actions/rounds.ts b/app/actions/rounds.ts index 957454a..6292fae 100644 --- a/app/actions/rounds.ts +++ b/app/actions/rounds.ts @@ -1,129 +1,239 @@ 'use server' import { z } from 'zod' -import { createClient, createAdminClient } from '@/lib/supabase/server' +import { db } from '@/lib/db' +import { rounds, roundPlayers, roundInvites, tees, courses, user } from '@/lib/db/schema' +import { eq, and } from 'drizzle-orm' +import { auth } from '@/lib/auth' +import { headers } from 'next/headers' +import { sendEmail } from '@/lib/email' import { revalidatePath } from 'next/cache' const CreateRoundSchema = z.object({ - course_id: z.string().uuid(), - tee_id: z.string().uuid(), - name: z.string().max(100).optional(), - date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + course_id: z.string().uuid(), + tee_id: z.string().uuid(), + name: z.string().max(100).optional(), + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), holes_count: z.union([z.literal(9), z.literal(18)]), - emails: z.array(z.string().email()).default([]), + emails: z.array(z.string().email()).default([]), }) export type CreateRoundInput = z.infer +function computeCourseHandicap( + handicapIndex: number, + slopeRating: number, + courseRating: number, + par: number, +): number { + return Math.round(handicapIndex * (slopeRating / 113) + (courseRating - par)) +} + export async function createRoundWithInvites(data: CreateRoundInput) { const parsed = CreateRoundSchema.safeParse(data) - if (!parsed.success) { - return { error: parsed.error.issues[0]?.message ?? 'Invalid data' } - } + if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? 'Invalid data' } - const supabase = await createClient() - const adminClient = await createAdminClient() - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const db = supabase as any - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const adminDb = adminClient as any + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) return { error: 'Not authenticated' } - const { - data: { user }, - } = await supabase.auth.getUser() - if (!user) return { error: 'Not authenticated' } + // Snapshot handicap + const [creator] = await db + .select({ handicapIndex: user.handicapIndex }) + .from(user) + .where(eq(user.id, session.user.id)) + .limit(1) - // Snapshot handicap and compute course handicap - const { data: profile } = await db - .from('profiles') - .select('handicap_index') - .eq('id', user.id) - .single() + const [tee] = await db + .select({ courseRating: tees.courseRating, slopeRating: tees.slopeRating }) + .from(tees) + .where(eq(tees.id, parsed.data.tee_id)) + .limit(1) - const { data: tee } = await db - .from('tees') - .select('course_rating, slope_rating') - .eq('id', parsed.data.tee_id) - .single() + const [course] = await db + .select({ par: courses.par }) + .from(courses) + .where(eq(courses.id, parsed.data.course_id)) + .limit(1) - const { data: course } = await db - .from('courses') - .select('par') - .eq('id', parsed.data.course_id) - .single() - - const handicapIndex: number | null = profile?.handicap_index ?? null + const handicapIndex = creator?.handicapIndex ? parseFloat(creator.handicapIndex) : null const courseHandicap = handicapIndex !== null && tee && course - ? Math.round(handicapIndex * (tee.slope_rating / 113) + (tee.course_rating - course.par)) + ? computeCourseHandicap( + handicapIndex, + tee.slopeRating, + parseFloat(tee.courseRating), + course.par, + ) : null - // Create round - const { data: round, error: roundError } = await db - .from('rounds') - .insert({ - course_id: parsed.data.course_id, - tee_id: parsed.data.tee_id, - name: parsed.data.name || null, - date: parsed.data.date, - holes_count: parsed.data.holes_count, - created_by: user.id, - status: 'lobby', + const [round] = await db + .insert(rounds) + .values({ + courseId: parsed.data.course_id, + teeId: parsed.data.tee_id, + name: parsed.data.name ?? null, + date: parsed.data.date, + holesCount: parsed.data.holes_count, + createdBy: session.user.id, + status: 'lobby', }) - .select('id') - .single() - if (roundError) return { error: roundError.message } + .returning({ id: rounds.id }) - // Add creator as admin player - await db.from('round_players').insert({ - round_id: round.id, - user_id: user.id, - role: 'admin', - handicap_index: handicapIndex, - course_handicap: courseHandicap, + await db.insert(roundPlayers).values({ + roundId: round.id, + userId: session.user.id, + role: 'admin', + handicapIndex: handicapIndex !== null ? String(handicapIndex) : null, + courseHandicap: courseHandicap, }) - // Send invites const failedInvites: string[] = [] for (const email of parsed.data.emails) { - const redirectTo = `${process.env.SITE_URL}/auth/confirm?next=${encodeURIComponent(`/rounds/${round.id}/join`)}` - const { error } = await adminDb.auth.admin.inviteUserByEmail(email, { redirectTo }) - if (error) { - failedInvites.push(email) - } else { - await db.from('round_invites').insert({ - round_id: round.id, - email, - invited_by: user.id, - }) - } + const result = await inviteToRound(round.id, email, session.user.id) + if (result.error) failedInvites.push(email) } - return { - roundId: round.id as string, - failedInvites, - } + return { roundId: round.id, failedInvites } } export async function startRound(roundId: string) { - const parsed = z.string().uuid().safeParse(roundId) - if (!parsed.success) return { error: 'Invalid round ID' } + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) return { error: 'Not authenticated' } - const supabase = await createClient() - const { - data: { user }, - } = await supabase.auth.getUser() - if (!user) return { error: 'Not authenticated' } + const [round] = await db + .select({ createdBy: rounds.createdBy, status: rounds.status }) + .from(rounds) + .where(eq(rounds.id, roundId)) + .limit(1) - const { error } = await (supabase as any) - .from('rounds') - .update({ status: 'active' }) - .eq('id', parsed.data) - .eq('created_by', user.id) - .eq('status', 'lobby') + if (!round) return { error: 'Round not found' } + if (round.createdBy !== session.user.id) return { error: 'Only the round creator can start it' } + if (round.status !== 'lobby') return { error: 'Round is not in lobby' } - if (error) return { error: error.message } + await db.update(rounds).set({ status: 'active' }).where(eq(rounds.id, roundId)) + revalidatePath(`/rounds/${roundId}`) + return { success: true } +} + +export async function completeRound(roundId: string) { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) return { error: 'Not authenticated' } + + const [round] = await db + .select({ createdBy: rounds.createdBy, status: rounds.status }) + .from(rounds) + .where(eq(rounds.id, roundId)) + .limit(1) + + if (!round) return { error: 'Round not found' } + if (round.createdBy !== session.user.id) return { error: 'Only the round creator can complete it' } + if (round.status !== 'active') return { error: 'Round is not active' } + + await db.update(rounds).set({ status: 'completed' }).where(eq(rounds.id, roundId)) + revalidatePath(`/rounds/${roundId}`) + return { success: true } +} + +export async function joinRound(roundId: string) { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) return { error: 'Not authenticated' } + + const [roundData] = await db + .select({ teeId: rounds.teeId, courseId: rounds.courseId, status: rounds.status }) + .from(rounds) + .where(eq(rounds.id, roundId)) + .limit(1) + + if (!roundData) return { error: 'Round not found' } + if (roundData.status !== 'lobby') return { error: 'Round is not accepting new players' } + + const [me] = await db + .select({ handicapIndex: user.handicapIndex }) + .from(user) + .where(eq(user.id, session.user.id)) + .limit(1) + + const [tee] = await db + .select({ courseRating: tees.courseRating, slopeRating: tees.slopeRating }) + .from(tees) + .where(eq(tees.id, roundData.teeId)) + .limit(1) + + const [course] = await db + .select({ par: courses.par }) + .from(courses) + .where(eq(courses.id, roundData.courseId)) + .limit(1) + + const handicapIndex = me?.handicapIndex ? parseFloat(me.handicapIndex) : null + const courseHandicap = + handicapIndex !== null && tee && course + ? computeCourseHandicap( + handicapIndex, + tee.slopeRating, + parseFloat(tee.courseRating), + course.par, + ) + : null + + try { + await db.insert(roundPlayers).values({ + roundId, + userId: session.user.id, + role: 'player', + handicapIndex: handicapIndex !== null ? String(handicapIndex) : null, + courseHandicap: courseHandicap, + }) + } catch { + return { error: 'Already a member of this round' } + } + + // Mark invite as accepted + await db + .update(roundInvites) + .set({ acceptedAt: new Date() }) + .where(and(eq(roundInvites.roundId, roundId), eq(roundInvites.email, session.user.email!))) revalidatePath(`/rounds/${roundId}`) return { success: true } } + +// Shared helper used by both createRoundWithInvites and inviteFromLobby +export async function inviteToRound(roundId: string, email: string, invitedByUserId: string) { + // Create user if they don't exist yet + const [existing] = await db + .select({ id: user.id }) + .from(user) + .where(eq(user.email, email.toLowerCase())) + .limit(1) + + if (!existing) { + await db.insert(user).values({ + id: crypto.randomUUID(), + email: email.toLowerCase(), + name: email.split('@')[0], + emailVerified: false, + createdAt: new Date(), + updatedAt: new Date(), + }) + } + + // Send magic link via better-auth + const callbackURL = `${process.env.SITE_URL}/rounds/${roundId}/join` + try { + await auth.api.signInMagicLink({ + body: { email: email.toLowerCase(), callbackURL }, + headers: await headers(), + }) + } catch (err: any) { + return { error: err?.message ?? 'Failed to send invite email' } + } + + // Record invite + await db + .insert(roundInvites) + .values({ roundId, email: email.toLowerCase(), invitedBy: invitedByUserId }) + .onConflictDoNothing() + + return { success: true } +} diff --git a/app/actions/scores.ts b/app/actions/scores.ts index ba19fff..a0fc778 100644 --- a/app/actions/scores.ts +++ b/app/actions/scores.ts @@ -1,13 +1,17 @@ 'use server' import { z } from 'zod' -import { createClient } from '@/lib/supabase/server' +import { db } from '@/lib/db' +import { scores, roundPlayers, rounds } from '@/lib/db/schema' +import { eq, and } from 'drizzle-orm' +import { auth } from '@/lib/auth' +import { headers } from 'next/headers' const UpsertScoreSchema = z.object({ - roundId: z.string().uuid(), - holeId: z.string().uuid(), - playerId: z.string().uuid(), - strokes: z.number().int().min(1).max(20), + roundId: z.string().uuid(), + holeId: z.string().uuid(), + playerId: z.string(), + strokes: z.number().int().min(1).max(20), }) export async function upsertScore( @@ -19,27 +23,39 @@ export async function upsertScore( const parsed = UpsertScoreSchema.safeParse({ roundId, holeId, playerId, strokes }) if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? 'Invalid data' } - const supabase = await createClient() - const { - data: { user }, - } = await supabase.auth.getUser() - if (!user) return { error: 'Not authenticated' } + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) return { error: 'Not authenticated' } - // Verify round is active and user is a member (RLS also enforces this) - const { error } = await (supabase as any) - .from('scores') - .upsert( - { - round_id: parsed.data.roundId, - hole_id: parsed.data.holeId, - player_id: parsed.data.playerId, - strokes: parsed.data.strokes, - updated_by: user.id, - updated_at: new Date().toISOString(), - }, - { onConflict: 'round_id,hole_id,player_id' }, - ) + // Caller must be a member of the round + const [membership] = await db + .select() + .from(roundPlayers) + .where(and(eq(roundPlayers.roundId, roundId), eq(roundPlayers.userId, session.user.id))) + .limit(1) + if (!membership) return { error: 'Not a member of this round' } + + // Round must be active + const [round] = await db + .select({ status: rounds.status }) + .from(rounds) + .where(eq(rounds.id, roundId)) + .limit(1) + if (round?.status !== 'active') return { error: 'Round is not active' } + + await db + .insert(scores) + .values({ + roundId, + holeId, + playerId, + strokes, + updatedBy: session.user.id, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [scores.roundId, scores.holeId, scores.playerId], + set: { strokes, updatedBy: session.user.id, updatedAt: new Date() }, + }) - if (error) return { error: error.message } return { success: true } } diff --git a/app/api/auth/[...all]/route.ts b/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..1648756 --- /dev/null +++ b/app/api/auth/[...all]/route.ts @@ -0,0 +1,4 @@ +import { auth } from '@/lib/auth' +import { toNextJsHandler } from 'better-auth/next-js' + +export const { GET, POST } = toNextJsHandler(auth.handler) diff --git a/app/api/rounds/[id]/stream/route.ts b/app/api/rounds/[id]/stream/route.ts new file mode 100644 index 0000000..4c22e0f --- /dev/null +++ b/app/api/rounds/[id]/stream/route.ts @@ -0,0 +1,75 @@ +import { auth } from '@/lib/auth' +import { db } from '@/lib/db' +import { scores, roundPlayers } from '@/lib/db/schema' +import { eq, and } from 'drizzle-orm' +import { headers } from 'next/headers' + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id: roundId } = await params + + const session = await auth.api.getSession({ headers: await headers() }) + if (!session) return new Response('Unauthorized', { status: 401 }) + + // Verify caller is a member of this round + const [membership] = await db + .select() + .from(roundPlayers) + .where(and(eq(roundPlayers.roundId, roundId), eq(roundPlayers.userId, session.user.id))) + .limit(1) + + if (!membership) return new Response('Forbidden', { status: 403 }) + + const encoder = new TextEncoder() + + const stream = new ReadableStream({ + async start(controller) { + async function send() { + const rows = await db + .select({ holeId: scores.holeId, playerId: scores.playerId, strokes: scores.strokes }) + .from(scores) + .where(eq(scores.roundId, roundId)) + + controller.enqueue(encoder.encode(`data: ${JSON.stringify(rows)}\n\n`)) + } + + // Initial payload + await send() + + // Poll every 3 seconds + const interval = setInterval(async () => { + try { await send() } catch { clearInterval(interval); controller.close() } + }, 3000) + + // Heartbeat every 30s to keep connection alive through proxies + const heartbeat = setInterval(() => { + try { controller.enqueue(encoder.encode(': heartbeat\n\n')) } catch { /* closed */ } + }, 30_000) + + // Clean up on disconnect or 4-hour timeout + const timeout = setTimeout(() => { + clearInterval(interval) + clearInterval(heartbeat) + controller.close() + }, 4 * 60 * 60 * 1000) + + request.signal.addEventListener('abort', () => { + clearInterval(interval) + clearInterval(heartbeat) + clearTimeout(timeout) + controller.close() + }) + }, + }) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', // disable nginx buffering + }, + }) +} diff --git a/components/round/join-round.tsx b/components/round/join-round.tsx index 7f3010b..7c2ad65 100644 --- a/components/round/join-round.tsx +++ b/components/round/join-round.tsx @@ -2,20 +2,21 @@ import { useState } from 'react' import { useRouter } from 'next/navigation' -import { createClient } from '@/lib/supabase/client' +import { joinRound } from '@/app/actions/rounds' import { Button } from '@/components/ui/button' -type Round = { - id: string - name: string | null - date: string - holes_count: number - status: string - course: { name: string } | null - tee: { name: string; color: string } | null +type Props = { + roundId: string + round: { + name: string | null + date: string + holesCount: number + course: { name: string } + tee: { name: string; color: string } + } } -export function JoinRound({ round, userId }: { round: Round; userId: string }) { +export function JoinRound({ roundId, round }: Props) { const router = useRouter() const [loading, setLoading] = useState(false) const [error, setError] = useState(null) @@ -24,69 +25,15 @@ export function JoinRound({ round, userId }: { round: Round; userId: string }) { setLoading(true) setError(null) - const supabase = createClient() - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const db = supabase as any + const result = await joinRound(roundId) - // Get current user's handicap for snapshot - const { data: profile } = await db - .from('profiles') - .select('handicap_index') - .eq('id', userId) - .single() - - // Get tee data for course handicap - const { data: tee } = await db - .from('tees') - .select('course_rating, slope_rating') - .eq('id', round.id) // note: round.tee.id would be better — using server action is cleaner - .single() - - const { data: roundData } = await db - .from('rounds') - .select('tee_id, course:courses(par)') - .eq('id', round.id) - .single() - - const { data: teeData } = roundData?.tee_id - ? await db - .from('tees') - .select('course_rating, slope_rating') - .eq('id', roundData.tee_id) - .single() - : { data: null } - - const handicapIndex = profile?.handicap_index ?? null - const courseHandicap = - handicapIndex !== null && teeData && roundData?.course - ? Math.round( - handicapIndex * (teeData.slope_rating / 113) + - (teeData.course_rating - roundData.course.par), - ) - : null - - const { error: joinError } = await db.from('round_players').insert({ - round_id: round.id, - user_id: userId, - role: 'player', - handicap_index: handicapIndex, - course_handicap: courseHandicap, - }) - - if (joinError) { - setError(joinError.message) + if (result.error) { + setError(result.error) setLoading(false) return } - // Mark invite as accepted - await db - .from('round_invites') - .update({ accepted_at: new Date().toISOString() }) - .eq('round_id', round.id) - .eq('email', (await supabase.auth.getUser()).data.user?.email) - - router.push(`/rounds/${round.id}`) + router.push(`/rounds/${roundId}`) } return ( @@ -98,15 +45,15 @@ export function JoinRound({ round, userId }: { round: Round; userId: string }) {
-
{round.course?.name}
+
{round.course.name}
{round.name &&
{round.name}
}
- {round.tee?.name} tees · {round.holes_count} holes + {round.tee.name} tees · {round.holesCount} holes
{new Date(round.date).toLocaleDateString()}
diff --git a/components/round/round-lobby.tsx b/components/round/round-lobby.tsx index b385589..d853df8 100644 --- a/components/round/round-lobby.tsx +++ b/components/round/round-lobby.tsx @@ -12,16 +12,16 @@ import Link from 'next/link' type Player = { id: string role: string - handicap_index: number | null - course_handicap: number | null - profile: { id: string; display_name: string; avatar_url: string | null } | null + handicapIndex: string | null + courseHandicap: number | null + profile: { id: string; displayName: string; avatarUrl: string | null } | null } type Invite = { id: string email: string - accepted_at: string | null - created_at: string + acceptedAt: Date | null + createdAt: Date } type Round = { @@ -29,10 +29,10 @@ type Round = { name: string | null date: string status: string - holes_count: number - created_by: string - course: { id: string; name: string; par: number } | null - tee: { id: string; name: string; color: string; course_rating: number; slope_rating: number } | null + holesCount: number + createdBy: string + course: { name: string } | null + tee: { name: string; color: string } | null } export function RoundLobby({ @@ -95,7 +95,7 @@ export function RoundLobby({

{round.course?.name} · {round.tee?.name} tees ·{' '} - {round.holes_count} holes ·{' '} + {round.holesCount} holes ·{' '} {new Date(round.date).toLocaleDateString()}

@@ -114,13 +114,13 @@ export function RoundLobby({ {players.map((p) => (
- {p.profile?.display_name} + {p.profile?.displayName} {p.profile?.id === currentUserId && ( (you) @@ -132,8 +132,8 @@ export function RoundLobby({ )}
- {p.handicap_index !== null - ? `HCP ${p.handicap_index} · Course HCP ${p.course_handicap}` + {p.handicapIndex !== null + ? `HCP ${p.handicapIndex} · Course HCP ${p.courseHandicap}` : 'No handicap'}
diff --git a/components/scorecard/full-grid.tsx b/components/scorecard/full-grid.tsx index 065612f..433564c 100644 --- a/components/scorecard/full-grid.tsx +++ b/components/scorecard/full-grid.tsx @@ -13,8 +13,8 @@ type Props = { export function FullGrid({ holes, players, scores, onTapScore, isActive }: Props) { const totalHoles = holes.length - const front = holes.filter((h) => h.hole_number <= 9) - const back = holes.filter((h) => h.hole_number > 9) + const front = holes.filter((h) => h.holeNumber <= 9) + const back = holes.filter((h) => h.holeNumber > 9) const hasBoth = front.length > 0 && back.length > 0 function getGross(playerId: string, hole: Hole) { @@ -24,8 +24,8 @@ export function FullGrid({ holes, players, scores, onTapScore, isActive }: Props function getNet(playerId: string, hole: Hole) { const gross = getGross(playerId, hole) if (gross === null) return null - const ch = players.find((p) => p.user_id === playerId)?.course_handicap ?? 0 - return gross - strokesReceived(ch, hole.stroke_index, totalHoles) + const ch = players.find((p) => p.userId === playerId)?.courseHandicap ?? 0 + return gross - strokesReceived(ch, hole.strokeIndex, totalHoles) } function subtotal(playerId: string, holeSet: Hole[], fn: (p: string, h: Hole) => number | null) { @@ -47,7 +47,7 @@ export function FullGrid({ holes, players, scores, onTapScore, isActive }: Props {front.map((h) => ( - {h.hole_number} + {h.holeNumber} ))} {hasBoth && ( @@ -55,7 +55,7 @@ export function FullGrid({ holes, players, scores, onTapScore, isActive }: Props )} {back.map((h) => ( - {h.hole_number} + {h.holeNumber} ))} {hasBoth && ( @@ -89,25 +89,25 @@ export function FullGrid({ holes, players, scores, onTapScore, isActive }: Props {players.map((player, pi) => { - const frontOut = subtotal(player.user_id, front, getGross) - const backIn = subtotal(player.user_id, back, getGross) + const frontOut = subtotal(player.userId, front, getGross) + const backIn = subtotal(player.userId, back, getGross) const total = frontOut !== null && backIn !== null ? frontOut + backIn : hasBoth ? null - : subtotal(player.user_id, holes, getGross) - const netTotal = subtotal(player.user_id, holes, getNet) + : subtotal(player.userId, holes, getGross) + const netTotal = subtotal(player.userId, holes, getNet) const netVsPar = netTotal !== null ? netTotal - parTotal : null return ( - + - {player.profile?.display_name} + {player.profile?.displayName} {front.map((h) => { - const gross = getGross(player.user_id, h) + const gross = getGross(player.userId, h) const grossVsPar = gross !== null ? gross - h.par : null return ( { - const gross = getGross(player.user_id, h) + const gross = getGross(player.userId, h) const grossVsPar = gross !== null ? gross - h.par : null return (
-
Hole {hole.hole_number}
+
Hole {hole.holeNumber}
- Par {hole.par} · SI {hole.stroke_index} + Par {hole.par} · SI {hole.strokeIndex}
@@ -77,34 +77,34 @@ export function HoleView({ {/* Players */}
{players.map((player) => { - const gross = scores[`${player.user_id}-${hole.id}`] ?? null - const ch = player.course_handicap ?? 0 - const received = strokesReceived(ch, hole.stroke_index, totalHoles) + const gross = scores[`${player.userId}-${hole.id}`] ?? null + const ch = player.courseHandicap ?? 0 + const received = strokesReceived(ch, hole.strokeIndex, totalHoles) const net = gross !== null ? gross - received : null const grossVsPar = gross !== null ? gross - hole.par : null const netVsPar = net !== null ? net - hole.par : null return (