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
+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 }
}