Initial commit v1.1

This commit is contained in:
Rolf
2026-03-18 13:34:41 +01:00
parent cb04bf2ab8
commit b982584244
38 changed files with 3196 additions and 1103 deletions
+5 -5
View File
@@ -83,13 +83,13 @@ export default function NewCoursePage() {
tees: tees.map((t) => ({
name: t.name,
color: t.color,
course_rating: parseFloat(t.course_rating),
slope_rating: parseInt(t.slope_rating),
courseRating: parseFloat(t.course_rating),
slopeRating: parseInt(t.slope_rating),
})),
holes: holes.slice(0, holesCount).map((h) => ({
hole_number: h.hole_number,
par: h.par,
stroke_index: h.stroke_index,
holeNumber: h.hole_number,
par: h.par,
strokeIndex: h.stroke_index,
})),
})
+60 -61
View File
@@ -1,45 +1,45 @@
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import Link from 'next/link'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
import { rounds, roundPlayers, courses, tees } from '@/lib/db/schema'
import { eq, inArray } from 'drizzle-orm'
import { buttonVariants } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { headers } from 'next/headers'
export default async function DashboardPage() {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
const session = await auth.api.getSession({ headers: await headers() })
if (!session) redirect('/login')
if (!user) redirect('/login')
const memberships = await db
.select({ roundId: roundPlayers.roundId })
.from(roundPlayers)
.where(eq(roundPlayers.userId, session.user.id))
type RoundRow = {
round: {
id: string
name: string | null
date: string
status: string
holes_count: number
course: { name: string } | null
tee: { name: string; color: string } | null
} | null
}
const roundIds = memberships.map((m) => m.roundId)
// Fetch rounds the user is participating in
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: rounds } = (await (supabase as any)
.from('round_players')
.select(
`
round:rounds (
id, name, date, status, holes_count,
course:courses (name),
tee:tees (name, color)
)
`,
)
.eq('user_id', user.id)
.in('round.status', ['lobby', 'active'])
.order('round(date)', { ascending: false })) as { data: RoundRow[] | null }
const activeRounds =
roundIds.length > 0
? await db
.select({
id: rounds.id,
name: rounds.name,
date: rounds.date,
status: rounds.status,
holesCount: rounds.holesCount,
courseName: courses.name,
teeName: tees.name,
teeColor: tees.color,
})
.from(rounds)
.innerJoin(courses, eq(rounds.courseId, courses.id))
.innerJoin(tees, eq(rounds.teeId, tees.id))
.where(inArray(rounds.id, roundIds))
.orderBy(rounds.date)
: []
const visibleRounds = activeRounds.filter((r) => r.status !== 'completed')
return (
<div className="p-4 space-y-4">
@@ -50,48 +50,47 @@ export default async function DashboardPage() {
</Link>
</div>
{!rounds?.length && (
<p className="text-muted-foreground text-sm py-8 text-center">
{visibleRounds.length === 0 && (
<p className="py-8 text-center text-sm text-muted-foreground">
No active rounds. Start one!
</p>
)}
<div className="space-y-3">
{rounds?.map(({ round }) => {
if (!round) return null
return (
<Link
key={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"
>
<div className="flex items-center justify-between">
<span className="font-semibold">{round.course?.name}</span>
<StatusBadge status={round.status} />
</div>
<div className="text-sm text-muted-foreground">
{round.name && <span>{round.name} · </span>}
{round.tee?.name} tees · {round.holes_count} holes ·{' '}
{new Date(round.date).toLocaleDateString()}
</div>
</Link>
)
})}
{visibleRounds.map((round) => (
<Link
key={round.id}
href={round.status === 'active' ? `/rounds/${round.id}/scorecard` : `/rounds/${round.id}`}
className="block rounded-xl border p-4 space-y-1 transition-colors hover:bg-muted"
>
<div className="flex items-center justify-between">
<span className="font-semibold">{round.courseName}</span>
<StatusBadge status={round.status} />
</div>
<div className="text-sm text-muted-foreground">
{round.name && <span>{round.name} · </span>}
<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()}
</div>
</Link>
))}
</div>
</div>
)
}
function StatusBadge({ status }: { status: string }) {
const styles = {
lobby: 'bg-yellow-100 text-yellow-800',
active: 'bg-green-100 text-green-800',
const styles: Record<string, string> = {
lobby: 'bg-yellow-100 text-yellow-800',
active: 'bg-green-100 text-green-800',
completed: 'bg-gray-100 text-gray-600',
}
return (
<span
className={`text-xs font-medium px-2 py-0.5 rounded-full ${styles[status as keyof typeof styles] ?? styles.completed}`}
>
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${styles[status] ?? styles.completed}`}>
{status}
</span>
)
+8 -12
View File
@@ -1,23 +1,19 @@
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
import { auth } from '@/lib/auth'
import { headers } from 'next/headers'
import { BottomNav } from '@/components/bottom-nav'
import { isAdminEmail } from '@/lib/admin'
export default async function AppLayout({
children,
}: {
children: React.ReactNode
}) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
export default async function AppLayout({ children }: { children: React.ReactNode }) {
const session = await auth.api.getSession({ headers: await headers() })
if (!session) redirect('/login')
if (!user) redirect('/login')
const isAdmin = isAdminEmail(session.user.email)
return (
<div className="flex min-h-screen flex-col">
<main className="flex-1 pb-20">{children}</main>
<BottomNav />
<BottomNav isAdmin={isAdmin} />
</div>
)
}
+43 -29
View File
@@ -1,43 +1,57 @@
import { createClient } from '@/lib/supabase/server'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
import { rounds, roundPlayers, courses, tees } from '@/lib/db/schema'
import { eq } from 'drizzle-orm'
import { redirect, notFound } from 'next/navigation'
import { headers } from 'next/headers'
import { JoinRound } from '@/components/round/join-round'
export default async function JoinRoundPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const supabase = await createClient()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db = supabase as any
const session = await auth.api.getSession({ headers: await headers() })
if (!session) redirect(`/login`)
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) redirect(`/login`)
const { data: round } = await db
.from('rounds')
.select(
`
id, name, date, holes_count, status, created_by,
course:courses (name),
tee:tees (name, color),
round_players (user_id)
`,
)
.eq('id', id)
.single()
const [round] = await db
.select({
id: rounds.id,
name: rounds.name,
date: rounds.date,
holesCount: rounds.holesCount,
status: rounds.status,
courseName: courses.name,
teeName: tees.name,
teeColor: tees.color,
})
.from(rounds)
.innerJoin(courses, eq(rounds.courseId, courses.id))
.innerJoin(tees, eq(rounds.teeId, tees.id))
.where(eq(rounds.id, id))
.limit(1)
if (!round) notFound()
// Already a member — go straight to the round
const alreadyJoined = round.round_players?.some(
(p: { user_id: string }) => p.user_id === user.id,
)
if (alreadyJoined) redirect(`/rounds/${id}`)
const [membership] = await db
.select({ id: roundPlayers.id })
.from(roundPlayers)
.where(eq(roundPlayers.roundId, id))
.limit(1)
if (membership) redirect(`/rounds/${id}`)
// Round must be in lobby to join
if (round.status !== 'lobby') {
redirect(`/dashboard`)
}
if (round.status !== 'lobby') redirect('/dashboard')
return <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 },
}}
/>
)
}
+67 -46
View File
@@ -1,63 +1,84 @@
import { createClient } from '@/lib/supabase/server'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
import { rounds, roundPlayers, roundInvites, courses, tees, user } from '@/lib/db/schema'
import { eq } from 'drizzle-orm'
import { redirect, notFound } from 'next/navigation'
import { headers } from 'next/headers'
import { RoundLobby } from '@/components/round/round-lobby'
export default async function RoundPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const supabase = await createClient()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db = supabase as any
const session = await auth.api.getSession({ headers: await headers() })
if (!session) redirect('/login')
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) redirect('/login')
const { data: round } = await db
.from('rounds')
.select(
`
id, name, date, status, holes_count, created_by,
course:courses (id, name, par),
tee:tees (id, name, color, course_rating, slope_rating)
`,
)
.eq('id', id)
.single()
const [round] = await db
.select({
id: rounds.id,
name: rounds.name,
date: rounds.date,
status: rounds.status,
holesCount: rounds.holesCount,
createdBy: rounds.createdBy,
courseName: courses.name,
teeName: tees.name,
teeColor: tees.color,
courseRating: tees.courseRating,
slopeRating: tees.slopeRating,
})
.from(rounds)
.innerJoin(courses, eq(rounds.courseId, courses.id))
.innerJoin(tees, eq(rounds.teeId, tees.id))
.where(eq(rounds.id, id))
.limit(1)
if (!round) notFound()
const { data: players } = await db
.from('round_players')
.select(
`
id, role, handicap_index, course_handicap, joined_at,
profile:profiles (id, display_name, avatar_url)
`,
)
.eq('round_id', id)
.order('joined_at')
const { data: invites } = await db
.from('round_invites')
.select('id, email, accepted_at, created_at')
.eq('round_id', id)
.is('accepted_at', null)
.order('created_at')
const isCreator = round.created_by === user.id
if (round.status === 'active' || round.status === 'completed') {
redirect(`/rounds/${id}/scorecard`)
}
const players = await db
.select({
id: roundPlayers.id,
userId: roundPlayers.userId,
role: roundPlayers.role,
handicapIndex: roundPlayers.handicapIndex,
courseHandicap: roundPlayers.courseHandicap,
joinedAt: roundPlayers.joinedAt,
displayName: user.name,
avatarUrl: user.image,
})
.from(roundPlayers)
.innerJoin(user, eq(roundPlayers.userId, user.id))
.where(eq(roundPlayers.roundId, id))
.orderBy(roundPlayers.joinedAt)
const pendingInvites = await db
.select({ id: roundInvites.id, email: roundInvites.email, createdAt: roundInvites.createdAt })
.from(roundInvites)
.where(eq(roundInvites.roundId, id))
return (
<RoundLobby
round={round}
players={players ?? []}
pendingInvites={invites ?? []}
currentUserId={user.id}
isCreator={isCreator}
round={{
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 },
}}
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}
/>
)
}
+89 -61
View File
@@ -1,24 +1,28 @@
import { createClient } from '@/lib/supabase/server'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
import { rounds, roundPlayers, holes, scores, courses, tees, user } from '@/lib/db/schema'
import { eq, asc } from 'drizzle-orm'
import { redirect, notFound } from 'next/navigation'
import { headers } from 'next/headers'
import { ScorecardView } from '@/components/scorecard/scorecard-view'
export type Hole = {
id: string
hole_number: number
holeNumber: number
par: number
stroke_index: number
strokeIndex: number
}
export type Player = {
user_id: string
course_handicap: number | null
handicap_index: number | null
profile: { id: string; display_name: string; avatar_url: string | null } | null
userId: string
courseHandicap: number | null
handicapIndex: string | null
profile: { id: string; displayName: string; avatarUrl: string | null } | null
}
export type ScoreRow = {
hole_id: string
player_id: string
holeId: string
playerId: string
strokes: number
}
@@ -27,8 +31,8 @@ export type RoundInfo = {
name: string | null
date: string
status: string
holes_count: number
created_by: string
holesCount: number
createdBy: string
course: { name: string } | null
tee: { name: string; color: string } | null
}
@@ -39,66 +43,90 @@ export default async function ScorecardPage({
params: Promise<{ id: string }>
}) {
const { id } = await params
const supabase = await createClient()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db = supabase as any
const session = await auth.api.getSession({ headers: await headers() })
if (!session) redirect('/login')
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) redirect('/login')
const { data: round } = await db
.from('rounds')
.select(
`id, name, date, status, holes_count, created_by,
course:courses (name),
tee:tees (name, color)`,
)
.eq('id', id)
.single()
const [round] = await db
.select({
id: rounds.id,
name: rounds.name,
date: rounds.date,
status: rounds.status,
holesCount: rounds.holesCount,
createdBy: rounds.createdBy,
courseId: rounds.courseId,
courseName: courses.name,
teeName: tees.name,
teeColor: tees.color,
})
.from(rounds)
.innerJoin(courses, eq(rounds.courseId, courses.id))
.innerJoin(tees, eq(rounds.teeId, tees.id))
.where(eq(rounds.id, id))
.limit(1)
if (!round) notFound()
if (round.status === 'lobby') redirect(`/rounds/${id}`)
// Holes ordered by hole_number, limited to holes_count
const { data: holes } = await db
.from('holes')
.select('id, hole_number, par, stroke_index')
.eq('course_id', await getCourseId(db, id))
.order('hole_number')
.limit(round.holes_count)
const holeRows = await db
.select({
id: holes.id,
holeNumber: holes.holeNumber,
par: holes.par,
strokeIndex: holes.strokeIndex,
})
.from(holes)
.where(eq(holes.courseId, round.courseId))
.orderBy(asc(holes.holeNumber))
.limit(round.holesCount)
const { data: players } = await db
.from('round_players')
.select(
`user_id, course_handicap, handicap_index,
profile:profiles (id, display_name, avatar_url)`,
)
.eq('round_id', id)
.order('joined_at')
const playerRows = await db
.select({
userId: roundPlayers.userId,
courseHandicap: roundPlayers.courseHandicap,
handicapIndex: roundPlayers.handicapIndex,
displayName: user.name,
avatarUrl: user.image,
})
.from(roundPlayers)
.innerJoin(user, eq(roundPlayers.userId, user.id))
.where(eq(roundPlayers.roundId, id))
.orderBy(roundPlayers.joinedAt)
const { data: scores } = await db
.from('scores')
.select('hole_id, player_id, strokes')
.eq('round_id', id)
const scoreRows = await db
.select({ holeId: scores.holeId, playerId: scores.playerId, strokes: scores.strokes })
.from(scores)
.where(eq(scores.roundId, id))
const roundInfo: RoundInfo = {
id: round.id,
name: round.name,
date: round.date,
status: round.status,
holesCount: round.holesCount,
createdBy: round.createdBy,
course: { name: round.courseName },
tee: { name: round.teeName, color: round.teeColor },
}
const players: Player[] = playerRows.map((p) => ({
userId: p.userId,
courseHandicap: p.courseHandicap,
handicapIndex: p.handicapIndex,
profile: {
id: p.userId,
displayName: p.displayName,
avatarUrl: p.avatarUrl,
},
}))
return (
<ScorecardView
round={round as RoundInfo}
holes={(holes as Hole[]) ?? []}
players={(players as Player[]) ?? []}
initialScores={(scores as ScoreRow[]) ?? []}
currentUserId={user.id}
round={roundInfo}
holes={holeRows}
players={players}
initialScores={scoreRows}
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 ?? ''
}
+30 -23
View File
@@ -1,28 +1,35 @@
import { createClient } from '@/lib/supabase/server'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
import { courses, tees } from '@/lib/db/schema'
import { eq } from 'drizzle-orm'
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { NewRoundWizard } from '@/components/round/new-round-wizard'
type Tee = {
id: string
name: string
color: string
course_rating: number
slope_rating: number
}
type Course = {
id: string
name: string
par: number
tees: Tee[]
}
export default async function NewRoundPage() {
const supabase = await createClient()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: courses } = await (supabase as any)
.from('courses')
.select('id, name, par, tees(id, name, color, course_rating, slope_rating)')
.order('name')
const session = await auth.api.getSession({ headers: await headers() })
if (!session) redirect('/login')
return <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} />
}
-19
View File
@@ -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`)
}
+6 -13
View File
@@ -1,7 +1,7 @@
'use client'
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import { authClient } from '@/lib/auth-client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -17,17 +17,13 @@ export default function LoginPage() {
setLoading(true)
setError(null)
const supabase = createClient()
const { error } = await supabase.auth.signInWithOtp({
const { error } = await authClient.signIn.magicLink({
email,
options: {
emailRedirectTo: `${window.location.origin}/auth/confirm`,
shouldCreateUser: false, // invite-only: only existing users can log in
},
callbackURL: '/dashboard',
})
if (error) {
setError(error.message)
setError(error.message ?? 'Could not send link. Have you been invited?')
} else {
setSubmitted(true)
}
@@ -41,8 +37,7 @@ export default function LoginPage() {
<div className="text-4xl"></div>
<h1 className="text-2xl font-bold">Check your email</h1>
<p className="text-muted-foreground">
We sent a magic link to <strong>{email}</strong>. Tap the link to
sign in.
We sent a magic link to <strong>{email}</strong>. Tap it to sign in.
</p>
</div>
</div>
@@ -72,9 +67,7 @@ export default function LoginPage() {
autoFocus
/>
</div>
{error && <p className="text-destructive text-sm">{error}</p>}
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? 'Sending…' : 'Send magic link'}
</Button>
+45 -36
View File
@@ -1,26 +1,29 @@
'use server'
import { z } from 'zod'
import { createClient } from '@/lib/supabase/server'
import { db } from '@/lib/db'
import { courses, tees, holes } from '@/lib/db/schema'
import { auth } from '@/lib/auth'
import { headers } from 'next/headers'
import { revalidatePath } from 'next/cache'
const TeeInput = z.object({
name: z.string().min(1, 'Tee name required'),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/),
course_rating: z.coerce.number().min(50).max(80),
slope_rating: z.coerce.number().int().min(55).max(155),
name: z.string().min(1, 'Tee name required'),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/),
courseRating: z.coerce.number().min(50).max(80),
slopeRating: z.coerce.number().int().min(55).max(155),
})
const HoleInput = z.object({
hole_number: z.number().int().min(1).max(18),
par: z.number().int().min(3).max(5),
stroke_index: z.number().int().min(1).max(18),
holeNumber: z.number().int().min(1).max(18),
par: z.number().int().min(3).max(5),
strokeIndex: z.number().int().min(1).max(18),
})
export const CreateCourseSchema = z.object({
name: z.string().min(1, 'Course name required').max(100),
par: z.number().int().min(27).max(90),
tees: z.array(TeeInput).min(1, 'At least one tee required'),
name: z.string().min(1, 'Course name required').max(100),
par: z.number().int().min(27).max(90),
tees: z.array(TeeInput).min(1, 'At least one tee required'),
holes: z.array(HoleInput).min(9),
})
@@ -28,35 +31,41 @@ export type CreateCourseInput = z.infer<typeof CreateCourseSchema>
export async function createCourse(data: CreateCourseInput) {
const parsed = CreateCourseSchema.safeParse(data)
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Invalid data' }
if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? 'Invalid data' }
const session = await auth.api.getSession({ headers: await headers() })
if (!session) return { error: 'Not authenticated' }
// Validate par sum equals declared course par
const parSum = parsed.data.holes.reduce((s, h) => s + h.par, 0)
if (parSum !== parsed.data.par) {
return { error: `Sum of hole pars (${parSum}) does not equal course par (${parsed.data.par})` }
}
const supabase = await createClient()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db = supabase as any
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const [course] = await db
.insert(courses)
.values({ name: parsed.data.name, par: parsed.data.par, createdBy: session.user.id })
.returning({ id: courses.id })
const { data: course, error: courseError } = await db
.from('courses')
.insert({ name: parsed.data.name, par: parsed.data.par, created_by: user.id })
.select('id')
.single()
if (courseError) return { error: courseError.message }
await db.insert(tees).values(
parsed.data.tees.map((t) => ({
courseId: course.id,
name: t.name,
color: t.color,
courseRating: String(t.courseRating),
slopeRating: t.slopeRating,
})),
)
const { error: teesError } = await db
.from('tees')
.insert(parsed.data.tees.map((t) => ({ ...t, course_id: course.id })))
if (teesError) return { error: teesError.message }
const { error: holesError } = await db
.from('holes')
.insert(parsed.data.holes.map((h) => ({ ...h, course_id: course.id })))
if (holesError) return { error: holesError.message }
await db.insert(holes).values(
parsed.data.holes.map((h) => ({
courseId: course.id,
holeNumber: h.holeNumber,
par: h.par,
strokeIndex: h.strokeIndex,
})),
)
revalidatePath('/rounds/new')
return { courseId: course.id as string }
return { courseId: course.id }
}
+17 -37
View File
@@ -1,50 +1,30 @@
'use server'
import { z } from 'zod'
import { createClient, createAdminClient } from '@/lib/supabase/server'
const InviteSchema = z.object({
roundId: z.string().uuid(),
email: z.string().email(),
})
import { db } from '@/lib/db'
import { rounds } from '@/lib/db/schema'
import { eq } from 'drizzle-orm'
import { auth } from '@/lib/auth'
import { headers } from 'next/headers'
import { inviteToRound } from './rounds'
export async function inviteFromLobby(roundId: string, email: string) {
const parsed = InviteSchema.safeParse({ roundId, email })
const parsed = z.object({ roundId: z.string().uuid(), email: z.string().email() })
.safeParse({ roundId, email })
if (!parsed.success) return { error: 'Invalid email address' }
const supabase = await createClient()
const adminClient = await createAdminClient()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db = supabase as any
const session = await auth.api.getSession({ headers: await headers() })
if (!session) return { error: 'Not authenticated' }
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: round } = await db
.from('rounds')
.select('status, created_by')
.eq('id', parsed.data.roundId)
.single()
const [round] = await db
.select({ status: rounds.status, createdBy: rounds.createdBy })
.from(rounds)
.where(eq(rounds.id, roundId))
.limit(1)
if (!round) return { error: 'Round not found' }
if (round.created_by !== user.id) return { error: 'Only the round creator can invite players' }
if (round.createdBy !== session.user.id) return { error: 'Only the round creator can invite players' }
if (round.status !== 'lobby') return { error: 'Cannot invite after the round has started' }
const redirectTo = `${process.env.SITE_URL}/auth/confirm?next=${encodeURIComponent(`/rounds/${parsed.data.roundId}/join`)}`
const { error: inviteError } = await (adminClient as any).auth.admin.inviteUserByEmail(
parsed.data.email,
{ redirectTo },
)
if (inviteError) return { error: inviteError.message }
await db.from('round_invites').insert({
round_id: parsed.data.roundId,
email: parsed.data.email,
invited_by: user.id,
})
return { success: true }
return inviteToRound(roundId, email, session.user.id)
}
+199 -89
View File
@@ -1,129 +1,239 @@
'use server'
import { z } from 'zod'
import { createClient, createAdminClient } from '@/lib/supabase/server'
import { db } from '@/lib/db'
import { rounds, roundPlayers, roundInvites, tees, courses, user } from '@/lib/db/schema'
import { eq, and } from 'drizzle-orm'
import { auth } from '@/lib/auth'
import { headers } from 'next/headers'
import { sendEmail } from '@/lib/email'
import { revalidatePath } from 'next/cache'
const CreateRoundSchema = z.object({
course_id: z.string().uuid(),
tee_id: z.string().uuid(),
name: z.string().max(100).optional(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
course_id: z.string().uuid(),
tee_id: z.string().uuid(),
name: z.string().max(100).optional(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
holes_count: z.union([z.literal(9), z.literal(18)]),
emails: z.array(z.string().email()).default([]),
emails: z.array(z.string().email()).default([]),
})
export type CreateRoundInput = z.infer<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) {
const parsed = CreateRoundSchema.safeParse(data)
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Invalid data' }
}
if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? 'Invalid data' }
const supabase = await createClient()
const adminClient = await createAdminClient()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db = supabase as any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const adminDb = adminClient as any
const session = await auth.api.getSession({ headers: await headers() })
if (!session) return { error: 'Not authenticated' }
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
// Snapshot handicap
const [creator] = await db
.select({ handicapIndex: user.handicapIndex })
.from(user)
.where(eq(user.id, session.user.id))
.limit(1)
// Snapshot handicap and compute course handicap
const { data: profile } = await db
.from('profiles')
.select('handicap_index')
.eq('id', user.id)
.single()
const [tee] = await db
.select({ courseRating: tees.courseRating, slopeRating: tees.slopeRating })
.from(tees)
.where(eq(tees.id, parsed.data.tee_id))
.limit(1)
const { data: tee } = await db
.from('tees')
.select('course_rating, slope_rating')
.eq('id', parsed.data.tee_id)
.single()
const [course] = await db
.select({ par: courses.par })
.from(courses)
.where(eq(courses.id, parsed.data.course_id))
.limit(1)
const { data: course } = await db
.from('courses')
.select('par')
.eq('id', parsed.data.course_id)
.single()
const handicapIndex: number | null = profile?.handicap_index ?? null
const handicapIndex = creator?.handicapIndex ? parseFloat(creator.handicapIndex) : null
const courseHandicap =
handicapIndex !== null && tee && course
? Math.round(handicapIndex * (tee.slope_rating / 113) + (tee.course_rating - course.par))
? computeCourseHandicap(
handicapIndex,
tee.slopeRating,
parseFloat(tee.courseRating),
course.par,
)
: null
// Create round
const { data: round, error: roundError } = await db
.from('rounds')
.insert({
course_id: parsed.data.course_id,
tee_id: parsed.data.tee_id,
name: parsed.data.name || null,
date: parsed.data.date,
holes_count: parsed.data.holes_count,
created_by: user.id,
status: 'lobby',
const [round] = await db
.insert(rounds)
.values({
courseId: parsed.data.course_id,
teeId: parsed.data.tee_id,
name: parsed.data.name ?? null,
date: parsed.data.date,
holesCount: parsed.data.holes_count,
createdBy: session.user.id,
status: 'lobby',
})
.select('id')
.single()
if (roundError) return { error: roundError.message }
.returning({ id: rounds.id })
// Add creator as admin player
await db.from('round_players').insert({
round_id: round.id,
user_id: user.id,
role: 'admin',
handicap_index: handicapIndex,
course_handicap: courseHandicap,
await db.insert(roundPlayers).values({
roundId: round.id,
userId: session.user.id,
role: 'admin',
handicapIndex: handicapIndex !== null ? String(handicapIndex) : null,
courseHandicap: courseHandicap,
})
// Send invites
const failedInvites: string[] = []
for (const email of parsed.data.emails) {
const redirectTo = `${process.env.SITE_URL}/auth/confirm?next=${encodeURIComponent(`/rounds/${round.id}/join`)}`
const { error } = await adminDb.auth.admin.inviteUserByEmail(email, { redirectTo })
if (error) {
failedInvites.push(email)
} else {
await db.from('round_invites').insert({
round_id: round.id,
email,
invited_by: user.id,
})
}
const result = await inviteToRound(round.id, email, session.user.id)
if (result.error) failedInvites.push(email)
}
return {
roundId: round.id as string,
failedInvites,
}
return { roundId: round.id, failedInvites }
}
export async function startRound(roundId: string) {
const parsed = z.string().uuid().safeParse(roundId)
if (!parsed.success) return { error: 'Invalid round ID' }
const session = await auth.api.getSession({ headers: await headers() })
if (!session) return { error: 'Not authenticated' }
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const [round] = await db
.select({ createdBy: rounds.createdBy, status: rounds.status })
.from(rounds)
.where(eq(rounds.id, roundId))
.limit(1)
const { error } = await (supabase as any)
.from('rounds')
.update({ status: 'active' })
.eq('id', parsed.data)
.eq('created_by', user.id)
.eq('status', 'lobby')
if (!round) return { error: 'Round not found' }
if (round.createdBy !== session.user.id) return { error: 'Only the round creator can start it' }
if (round.status !== 'lobby') return { error: 'Round is not in lobby' }
if (error) return { error: error.message }
await db.update(rounds).set({ status: 'active' }).where(eq(rounds.id, roundId))
revalidatePath(`/rounds/${roundId}`)
return { success: true }
}
export async function completeRound(roundId: string) {
const session = await auth.api.getSession({ headers: await headers() })
if (!session) return { error: 'Not authenticated' }
const [round] = await db
.select({ createdBy: rounds.createdBy, status: rounds.status })
.from(rounds)
.where(eq(rounds.id, roundId))
.limit(1)
if (!round) return { error: 'Round not found' }
if (round.createdBy !== session.user.id) return { error: 'Only the round creator can complete it' }
if (round.status !== 'active') return { error: 'Round is not active' }
await db.update(rounds).set({ status: 'completed' }).where(eq(rounds.id, roundId))
revalidatePath(`/rounds/${roundId}`)
return { success: true }
}
export async function joinRound(roundId: string) {
const session = await auth.api.getSession({ headers: await headers() })
if (!session) return { error: 'Not authenticated' }
const [roundData] = await db
.select({ teeId: rounds.teeId, courseId: rounds.courseId, status: rounds.status })
.from(rounds)
.where(eq(rounds.id, roundId))
.limit(1)
if (!roundData) return { error: 'Round not found' }
if (roundData.status !== 'lobby') return { error: 'Round is not accepting new players' }
const [me] = await db
.select({ handicapIndex: user.handicapIndex })
.from(user)
.where(eq(user.id, session.user.id))
.limit(1)
const [tee] = await db
.select({ courseRating: tees.courseRating, slopeRating: tees.slopeRating })
.from(tees)
.where(eq(tees.id, roundData.teeId))
.limit(1)
const [course] = await db
.select({ par: courses.par })
.from(courses)
.where(eq(courses.id, roundData.courseId))
.limit(1)
const handicapIndex = me?.handicapIndex ? parseFloat(me.handicapIndex) : null
const courseHandicap =
handicapIndex !== null && tee && course
? computeCourseHandicap(
handicapIndex,
tee.slopeRating,
parseFloat(tee.courseRating),
course.par,
)
: null
try {
await db.insert(roundPlayers).values({
roundId,
userId: session.user.id,
role: 'player',
handicapIndex: handicapIndex !== null ? String(handicapIndex) : null,
courseHandicap: courseHandicap,
})
} catch {
return { error: 'Already a member of this round' }
}
// Mark invite as accepted
await db
.update(roundInvites)
.set({ acceptedAt: new Date() })
.where(and(eq(roundInvites.roundId, roundId), eq(roundInvites.email, session.user.email!)))
revalidatePath(`/rounds/${roundId}`)
return { success: true }
}
// Shared helper used by both createRoundWithInvites and inviteFromLobby
export async function inviteToRound(roundId: string, email: string, invitedByUserId: string) {
// Create user if they don't exist yet
const [existing] = await db
.select({ id: user.id })
.from(user)
.where(eq(user.email, email.toLowerCase()))
.limit(1)
if (!existing) {
await db.insert(user).values({
id: crypto.randomUUID(),
email: email.toLowerCase(),
name: email.split('@')[0],
emailVerified: false,
createdAt: new Date(),
updatedAt: new Date(),
})
}
// Send magic link via better-auth
const callbackURL = `${process.env.SITE_URL}/rounds/${roundId}/join`
try {
await auth.api.signInMagicLink({
body: { email: email.toLowerCase(), callbackURL },
headers: await headers(),
})
} catch (err: any) {
return { error: err?.message ?? 'Failed to send invite email' }
}
// Record invite
await db
.insert(roundInvites)
.values({ roundId, email: email.toLowerCase(), invitedBy: invitedByUserId })
.onConflictDoNothing()
return { success: true }
}
+41 -25
View File
@@ -1,13 +1,17 @@
'use server'
import { z } from 'zod'
import { createClient } from '@/lib/supabase/server'
import { db } from '@/lib/db'
import { scores, roundPlayers, rounds } from '@/lib/db/schema'
import { eq, and } from 'drizzle-orm'
import { auth } from '@/lib/auth'
import { headers } from 'next/headers'
const UpsertScoreSchema = z.object({
roundId: z.string().uuid(),
holeId: z.string().uuid(),
playerId: z.string().uuid(),
strokes: z.number().int().min(1).max(20),
roundId: z.string().uuid(),
holeId: z.string().uuid(),
playerId: z.string(),
strokes: z.number().int().min(1).max(20),
})
export async function upsertScore(
@@ -19,27 +23,39 @@ export async function upsertScore(
const parsed = UpsertScoreSchema.safeParse({ roundId, holeId, playerId, strokes })
if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? 'Invalid data' }
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const session = await auth.api.getSession({ headers: await headers() })
if (!session) return { error: 'Not authenticated' }
// Verify round is active and user is a member (RLS also enforces this)
const { error } = await (supabase as any)
.from('scores')
.upsert(
{
round_id: parsed.data.roundId,
hole_id: parsed.data.holeId,
player_id: parsed.data.playerId,
strokes: parsed.data.strokes,
updated_by: user.id,
updated_at: new Date().toISOString(),
},
{ onConflict: 'round_id,hole_id,player_id' },
)
// Caller must be a member of the round
const [membership] = await db
.select()
.from(roundPlayers)
.where(and(eq(roundPlayers.roundId, roundId), eq(roundPlayers.userId, session.user.id)))
.limit(1)
if (!membership) return { error: 'Not a member of this round' }
// Round must be active
const [round] = await db
.select({ status: rounds.status })
.from(rounds)
.where(eq(rounds.id, roundId))
.limit(1)
if (round?.status !== 'active') return { error: 'Round is not active' }
await db
.insert(scores)
.values({
roundId,
holeId,
playerId,
strokes,
updatedBy: session.user.id,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [scores.roundId, scores.holeId, scores.playerId],
set: { strokes, updatedBy: session.user.id, updatedAt: new Date() },
})
if (error) return { error: error.message }
return { success: true }
}
+4
View File
@@ -0,0 +1,4 @@
import { auth } from '@/lib/auth'
import { toNextJsHandler } from 'better-auth/next-js'
export const { GET, POST } = toNextJsHandler(auth.handler)
+75
View File
@@ -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
},
})
}