Initial commit v1.1
This commit is contained in:
@@ -4,14 +4,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## What This Is
|
## 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
|
## Commands
|
||||||
|
|
||||||
All commands run inside Docker (Node.js is not installed on the host):
|
All commands run inside Docker (Node.js is not installed on the host):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Development (with Studio + Mailpit)
|
# Development (with Mailpit for email)
|
||||||
docker compose --profile dev up
|
docker compose --profile dev up
|
||||||
|
|
||||||
# Production
|
# Production
|
||||||
@@ -23,70 +23,72 @@ docker run --rm -v $(pwd):/app -w /app node:22-alpine npm install <package>
|
|||||||
# Add a shadcn/ui component
|
# Add a shadcn/ui component
|
||||||
docker run --rm -v $(pwd):/app -w /app node:22-alpine npx shadcn@latest add <component>
|
docker run --rm -v $(pwd):/app -w /app node:22-alpine npx shadcn@latest add <component>
|
||||||
|
|
||||||
# Type-check
|
# Type-check / build
|
||||||
docker run --rm -v $(pwd):/app -w /app node:22-alpine npm run 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
|
## 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:**
|
**Routing groups:**
|
||||||
- `app/(auth)/` — unauthenticated pages (login, confirm)
|
- `app/(auth)/` — unauthenticated pages (login only)
|
||||||
- `app/(app)/` — authenticated app shell with bottom navigation
|
- `app/(app)/` — authenticated app shell with bottom navigation
|
||||||
|
|
||||||
**Supabase clients:**
|
**Auth (`lib/auth.ts`):**
|
||||||
- `lib/supabase/client.ts` — browser client (use in `'use client'` components)
|
- better-auth with `magicLink` plugin (`disableSignUp: true` for invite-only)
|
||||||
- `lib/supabase/server.ts` — server client + admin client (Server Components, Server Actions, Route Handlers)
|
- `drizzleAdapter` pointing to `lib/db/schema.ts` tables: `user`, `session`, `verification`
|
||||||
- Always use `supabase.auth.getUser()` server-side, never `getSession()` — getUser() cryptographically verifies the JWT
|
- 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:**
|
**Database (`lib/db/`):**
|
||||||
- Invite-only. New users are invited via `supabase.auth.admin.inviteUserByEmail()` (uses service role key).
|
- `index.ts` — `drizzle(postgres(DATABASE_URL))` export
|
||||||
- Magic link → `/auth/confirm` route handler exchanges the PKCE code for a session.
|
- `schema.ts` — all table definitions (camelCase column names)
|
||||||
- `middleware.ts` guards all `/(app)` routes; redirects unauthenticated users to `/login`.
|
- `migrations/` — generated SQL files applied via `instrumentation.ts` on startup
|
||||||
- Admin email is set via `ADMIN_EMAIL` env var; `lib/admin.ts` exposes `isAdminEmail()`.
|
- **All Drizzle field names are camelCase** (`holesCount`, `courseHandicap`, `holeNumber`, `strokeIndex`)
|
||||||
|
|
||||||
**Real-time scores:**
|
**Real-time scores:**
|
||||||
- Supabase Postgres Changes subscriptions on the `scores` table, filtered by `round_id`.
|
- `app/api/rounds/[id]/stream/route.ts` — SSE Route Handler polling `scores` table every 3s
|
||||||
- TanStack Query cache is invalidated on change events.
|
- Client connects via `new EventSource('/api/rounds/{id}/stream')`
|
||||||
- Score entry uses UPSERT — `unique(round_id, hole_id, player_id)` constraint.
|
- Payload: `ScoreRow[]` with `{ holeId, playerId, strokes }`
|
||||||
|
|
||||||
**Round lifecycle:** `lobby → active → completed`
|
**Round lifecycle:** `lobby → active → completed`
|
||||||
- Lobby: players can be invited and join; no scoring yet.
|
- Lobby: players join/are invited; no scoring
|
||||||
- Active: started by round creator; scoring enabled; no new players.
|
- Active: started by round creator; scoring enabled
|
||||||
- Completed: closed by round creator; read-only.
|
- 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)
|
## UI Conventions
|
||||||
- `00002_rls.sql` — Row Level Security policies for all tables
|
|
||||||
|
|
||||||
**Key RLS rules:**
|
- shadcn/ui v4 uses `@base-ui/react` — **no `asChild` prop on Button**. Use `buttonVariants()` + `<Link>` instead
|
||||||
- Scores can be inserted/updated by any round member, but only when `round.status = 'active'`
|
- Net score vs par (WHS handicap-adjusted) is always **primary** display, bold
|
||||||
- `is_round_member(round_id)` helper function used throughout RLS policies
|
- Raw strokes vs course par is secondary
|
||||||
- `service_role` key bypasses RLS — only use server-side, never expose to client
|
- Score entry defaults to hole par; +/− buttons; tap number for direct input
|
||||||
|
- Bottom navigation: Rounds · History · Profile (+ Admin tab for admin user)
|
||||||
**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`.
|
- Mobile-first: minimum 44×44px tap targets, swipe between holes
|
||||||
|
|
||||||
**Handicap calculation (WHS):**
|
**Handicap calculation (WHS):**
|
||||||
`course_handicap = handicap_index × (slope_rating ÷ 113) + (course_rating − par)`
|
`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
|
## Infrastructure
|
||||||
|
|
||||||
- **Docker Compose** — all services defined in `docker-compose.yml`
|
- **Docker Compose** — `app` + `db` (postgres:17-alpine) + `mailpit` (dev profile)
|
||||||
- **Traefik** — external reverse proxy; services expose themselves via labels only (no port mappings)
|
- **Traefik** — external reverse proxy; services expose via labels only
|
||||||
- `studio` and `mailpit` services are in the `dev` profile — not started in production
|
- **Email dev:** Mailpit (`docker compose --profile dev up`)
|
||||||
- **Email dev:** Mailpit catches all outgoing email (`SMTP_HOST=mailpit`)
|
- **Email prod:** Set `SMTP_HOST`, `SMTP_USER`, `SMTP_PASS` in `.env`
|
||||||
- **Email prod:** Set `SMTP_HOST`, `SMTP_USER`, `SMTP_PASS` to Resend/Mailgun/etc. in `.env`
|
- Copy `.env.example` → `.env` before first run
|
||||||
- Copy `.env.example` → `.env` and fill all values before first run
|
- `BETTER_AUTH_URL` must equal the app's public URL for magic links to work
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ RUN adduser --system --uid 1001 nextjs
|
|||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
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/.next/static ./.next/static
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/lib/db/migrations ./lib/db/migrations
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|||||||
@@ -83,13 +83,13 @@ export default function NewCoursePage() {
|
|||||||
tees: tees.map((t) => ({
|
tees: tees.map((t) => ({
|
||||||
name: t.name,
|
name: t.name,
|
||||||
color: t.color,
|
color: t.color,
|
||||||
course_rating: parseFloat(t.course_rating),
|
courseRating: parseFloat(t.course_rating),
|
||||||
slope_rating: parseInt(t.slope_rating),
|
slopeRating: parseInt(t.slope_rating),
|
||||||
})),
|
})),
|
||||||
holes: holes.slice(0, holesCount).map((h) => ({
|
holes: holes.slice(0, holesCount).map((h) => ({
|
||||||
hole_number: h.hole_number,
|
holeNumber: h.hole_number,
|
||||||
par: h.par,
|
par: h.par,
|
||||||
stroke_index: h.stroke_index,
|
strokeIndex: h.stroke_index,
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,45 +1,45 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
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 { buttonVariants } from '@/components/ui/button'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { headers } from 'next/headers'
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const supabase = await createClient()
|
const session = await auth.api.getSession({ headers: await headers() })
|
||||||
const {
|
if (!session) redirect('/login')
|
||||||
data: { user },
|
|
||||||
} = await supabase.auth.getUser()
|
|
||||||
|
|
||||||
if (!user) redirect('/login')
|
const memberships = await db
|
||||||
|
.select({ roundId: roundPlayers.roundId })
|
||||||
|
.from(roundPlayers)
|
||||||
|
.where(eq(roundPlayers.userId, session.user.id))
|
||||||
|
|
||||||
type RoundRow = {
|
const roundIds = memberships.map((m) => m.roundId)
|
||||||
round: {
|
|
||||||
id: string
|
|
||||||
name: string | null
|
|
||||||
date: string
|
|
||||||
status: string
|
|
||||||
holes_count: number
|
|
||||||
course: { name: string } | null
|
|
||||||
tee: { name: string; color: string } | null
|
|
||||||
} | null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch rounds the user is participating in
|
const activeRounds =
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
roundIds.length > 0
|
||||||
const { data: rounds } = (await (supabase as any)
|
? await db
|
||||||
.from('round_players')
|
.select({
|
||||||
.select(
|
id: rounds.id,
|
||||||
`
|
name: rounds.name,
|
||||||
round:rounds (
|
date: rounds.date,
|
||||||
id, name, date, status, holes_count,
|
status: rounds.status,
|
||||||
course:courses (name),
|
holesCount: rounds.holesCount,
|
||||||
tee:tees (name, color)
|
courseName: courses.name,
|
||||||
)
|
teeName: tees.name,
|
||||||
`,
|
teeColor: tees.color,
|
||||||
)
|
})
|
||||||
.eq('user_id', user.id)
|
.from(rounds)
|
||||||
.in('round.status', ['lobby', 'active'])
|
.innerJoin(courses, eq(rounds.courseId, courses.id))
|
||||||
.order('round(date)', { ascending: false })) as { data: RoundRow[] | null }
|
.innerJoin(tees, eq(rounds.teeId, tees.id))
|
||||||
|
.where(inArray(rounds.id, roundIds))
|
||||||
|
.orderBy(rounds.date)
|
||||||
|
: []
|
||||||
|
|
||||||
|
const visibleRounds = activeRounds.filter((r) => r.status !== 'completed')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 space-y-4">
|
<div className="p-4 space-y-4">
|
||||||
@@ -50,48 +50,47 @@ export default async function DashboardPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!rounds?.length && (
|
{visibleRounds.length === 0 && (
|
||||||
<p className="text-muted-foreground text-sm py-8 text-center">
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
No active rounds. Start one!
|
No active rounds. Start one!
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{rounds?.map(({ round }) => {
|
{visibleRounds.map((round) => (
|
||||||
if (!round) return null
|
|
||||||
return (
|
|
||||||
<Link
|
<Link
|
||||||
key={round.id}
|
key={round.id}
|
||||||
href={round.status === 'active' ? `/rounds/${round.id}/scorecard` : `/rounds/${round.id}`}
|
href={round.status === 'active' ? `/rounds/${round.id}/scorecard` : `/rounds/${round.id}`}
|
||||||
className="block rounded-xl border p-4 space-y-1 hover:bg-muted transition-colors"
|
className="block rounded-xl border p-4 space-y-1 transition-colors hover:bg-muted"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-semibold">{round.course?.name}</span>
|
<span className="font-semibold">{round.courseName}</span>
|
||||||
<StatusBadge status={round.status} />
|
<StatusBadge status={round.status} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{round.name && <span>{round.name} · </span>}
|
{round.name && <span>{round.name} · </span>}
|
||||||
{round.tee?.name} tees · {round.holes_count} holes ·{' '}
|
<span
|
||||||
|
className="inline-block h-2.5 w-2.5 rounded-full border align-middle mr-1"
|
||||||
|
style={{ backgroundColor: round.teeColor }}
|
||||||
|
/>
|
||||||
|
{round.teeName} · {round.holesCount} holes ·{' '}
|
||||||
{new Date(round.date).toLocaleDateString()}
|
{new Date(round.date).toLocaleDateString()}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: string }) {
|
function StatusBadge({ status }: { status: string }) {
|
||||||
const styles = {
|
const styles: Record<string, string> = {
|
||||||
lobby: 'bg-yellow-100 text-yellow-800',
|
lobby: 'bg-yellow-100 text-yellow-800',
|
||||||
active: 'bg-green-100 text-green-800',
|
active: 'bg-green-100 text-green-800',
|
||||||
completed: 'bg-gray-100 text-gray-600',
|
completed: 'bg-gray-100 text-gray-600',
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span
|
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${styles[status] ?? styles.completed}`}>
|
||||||
className={`text-xs font-medium px-2 py-0.5 rounded-full ${styles[status as keyof typeof styles] ?? styles.completed}`}
|
|
||||||
>
|
|
||||||
{status}
|
{status}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|||||||
+8
-12
@@ -1,23 +1,19 @@
|
|||||||
import { redirect } from 'next/navigation'
|
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 { BottomNav } from '@/components/bottom-nav'
|
||||||
|
import { isAdminEmail } from '@/lib/admin'
|
||||||
|
|
||||||
export default async function AppLayout({
|
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
||||||
children,
|
const session = await auth.api.getSession({ headers: await headers() })
|
||||||
}: {
|
if (!session) redirect('/login')
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
const supabase = await createClient()
|
|
||||||
const {
|
|
||||||
data: { user },
|
|
||||||
} = await supabase.auth.getUser()
|
|
||||||
|
|
||||||
if (!user) redirect('/login')
|
const isAdmin = isAdminEmail(session.user.email)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col">
|
<div className="flex min-h-screen flex-col">
|
||||||
<main className="flex-1 pb-20">{children}</main>
|
<main className="flex-1 pb-20">{children}</main>
|
||||||
<BottomNav />
|
<BottomNav isAdmin={isAdmin} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { redirect, notFound } from 'next/navigation'
|
||||||
|
import { headers } from 'next/headers'
|
||||||
import { JoinRound } from '@/components/round/join-round'
|
import { JoinRound } from '@/components/round/join-round'
|
||||||
|
|
||||||
export default async function JoinRoundPage({ params }: { params: Promise<{ id: string }> }) {
|
export default async function JoinRoundPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const supabase = await createClient()
|
const session = await auth.api.getSession({ headers: await headers() })
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
if (!session) redirect(`/login`)
|
||||||
const db = supabase as any
|
|
||||||
|
|
||||||
const {
|
const [round] = await db
|
||||||
data: { user },
|
.select({
|
||||||
} = await supabase.auth.getUser()
|
id: rounds.id,
|
||||||
if (!user) redirect(`/login`)
|
name: rounds.name,
|
||||||
|
date: rounds.date,
|
||||||
const { data: round } = await db
|
holesCount: rounds.holesCount,
|
||||||
.from('rounds')
|
status: rounds.status,
|
||||||
.select(
|
courseName: courses.name,
|
||||||
`
|
teeName: tees.name,
|
||||||
id, name, date, holes_count, status, created_by,
|
teeColor: tees.color,
|
||||||
course:courses (name),
|
})
|
||||||
tee:tees (name, color),
|
.from(rounds)
|
||||||
round_players (user_id)
|
.innerJoin(courses, eq(rounds.courseId, courses.id))
|
||||||
`,
|
.innerJoin(tees, eq(rounds.teeId, tees.id))
|
||||||
)
|
.where(eq(rounds.id, id))
|
||||||
.eq('id', id)
|
.limit(1)
|
||||||
.single()
|
|
||||||
|
|
||||||
if (!round) notFound()
|
if (!round) notFound()
|
||||||
|
|
||||||
// Already a member — go straight to the round
|
// Already a member — go straight to the round
|
||||||
const alreadyJoined = round.round_players?.some(
|
const [membership] = await db
|
||||||
(p: { user_id: string }) => p.user_id === user.id,
|
.select({ id: roundPlayers.id })
|
||||||
)
|
.from(roundPlayers)
|
||||||
if (alreadyJoined) redirect(`/rounds/${id}`)
|
.where(eq(roundPlayers.roundId, id))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (membership) redirect(`/rounds/${id}`)
|
||||||
|
|
||||||
// Round must be in lobby to join
|
// Round must be in lobby to join
|
||||||
if (round.status !== 'lobby') {
|
if (round.status !== 'lobby') redirect('/dashboard')
|
||||||
redirect(`/dashboard`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return <JoinRound round={round} userId={user.id} />
|
return (
|
||||||
|
<JoinRound
|
||||||
|
roundId={id}
|
||||||
|
round={{
|
||||||
|
name: round.name,
|
||||||
|
date: round.date,
|
||||||
|
holesCount: round.holesCount,
|
||||||
|
course: { name: round.courseName },
|
||||||
|
tee: { name: round.teeName, color: round.teeColor },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { redirect, notFound } from 'next/navigation'
|
||||||
|
import { headers } from 'next/headers'
|
||||||
import { RoundLobby } from '@/components/round/round-lobby'
|
import { RoundLobby } from '@/components/round/round-lobby'
|
||||||
|
|
||||||
export default async function RoundPage({ params }: { params: Promise<{ id: string }> }) {
|
export default async function RoundPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const supabase = await createClient()
|
const session = await auth.api.getSession({ headers: await headers() })
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
if (!session) redirect('/login')
|
||||||
const db = supabase as any
|
|
||||||
|
|
||||||
const {
|
const [round] = await db
|
||||||
data: { user },
|
.select({
|
||||||
} = await supabase.auth.getUser()
|
id: rounds.id,
|
||||||
if (!user) redirect('/login')
|
name: rounds.name,
|
||||||
|
date: rounds.date,
|
||||||
const { data: round } = await db
|
status: rounds.status,
|
||||||
.from('rounds')
|
holesCount: rounds.holesCount,
|
||||||
.select(
|
createdBy: rounds.createdBy,
|
||||||
`
|
courseName: courses.name,
|
||||||
id, name, date, status, holes_count, created_by,
|
teeName: tees.name,
|
||||||
course:courses (id, name, par),
|
teeColor: tees.color,
|
||||||
tee:tees (id, name, color, course_rating, slope_rating)
|
courseRating: tees.courseRating,
|
||||||
`,
|
slopeRating: tees.slopeRating,
|
||||||
)
|
})
|
||||||
.eq('id', id)
|
.from(rounds)
|
||||||
.single()
|
.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) 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') {
|
if (round.status === 'active' || round.status === 'completed') {
|
||||||
redirect(`/rounds/${id}/scorecard`)
|
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 (
|
return (
|
||||||
<RoundLobby
|
<RoundLobby
|
||||||
round={round}
|
round={{
|
||||||
players={players ?? []}
|
id: round.id,
|
||||||
pendingInvites={invites ?? []}
|
name: round.name,
|
||||||
currentUserId={user.id}
|
date: round.date,
|
||||||
isCreator={isCreator}
|
status: round.status,
|
||||||
|
holesCount: round.holesCount,
|
||||||
|
createdBy: round.createdBy,
|
||||||
|
course: { name: round.courseName },
|
||||||
|
tee: { name: round.teeName, color: round.teeColor },
|
||||||
|
}}
|
||||||
|
players={players.map((p) => ({
|
||||||
|
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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { redirect, notFound } from 'next/navigation'
|
||||||
|
import { headers } from 'next/headers'
|
||||||
import { ScorecardView } from '@/components/scorecard/scorecard-view'
|
import { ScorecardView } from '@/components/scorecard/scorecard-view'
|
||||||
|
|
||||||
export type Hole = {
|
export type Hole = {
|
||||||
id: string
|
id: string
|
||||||
hole_number: number
|
holeNumber: number
|
||||||
par: number
|
par: number
|
||||||
stroke_index: number
|
strokeIndex: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Player = {
|
export type Player = {
|
||||||
user_id: string
|
userId: string
|
||||||
course_handicap: number | null
|
courseHandicap: number | null
|
||||||
handicap_index: number | null
|
handicapIndex: string | null
|
||||||
profile: { id: string; display_name: string; avatar_url: string | null } | null
|
profile: { id: string; displayName: string; avatarUrl: string | null } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScoreRow = {
|
export type ScoreRow = {
|
||||||
hole_id: string
|
holeId: string
|
||||||
player_id: string
|
playerId: string
|
||||||
strokes: number
|
strokes: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,8 +31,8 @@ export type RoundInfo = {
|
|||||||
name: string | null
|
name: string | null
|
||||||
date: string
|
date: string
|
||||||
status: string
|
status: string
|
||||||
holes_count: number
|
holesCount: number
|
||||||
created_by: string
|
createdBy: string
|
||||||
course: { name: string } | null
|
course: { name: string } | null
|
||||||
tee: { name: string; color: string } | null
|
tee: { name: string; color: string } | null
|
||||||
}
|
}
|
||||||
@@ -39,66 +43,90 @@ export default async function ScorecardPage({
|
|||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
}) {
|
}) {
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const supabase = await createClient()
|
const session = await auth.api.getSession({ headers: await headers() })
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
if (!session) redirect('/login')
|
||||||
const db = supabase as any
|
|
||||||
|
|
||||||
const {
|
const [round] = await db
|
||||||
data: { user },
|
.select({
|
||||||
} = await supabase.auth.getUser()
|
id: rounds.id,
|
||||||
if (!user) redirect('/login')
|
name: rounds.name,
|
||||||
|
date: rounds.date,
|
||||||
const { data: round } = await db
|
status: rounds.status,
|
||||||
.from('rounds')
|
holesCount: rounds.holesCount,
|
||||||
.select(
|
createdBy: rounds.createdBy,
|
||||||
`id, name, date, status, holes_count, created_by,
|
courseId: rounds.courseId,
|
||||||
course:courses (name),
|
courseName: courses.name,
|
||||||
tee:tees (name, color)`,
|
teeName: tees.name,
|
||||||
)
|
teeColor: tees.color,
|
||||||
.eq('id', id)
|
})
|
||||||
.single()
|
.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) notFound()
|
||||||
if (round.status === 'lobby') redirect(`/rounds/${id}`)
|
if (round.status === 'lobby') redirect(`/rounds/${id}`)
|
||||||
|
|
||||||
// Holes ordered by hole_number, limited to holes_count
|
const holeRows = await db
|
||||||
const { data: holes } = await db
|
.select({
|
||||||
.from('holes')
|
id: holes.id,
|
||||||
.select('id, hole_number, par, stroke_index')
|
holeNumber: holes.holeNumber,
|
||||||
.eq('course_id', await getCourseId(db, id))
|
par: holes.par,
|
||||||
.order('hole_number')
|
strokeIndex: holes.strokeIndex,
|
||||||
.limit(round.holes_count)
|
})
|
||||||
|
.from(holes)
|
||||||
|
.where(eq(holes.courseId, round.courseId))
|
||||||
|
.orderBy(asc(holes.holeNumber))
|
||||||
|
.limit(round.holesCount)
|
||||||
|
|
||||||
const { data: players } = await db
|
const playerRows = await db
|
||||||
.from('round_players')
|
.select({
|
||||||
.select(
|
userId: roundPlayers.userId,
|
||||||
`user_id, course_handicap, handicap_index,
|
courseHandicap: roundPlayers.courseHandicap,
|
||||||
profile:profiles (id, display_name, avatar_url)`,
|
handicapIndex: roundPlayers.handicapIndex,
|
||||||
)
|
displayName: user.name,
|
||||||
.eq('round_id', id)
|
avatarUrl: user.image,
|
||||||
.order('joined_at')
|
})
|
||||||
|
.from(roundPlayers)
|
||||||
|
.innerJoin(user, eq(roundPlayers.userId, user.id))
|
||||||
|
.where(eq(roundPlayers.roundId, id))
|
||||||
|
.orderBy(roundPlayers.joinedAt)
|
||||||
|
|
||||||
const { data: scores } = await db
|
const scoreRows = await db
|
||||||
.from('scores')
|
.select({ holeId: scores.holeId, playerId: scores.playerId, strokes: scores.strokes })
|
||||||
.select('hole_id, player_id, strokes')
|
.from(scores)
|
||||||
.eq('round_id', id)
|
.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 (
|
return (
|
||||||
<ScorecardView
|
<ScorecardView
|
||||||
round={round as RoundInfo}
|
round={roundInfo}
|
||||||
holes={(holes as Hole[]) ?? []}
|
holes={holeRows}
|
||||||
players={(players as Player[]) ?? []}
|
players={players}
|
||||||
initialScores={(scores as ScoreRow[]) ?? []}
|
initialScores={scoreRows}
|
||||||
currentUserId={user.id}
|
currentUserId={session.user.id}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getCourseId(db: any, roundId: string): Promise<string> {
|
|
||||||
const { data } = await db
|
|
||||||
.from('rounds')
|
|
||||||
.select('course_id')
|
|
||||||
.eq('id', roundId)
|
|
||||||
.single()
|
|
||||||
return data?.course_id ?? ''
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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'
|
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() {
|
export default async function NewRoundPage() {
|
||||||
const supabase = await createClient()
|
const session = await auth.api.getSession({ headers: await headers() })
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
if (!session) redirect('/login')
|
||||||
const { data: courses } = await (supabase as any)
|
|
||||||
.from('courses')
|
|
||||||
.select('id, name, par, tees(id, name, color, course_rating, slope_rating)')
|
|
||||||
.order('name')
|
|
||||||
|
|
||||||
return <NewRoundWizard courses={(courses as Course[]) ?? []} />
|
const courseList = await db.select().from(courses).orderBy(courses.name)
|
||||||
|
|
||||||
|
const teesData = await db.select().from(tees)
|
||||||
|
const teesById = teesData.reduce<Record<string, typeof teesData>>((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 <NewRoundWizard courses={coursesWithTees} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`)
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { createClient } from '@/lib/supabase/client'
|
import { authClient } from '@/lib/auth-client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
@@ -17,17 +17,13 @@ export default function LoginPage() {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
const supabase = createClient()
|
const { error } = await authClient.signIn.magicLink({
|
||||||
const { error } = await supabase.auth.signInWithOtp({
|
|
||||||
email,
|
email,
|
||||||
options: {
|
callbackURL: '/dashboard',
|
||||||
emailRedirectTo: `${window.location.origin}/auth/confirm`,
|
|
||||||
shouldCreateUser: false, // invite-only: only existing users can log in
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
setError(error.message)
|
setError(error.message ?? 'Could not send link. Have you been invited?')
|
||||||
} else {
|
} else {
|
||||||
setSubmitted(true)
|
setSubmitted(true)
|
||||||
}
|
}
|
||||||
@@ -41,8 +37,7 @@ export default function LoginPage() {
|
|||||||
<div className="text-4xl">⛳</div>
|
<div className="text-4xl">⛳</div>
|
||||||
<h1 className="text-2xl font-bold">Check your email</h1>
|
<h1 className="text-2xl font-bold">Check your email</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
We sent a magic link to <strong>{email}</strong>. Tap the link to
|
We sent a magic link to <strong>{email}</strong>. Tap it to sign in.
|
||||||
sign in.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,9 +67,7 @@ export default function LoginPage() {
|
|||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
{error && <p className="text-destructive text-sm">{error}</p>}
|
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={loading}>
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
{loading ? 'Sending…' : 'Send magic link'}
|
{loading ? 'Sending…' : 'Send magic link'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
+39
-30
@@ -1,20 +1,23 @@
|
|||||||
'use server'
|
'use server'
|
||||||
|
|
||||||
import { z } from 'zod'
|
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'
|
import { revalidatePath } from 'next/cache'
|
||||||
|
|
||||||
const TeeInput = z.object({
|
const TeeInput = z.object({
|
||||||
name: z.string().min(1, 'Tee name required'),
|
name: z.string().min(1, 'Tee name required'),
|
||||||
color: z.string().regex(/^#[0-9a-fA-F]{6}$/),
|
color: z.string().regex(/^#[0-9a-fA-F]{6}$/),
|
||||||
course_rating: z.coerce.number().min(50).max(80),
|
courseRating: z.coerce.number().min(50).max(80),
|
||||||
slope_rating: z.coerce.number().int().min(55).max(155),
|
slopeRating: z.coerce.number().int().min(55).max(155),
|
||||||
})
|
})
|
||||||
|
|
||||||
const HoleInput = z.object({
|
const HoleInput = z.object({
|
||||||
hole_number: z.number().int().min(1).max(18),
|
holeNumber: z.number().int().min(1).max(18),
|
||||||
par: z.number().int().min(3).max(5),
|
par: z.number().int().min(3).max(5),
|
||||||
stroke_index: z.number().int().min(1).max(18),
|
strokeIndex: z.number().int().min(1).max(18),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const CreateCourseSchema = z.object({
|
export const CreateCourseSchema = z.object({
|
||||||
@@ -28,35 +31,41 @@ export type CreateCourseInput = z.infer<typeof CreateCourseSchema>
|
|||||||
|
|
||||||
export async function createCourse(data: CreateCourseInput) {
|
export async function createCourse(data: CreateCourseInput) {
|
||||||
const parsed = CreateCourseSchema.safeParse(data)
|
const parsed = CreateCourseSchema.safeParse(data)
|
||||||
if (!parsed.success) {
|
if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? 'Invalid data' }
|
||||||
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()
|
const [course] = await db
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
.insert(courses)
|
||||||
const db = supabase as any
|
.values({ name: parsed.data.name, par: parsed.data.par, createdBy: session.user.id })
|
||||||
const {
|
.returning({ id: courses.id })
|
||||||
data: { user },
|
|
||||||
} = await supabase.auth.getUser()
|
|
||||||
if (!user) return { error: 'Not authenticated' }
|
|
||||||
|
|
||||||
const { data: course, error: courseError } = await db
|
await db.insert(tees).values(
|
||||||
.from('courses')
|
parsed.data.tees.map((t) => ({
|
||||||
.insert({ name: parsed.data.name, par: parsed.data.par, created_by: user.id })
|
courseId: course.id,
|
||||||
.select('id')
|
name: t.name,
|
||||||
.single()
|
color: t.color,
|
||||||
if (courseError) return { error: courseError.message }
|
courseRating: String(t.courseRating),
|
||||||
|
slopeRating: t.slopeRating,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
const { error: teesError } = await db
|
await db.insert(holes).values(
|
||||||
.from('tees')
|
parsed.data.holes.map((h) => ({
|
||||||
.insert(parsed.data.tees.map((t) => ({ ...t, course_id: course.id })))
|
courseId: course.id,
|
||||||
if (teesError) return { error: teesError.message }
|
holeNumber: h.holeNumber,
|
||||||
|
par: h.par,
|
||||||
const { error: holesError } = await db
|
strokeIndex: h.strokeIndex,
|
||||||
.from('holes')
|
})),
|
||||||
.insert(parsed.data.holes.map((h) => ({ ...h, course_id: course.id })))
|
)
|
||||||
if (holesError) return { error: holesError.message }
|
|
||||||
|
|
||||||
revalidatePath('/rounds/new')
|
revalidatePath('/rounds/new')
|
||||||
return { courseId: course.id as string }
|
return { courseId: course.id }
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-37
@@ -1,50 +1,30 @@
|
|||||||
'use server'
|
'use server'
|
||||||
|
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { createClient, createAdminClient } from '@/lib/supabase/server'
|
import { db } from '@/lib/db'
|
||||||
|
import { rounds } from '@/lib/db/schema'
|
||||||
const InviteSchema = z.object({
|
import { eq } from 'drizzle-orm'
|
||||||
roundId: z.string().uuid(),
|
import { auth } from '@/lib/auth'
|
||||||
email: z.string().email(),
|
import { headers } from 'next/headers'
|
||||||
})
|
import { inviteToRound } from './rounds'
|
||||||
|
|
||||||
export async function inviteFromLobby(roundId: string, email: string) {
|
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' }
|
if (!parsed.success) return { error: 'Invalid email address' }
|
||||||
|
|
||||||
const supabase = await createClient()
|
const session = await auth.api.getSession({ headers: await headers() })
|
||||||
const adminClient = await createAdminClient()
|
if (!session) return { error: 'Not authenticated' }
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const db = supabase as any
|
|
||||||
|
|
||||||
const {
|
const [round] = await db
|
||||||
data: { user },
|
.select({ status: rounds.status, createdBy: rounds.createdBy })
|
||||||
} = await supabase.auth.getUser()
|
.from(rounds)
|
||||||
if (!user) return { error: 'Not authenticated' }
|
.where(eq(rounds.id, roundId))
|
||||||
|
.limit(1)
|
||||||
const { data: round } = await db
|
|
||||||
.from('rounds')
|
|
||||||
.select('status, created_by')
|
|
||||||
.eq('id', parsed.data.roundId)
|
|
||||||
.single()
|
|
||||||
|
|
||||||
if (!round) return { error: 'Round not found' }
|
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' }
|
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`)}`
|
return inviteToRound(roundId, email, session.user.id)
|
||||||
|
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
|
|||||||
+191
-81
@@ -1,7 +1,12 @@
|
|||||||
'use server'
|
'use server'
|
||||||
|
|
||||||
import { z } from 'zod'
|
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'
|
import { revalidatePath } from 'next/cache'
|
||||||
|
|
||||||
const CreateRoundSchema = z.object({
|
const CreateRoundSchema = z.object({
|
||||||
@@ -15,115 +20,220 @@ const CreateRoundSchema = z.object({
|
|||||||
|
|
||||||
export type CreateRoundInput = z.infer<typeof CreateRoundSchema>
|
export type CreateRoundInput = z.infer<typeof CreateRoundSchema>
|
||||||
|
|
||||||
|
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) {
|
export async function createRoundWithInvites(data: CreateRoundInput) {
|
||||||
const parsed = CreateRoundSchema.safeParse(data)
|
const parsed = CreateRoundSchema.safeParse(data)
|
||||||
if (!parsed.success) {
|
if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? 'Invalid data' }
|
||||||
return { error: parsed.error.issues[0]?.message ?? 'Invalid data' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabase = await createClient()
|
const session = await auth.api.getSession({ headers: await headers() })
|
||||||
const adminClient = await createAdminClient()
|
if (!session) return { error: 'Not authenticated' }
|
||||||
// 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 {
|
// Snapshot handicap
|
||||||
data: { user },
|
const [creator] = await db
|
||||||
} = await supabase.auth.getUser()
|
.select({ handicapIndex: user.handicapIndex })
|
||||||
if (!user) return { error: 'Not authenticated' }
|
.from(user)
|
||||||
|
.where(eq(user.id, session.user.id))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
// Snapshot handicap and compute course handicap
|
const [tee] = await db
|
||||||
const { data: profile } = await db
|
.select({ courseRating: tees.courseRating, slopeRating: tees.slopeRating })
|
||||||
.from('profiles')
|
.from(tees)
|
||||||
.select('handicap_index')
|
.where(eq(tees.id, parsed.data.tee_id))
|
||||||
.eq('id', user.id)
|
.limit(1)
|
||||||
.single()
|
|
||||||
|
|
||||||
const { data: tee } = await db
|
const [course] = await db
|
||||||
.from('tees')
|
.select({ par: courses.par })
|
||||||
.select('course_rating, slope_rating')
|
.from(courses)
|
||||||
.eq('id', parsed.data.tee_id)
|
.where(eq(courses.id, parsed.data.course_id))
|
||||||
.single()
|
.limit(1)
|
||||||
|
|
||||||
const { data: course } = await db
|
const handicapIndex = creator?.handicapIndex ? parseFloat(creator.handicapIndex) : null
|
||||||
.from('courses')
|
|
||||||
.select('par')
|
|
||||||
.eq('id', parsed.data.course_id)
|
|
||||||
.single()
|
|
||||||
|
|
||||||
const handicapIndex: number | null = profile?.handicap_index ?? null
|
|
||||||
const courseHandicap =
|
const courseHandicap =
|
||||||
handicapIndex !== null && tee && course
|
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
|
: null
|
||||||
|
|
||||||
// Create round
|
const [round] = await db
|
||||||
const { data: round, error: roundError } = await db
|
.insert(rounds)
|
||||||
.from('rounds')
|
.values({
|
||||||
.insert({
|
courseId: parsed.data.course_id,
|
||||||
course_id: parsed.data.course_id,
|
teeId: parsed.data.tee_id,
|
||||||
tee_id: parsed.data.tee_id,
|
name: parsed.data.name ?? null,
|
||||||
name: parsed.data.name || null,
|
|
||||||
date: parsed.data.date,
|
date: parsed.data.date,
|
||||||
holes_count: parsed.data.holes_count,
|
holesCount: parsed.data.holes_count,
|
||||||
created_by: user.id,
|
createdBy: session.user.id,
|
||||||
status: 'lobby',
|
status: 'lobby',
|
||||||
})
|
})
|
||||||
.select('id')
|
.returning({ id: rounds.id })
|
||||||
.single()
|
|
||||||
if (roundError) return { error: roundError.message }
|
|
||||||
|
|
||||||
// Add creator as admin player
|
await db.insert(roundPlayers).values({
|
||||||
await db.from('round_players').insert({
|
roundId: round.id,
|
||||||
round_id: round.id,
|
userId: session.user.id,
|
||||||
user_id: user.id,
|
|
||||||
role: 'admin',
|
role: 'admin',
|
||||||
handicap_index: handicapIndex,
|
handicapIndex: handicapIndex !== null ? String(handicapIndex) : null,
|
||||||
course_handicap: courseHandicap,
|
courseHandicap: courseHandicap,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Send invites
|
|
||||||
const failedInvites: string[] = []
|
const failedInvites: string[] = []
|
||||||
for (const email of parsed.data.emails) {
|
for (const email of parsed.data.emails) {
|
||||||
const redirectTo = `${process.env.SITE_URL}/auth/confirm?next=${encodeURIComponent(`/rounds/${round.id}/join`)}`
|
const result = await inviteToRound(round.id, email, session.user.id)
|
||||||
const { error } = await adminDb.auth.admin.inviteUserByEmail(email, { redirectTo })
|
if (result.error) failedInvites.push(email)
|
||||||
if (error) {
|
|
||||||
failedInvites.push(email)
|
|
||||||
} else {
|
|
||||||
await db.from('round_invites').insert({
|
|
||||||
round_id: round.id,
|
|
||||||
email,
|
|
||||||
invited_by: user.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { roundId: round.id, failedInvites }
|
||||||
roundId: round.id as string,
|
|
||||||
failedInvites,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startRound(roundId: string) {
|
export async function startRound(roundId: string) {
|
||||||
const parsed = z.string().uuid().safeParse(roundId)
|
const session = await auth.api.getSession({ headers: await headers() })
|
||||||
if (!parsed.success) return { error: 'Invalid round ID' }
|
if (!session) return { error: 'Not authenticated' }
|
||||||
|
|
||||||
const supabase = await createClient()
|
const [round] = await db
|
||||||
const {
|
.select({ createdBy: rounds.createdBy, status: rounds.status })
|
||||||
data: { user },
|
.from(rounds)
|
||||||
} = await supabase.auth.getUser()
|
.where(eq(rounds.id, roundId))
|
||||||
if (!user) return { error: 'Not authenticated' }
|
.limit(1)
|
||||||
|
|
||||||
const { error } = await (supabase as any)
|
if (!round) return { error: 'Round not found' }
|
||||||
.from('rounds')
|
if (round.createdBy !== session.user.id) return { error: 'Only the round creator can start it' }
|
||||||
.update({ status: 'active' })
|
if (round.status !== 'lobby') return { error: 'Round is not in lobby' }
|
||||||
.eq('id', parsed.data)
|
|
||||||
.eq('created_by', user.id)
|
|
||||||
.eq('status', '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}`)
|
revalidatePath(`/rounds/${roundId}`)
|
||||||
return { success: true }
|
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 }
|
||||||
|
}
|
||||||
|
|||||||
+38
-22
@@ -1,12 +1,16 @@
|
|||||||
'use server'
|
'use server'
|
||||||
|
|
||||||
import { z } from 'zod'
|
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({
|
const UpsertScoreSchema = z.object({
|
||||||
roundId: z.string().uuid(),
|
roundId: z.string().uuid(),
|
||||||
holeId: z.string().uuid(),
|
holeId: z.string().uuid(),
|
||||||
playerId: z.string().uuid(),
|
playerId: z.string(),
|
||||||
strokes: z.number().int().min(1).max(20),
|
strokes: z.number().int().min(1).max(20),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -19,27 +23,39 @@ export async function upsertScore(
|
|||||||
const parsed = UpsertScoreSchema.safeParse({ roundId, holeId, playerId, strokes })
|
const parsed = UpsertScoreSchema.safeParse({ roundId, holeId, playerId, strokes })
|
||||||
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 session = await auth.api.getSession({ headers: await headers() })
|
||||||
const {
|
if (!session) return { error: 'Not authenticated' }
|
||||||
data: { user },
|
|
||||||
} = await supabase.auth.getUser()
|
|
||||||
if (!user) return { error: 'Not authenticated' }
|
|
||||||
|
|
||||||
// Verify round is active and user is a member (RLS also enforces this)
|
// Caller must be a member of the round
|
||||||
const { error } = await (supabase as any)
|
const [membership] = await db
|
||||||
.from('scores')
|
.select()
|
||||||
.upsert(
|
.from(roundPlayers)
|
||||||
{
|
.where(and(eq(roundPlayers.roundId, roundId), eq(roundPlayers.userId, session.user.id)))
|
||||||
round_id: parsed.data.roundId,
|
.limit(1)
|
||||||
hole_id: parsed.data.holeId,
|
if (!membership) return { error: 'Not a member of this round' }
|
||||||
player_id: parsed.data.playerId,
|
|
||||||
strokes: parsed.data.strokes,
|
// Round must be active
|
||||||
updated_by: user.id,
|
const [round] = await db
|
||||||
updated_at: new Date().toISOString(),
|
.select({ status: rounds.status })
|
||||||
},
|
.from(rounds)
|
||||||
{ onConflict: 'round_id,hole_id,player_id' },
|
.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 }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { toNextJsHandler } from 'better-auth/next-js'
|
||||||
|
|
||||||
|
export const { GET, POST } = toNextJsHandler(auth.handler)
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,20 +2,21 @@
|
|||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { createClient } from '@/lib/supabase/client'
|
import { joinRound } from '@/app/actions/rounds'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
type Round = {
|
type Props = {
|
||||||
id: string
|
roundId: string
|
||||||
|
round: {
|
||||||
name: string | null
|
name: string | null
|
||||||
date: string
|
date: string
|
||||||
holes_count: number
|
holesCount: number
|
||||||
status: string
|
course: { name: string }
|
||||||
course: { name: string } | null
|
tee: { name: string; color: string }
|
||||||
tee: { name: string; color: string } | null
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function JoinRound({ round, userId }: { round: Round; userId: string }) {
|
export function JoinRound({ roundId, round }: Props) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -24,69 +25,15 @@ export function JoinRound({ round, userId }: { round: Round; userId: string }) {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
const supabase = createClient()
|
const result = await joinRound(roundId)
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const db = supabase as any
|
|
||||||
|
|
||||||
// Get current user's handicap for snapshot
|
if (result.error) {
|
||||||
const { data: profile } = await db
|
setError(result.error)
|
||||||
.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)
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark invite as accepted
|
router.push(`/rounds/${roundId}`)
|
||||||
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}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -98,15 +45,15 @@ export function JoinRound({ round, userId }: { round: Round; userId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl border p-4 space-y-2">
|
<div className="rounded-xl border p-4 space-y-2">
|
||||||
<div className="font-semibold">{round.course?.name}</div>
|
<div className="font-semibold">{round.course.name}</div>
|
||||||
<div className="text-sm text-muted-foreground space-y-1">
|
<div className="text-sm text-muted-foreground space-y-1">
|
||||||
{round.name && <div>{round.name}</div>}
|
{round.name && <div>{round.name}</div>}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className="h-3 w-3 rounded-full border"
|
className="h-3 w-3 rounded-full border"
|
||||||
style={{ backgroundColor: round.tee?.color }}
|
style={{ backgroundColor: round.tee.color }}
|
||||||
/>
|
/>
|
||||||
{round.tee?.name} tees · {round.holes_count} holes
|
{round.tee.name} tees · {round.holesCount} holes
|
||||||
</div>
|
</div>
|
||||||
<div>{new Date(round.date).toLocaleDateString()}</div>
|
<div>{new Date(round.date).toLocaleDateString()}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,16 +12,16 @@ import Link from 'next/link'
|
|||||||
type Player = {
|
type Player = {
|
||||||
id: string
|
id: string
|
||||||
role: string
|
role: string
|
||||||
handicap_index: number | null
|
handicapIndex: string | null
|
||||||
course_handicap: number | null
|
courseHandicap: number | null
|
||||||
profile: { id: string; display_name: string; avatar_url: string | null } | null
|
profile: { id: string; displayName: string; avatarUrl: string | null } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type Invite = {
|
type Invite = {
|
||||||
id: string
|
id: string
|
||||||
email: string
|
email: string
|
||||||
accepted_at: string | null
|
acceptedAt: Date | null
|
||||||
created_at: string
|
createdAt: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
type Round = {
|
type Round = {
|
||||||
@@ -29,10 +29,10 @@ type Round = {
|
|||||||
name: string | null
|
name: string | null
|
||||||
date: string
|
date: string
|
||||||
status: string
|
status: string
|
||||||
holes_count: number
|
holesCount: number
|
||||||
created_by: string
|
createdBy: string
|
||||||
course: { id: string; name: string; par: number } | null
|
course: { name: string } | null
|
||||||
tee: { id: string; name: string; color: string; course_rating: number; slope_rating: number } | null
|
tee: { name: string; color: string } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RoundLobby({
|
export function RoundLobby({
|
||||||
@@ -95,7 +95,7 @@ export function RoundLobby({
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{round.course?.name} · {round.tee?.name} tees ·{' '}
|
{round.course?.name} · {round.tee?.name} tees ·{' '}
|
||||||
{round.holes_count} holes ·{' '}
|
{round.holesCount} holes ·{' '}
|
||||||
{new Date(round.date).toLocaleDateString()}
|
{new Date(round.date).toLocaleDateString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,13 +114,13 @@ export function RoundLobby({
|
|||||||
{players.map((p) => (
|
{players.map((p) => (
|
||||||
<div key={p.id} className="flex items-center gap-3 rounded-xl border p-3">
|
<div key={p.id} className="flex items-center gap-3 rounded-xl border p-3">
|
||||||
<Avatar
|
<Avatar
|
||||||
name={p.profile?.display_name ?? '?'}
|
name={p.profile?.displayName ?? '?'}
|
||||||
url={p.profile?.avatar_url}
|
url={p.profile?.avatarUrl}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium truncate">
|
<span className="font-medium truncate">
|
||||||
{p.profile?.display_name}
|
{p.profile?.displayName}
|
||||||
</span>
|
</span>
|
||||||
{p.profile?.id === currentUserId && (
|
{p.profile?.id === currentUserId && (
|
||||||
<span className="text-xs text-muted-foreground">(you)</span>
|
<span className="text-xs text-muted-foreground">(you)</span>
|
||||||
@@ -132,8 +132,8 @@ export function RoundLobby({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{p.handicap_index !== null
|
{p.handicapIndex !== null
|
||||||
? `HCP ${p.handicap_index} · Course HCP ${p.course_handicap}`
|
? `HCP ${p.handicapIndex} · Course HCP ${p.courseHandicap}`
|
||||||
: 'No handicap'}
|
: 'No handicap'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ type Props = {
|
|||||||
|
|
||||||
export function FullGrid({ holes, players, scores, onTapScore, isActive }: Props) {
|
export function FullGrid({ holes, players, scores, onTapScore, isActive }: Props) {
|
||||||
const totalHoles = holes.length
|
const totalHoles = holes.length
|
||||||
const front = holes.filter((h) => h.hole_number <= 9)
|
const front = holes.filter((h) => h.holeNumber <= 9)
|
||||||
const back = holes.filter((h) => h.hole_number > 9)
|
const back = holes.filter((h) => h.holeNumber > 9)
|
||||||
const hasBoth = front.length > 0 && back.length > 0
|
const hasBoth = front.length > 0 && back.length > 0
|
||||||
|
|
||||||
function getGross(playerId: string, hole: Hole) {
|
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) {
|
function getNet(playerId: string, hole: Hole) {
|
||||||
const gross = getGross(playerId, hole)
|
const gross = getGross(playerId, hole)
|
||||||
if (gross === null) return null
|
if (gross === null) return null
|
||||||
const ch = players.find((p) => p.user_id === playerId)?.course_handicap ?? 0
|
const ch = players.find((p) => p.userId === playerId)?.courseHandicap ?? 0
|
||||||
return gross - strokesReceived(ch, hole.stroke_index, totalHoles)
|
return gross - strokesReceived(ch, hole.strokeIndex, totalHoles)
|
||||||
}
|
}
|
||||||
|
|
||||||
function subtotal(playerId: string, holeSet: Hole[], fn: (p: string, h: Hole) => number | null) {
|
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
|
|||||||
</th>
|
</th>
|
||||||
{front.map((h) => (
|
{front.map((h) => (
|
||||||
<th key={h.id} className="px-2 py-2 text-center font-medium w-8">
|
<th key={h.id} className="px-2 py-2 text-center font-medium w-8">
|
||||||
{h.hole_number}
|
{h.holeNumber}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
{hasBoth && (
|
{hasBoth && (
|
||||||
@@ -55,7 +55,7 @@ export function FullGrid({ holes, players, scores, onTapScore, isActive }: Props
|
|||||||
)}
|
)}
|
||||||
{back.map((h) => (
|
{back.map((h) => (
|
||||||
<th key={h.id} className="px-2 py-2 text-center font-medium w-8">
|
<th key={h.id} className="px-2 py-2 text-center font-medium w-8">
|
||||||
{h.hole_number}
|
{h.holeNumber}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
{hasBoth && (
|
{hasBoth && (
|
||||||
@@ -89,25 +89,25 @@ export function FullGrid({ holes, players, scores, onTapScore, isActive }: Props
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y">
|
<tbody className="divide-y">
|
||||||
{players.map((player, pi) => {
|
{players.map((player, pi) => {
|
||||||
const frontOut = subtotal(player.user_id, front, getGross)
|
const frontOut = subtotal(player.userId, front, getGross)
|
||||||
const backIn = subtotal(player.user_id, back, getGross)
|
const backIn = subtotal(player.userId, back, getGross)
|
||||||
const total =
|
const total =
|
||||||
frontOut !== null && backIn !== null
|
frontOut !== null && backIn !== null
|
||||||
? frontOut + backIn
|
? frontOut + backIn
|
||||||
: hasBoth
|
: hasBoth
|
||||||
? null
|
? null
|
||||||
: subtotal(player.user_id, holes, getGross)
|
: subtotal(player.userId, holes, getGross)
|
||||||
const netTotal = subtotal(player.user_id, holes, getNet)
|
const netTotal = subtotal(player.userId, holes, getNet)
|
||||||
const netVsPar = netTotal !== null ? netTotal - parTotal : null
|
const netVsPar = netTotal !== null ? netTotal - parTotal : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={player.user_id} className={pi % 2 === 0 ? '' : 'bg-muted/20'}>
|
<tr key={player.userId} className={pi % 2 === 0 ? '' : 'bg-muted/20'}>
|
||||||
<td className="sticky left-0 z-10 bg-background px-3 py-2 font-medium truncate max-w-[100px]">
|
<td className="sticky left-0 z-10 bg-background px-3 py-2 font-medium truncate max-w-[100px]">
|
||||||
{player.profile?.display_name}
|
{player.profile?.displayName}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{front.map((h) => {
|
{front.map((h) => {
|
||||||
const gross = getGross(player.user_id, h)
|
const gross = getGross(player.userId, h)
|
||||||
const grossVsPar = gross !== null ? gross - h.par : null
|
const grossVsPar = gross !== null ? gross - h.par : null
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
@@ -133,7 +133,7 @@ export function FullGrid({ holes, players, scores, onTapScore, isActive }: Props
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{back.map((h) => {
|
{back.map((h) => {
|
||||||
const gross = getGross(player.user_id, h)
|
const gross = getGross(player.userId, h)
|
||||||
const grossVsPar = gross !== null ? gross - h.par : null
|
const grossVsPar = gross !== null ? gross - h.par : null
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
|
|||||||
@@ -59,9 +59,9 @@ export function HoleView({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-lg font-bold">Hole {hole.hole_number}</div>
|
<div className="text-lg font-bold">Hole {hole.holeNumber}</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Par {hole.par} · SI {hole.stroke_index}
|
Par {hole.par} · SI {hole.strokeIndex}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -77,34 +77,34 @@ export function HoleView({
|
|||||||
{/* Players */}
|
{/* Players */}
|
||||||
<div className="flex-1 divide-y overflow-y-auto">
|
<div className="flex-1 divide-y overflow-y-auto">
|
||||||
{players.map((player) => {
|
{players.map((player) => {
|
||||||
const gross = scores[`${player.user_id}-${hole.id}`] ?? null
|
const gross = scores[`${player.userId}-${hole.id}`] ?? null
|
||||||
const ch = player.course_handicap ?? 0
|
const ch = player.courseHandicap ?? 0
|
||||||
const received = strokesReceived(ch, hole.stroke_index, totalHoles)
|
const received = strokesReceived(ch, hole.strokeIndex, totalHoles)
|
||||||
const net = gross !== null ? gross - received : null
|
const net = gross !== null ? gross - received : null
|
||||||
const grossVsPar = gross !== null ? gross - hole.par : null
|
const grossVsPar = gross !== null ? gross - hole.par : null
|
||||||
const netVsPar = net !== null ? net - hole.par : null
|
const netVsPar = net !== null ? net - hole.par : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={player.user_id}
|
key={player.userId}
|
||||||
onClick={() => isActive && onTapScore(player)}
|
onClick={() => isActive && onTapScore(player)}
|
||||||
disabled={!isActive}
|
disabled={!isActive}
|
||||||
className="flex w-full items-center gap-3 px-4 py-4 text-left transition-colors hover:bg-muted/50 active:bg-muted disabled:cursor-default"
|
className="flex w-full items-center gap-3 px-4 py-4 text-left transition-colors hover:bg-muted/50 active:bg-muted disabled:cursor-default"
|
||||||
>
|
>
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<PlayerAvatar
|
<PlayerAvatar
|
||||||
name={player.profile?.display_name ?? '?'}
|
name={player.profile?.displayName ?? '?'}
|
||||||
url={player.profile?.avatar_url}
|
url={player.profile?.avatarUrl}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Name + running total */}
|
{/* Name + running total */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-medium truncate">
|
<div className="font-medium truncate">
|
||||||
{player.profile?.display_name}
|
{player.profile?.displayName}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{runningTotals[player.user_id]
|
{runningTotals[player.userId]
|
||||||
? `${runningTotals[player.user_id].holesPlayed} holes · ${formatVsPar(runningTotals[player.user_id].net)} net`
|
? `${runningTotals[player.userId].holesPlayed} holes · ${formatVsPar(runningTotals[player.userId].net)} net`
|
||||||
: 'No scores yet'}
|
: 'No scores yet'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ export function LeaderboardView({ holes, players, scores }: Props) {
|
|||||||
let parPlayed = 0
|
let parPlayed = 0
|
||||||
|
|
||||||
for (const hole of holes) {
|
for (const hole of holes) {
|
||||||
const gross = scores[`${player.user_id}-${hole.id}`]
|
const gross = scores[`${player.userId}-${hole.id}`]
|
||||||
if (gross !== undefined) {
|
if (gross !== undefined) {
|
||||||
const ch = player.course_handicap ?? 0
|
const ch = player.courseHandicap ?? 0
|
||||||
const net = gross - strokesReceived(ch, hole.stroke_index, totalHoles)
|
const net = gross - strokesReceived(ch, hole.strokeIndex, totalHoles)
|
||||||
grossTotal += gross
|
grossTotal += gross
|
||||||
netTotal += net
|
netTotal += net
|
||||||
parPlayed += hole.par
|
parPlayed += hole.par
|
||||||
@@ -57,7 +57,7 @@ export function LeaderboardView({ holes, players, scores }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={player.user_id}
|
key={player.userId}
|
||||||
className={`flex items-center gap-4 px-4 py-4 ${isLead ? 'bg-primary/5' : ''}`}
|
className={`flex items-center gap-4 px-4 py-4 ${isLead ? 'bg-primary/5' : ''}`}
|
||||||
>
|
>
|
||||||
{/* Position */}
|
{/* Position */}
|
||||||
@@ -71,21 +71,21 @@ export function LeaderboardView({ holes, players, scores }: Props) {
|
|||||||
|
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<PlayerAvatar
|
<PlayerAvatar
|
||||||
name={player.profile?.display_name ?? '?'}
|
name={player.profile?.displayName ?? '?'}
|
||||||
url={player.profile?.avatar_url}
|
url={player.profile?.avatarUrl}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Name + holes played */}
|
{/* Name + holes played */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-semibold truncate">
|
<div className="font-semibold truncate">
|
||||||
{player.profile?.display_name}
|
{player.profile?.displayName}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{holesPlayed > 0
|
{holesPlayed > 0
|
||||||
? `${holesPlayed} of ${totalHoles} holes`
|
? `${holesPlayed} of ${totalHoles} holes`
|
||||||
: 'No scores yet'}
|
: 'No scores yet'}
|
||||||
{player.course_handicap !== null &&
|
{player.courseHandicap !== null &&
|
||||||
` · HCP ${player.course_handicap}`}
|
` · HCP ${player.courseHandicap}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export function ScoreEntrySheet({ open, hole, player, currentStrokes, onSave, on
|
|||||||
setStrokes(currentStrokes ?? hole?.par ?? 4)
|
setStrokes(currentStrokes ?? hole?.par ?? 4)
|
||||||
setEditingNumber(false)
|
setEditingNumber(false)
|
||||||
}
|
}
|
||||||
}, [open, hole?.id, player?.user_id])
|
}, [open, hole?.id, player?.userId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editingNumber) inputRef.current?.select()
|
if (editingNumber) inputRef.current?.select()
|
||||||
@@ -65,9 +65,9 @@ export function ScoreEntrySheet({ open, hole, player, currentStrokes, onSave, on
|
|||||||
{/* Player + hole info */}
|
{/* Player + hole info */}
|
||||||
<div className="flex items-center justify-between px-6 py-3 border-b">
|
<div className="flex items-center justify-between px-6 py-3 border-b">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold">{player.profile?.display_name}</div>
|
<div className="font-semibold">{player.profile?.displayName}</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Hole {hole.hole_number} · Par {par} · SI {hole.stroke_index}
|
Hole {hole.holeNumber} · Par {par} · SI {hole.strokeIndex}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { createClient } from '@/lib/supabase/client'
|
|
||||||
import { upsertScore } from '@/app/actions/scores'
|
import { upsertScore } from '@/app/actions/scores'
|
||||||
import { strokesReceived } from '@/lib/handicap'
|
import { strokesReceived } from '@/lib/handicap'
|
||||||
import { HoleView } from './hole-view'
|
import { HoleView } from './hole-view'
|
||||||
@@ -21,7 +19,7 @@ type EntryState = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildScoreMap(rows: ScoreRow[]): Record<string, number> {
|
function buildScoreMap(rows: ScoreRow[]): Record<string, number> {
|
||||||
return Object.fromEntries(rows.map((r) => [`${r.player_id}-${r.hole_id}`, r.strokes]))
|
return Object.fromEntries(rows.map((r) => [`${r.playerId}-${r.holeId}`, r.strokes]))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScorecardView({
|
export function ScorecardView({
|
||||||
@@ -37,7 +35,6 @@ export function ScorecardView({
|
|||||||
initialScores: ScoreRow[]
|
initialScores: ScoreRow[]
|
||||||
currentUserId: string
|
currentUserId: string
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter()
|
|
||||||
const [scores, setScores] = useState<Record<string, number>>(buildScoreMap(initialScores))
|
const [scores, setScores] = useState<Record<string, number>>(buildScoreMap(initialScores))
|
||||||
const [activeHoleIdx, setActiveHoleIdx] = useState(0)
|
const [activeHoleIdx, setActiveHoleIdx] = useState(0)
|
||||||
const [view, setView] = useState<View>('hole')
|
const [view, setView] = useState<View>('hole')
|
||||||
@@ -47,33 +44,20 @@ export function ScorecardView({
|
|||||||
const isActive = round.status === 'active'
|
const isActive = round.status === 'active'
|
||||||
const activeHole = holes[activeHoleIdx]
|
const activeHole = holes[activeHoleIdx]
|
||||||
|
|
||||||
// ── Realtime subscription ──────────────────────────────────
|
// ── SSE real-time subscription ─────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const supabase = createClient()
|
const es = new EventSource(`/api/rounds/${round.id}/stream`)
|
||||||
const channel = supabase
|
|
||||||
.channel(`scores:${round.id}`)
|
|
||||||
.on(
|
|
||||||
'postgres_changes',
|
|
||||||
{
|
|
||||||
event: '*',
|
|
||||||
schema: 'public',
|
|
||||||
table: 'scores',
|
|
||||||
filter: `round_id=eq.${round.id}`,
|
|
||||||
},
|
|
||||||
(payload) => {
|
|
||||||
if (payload.eventType === 'DELETE') return
|
|
||||||
const row = payload.new as ScoreRow
|
|
||||||
setScores((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[`${row.player_id}-${row.hole_id}`]: row.strokes,
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.subscribe()
|
|
||||||
|
|
||||||
return () => {
|
es.onmessage = (event) => {
|
||||||
supabase.removeChannel(channel)
|
try {
|
||||||
|
const rows: ScoreRow[] = JSON.parse(event.data)
|
||||||
|
setScores(buildScoreMap(rows))
|
||||||
|
} catch {
|
||||||
|
// ignore parse errors
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => es.close()
|
||||||
}, [round.id])
|
}, [round.id])
|
||||||
|
|
||||||
// ── Running totals per player ──────────────────────────────
|
// ── Running totals per player ──────────────────────────────
|
||||||
@@ -83,16 +67,16 @@ export function ScorecardView({
|
|||||||
let net = 0
|
let net = 0
|
||||||
let gross = 0
|
let gross = 0
|
||||||
let played = 0
|
let played = 0
|
||||||
const ch = player.course_handicap ?? 0
|
const ch = player.courseHandicap ?? 0
|
||||||
for (const hole of holes) {
|
for (const hole of holes) {
|
||||||
const g = scores[`${player.user_id}-${hole.id}`]
|
const g = scores[`${player.userId}-${hole.id}`]
|
||||||
if (g !== undefined) {
|
if (g !== undefined) {
|
||||||
gross += g
|
gross += g
|
||||||
net += g - strokesReceived(ch, hole.stroke_index, holes.length)
|
net += g - strokesReceived(ch, hole.strokeIndex, holes.length)
|
||||||
played++
|
played++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
totals[player.user_id] = { net, gross, holesPlayed: played }
|
totals[player.userId] = { net, gross, holesPlayed: played }
|
||||||
}
|
}
|
||||||
return totals
|
return totals
|
||||||
}, [scores, players, holes])
|
}, [scores, players, holes])
|
||||||
@@ -102,14 +86,14 @@ export function ScorecardView({
|
|||||||
if (!entry) return
|
if (!entry) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
|
|
||||||
const key = `${entry.player.user_id}-${entry.hole.id}`
|
const key = `${entry.player.userId}-${entry.hole.id}`
|
||||||
const prev = scores[key]
|
const prev = scores[key]
|
||||||
|
|
||||||
// Optimistic update
|
// Optimistic update
|
||||||
setScores((s) => ({ ...s, [key]: strokes }))
|
setScores((s) => ({ ...s, [key]: strokes }))
|
||||||
setEntry(null)
|
setEntry(null)
|
||||||
|
|
||||||
const result = await upsertScore(round.id, entry.hole.id, entry.player.user_id, strokes)
|
const result = await upsertScore(round.id, entry.hole.id, entry.player.userId, strokes)
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
// Revert
|
// Revert
|
||||||
@@ -140,7 +124,7 @@ export function ScorecardView({
|
|||||||
{round.name ?? round.course?.name ?? 'Scorecard'}
|
{round.name ?? round.course?.name ?? 'Scorecard'}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{round.tee?.name} tees · {round.holes_count} holes
|
{round.tee?.name} tees · {round.holesCount} holes
|
||||||
{!isActive && (
|
{!isActive && (
|
||||||
<span className="ml-1 text-muted-foreground">(completed)</span>
|
<span className="ml-1 text-muted-foreground">(completed)</span>
|
||||||
)}
|
)}
|
||||||
@@ -210,7 +194,7 @@ export function ScorecardView({
|
|||||||
hole={entry?.hole ?? null}
|
hole={entry?.hole ?? null}
|
||||||
player={entry?.player ?? null}
|
player={entry?.player ?? null}
|
||||||
currentStrokes={
|
currentStrokes={
|
||||||
entry ? (scores[`${entry.player.user_id}-${entry.hole.id}`] ?? null) : null
|
entry ? (scores[`${entry.player.userId}-${entry.hole.id}`] ?? null) : null
|
||||||
}
|
}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onClose={() => setEntry(null)}
|
onClose={() => setEntry(null)}
|
||||||
|
|||||||
+14
-195
@@ -1,14 +1,5 @@
|
|||||||
name: mulliganmates
|
name: mulliganmates
|
||||||
|
|
||||||
x-supabase-env: &supabase-env
|
|
||||||
POSTGRES_HOST: ${POSTGRES_HOST:-db}
|
|
||||||
POSTGRES_PORT: ${POSTGRES_PORT:-5432}
|
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-postgres}
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
|
||||||
ANON_KEY: ${ANON_KEY}
|
|
||||||
SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
# ── Application ────────────────────────────────────────────
|
# ── Application ────────────────────────────────────────────
|
||||||
@@ -18,10 +9,16 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
NEXT_PUBLIC_SUPABASE_URL: ${SUPABASE_PUBLIC_URL}
|
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-postgres}
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY}
|
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
|
||||||
SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
|
BETTER_AUTH_URL: ${SITE_URL}
|
||||||
|
SITE_URL: ${SITE_URL}
|
||||||
ADMIN_EMAIL: ${ADMIN_EMAIL}
|
ADMIN_EMAIL: ${ADMIN_EMAIL}
|
||||||
|
SMTP_HOST: ${SMTP_HOST}
|
||||||
|
SMTP_PORT: ${SMTP_PORT:-1025}
|
||||||
|
SMTP_USER: ${SMTP_USER:-}
|
||||||
|
SMTP_PASS: ${SMTP_PASS:-}
|
||||||
|
SMTP_SENDER_NAME: ${SMTP_SENDER_NAME:-MulliganMates}
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.mulliganmates.rule=Host(`${APP_DOMAIN}`)"
|
- "traefik.http.routers.mulliganmates.rule=Host(`${APP_DOMAIN}`)"
|
||||||
@@ -34,201 +31,24 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
auth:
|
|
||||||
condition: service_started
|
|
||||||
|
|
||||||
# ── Supabase: Database ─────────────────────────────────────
|
# ── Database ───────────────────────────────────────────────
|
||||||
db:
|
db:
|
||||||
image: supabase/postgres:15.8.1.085
|
image: postgres:17-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "pg_isready", "-U", "postgres", "-d", "postgres"]
|
test: ["CMD-SHELL", "pg_isready -U postgres -d ${POSTGRES_DB:-postgres}"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
environment:
|
environment:
|
||||||
<<: *supabase-env
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
POSTGRES_HOST: /var/run/postgresql
|
POSTGRES_DB: ${POSTGRES_DB:-postgres}
|
||||||
volumes:
|
volumes:
|
||||||
- db_data:/var/lib/postgresql/data
|
- db_data:/var/lib/postgresql/data
|
||||||
- ./supabase/migrations:/docker-entrypoint-initdb.d:ro
|
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
|
|
||||||
# ── Supabase: Kong (API Gateway) ───────────────────────────
|
|
||||||
kong:
|
|
||||||
image: kong/kong:3.9.1
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
KONG_DATABASE: "off"
|
|
||||||
KONG_DECLARATIVE_CONFIG: /etc/kong/kong.yml
|
|
||||||
KONG_DNS_ORDER: LAST,A,CNAME
|
|
||||||
KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth
|
|
||||||
KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
|
|
||||||
KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
|
|
||||||
SUPABASE_ANON_KEY: ${ANON_KEY}
|
|
||||||
SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
|
|
||||||
volumes:
|
|
||||||
- ./volumes/api/kong.yml:/etc/kong/kong.yml:ro
|
|
||||||
labels:
|
|
||||||
- "traefik.enable=true"
|
|
||||||
- "traefik.http.routers.supabase-api.rule=Host(`${SUPABASE_DOMAIN}`)"
|
|
||||||
- "traefik.http.routers.supabase-api.entrypoints=websecure"
|
|
||||||
- "traefik.http.routers.supabase-api.tls.certresolver=letsencrypt"
|
|
||||||
- "traefik.http.services.supabase-api.loadbalancer.server.port=8000"
|
|
||||||
networks:
|
|
||||||
- traefik
|
|
||||||
- internal
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
# ── Supabase: Auth (GoTrue) ────────────────────────────────
|
|
||||||
auth:
|
|
||||||
image: supabase/gotrue:v2.186.0
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
<<: *supabase-env
|
|
||||||
GOTRUE_API_HOST: "0.0.0.0"
|
|
||||||
GOTRUE_API_PORT: 9999
|
|
||||||
API_EXTERNAL_URL: ${SUPABASE_PUBLIC_URL}
|
|
||||||
GOTRUE_DB_DRIVER: postgres
|
|
||||||
GOTRUE_DB_DATABASE_URL: "postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST:-db}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-postgres}"
|
|
||||||
GOTRUE_SITE_URL: ${SITE_URL}
|
|
||||||
GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS:-}
|
|
||||||
GOTRUE_DISABLE_SIGNUP: "true"
|
|
||||||
GOTRUE_JWT_SECRET: ${JWT_SECRET}
|
|
||||||
GOTRUE_JWT_EXP: ${JWT_EXPIRY:-3600}
|
|
||||||
GOTRUE_MAILER_AUTOCONFIRM: "false"
|
|
||||||
GOTRUE_SMTP_HOST: ${SMTP_HOST}
|
|
||||||
GOTRUE_SMTP_PORT: ${SMTP_PORT:-1025}
|
|
||||||
GOTRUE_SMTP_USER: ${SMTP_USER:-}
|
|
||||||
GOTRUE_SMTP_PASS: ${SMTP_PASS:-}
|
|
||||||
GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME:-MulliganMates}
|
|
||||||
GOTRUE_MAILER_URLPATHS_INVITE: "/auth/v1/verify"
|
|
||||||
GOTRUE_MAILER_URLPATHS_CONFIRMATION: "/auth/v1/verify"
|
|
||||||
GOTRUE_MAILER_URLPATHS_RECOVERY: "/auth/v1/verify"
|
|
||||||
GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: "/auth/v1/verify"
|
|
||||||
networks:
|
|
||||||
- internal
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
# ── Supabase: REST (PostgREST) ─────────────────────────────
|
|
||||||
rest:
|
|
||||||
image: postgrest/postgrest:v14.6
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
PGRST_DB_URI: "postgres://authenticator:${POSTGRES_PASSWORD}@${POSTGRES_HOST:-db}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-postgres}"
|
|
||||||
PGRST_DB_SCHEMAS: "public,storage,graphql_public"
|
|
||||||
PGRST_DB_ANON_ROLE: anon
|
|
||||||
PGRST_JWT_SECRET: ${JWT_SECRET}
|
|
||||||
PGRST_DB_USE_LEGACY_GUCS: "false"
|
|
||||||
PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET}
|
|
||||||
PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY:-3600}
|
|
||||||
networks:
|
|
||||||
- internal
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
# ── Supabase: Realtime ─────────────────────────────────────
|
|
||||||
realtime:
|
|
||||||
image: supabase/realtime:v2.76.5
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
PORT: 4000
|
|
||||||
DB_HOST: ${POSTGRES_HOST:-db}
|
|
||||||
DB_PORT: ${POSTGRES_PORT:-5432}
|
|
||||||
DB_USER: supabase_admin
|
|
||||||
DB_PASSWORD: ${POSTGRES_PASSWORD}
|
|
||||||
DB_NAME: ${POSTGRES_DB:-postgres}
|
|
||||||
DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime"
|
|
||||||
DB_ENC_KEY: ${SECRET_KEY_BASE}
|
|
||||||
API_JWT_SECRET: ${JWT_SECRET}
|
|
||||||
FLY_ALLOC_ID: fly123
|
|
||||||
FLY_APP_NAME: realtime
|
|
||||||
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
|
|
||||||
ERL_AFLAGS: -proto_dist inet_tcp
|
|
||||||
DNS_NODES: "''"
|
|
||||||
RLIMIT_NOFILE: "10000"
|
|
||||||
networks:
|
|
||||||
- internal
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
# ── Supabase: Storage ──────────────────────────────────────
|
|
||||||
storage:
|
|
||||||
image: supabase/storage-api:v1.44.2
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
<<: *supabase-env
|
|
||||||
ANON_KEY: ${ANON_KEY}
|
|
||||||
SERVICE_KEY: ${SERVICE_ROLE_KEY}
|
|
||||||
POSTGREST_URL: http://rest:3000
|
|
||||||
PGRST_JWT_SECRET: ${JWT_SECRET}
|
|
||||||
DATABASE_URL: "postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST:-db}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-postgres}"
|
|
||||||
FILE_SIZE_LIMIT: 52428800
|
|
||||||
STORAGE_BACKEND: file
|
|
||||||
FILE_STORAGE_BACKEND_PATH: /var/lib/storage
|
|
||||||
TENANT_ID: stub
|
|
||||||
REGION: stub
|
|
||||||
GLOBAL_S3_BUCKET: stub
|
|
||||||
volumes:
|
|
||||||
- storage_data:/var/lib/storage
|
|
||||||
networks:
|
|
||||||
- internal
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
rest:
|
|
||||||
condition: service_started
|
|
||||||
|
|
||||||
# ── Supabase: Meta ─────────────────────────────────────────
|
|
||||||
meta:
|
|
||||||
image: supabase/postgres-meta:v0.95.2
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
PG_META_PORT: 8080
|
|
||||||
PG_META_DB_HOST: ${POSTGRES_HOST:-db}
|
|
||||||
PG_META_DB_PORT: ${POSTGRES_PORT:-5432}
|
|
||||||
PG_META_DB_NAME: ${POSTGRES_DB:-postgres}
|
|
||||||
PG_META_DB_USER: supabase_admin
|
|
||||||
PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD}
|
|
||||||
networks:
|
|
||||||
- internal
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
# ── Supabase: Studio (dev only) ────────────────────────────
|
|
||||||
studio:
|
|
||||||
image: supabase/studio:2026.03.16-sha-5528817
|
|
||||||
restart: unless-stopped
|
|
||||||
profiles: ["dev"]
|
|
||||||
environment:
|
|
||||||
STUDIO_PG_META_URL: http://meta:8080
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
|
||||||
DEFAULT_ORGANIZATION_NAME: MulliganMates
|
|
||||||
DEFAULT_PROJECT_NAME: MulliganMates
|
|
||||||
SUPABASE_URL: http://kong:8000
|
|
||||||
SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL}
|
|
||||||
SUPABASE_ANON_KEY: ${ANON_KEY}
|
|
||||||
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
|
|
||||||
LOGFLARE_API_KEY: ${LOGFLARE_PUBLIC_ACCESS_TOKEN:-}
|
|
||||||
NEXT_PUBLIC_ENABLE_LOGS: "false"
|
|
||||||
labels:
|
|
||||||
- "traefik.enable=true"
|
|
||||||
- "traefik.http.routers.supabase-studio.rule=Host(`${STUDIO_DOMAIN}`)"
|
|
||||||
- "traefik.http.routers.supabase-studio.entrypoints=websecure"
|
|
||||||
- "traefik.http.routers.supabase-studio.tls.certresolver=letsencrypt"
|
|
||||||
- "traefik.http.services.supabase-studio.loadbalancer.server.port=3000"
|
|
||||||
networks:
|
|
||||||
- traefik
|
|
||||||
- internal
|
|
||||||
|
|
||||||
# ── Email: Mailpit (dev only) ──────────────────────────────
|
# ── Email: Mailpit (dev only) ──────────────────────────────
|
||||||
mailpit:
|
mailpit:
|
||||||
image: axllent/mailpit:latest
|
image: axllent/mailpit:latest
|
||||||
@@ -246,7 +66,6 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db_data:
|
db_data:
|
||||||
storage_data:
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
traefik:
|
traefik:
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'drizzle-kit'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: './lib/db/schema.ts',
|
||||||
|
out: './lib/db/migrations',
|
||||||
|
dialect: 'postgresql',
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL!,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export async function register() {
|
||||||
|
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||||
|
const { migrate } = await import('drizzle-orm/postgres-js/migrator')
|
||||||
|
const { db } = await import('@/lib/db')
|
||||||
|
await migrate(db, { migrationsFolder: './lib/db/migrations' })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { createAuthClient } from 'better-auth/react'
|
||||||
|
import { magicLinkClient } from 'better-auth/client/plugins'
|
||||||
|
|
||||||
|
export const authClient = createAuthClient({
|
||||||
|
plugins: [magicLinkClient()],
|
||||||
|
})
|
||||||
|
|
||||||
|
export type { Session } from 'better-auth/types'
|
||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
import { betterAuth } from 'better-auth'
|
||||||
|
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
||||||
|
import { magicLink } from 'better-auth/plugins'
|
||||||
|
import { db } from './db'
|
||||||
|
import * as schema from './db/schema'
|
||||||
|
import { sendEmail } from './email'
|
||||||
|
|
||||||
|
export const auth = betterAuth({
|
||||||
|
database: drizzleAdapter(db, {
|
||||||
|
provider: 'pg',
|
||||||
|
schema: {
|
||||||
|
user: schema.user,
|
||||||
|
session: schema.session,
|
||||||
|
verification: schema.verification,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Disable password-based signup — invite-only, magic link only
|
||||||
|
emailAndPassword: { enabled: false },
|
||||||
|
|
||||||
|
plugins: [
|
||||||
|
magicLink({
|
||||||
|
disableSignUp: true, // only existing (invited) users can sign in
|
||||||
|
sendMagicLink: async ({ email, url }) => {
|
||||||
|
await sendEmail({
|
||||||
|
to: email,
|
||||||
|
subject: 'Sign in to MulliganMates',
|
||||||
|
html: `
|
||||||
|
<div style="font-family:sans-serif;max-width:480px;margin:0 auto;padding:32px">
|
||||||
|
<div style="font-size:32px;margin-bottom:8px">⛳</div>
|
||||||
|
<h1 style="font-size:24px;font-weight:700;color:#16a34a;margin:0 0 8px">MulliganMates</h1>
|
||||||
|
<p style="color:#374151;margin:0 0 24px">Click the button below to sign in. This link expires in 1 hour.</p>
|
||||||
|
<a href="${url}"
|
||||||
|
style="display:inline-block;background:#16a34a;color:#fff;text-decoration:none;
|
||||||
|
font-weight:600;font-size:16px;padding:14px 28px;border-radius:8px">
|
||||||
|
Sign in
|
||||||
|
</a>
|
||||||
|
<p style="color:#6b7280;font-size:13px;margin-top:24px">
|
||||||
|
Or copy this link:<br>
|
||||||
|
<a href="${url}" style="color:#16a34a;word-break:break-all">${url}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
|
||||||
|
user: {
|
||||||
|
additionalFields: {
|
||||||
|
handicapIndex: {
|
||||||
|
type: 'number',
|
||||||
|
nullable: true,
|
||||||
|
fieldName: 'handicap_index',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export type Session = typeof auth.$Infer.Session
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/postgres-js'
|
||||||
|
import postgres from 'postgres'
|
||||||
|
import * as schema from './schema'
|
||||||
|
|
||||||
|
const client = postgres(process.env.DATABASE_URL!)
|
||||||
|
export const db = drizzle(client, { schema })
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import {
|
||||||
|
pgTable, pgEnum, text, boolean, integer,
|
||||||
|
numeric, timestamp, date, unique,
|
||||||
|
uuid,
|
||||||
|
} from 'drizzle-orm/pg-core'
|
||||||
|
import { sql } from 'drizzle-orm'
|
||||||
|
|
||||||
|
// ── better-auth managed tables ────────────────────────────────
|
||||||
|
export const user = pgTable('user', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
email: text('email').notNull().unique(),
|
||||||
|
emailVerified: boolean('email_verified').notNull().default(false),
|
||||||
|
image: text('image'),
|
||||||
|
createdAt: timestamp('created_at').notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').notNull(),
|
||||||
|
// App-specific additional field
|
||||||
|
handicapIndex: numeric('handicap_index', { precision: 4, scale: 1 }),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const session = pgTable('session', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
expiresAt: timestamp('expires_at').notNull(),
|
||||||
|
token: text('token').notNull().unique(),
|
||||||
|
createdAt: timestamp('created_at').notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').notNull(),
|
||||||
|
ipAddress: text('ip_address'),
|
||||||
|
userAgent: text('user_agent'),
|
||||||
|
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const verification = pgTable('verification', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
identifier: text('identifier').notNull(),
|
||||||
|
value: text('value').notNull(),
|
||||||
|
expiresAt: timestamp('expires_at').notNull(),
|
||||||
|
createdAt: timestamp('created_at'),
|
||||||
|
updatedAt: timestamp('updated_at'),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── App tables ────────────────────────────────────────────────
|
||||||
|
export const courses = pgTable('courses', {
|
||||||
|
id: uuid('id').primaryKey().default(sql`gen_random_uuid()`),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
par: integer('par').notNull(),
|
||||||
|
createdBy: text('created_by').notNull().references(() => user.id, { onDelete: 'set null' }),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const tees = pgTable('tees', {
|
||||||
|
id: uuid('id').primaryKey().default(sql`gen_random_uuid()`),
|
||||||
|
courseId: uuid('course_id').notNull().references(() => courses.id, { onDelete: 'cascade' }),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
color: text('color').notNull().default('#888888'),
|
||||||
|
courseRating: numeric('course_rating', { precision: 4, scale: 1 }).notNull(),
|
||||||
|
slopeRating: integer('slope_rating').notNull(),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const holes = pgTable('holes', {
|
||||||
|
id: uuid('id').primaryKey().default(sql`gen_random_uuid()`),
|
||||||
|
courseId: uuid('course_id').notNull().references(() => courses.id, { onDelete: 'cascade' }),
|
||||||
|
holeNumber: integer('hole_number').notNull(),
|
||||||
|
par: integer('par').notNull(),
|
||||||
|
strokeIndex: integer('stroke_index').notNull(),
|
||||||
|
}, (t) => [unique().on(t.courseId, t.holeNumber)])
|
||||||
|
|
||||||
|
export const roundStatusEnum = pgEnum('round_status', ['lobby', 'active', 'completed'])
|
||||||
|
|
||||||
|
export const rounds = pgTable('rounds', {
|
||||||
|
id: uuid('id').primaryKey().default(sql`gen_random_uuid()`),
|
||||||
|
courseId: uuid('course_id').notNull().references(() => courses.id),
|
||||||
|
teeId: uuid('tee_id').notNull().references(() => tees.id),
|
||||||
|
name: text('name'),
|
||||||
|
date: date('date').notNull().default(sql`current_date`),
|
||||||
|
holesCount: integer('holes_count').notNull(),
|
||||||
|
status: roundStatusEnum('status').notNull().default('lobby'),
|
||||||
|
createdBy: text('created_by').notNull().references(() => user.id),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const roundPlayers = pgTable('round_players', {
|
||||||
|
id: uuid('id').primaryKey().default(sql`gen_random_uuid()`),
|
||||||
|
roundId: uuid('round_id').notNull().references(() => rounds.id, { onDelete: 'cascade' }),
|
||||||
|
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
role: text('role').notNull().default('player'),
|
||||||
|
handicapIndex: numeric('handicap_index', { precision: 4, scale: 1 }),
|
||||||
|
courseHandicap: integer('course_handicap'),
|
||||||
|
joinedAt: timestamp('joined_at').notNull().defaultNow(),
|
||||||
|
}, (t) => [unique().on(t.roundId, t.userId)])
|
||||||
|
|
||||||
|
export const roundInvites = pgTable('round_invites', {
|
||||||
|
id: uuid('id').primaryKey().default(sql`gen_random_uuid()`),
|
||||||
|
roundId: uuid('round_id').notNull().references(() => rounds.id, { onDelete: 'cascade' }),
|
||||||
|
email: text('email').notNull(),
|
||||||
|
invitedBy: text('invited_by').notNull().references(() => user.id),
|
||||||
|
acceptedAt: timestamp('accepted_at'),
|
||||||
|
expiresAt: timestamp('expires_at').notNull().default(sql`now() + interval '7 days'`),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const scores = pgTable('scores', {
|
||||||
|
id: uuid('id').primaryKey().default(sql`gen_random_uuid()`),
|
||||||
|
roundId: uuid('round_id').notNull().references(() => rounds.id, { onDelete: 'cascade' }),
|
||||||
|
holeId: uuid('hole_id').notNull().references(() => holes.id, { onDelete: 'cascade' }),
|
||||||
|
playerId: text('player_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
strokes: integer('strokes').notNull(),
|
||||||
|
updatedBy: text('updated_by').notNull().references(() => user.id),
|
||||||
|
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||||
|
}, (t) => [unique().on(t.roundId, t.holeId, t.playerId)])
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import nodemailer from 'nodemailer'
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: process.env.SMTP_HOST ?? 'mailpit',
|
||||||
|
port: parseInt(process.env.SMTP_PORT ?? '1025'),
|
||||||
|
secure: process.env.SMTP_PORT === '465',
|
||||||
|
auth:
|
||||||
|
process.env.SMTP_USER
|
||||||
|
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function sendEmail({
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
}: {
|
||||||
|
to: string
|
||||||
|
subject: string
|
||||||
|
html: string
|
||||||
|
}) {
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `MulliganMates <${process.env.SMTP_FROM ?? 'noreply@golf.gy.gl'}>`,
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { createBrowserClient } from '@supabase/ssr'
|
|
||||||
import type { Database } from './types'
|
|
||||||
|
|
||||||
export function createClient() {
|
|
||||||
return createBrowserClient<Database>(
|
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { createServerClient } from '@supabase/ssr'
|
|
||||||
import { cookies } from 'next/headers'
|
|
||||||
import type { Database } from './types'
|
|
||||||
|
|
||||||
export async function createClient() {
|
|
||||||
const cookieStore = await cookies()
|
|
||||||
|
|
||||||
return createServerClient<Database>(
|
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
||||||
{
|
|
||||||
cookies: {
|
|
||||||
getAll() {
|
|
||||||
return cookieStore.getAll()
|
|
||||||
},
|
|
||||||
setAll(cookiesToSet) {
|
|
||||||
try {
|
|
||||||
cookiesToSet.forEach(({ name, value, options }) =>
|
|
||||||
cookieStore.set(name, value, options),
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
// Server Component — cookie setting ignored (middleware handles refresh)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createAdminClient() {
|
|
||||||
const cookieStore = await cookies()
|
|
||||||
|
|
||||||
return createServerClient<Database>(
|
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
||||||
process.env.SUPABASE_SERVICE_ROLE_KEY!,
|
|
||||||
{
|
|
||||||
cookies: {
|
|
||||||
getAll() {
|
|
||||||
return cookieStore.getAll()
|
|
||||||
},
|
|
||||||
setAll(cookiesToSet) {
|
|
||||||
try {
|
|
||||||
cookiesToSet.forEach(({ name, value, options }) =>
|
|
||||||
cookieStore.set(name, value, options),
|
|
||||||
)
|
|
||||||
} catch {}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
// Auto-generated types will go here after running: supabase gen types typescript
|
|
||||||
// For now, a placeholder that will be replaced once the DB schema is applied.
|
|
||||||
export type Database = {
|
|
||||||
public: {
|
|
||||||
Tables: Record<string, never>
|
|
||||||
Views: Record<string, never>
|
|
||||||
Functions: Record<string, never>
|
|
||||||
Enums: Record<string, never>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+14
-40
@@ -1,54 +1,28 @@
|
|||||||
import { createServerClient } from '@supabase/ssr'
|
|
||||||
import { NextResponse, type NextRequest } from 'next/server'
|
import { NextResponse, type NextRequest } from 'next/server'
|
||||||
|
import { betterFetch } from '@better-fetch/fetch'
|
||||||
|
|
||||||
const PUBLIC_PATHS = ['/login', '/auth/confirm']
|
const PUBLIC_PATHS = ['/login', '/api/auth']
|
||||||
|
|
||||||
|
type Session = { user: { id: string; email: string } }
|
||||||
|
|
||||||
export async function middleware(request: NextRequest) {
|
export async function middleware(request: NextRequest) {
|
||||||
let supabaseResponse = NextResponse.next({ request })
|
|
||||||
|
|
||||||
const supabase = createServerClient(
|
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
||||||
{
|
|
||||||
cookies: {
|
|
||||||
getAll() {
|
|
||||||
return request.cookies.getAll()
|
|
||||||
},
|
|
||||||
setAll(cookiesToSet) {
|
|
||||||
cookiesToSet.forEach(({ name, value }) =>
|
|
||||||
request.cookies.set(name, value),
|
|
||||||
)
|
|
||||||
supabaseResponse = NextResponse.next({ request })
|
|
||||||
cookiesToSet.forEach(({ name, value, options }) =>
|
|
||||||
supabaseResponse.cookies.set(name, value, options),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// Use getClaims() for cryptographic JWT verification (not getSession())
|
|
||||||
const {
|
|
||||||
data: { user },
|
|
||||||
} = await supabase.auth.getUser()
|
|
||||||
|
|
||||||
const { pathname } = request.nextUrl
|
const { pathname } = request.nextUrl
|
||||||
|
const isPublic = PUBLIC_PATHS.some((p) => pathname.startsWith(p))
|
||||||
|
|
||||||
const isPublicPath = PUBLIC_PATHS.some((p) => pathname.startsWith(p))
|
const { data: session } = await betterFetch<Session>('/api/auth/get-session', {
|
||||||
|
baseURL: request.nextUrl.origin,
|
||||||
|
headers: { cookie: request.headers.get('cookie') ?? '' },
|
||||||
|
})
|
||||||
|
|
||||||
if (!user && !isPublicPath) {
|
if (!session && !isPublic) {
|
||||||
const url = request.nextUrl.clone()
|
return NextResponse.redirect(new URL('/login', request.url))
|
||||||
url.pathname = '/login'
|
|
||||||
return NextResponse.redirect(url)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user && pathname === '/login') {
|
if (session && pathname === '/login') {
|
||||||
const url = request.nextUrl.clone()
|
return NextResponse.redirect(new URL('/dashboard', request.url))
|
||||||
url.pathname = '/dashboard'
|
|
||||||
return NextResponse.redirect(url)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return supabaseResponse
|
return NextResponse.next()
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
|
|||||||
Generated
+2106
-136
File diff suppressed because it is too large
Load Diff
+6
-2
@@ -10,15 +10,17 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.3.0",
|
"@base-ui/react": "^1.3.0",
|
||||||
"@supabase/ssr": "^0.9.0",
|
|
||||||
"@supabase/supabase-js": "^2.99.2",
|
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@tanstack/react-query-devtools": "^5.91.3",
|
"@tanstack/react-query-devtools": "^5.91.3",
|
||||||
|
"better-auth": "^1.5.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"drizzle-orm": "^0.45.1",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"next": "16.1.7",
|
"next": "16.1.7",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"nodemailer": "^8.0.2",
|
||||||
|
"postgres": "^3.4.8",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"serwist": "^9.5.7",
|
"serwist": "^9.5.7",
|
||||||
@@ -31,8 +33,10 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20.19.37",
|
"@types/node": "^20.19.37",
|
||||||
|
"@types/nodemailer": "^7.0.11",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"drizzle-kit": "^0.31.10",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.7",
|
"eslint-config-next": "16.1.7",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user