From d3413c727f3f565178a38c6d9850b8ec44bb977f Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 1 Feb 2026 06:38:13 +0000 Subject: [PATCH] feat(api): add courts and availability endpoints Add REST API endpoints for court management and availability: - GET/POST /api/courts for listing and creating courts - GET/PUT/DELETE /api/courts/[id] for single court operations - GET /api/courts/[id]/availability for time slot availability - GET /api/sites for listing organization sites Features include organization-based filtering, role-based access control, premium pricing for evening/weekend slots, and booking conflict detection. Co-Authored-By: Claude Opus 4.5 --- .../app/api/courts/[id]/availability/route.ts | 190 ++++++++++++++ apps/web/app/api/courts/[id]/route.ts | 236 ++++++++++++++++++ apps/web/app/api/courts/route.ts | 171 +++++++++++++ apps/web/app/api/sites/route.ts | 70 ++++++ 4 files changed, 667 insertions(+) create mode 100644 apps/web/app/api/courts/[id]/availability/route.ts create mode 100644 apps/web/app/api/courts/[id]/route.ts create mode 100644 apps/web/app/api/courts/route.ts create mode 100644 apps/web/app/api/sites/route.ts diff --git a/apps/web/app/api/courts/[id]/availability/route.ts b/apps/web/app/api/courts/[id]/availability/route.ts new file mode 100644 index 0000000..e6fd848 --- /dev/null +++ b/apps/web/app/api/courts/[id]/availability/route.ts @@ -0,0 +1,190 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { db } from '@/lib/db'; + +interface RouteContext { + params: Promise<{ id: string }>; +} + +interface TimeSlot { + time: string; + available: boolean; + price: number; + bookingId?: string; +} + +// Helper function to parse time string (HH:MM) to minutes since midnight +function timeToMinutes(time: string): number { + const [hours, minutes] = time.split(':').map(Number); + return hours * 60 + minutes; +} + +// Helper function to format minutes to HH:MM +function minutesToTime(minutes: number): string { + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return `${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}`; +} + +// Helper function to check if a time is premium (after 18:00 or weekend) +function isPremiumTime(date: Date, timeMinutes: number): boolean { + const dayOfWeek = date.getDay(); // 0 = Sunday, 6 = Saturday + const isWeekend = dayOfWeek === 0 || dayOfWeek === 6; + const isEveningTime = timeMinutes >= 18 * 60; // After 18:00 + + return isWeekend || isEveningTime; +} + +// GET /api/courts/[id]/availability - Get availability for a court on a specific date +export async function GET( + request: NextRequest, + context: RouteContext +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const { id: courtId } = await context.params; + const { searchParams } = new URL(request.url); + const dateParam = searchParams.get('date'); + + // Validate date parameter + if (!dateParam) { + return NextResponse.json( + { error: 'Missing required query parameter: date (YYYY-MM-DD format)' }, + { status: 400 } + ); + } + + // Parse and validate date format + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + if (!dateRegex.test(dateParam)) { + return NextResponse.json( + { error: 'Invalid date format. Use YYYY-MM-DD' }, + { status: 400 } + ); + } + + const requestedDate = new Date(dateParam); + if (isNaN(requestedDate.getTime())) { + return NextResponse.json( + { error: 'Invalid date value' }, + { status: 400 } + ); + } + + // Fetch court with site info + const court = await db.court.findFirst({ + where: { + id: courtId, + site: { + organizationId: session.user.organizationId, + }, + }, + include: { + site: { + select: { + id: true, + name: true, + openTime: true, + closeTime: true, + timezone: true, + }, + }, + }, + }); + + if (!court) { + return NextResponse.json( + { error: 'Court not found' }, + { status: 404 } + ); + } + + // Get the start and end of the requested date + const startOfDay = new Date(dateParam); + startOfDay.setHours(0, 0, 0, 0); + + const endOfDay = new Date(dateParam); + endOfDay.setHours(23, 59, 59, 999); + + // Fetch existing bookings for this court on the specified date + const bookings = await db.booking.findMany({ + where: { + courtId, + status: { + in: ['PENDING', 'CONFIRMED'], + }, + startTime: { + gte: startOfDay, + lte: endOfDay, + }, + }, + select: { + id: true, + startTime: true, + endTime: true, + }, + orderBy: { + startTime: 'asc', + }, + }); + + // Create a map of booked time slots + const bookedSlots = new Map(); + bookings.forEach((booking) => { + const startHour = booking.startTime.getHours(); + const startMinutes = booking.startTime.getMinutes(); + const timeKey = `${startHour.toString().padStart(2, '0')}:${startMinutes.toString().padStart(2, '0')}`; + bookedSlots.set(timeKey, booking.id); + }); + + // Generate time slots from open to close (1 hour each) + const openMinutes = timeToMinutes(court.site.openTime); + const closeMinutes = timeToMinutes(court.site.closeTime); + + const slots: TimeSlot[] = []; + const basePrice = Number(court.pricePerHour); + const premiumMultiplier = 1.25; // 25% premium for evening/weekend + + for (let minutes = openMinutes; minutes < closeMinutes; minutes += 60) { + const time = minutesToTime(minutes); + const bookingId = bookedSlots.get(time); + const isPremium = isPremiumTime(requestedDate, minutes); + const price = isPremium ? basePrice * premiumMultiplier : basePrice; + + slots.push({ + time, + available: !bookingId && court.status === 'AVAILABLE' && court.isActive, + price: Math.round(price * 100) / 100, // Round to 2 decimal places + ...(bookingId && { bookingId }), + }); + } + + return NextResponse.json({ + court: { + id: court.id, + name: court.name, + type: court.type, + status: court.status, + isActive: court.isActive, + site: court.site, + }, + date: dateParam, + slots, + }); + } catch (error) { + console.error('Error fetching court availability:', error); + return NextResponse.json( + { error: 'Failed to fetch court availability' }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/courts/[id]/route.ts b/apps/web/app/api/courts/[id]/route.ts new file mode 100644 index 0000000..1298a72 --- /dev/null +++ b/apps/web/app/api/courts/[id]/route.ts @@ -0,0 +1,236 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { db } from '@/lib/db'; + +interface RouteContext { + params: Promise<{ id: string }>; +} + +// GET /api/courts/[id] - Get a single court by ID +export async function GET( + request: NextRequest, + context: RouteContext +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const { id } = await context.params; + + const court = await db.court.findFirst({ + where: { + id, + site: { + organizationId: session.user.organizationId, + }, + }, + include: { + site: { + select: { + id: true, + name: true, + slug: true, + openTime: true, + closeTime: true, + timezone: true, + address: true, + phone: true, + email: true, + }, + }, + }, + }); + + if (!court) { + return NextResponse.json( + { error: 'Court not found' }, + { status: 404 } + ); + } + + return NextResponse.json(court); + } catch (error) { + console.error('Error fetching court:', error); + return NextResponse.json( + { error: 'Failed to fetch court' }, + { status: 500 } + ); + } +} + +// PUT /api/courts/[id] - Update a court +export async function PUT( + request: NextRequest, + context: RouteContext +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Check if user has admin role + const allowedRoles = ['SUPER_ADMIN', 'SITE_ADMIN']; + if (!allowedRoles.includes(session.user.role)) { + return NextResponse.json( + { error: 'Forbidden: Insufficient permissions' }, + { status: 403 } + ); + } + + const { id } = await context.params; + + // Verify court exists and belongs to user's organization + const existingCourt = await db.court.findFirst({ + where: { + id, + site: { + organizationId: session.user.organizationId, + }, + }, + include: { + site: true, + }, + }); + + if (!existingCourt) { + return NextResponse.json( + { error: 'Court not found' }, + { status: 404 } + ); + } + + // If user is SITE_ADMIN, verify they have access to this site + if (session.user.role === 'SITE_ADMIN' && session.user.siteId !== existingCourt.siteId) { + return NextResponse.json( + { error: 'Forbidden: You do not have access to this court' }, + { status: 403 } + ); + } + + const body = await request.json(); + const { + name, + type, + status, + pricePerHour, + description, + features, + displayOrder, + isActive, + } = body; + + const court = await db.court.update({ + where: { id }, + data: { + ...(name !== undefined && { name }), + ...(type !== undefined && { type }), + ...(status !== undefined && { status }), + ...(pricePerHour !== undefined && { pricePerHour }), + ...(description !== undefined && { description }), + ...(features !== undefined && { features }), + ...(displayOrder !== undefined && { displayOrder }), + ...(isActive !== undefined && { isActive }), + }, + include: { + site: { + select: { + id: true, + name: true, + slug: true, + openTime: true, + closeTime: true, + timezone: true, + }, + }, + }, + }); + + return NextResponse.json(court); + } catch (error) { + console.error('Error updating court:', error); + return NextResponse.json( + { error: 'Failed to update court' }, + { status: 500 } + ); + } +} + +// DELETE /api/courts/[id] - Delete a court +export async function DELETE( + request: NextRequest, + context: RouteContext +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Check if user has admin role + const allowedRoles = ['SUPER_ADMIN', 'SITE_ADMIN']; + if (!allowedRoles.includes(session.user.role)) { + return NextResponse.json( + { error: 'Forbidden: Insufficient permissions' }, + { status: 403 } + ); + } + + const { id } = await context.params; + + // Verify court exists and belongs to user's organization + const existingCourt = await db.court.findFirst({ + where: { + id, + site: { + organizationId: session.user.organizationId, + }, + }, + }); + + if (!existingCourt) { + return NextResponse.json( + { error: 'Court not found' }, + { status: 404 } + ); + } + + // If user is SITE_ADMIN, verify they have access to this site + if (session.user.role === 'SITE_ADMIN' && session.user.siteId !== existingCourt.siteId) { + return NextResponse.json( + { error: 'Forbidden: You do not have access to this court' }, + { status: 403 } + ); + } + + await db.court.delete({ + where: { id }, + }); + + return NextResponse.json( + { message: 'Court deleted successfully' }, + { status: 200 } + ); + } catch (error) { + console.error('Error deleting court:', error); + return NextResponse.json( + { error: 'Failed to delete court' }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/courts/route.ts b/apps/web/app/api/courts/route.ts new file mode 100644 index 0000000..b54d006 --- /dev/null +++ b/apps/web/app/api/courts/route.ts @@ -0,0 +1,171 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { db } from '@/lib/db'; + +// GET /api/courts - List courts filtered by organization and optionally by siteId +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const { searchParams } = new URL(request.url); + const siteId = searchParams.get('siteId'); + + // Build the where clause + const whereClause: { + site: { + organizationId: string; + id?: string; + }; + } = { + site: { + organizationId: session.user.organizationId, + }, + }; + + // If a specific siteId is provided in query params, use it + if (siteId) { + whereClause.site.id = siteId; + } else if (session.user.siteId) { + // Otherwise, if user has a siteId assigned, filter by that + whereClause.site.id = session.user.siteId; + } + + const courts = await db.court.findMany({ + where: whereClause, + include: { + site: { + select: { + id: true, + name: true, + slug: true, + openTime: true, + closeTime: true, + timezone: true, + }, + }, + }, + orderBy: [ + { site: { name: 'asc' } }, + { displayOrder: 'asc' }, + { name: 'asc' }, + ], + }); + + return NextResponse.json(courts); + } catch (error) { + console.error('Error fetching courts:', error); + return NextResponse.json( + { error: 'Failed to fetch courts' }, + { status: 500 } + ); + } +} + +// POST /api/courts - Create a new court +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Check if user has admin role + const allowedRoles = ['SUPER_ADMIN', 'SITE_ADMIN']; + if (!allowedRoles.includes(session.user.role)) { + return NextResponse.json( + { error: 'Forbidden: Insufficient permissions' }, + { status: 403 } + ); + } + + const body = await request.json(); + const { + siteId, + name, + type, + status, + pricePerHour, + description, + features, + displayOrder, + isActive, + } = body; + + // Validate required fields + if (!siteId || !name || pricePerHour === undefined) { + return NextResponse.json( + { error: 'Missing required fields: siteId, name, pricePerHour' }, + { status: 400 } + ); + } + + // Verify site belongs to user's organization + const site = await db.site.findFirst({ + where: { + id: siteId, + organizationId: session.user.organizationId, + }, + }); + + if (!site) { + return NextResponse.json( + { error: 'Site not found or does not belong to your organization' }, + { status: 404 } + ); + } + + // If user is SITE_ADMIN, verify they have access to this site + if (session.user.role === 'SITE_ADMIN' && session.user.siteId !== siteId) { + return NextResponse.json( + { error: 'Forbidden: You do not have access to this site' }, + { status: 403 } + ); + } + + const court = await db.court.create({ + data: { + siteId, + name, + type: type || 'INDOOR', + status: status || 'AVAILABLE', + pricePerHour, + description: description || null, + features: features || [], + displayOrder: displayOrder ?? 0, + isActive: isActive ?? true, + }, + include: { + site: { + select: { + id: true, + name: true, + slug: true, + openTime: true, + closeTime: true, + timezone: true, + }, + }, + }, + }); + + return NextResponse.json(court, { status: 201 }); + } catch (error) { + console.error('Error creating court:', error); + return NextResponse.json( + { error: 'Failed to create court' }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/sites/route.ts b/apps/web/app/api/sites/route.ts new file mode 100644 index 0000000..fdd47ed --- /dev/null +++ b/apps/web/app/api/sites/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { db } from '@/lib/db'; + +// GET /api/sites - List sites for user's organization +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const sites = await db.site.findMany({ + where: { + organizationId: session.user.organizationId, + isActive: true, + }, + select: { + id: true, + name: true, + slug: true, + address: true, + phone: true, + email: true, + timezone: true, + openTime: true, + closeTime: true, + _count: { + select: { + courts: { + where: { + isActive: true, + }, + }, + }, + }, + }, + orderBy: { + name: 'asc', + }, + }); + + // Transform to include court count at top level + const transformedSites = sites.map((site) => ({ + id: site.id, + name: site.name, + slug: site.slug, + address: site.address, + phone: site.phone, + email: site.email, + timezone: site.timezone, + openTime: site.openTime, + closeTime: site.closeTime, + courtCount: site._count.courts, + })); + + return NextResponse.json(transformedSites); + } catch (error) { + console.error('Error fetching sites:', error); + return NextResponse.json( + { error: 'Failed to fetch sites' }, + { status: 500 } + ); + } +}