240 lines
7.3 KiB
TypeScript
240 lines
7.3 KiB
TypeScript
'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<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' }
|
|
|
|
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 }
|
|
}
|