40c56770a2
- Added AdminBlocksManagement component for managing court blocks. - Implemented functionality to create, edit, and delete blocks. - Integrated fetching of courts and blocks from the API. - Added validation for block creation and editing forms. - Enhanced UI with responsive design for mobile and desktop views. - Created database migration for court_blocks table and updated users table with theme_preference.
309 lines
8.8 KiB
TypeScript
309 lines
8.8 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { db } from '@/lib/db';
|
|
import { bookings, courts, timeSlots, settings, metrics, courtBlocks } from '@/lib/db/schema';
|
|
import { eq, and, gte, asc, or, isNull } from 'drizzle-orm';
|
|
import { getSession } from '@/lib/session';
|
|
import { logActivity, ACTIONS, ENTITY_TYPES } from '@/lib/activity-logger';
|
|
|
|
export async function GET(request: NextRequest) {
|
|
try {
|
|
const session = await getSession();
|
|
if (!session) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
}
|
|
|
|
// Get today's date to filter out past bookings
|
|
const today = new Date().toISOString().split('T')[0];
|
|
|
|
const userBookings = await db
|
|
.select({
|
|
id: bookings.id,
|
|
courtId: bookings.courtId,
|
|
date: bookings.date,
|
|
startTime: bookings.startTime,
|
|
endTime: bookings.endTime,
|
|
status: bookings.status,
|
|
notes: bookings.notes,
|
|
createdAt: bookings.createdAt,
|
|
court: {
|
|
id: courts.id,
|
|
name: courts.name,
|
|
},
|
|
})
|
|
.from(bookings)
|
|
.innerJoin(courts, eq(bookings.courtId, courts.id))
|
|
.where(and(eq(bookings.userId, session.userId), eq(bookings.status, 'active'), gte(bookings.date, today)))
|
|
.orderBy(asc(bookings.date), asc(bookings.startTime));
|
|
|
|
return NextResponse.json({ bookings: userBookings });
|
|
} catch (error) {
|
|
console.error('Error fetching bookings:', error);
|
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
const session = await getSession();
|
|
if (!session) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
}
|
|
|
|
const { courtId, date, timeSlot, notes } = await request.json();
|
|
|
|
if (!courtId || !date || !timeSlot) {
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Missing required fields: courtId, date, timeSlot',
|
|
},
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Parse timeSlot (e.g., "14:00") to get start and end times
|
|
const startTime = timeSlot;
|
|
const [hours, minutes] = timeSlot.split(':').map(Number);
|
|
const endTime = `${String(hours + 1).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
|
|
|
|
// Check if user is admin (for bypassing certain restrictions)
|
|
const isAdmin = session.role === 'admin';
|
|
|
|
// Validate booking date is not in the past
|
|
const bookingDate = new Date(date);
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
|
|
if (bookingDate < today) {
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Cannot book dates in the past',
|
|
},
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Check if court exists and is active
|
|
const court = await db.select().from(courts).where(eq(courts.id, courtId)).limit(1);
|
|
if (court.length === 0 || !court[0].isActive) {
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Court not found or inactive',
|
|
},
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// CHECK FOR BLOCKS - applies to everyone including admins
|
|
// A block prevents any booking on that court/time (admins should remove the block first)
|
|
const requestedHour = parseInt(startTime.split(':')[0]);
|
|
const activeBlocks = await db
|
|
.select()
|
|
.from(courtBlocks)
|
|
.where(
|
|
and(
|
|
eq(courtBlocks.date, date),
|
|
or(
|
|
eq(courtBlocks.courtId, courtId), // Block for this specific court
|
|
isNull(courtBlocks.courtId) // Block for all courts
|
|
)
|
|
)
|
|
);
|
|
|
|
// Check if any block covers this time slot
|
|
const isBlockedSlot = activeBlocks.some((block) => {
|
|
const blockStartHour = parseInt(block.startTime.split(':')[0]);
|
|
const blockEndHour = parseInt(block.endTime.split(':')[0]);
|
|
return requestedHour >= blockStartHour && requestedHour < blockEndHour;
|
|
});
|
|
|
|
if (isBlockedSlot) {
|
|
const blockingBlock = activeBlocks.find((block) => {
|
|
const blockStartHour = parseInt(block.startTime.split(':')[0]);
|
|
const blockEndHour = parseInt(block.endTime.split(':')[0]);
|
|
return requestedHour >= blockStartHour && requestedHour < blockEndHour;
|
|
});
|
|
return NextResponse.json(
|
|
{
|
|
error: `This slot is blocked: ${
|
|
blockingBlock?.reason || 'Court unavailable'
|
|
}. Please choose a different time.`,
|
|
},
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// CRITICAL: Validate that booking is allowed for this day and time
|
|
const dayOfWeek = bookingDate.getDay();
|
|
const availableTimeSlots = await db
|
|
.select()
|
|
.from(timeSlots)
|
|
.where(and(eq(timeSlots.dayOfWeek, dayOfWeek), eq(timeSlots.isActive, true)));
|
|
|
|
// Check if any time slots are configured for this day (admins can bypass if needed)
|
|
if (availableTimeSlots.length === 0 && !isAdmin) {
|
|
return NextResponse.json(
|
|
{
|
|
error: `No bookings are allowed on ${
|
|
['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][dayOfWeek]
|
|
}s. The facility is closed on this day.`,
|
|
},
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Check if the requested time slot is within any of the allowed time ranges
|
|
// Admins can bypass time slot restrictions
|
|
if (!isAdmin) {
|
|
const isTimeSlotValid = availableTimeSlots.some((slot) => {
|
|
const slotStartHour = parseInt(slot.startTime.split(':')[0]);
|
|
const slotEndHour = parseInt(slot.endTime.split(':')[0]);
|
|
return requestedHour >= slotStartHour && requestedHour < slotEndHour;
|
|
});
|
|
|
|
if (!isTimeSlotValid) {
|
|
const allowedRanges = availableTimeSlots.map((slot) => `${slot.startTime}-${slot.endTime}`).join(', ');
|
|
return NextResponse.json(
|
|
{
|
|
error: `Time slot ${startTime} is not available on ${
|
|
['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][dayOfWeek]
|
|
}s. Available times: ${allowedRanges}`,
|
|
},
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// Check booking restrictions per user per hour per day
|
|
// Admins bypass this restriction
|
|
if (!isAdmin) {
|
|
const maxBookingsSetting = await db
|
|
.select()
|
|
.from(settings)
|
|
.where(eq(settings.key, 'max_bookings_per_user_per_hour_per_day'))
|
|
.limit(1);
|
|
|
|
const maxBookingsPerHour = maxBookingsSetting.length > 0 ? parseInt(maxBookingsSetting[0].value) : 1;
|
|
|
|
// Count user's existing bookings for this hour on this day
|
|
const userBookingsThisHour = await db
|
|
.select()
|
|
.from(bookings)
|
|
.where(
|
|
and(
|
|
eq(bookings.userId, session.userId),
|
|
eq(bookings.date, date),
|
|
eq(bookings.startTime, startTime),
|
|
eq(bookings.status, 'active')
|
|
)
|
|
);
|
|
|
|
if (userBookingsThisHour.length >= maxBookingsPerHour) {
|
|
return NextResponse.json(
|
|
{
|
|
error: `You have reached the maximum limit of ${maxBookingsPerHour} booking(s) per hour. You already have ${userBookingsThisHour.length} booking(s) at ${startTime} on this date.`,
|
|
},
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// Check if slot is already booked
|
|
const existingBooking = await db
|
|
.select()
|
|
.from(bookings)
|
|
.where(
|
|
and(
|
|
eq(bookings.courtId, courtId),
|
|
eq(bookings.date, date),
|
|
eq(bookings.startTime, startTime),
|
|
eq(bookings.status, 'active')
|
|
)
|
|
)
|
|
.limit(1);
|
|
|
|
if (existingBooking.length > 0) {
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Time slot already booked',
|
|
},
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Create the booking
|
|
const [newBooking] = await db
|
|
.insert(bookings)
|
|
.values({
|
|
id: crypto.randomUUID(),
|
|
userId: session.userId,
|
|
courtId,
|
|
date,
|
|
startTime,
|
|
endTime,
|
|
status: 'active',
|
|
notes: notes || null, // Include notes from the request
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.returning();
|
|
|
|
// Log the activity
|
|
await logActivity({
|
|
userId: session.userId,
|
|
action: ACTIONS.BOOKING_CREATE,
|
|
entityType: ENTITY_TYPES.BOOKING,
|
|
entityId: newBooking.id,
|
|
details: {
|
|
courtId,
|
|
courtName: court[0].name,
|
|
date,
|
|
startTime,
|
|
endTime,
|
|
},
|
|
request,
|
|
});
|
|
|
|
// Update monthly metrics
|
|
const currentMonth = new Date().toISOString().substring(0, 7); // "2025-09"
|
|
try {
|
|
const existingMetric = await db
|
|
.select()
|
|
.from(metrics)
|
|
.where(and(eq(metrics.metricType, 'monthly_bookings'), eq(metrics.period, currentMonth)))
|
|
.limit(1);
|
|
|
|
if (existingMetric.length > 0) {
|
|
// Increment existing metric
|
|
await db
|
|
.update(metrics)
|
|
.set({
|
|
value: existingMetric[0].value + 1,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(metrics.id, existingMetric[0].id));
|
|
} else {
|
|
// Create new metric for this month
|
|
await db.insert(metrics).values({
|
|
id: crypto.randomUUID(),
|
|
metricType: 'monthly_bookings',
|
|
period: currentMonth,
|
|
value: 1,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Error updating monthly metrics:', error);
|
|
// Don't fail the booking if metrics update fails
|
|
}
|
|
|
|
return NextResponse.json({
|
|
booking: newBooking,
|
|
message: 'Booking created successfully',
|
|
});
|
|
} catch (error) {
|
|
console.error('Error creating booking:', error);
|
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
}
|
|
}
|