diff --git a/backend/src/controllers/bookingCtrl.ts b/backend/src/controllers/bookingCtrl.ts index d4cdfa6..ec56db7 100644 --- a/backend/src/controllers/bookingCtrl.ts +++ b/backend/src/controllers/bookingCtrl.ts @@ -2,15 +2,215 @@ * bookingCtrl.ts * ───────────────────────────────────────────────────────────── * HTTP handlers for /api/bookings. + * + * Full CRUD: + * GET / → getAllBookings + * GET /:id → getBookingById + * POST / → createBooking + * PATCH /:id → updateBooking + * DELETE /:id → deleteBooking + * + * Existing admin endpoints (preserved): + * GET /pending → getPendingBookings + * PUT /:id/status → updateBookingStatus + * PATCH /:id/status → updateBookingStatus * ───────────────────────────────────────────────────────────── */ import { Response } from "express"; import { AuthRequest } from "../middleware/auth.middleware"; import { supabase } from "../config/supabaseClient"; +import { BookingModel } from "../models/booking.model"; import { sendNotification } from "../services/notificationService"; import { sendNotificationToUser } from "../services/socketService"; import { sendBookingConfirmationEmail, sendBookingRejectionEmail } from "../services/emailService"; +// ── GET /api/bookings ────────────────────────────────────── +/** + * List bookings with optional query-string filters: + * ?user_id= — filter by user + * ?resource_id= — filter by resource + * ?status=Pending|Approved|Cancelled|Completed + * ?from= — bookings ending after this date + * ?to= — bookings starting before this date + */ +export const getAllBookings = async (req: AuthRequest, res: Response): Promise => { + try { + const { user_id, resource_id, status, from, to } = req.query as Record; + + const data = await BookingModel.findAll( + { user_id, resource_id, status, from, to }, + req.supabase + ); + + res.json({ status: "success", data }); + } catch (error: any) { + console.error("Error fetching bookings:", error); + res.status(500).json({ status: "error", message: error.message }); + } +}; + +// ── GET /api/bookings/:id ────────────────────────────────── +export const getBookingById = async (req: AuthRequest, res: Response): Promise => { + try { + const booking = await BookingModel.findById(req.params.id as string, req.supabase); + + if (!booking) { + res.status(404).json({ status: "error", message: "Booking not found" }); + return; + } + + res.json({ status: "success", data: booking }); + } catch (error: any) { + console.error("Error fetching booking:", error); + res.status(500).json({ status: "error", message: error.message }); + } +}; + +// ── POST /api/bookings ───────────────────────────────────── +export const createBooking = async (req: AuthRequest, res: Response): Promise => { + try { + const { resource_id, start_time, end_time } = req.body; + + // Validation + if (!resource_id || !start_time || !end_time) { + res.status(400).json({ + status: "error", + message: "Missing required fields: resource_id, start_time, end_time", + }); + return; + } + + const start = new Date(start_time); + const end = new Date(end_time); + if (isNaN(start.getTime()) || isNaN(end.getTime())) { + res.status(400).json({ status: "error", message: "Invalid date format for start_time or end_time" }); + return; + } + if (end <= start) { + res.status(400).json({ status: "error", message: "end_time must be after start_time" }); + return; + } + + // Conflict check + const hasConflict = await BookingModel.checkConflicts( + resource_id, + start.toISOString(), + end.toISOString(), + undefined, + req.supabase + ); + if (hasConflict) { + res.status(409).json({ + status: "error", + message: "Time slot conflicts with an existing booking for this resource", + }); + return; + } + + // Use authenticated user as owner + const user_id = req.body.user_id || req.user?.uid; + + const created = await BookingModel.create( + { + resource_id, + user_id, + start_time: start.toISOString(), + end_time: end.toISOString(), + status: "Pending", + }, + req.supabase + ); + + res.status(201).json({ status: "success", message: "Booking created", data: created }); + } catch (error: any) { + console.error("Error creating booking:", error); + res.status(500).json({ status: "error", message: error.message }); + } +}; + +// ── PATCH /api/bookings/:id ──────────────────────────────── +export const updateBooking = async (req: AuthRequest, res: Response): Promise => { + try { + const bookingId = req.params.id as string; + const { resource_id, start_time, end_time, status } = req.body; + + // Verify booking exists + const existing = await BookingModel.findById(bookingId, req.supabase); + if (!existing) { + res.status(404).json({ status: "error", message: "Booking not found" }); + return; + } + + // If times or resource are being changed, re-validate + const newStart = start_time ? new Date(start_time) : new Date(existing.start_time); + const newEnd = end_time ? new Date(end_time) : new Date(existing.end_time); + const newResourceId = resource_id || existing.resource_id; + + if (newEnd <= newStart) { + res.status(400).json({ status: "error", message: "end_time must be after start_time" }); + return; + } + + // Conflict check (exclude the booking being updated) + if (start_time || end_time || resource_id) { + const hasConflict = await BookingModel.checkConflicts( + newResourceId, + newStart.toISOString(), + newEnd.toISOString(), + bookingId, + req.supabase + ); + if (hasConflict) { + res.status(409).json({ + status: "error", + message: "Time slot conflicts with an existing booking for this resource", + }); + return; + } + } + + // Only pass fields that were provided + const updatePayload: Record = {}; + if (resource_id) updatePayload.resource_id = resource_id; + if (start_time) updatePayload.start_time = newStart.toISOString(); + if (end_time) updatePayload.end_time = newEnd.toISOString(); + if (status) updatePayload.status = status; + + const updated = await BookingModel.update(bookingId, updatePayload, req.supabase); + if (!updated) { + res.status(404).json({ status: "error", message: "Booking not found or no fields to update" }); + return; + } + + res.json({ status: "success", message: "Booking updated", data: updated }); + } catch (error: any) { + console.error("Error updating booking:", error); + res.status(500).json({ status: "error", message: error.message }); + } +}; + +// ── DELETE /api/bookings/:id ─────────────────────────────── +export const deleteBooking = async (req: AuthRequest, res: Response): Promise => { + try { + const bookingId = req.params.id as string; + + const deleted = await BookingModel.delete(bookingId, req.supabase); + if (!deleted) { + res.status(404).json({ status: "error", message: "Booking not found" }); + return; + } + + res.json({ status: "success", message: "Booking deleted" }); + } catch (error: any) { + console.error("Error deleting booking:", error); + res.status(500).json({ status: "error", message: error.message }); + } +}; + +// ═══════════════════════════════════════════════════════════ +// Existing admin endpoints (preserved from original) +// ═══════════════════════════════════════════════════════════ + /** * GET /api/bookings/pending * Fetch all pending booking requests. Only accessible by lecturers/admins. diff --git a/backend/src/models/booking.model.ts b/backend/src/models/booking.model.ts new file mode 100644 index 0000000..ae9a658 --- /dev/null +++ b/backend/src/models/booking.model.ts @@ -0,0 +1,203 @@ +/** + * booking.model.ts + * ───────────────────────────────────────────────────────────── + * Data-access layer for the `bookings` table in Supabase. + * + * Table schema (Supabase / PostgreSQL): + * id uuid PK default gen_random_uuid() + * resource_id uuid NOT NULL references resources(id) + * user_id text NOT NULL + * start_time timestamptz NOT NULL + * end_time timestamptz NOT NULL + * status text NOT NULL default 'Pending' + * created_at timestamptz default now() + * ───────────────────────────────────────────────────────────── + */ +import { SupabaseClient } from '@supabase/supabase-js'; +import globalSupabase from '../config/supabaseClient'; + +export interface Booking { + id?: string; + resource_id: string; + user_id: string; + start_time: string; + end_time: string; + status: string; + created_at?: string; +} + +export interface BookingFilters { + user_id?: string; + resource_id?: string; + status?: string; + from?: string; // ISO date — bookings ending after this + to?: string; // ISO date — bookings starting before this +} + +/** + * Convert a partial Booking into a plain row object for insert/update. + */ +function toRow(booking: Partial): Record { + const row: Record = {}; + if (booking.resource_id !== undefined) row.resource_id = booking.resource_id; + if (booking.user_id !== undefined) row.user_id = booking.user_id; + if (booking.start_time !== undefined) row.start_time = booking.start_time; + if (booking.end_time !== undefined) row.end_time = booking.end_time; + if (booking.status !== undefined) row.status = booking.status; + return row; +} + +// ─── Model ──────────────────────────────────────────────────── +export class BookingModel { + + // ── findAll (with optional filters) ───────────────────── + static async findAll( + filters: BookingFilters = {}, + client: SupabaseClient = globalSupabase + ): Promise { + let query = client + .from('bookings') + .select('id, resource_id, user_id, start_time, end_time, status, created_at, resources ( id, name, type, location ), users ( id, name, email )') + .order('start_time', { ascending: false }); + + if (filters.user_id) query = query.eq('user_id', filters.user_id); + if (filters.resource_id) query = query.eq('resource_id', filters.resource_id); + if (filters.status) query = query.eq('status', filters.status); + if (filters.from) query = query.gt('end_time', filters.from); + if (filters.to) query = query.lt('start_time', filters.to); + + const { data, error } = await query; + + if (error) { + console.error('❌ Supabase query failed in BookingModel.findAll:', error.message); + throw new Error(error.message); + } + + return data || []; + } + + // ── findById ──────────────────────────────────────────── + static async findById( + id: string, + client: SupabaseClient = globalSupabase + ): Promise { + const { data, error } = await client + .from('bookings') + .select('id, resource_id, user_id, start_time, end_time, status, created_at, resources ( id, name, type, location ), users ( id, name, email )') + .eq('id', id) + .maybeSingle(); + + if (error) { + console.error('❌ Supabase query failed in BookingModel.findById:', error.message); + throw new Error(error.message); + } + + return data || null; + } + + // ── create ────────────────────────────────────────────── + static async create( + booking: Partial, + client: SupabaseClient = globalSupabase + ): Promise { + const row = toRow({ + ...booking, + status: booking.status || 'Pending', + }); + + const { data, error } = await client + .from('bookings') + .insert(row) + .select('id, resource_id, user_id, start_time, end_time, status, created_at') + .single(); + + if (error) { + console.error('❌ Supabase insert failed in BookingModel.create:', error.message); + if (error.code === '42501') { + throw new Error(`Permission denied: ${error.message}`); + } + throw new Error(error.message); + } + + if (!data) throw new Error('Insert returned no data'); + return data; + } + + // ── update ────────────────────────────────────────────── + static async update( + id: string, + data: Partial, + client: SupabaseClient = globalSupabase + ): Promise { + const row = toRow(data); + if (Object.keys(row).length === 0) return null; + + const { data: updated, error } = await client + .from('bookings') + .update(row) + .eq('id', id) + .select('id, resource_id, user_id, start_time, end_time, status, created_at') + .maybeSingle(); + + if (error) { + console.error('❌ Supabase update failed in BookingModel.update:', error.message); + if (error.code === '42501') throw new Error('Permission denied (RLS)'); + throw new Error(error.message); + } + + return updated || null; + } + + // ── delete ────────────────────────────────────────────── + static async delete( + id: string, + client: SupabaseClient = globalSupabase + ): Promise { + // Verify booking exists first + const existing = await BookingModel.findById(id, client); + if (!existing) return false; + + const { error } = await client + .from('bookings') + .delete() + .eq('id', id); + + if (error) { + console.error('❌ Supabase delete failed in BookingModel.delete:', error.message); + if (error.code === '42501') throw new Error('Permission denied (RLS)'); + throw new Error(error.message); + } + + return true; + } + + // ── checkConflicts ────────────────────────────────────── + /** + * Returns true if there is at least one non-cancelled booking for the + * given resource that overlaps [start_time, end_time). + */ + static async checkConflicts( + resourceId: string, + startTime: string, + endTime: string, + excludeBookingId?: string, + client: SupabaseClient = globalSupabase + ): Promise { + let query = client + .from('bookings') + .select('id') + .eq('resource_id', resourceId) + .neq('status', 'Cancelled') + .lt('start_time', endTime) + .gt('end_time', startTime) + .limit(1); + + if (excludeBookingId) { + query = query.neq('id', excludeBookingId); + } + + const { data, error } = await query; + if (error) return false; + return (data?.length ?? 0) > 0; + } +} diff --git a/backend/src/routes/bookingRoutes.ts b/backend/src/routes/bookingRoutes.ts index e98815f..8973229 100644 --- a/backend/src/routes/bookingRoutes.ts +++ b/backend/src/routes/bookingRoutes.ts @@ -2,21 +2,50 @@ * bookingRoutes.ts * ───────────────────────────────────────────────────────────── * Route definitions for /api/bookings. + * + * CRUD: + * GET / → list all bookings (filterable) + * GET /:id → get single booking + * POST / → create a new booking + * PATCH /:id → update a booking + * DELETE /:id → delete a booking + * + * Admin: + * GET /pending → pending approvals queue + * PUT /:id/status → approve / reject + * PATCH /:id/status → approve / reject * ───────────────────────────────────────────────────────────── */ import express from "express"; import { verifyToken } from "../middleware/auth.middleware"; -import { getPendingBookings, updateBookingStatus } from "../controllers/bookingCtrl"; +import { + getAllBookings, + getBookingById, + createBooking, + updateBooking, + deleteBooking, + getPendingBookings, + updateBookingStatus, +} from "../controllers/bookingCtrl"; const router = express.Router(); // Apply auth middleware to all booking routes router.use(verifyToken); -// Pending approvals queue +// ── CRUD ──────────────────────────────────────────────────── +router.get("/", getAllBookings as any); +router.post("/", createBooking as any); + +// ── Admin: pending queue (must come before /:id) ──────────── router.get("/pending", getPendingBookings as any); -// Status update (Approve/Reject) +// ── Single booking operations ─────────────────────────────── +router.get("/:id", getBookingById as any); +router.patch("/:id", updateBooking as any); +router.delete("/:id", deleteBooking as any); + +// ── Admin: status update ──────────────────────────────────── router.put("/:id/status", updateBookingStatus as any); router.patch("/:id/status", updateBookingStatus as any);