'use server' import { z } from 'zod' 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}$/), holes_count: z.union([z.literal(9), z.literal(18)]), emails: z.array(z.string().email()).default([]), }) export type CreateRoundInput = z.infer function computeCourseHandicap( handicapIndex: number, slopeRating: number, courseRating: number, par: number, ): number { return Math.round(handicapIndex * (slopeRating / 113) + (courseRating - par)) } export async function createRoundWithInvites(data: CreateRoundInput) { const parsed = CreateRoundSchema.safeParse(data) if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? 'Invalid data' } const session = await auth.api.getSession({ headers: await headers() }) if (!session) 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) const [tee] = await db .select({ courseRating: tees.courseRating, slopeRating: tees.slopeRating }) .from(tees) .where(eq(tees.id, parsed.data.tee_id)) .limit(1) const [course] = await db .select({ par: courses.par }) .from(courses) .where(eq(courses.id, parsed.data.course_id)) .limit(1) const handicapIndex = creator?.handicapIndex ? parseFloat(creator.handicapIndex) : null const courseHandicap = handicapIndex !== null && tee && course ? computeCourseHandicap( handicapIndex, tee.slopeRating, parseFloat(tee.courseRating), course.par, ) : null 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', }) .returning({ id: rounds.id }) await db.insert(roundPlayers).values({ roundId: round.id, userId: session.user.id, role: 'admin', handicapIndex: handicapIndex !== null ? String(handicapIndex) : null, courseHandicap: courseHandicap, }) const failedInvites: string[] = [] for (const email of parsed.data.emails) { const result = await inviteToRound(round.id, email, session.user.id) if (result.error) failedInvites.push(email) } return { roundId: round.id, failedInvites } } export async function startRound(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 start it' } if (round.status !== 'lobby') return { error: 'Round is not in lobby' } 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 } }