diff --git a/Dockerfile-frontend b/Dockerfile-frontend index 4cbac382..f17a0abb 100644 --- a/Dockerfile-frontend +++ b/Dockerfile-frontend @@ -34,6 +34,12 @@ RUN yarn install --frozen-lockfile # Copying the rest of the application code COPY frontend/ . +# Copy .env for build-time environment variables +COPY .env ./ + +# Set build-time ARG for log level (optional, for CI/CD) +ARG REACT_APP_FRONTEND_LOG_LEVEL=info +ENV REACT_APP_FRONTEND_LOG_LEVEL=$REACT_APP_FRONTEND_LOG_LEVEL # Building the TypeScript React app for production RUN yarn run build @@ -59,9 +65,11 @@ COPY --from=builder /app/config-overrides.js ./ COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/public ./public COPY --from=builder /app/build ./build +COPY --from=builder /app/.env ./ # Set environment variables for runtime ENV FRONTEND_PORT=${FRONTEND_PORT} +ENV REACT_APP_FRONTEND_LOG_LEVEL=${REACT_APP_FRONTEND_LOG_LEVEL} # Starting the server to serve the built React app CMD ["serve", "-s", "build", "-l", "$FRONTEND_PORT"] diff --git a/backend/src/backend/services/bookings.py b/backend/src/backend/services/bookings.py index 1c20df1c..8235c5e1 100644 --- a/backend/src/backend/services/bookings.py +++ b/backend/src/backend/services/bookings.py @@ -116,6 +116,10 @@ async def validate_no_overlap( Raises: ValueError: If booking times overlap with an existing booking. """ + logger.debug( + f"validate_no_overlap: room_id={room_id}, start_time={start_time}," + f" end_time={end_time}, exclude_booking_id={exclude_booking_id}" + ) conditions: list[ColumnElement[bool]] = [ Booking.room_id == room_id, func.tstzrange(Booking.start_time, Booking.end_time, "[)").op("&&")( @@ -123,12 +127,17 @@ async def validate_no_overlap( ), ] if exclude_booking_id is not None: + logger.debug(f"validate_no_overlap: excluding booking id {exclude_booking_id}") conditions.append(Booking.id != exclude_booking_id) overlap_stmt = select(Booking).where(*conditions) result = await session.scalars(overlap_stmt) overlapping = result.first() if overlapping: - logger.warning(f"Attempted to create overlapping booking for room_id {room_id}") + logger.warning( + f"Attempted to create overlapping booking for room_id {room_id}." + f" Overlapping booking id: {overlapping.id if overlapping else None}," + f" exclude_booking_id: {exclude_booking_id}" + ) raise ValueError( "Booking times overlap with an existing booking for this room." ) @@ -428,6 +437,11 @@ async def update_booking( await _remove_old_invitees(session, current, new_invitees) # Run validations + logger.debug( + "update_booking: calling _do_validation with" + f" exclude_booking_id={booking_id}, new_room_id={new_room_id}," + f" new_start={new_start}, new_end={new_end}, new_invitees={new_invitees}" + ) await _do_validation( session, new_room_id, diff --git a/compose.dev.yml b/compose.dev.yml index ea12f6a6..24835f7f 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -30,6 +30,8 @@ services: extends: file: compose.yml service: frontend + environment: + REACT_APP_FRONTEND_LOG_LEVEL: debug volumes: - ./frontend/src:/app/src - ./frontend/public:/app/public @@ -42,6 +44,7 @@ services: - ./frontend/babel.config.js:/app/babel.config.js - ./frontend/jest.config.js:/app/jest.config.js - ./frontend/jest.setup.js:/app/jest.setup.js + - ./.env:/app/.env command: ["yarn", "start"] restart: always diff --git a/compose.yml b/compose.yml index a3b9367c..5080a622 100644 --- a/compose.yml +++ b/compose.yml @@ -25,7 +25,7 @@ services: env_file: - .env environment: - FRONTEND_LOG_LEVEL: WARNING + REACT_APP_FRONTEND_LOG_LEVEL: WARNING ports: - "${FRONTEND_PORT}:${FRONTEND_PORT}" depends_on: diff --git a/frontend/src/components/BookingConfirmation.tsx b/frontend/src/components/BookingConfirmation.tsx index ec27b0ea..f71a5971 100644 --- a/frontend/src/components/BookingConfirmation.tsx +++ b/frontend/src/components/BookingConfirmation.tsx @@ -17,7 +17,7 @@ import { Box, Button } from "@mui/material"; // Internal imports // Type-only imports -import type { BookingConfirmationProps } from "../interfaces"; +import type { Booking } from "../interfaces"; /** * BookingConfirmation component @@ -27,15 +27,23 @@ import type { BookingConfirmationProps } from "../interfaces"; * @param {BookingConfirmationProps} props - Component props * @returns {JSX.Element} Confirmation UI */ + +interface BookingConfirmationProps { + booking: Booking | null; + deleted?: boolean; + onEdit?: () => void; + onBack: () => void; +} + const BookingConfirmation: React.FC = ({ booking, + deleted = false, onEdit, onBack, }) => { - if (!booking) { + if (!booking && !deleted) { return null; } - const { room, start_time, end_time, title, invitees } = booking; return ( = ({ textAlign: "center", }} > -

Booking Confirmed

- - Room: {room?.name || "-"} - - - Time: {new Date(start_time).toLocaleString()} –{" "} - {new Date(end_time).toLocaleString()} - - {title && ( - - Title: {title} - - )} - {invitees && invitees.length > 0 && ( - - Invitees: {invitees.join(", ")} - + {deleted ? ( + <> +

Booking Deleted

+ + This booking has been deleted and can no longer be edited. + + + ) : ( + <> +

Booking Confirmed

+ + Room: {booking?.room?.name || "-"} + + + Time:{" "} + {booking && new Date(booking.start_time).toLocaleString()} –{" "} + {booking && new Date(booking.end_time).toLocaleString()} + + {booking?.title && ( + + Title: {booking.title} + + )} + {booking?.invitees && booking.invitees.length > 0 && ( + + Invitees: {booking.invitees.join(", ")} + + )} + )} - + {!deleted && onEdit && ( + + )} diff --git a/frontend/src/components/BookingForm.tsx b/frontend/src/components/BookingForm.tsx index 4a5f1e07..198f8022 100644 --- a/frontend/src/components/BookingForm.tsx +++ b/frontend/src/components/BookingForm.tsx @@ -37,6 +37,7 @@ import { validateInvitees, validateRoomId, validateStart, + validateRoomAvailability, } from "../helpers/validation"; import { roundToStrictlyFutureQuarter } from "../utils/date"; import { useBookings } from "../context/BookingContext"; @@ -46,12 +47,17 @@ import { useUsers } from "../context/UserContext"; // Type-only imports import type { Booking, ConferenceRoom } from "../interfaces"; // BookingForm component + interface BookingFormProps { open: boolean; onClose: () => void; editBooking?: Booking; slotInfo?: { room_id?: string | number; start?: string }; - onBookingSuccess?: () => void; + /** + * Called when booking is successful or deleted. + * If booking is provided, navigate to confirmation. If not, just close the form. + */ + onBookingSuccess?: (_booking?: Booking) => void; } const BookingForm: React.FC = ({ @@ -69,11 +75,11 @@ const BookingForm: React.FC = ({ }, []); React.useEffect(() => { - logger.debug("[BookingForm] editBooking updated", editBooking); + // logger.debug("[BookingForm] editBooking updated", editBooking); }, [editBooking]); React.useEffect(() => { - logger.debug("[BookingForm] slotInfo updated", slotInfo); + // logger.debug("[BookingForm] slotInfo updated", slotInfo); }, [slotInfo]); // Contexts const bookingsContext = useBookings(); @@ -98,20 +104,18 @@ const BookingForm: React.FC = ({ // Room selection const initialRoomId = useMemo(() => { if (isEdit && editBooking) { - return getEditBookingRoomId(editBooking); + return Number(getEditBookingRoomId(editBooking)); } if (slotInfo?.room_id) { - return String(slotInfo.room_id); + return Number(slotInfo.room_id); } if (rooms.length > 0) { - return String((rooms[0] as ConferenceRoom).id); + return rooms[0].id; } - return ""; + return 0; }, [isEdit, editBooking, slotInfo, rooms]); - const [room_id, setRoomId] = useState(initialRoomId); - const currentRoom = rooms.find( - (r: ConferenceRoom) => String(r.id) === String(room_id) - ); + const [room_id, setRoomId] = useState(initialRoomId); + const currentRoom = rooms.find((r: ConferenceRoom) => r.id === room_id); const roomCapacity = currentRoom?.capacity || Infinity; // Title @@ -192,7 +196,7 @@ const BookingForm: React.FC = ({ function validateFields() { const newErrors: { [key: string]: string } = {}; // Room - const roomIdError = validateRoomId(room_id); + const roomIdError = validateRoomId(String(room_id)); if (roomIdError) { newErrors.room_id = roomIdError; } @@ -206,22 +210,24 @@ const BookingForm: React.FC = ({ if (endError) { newErrors.end = endError; } - // Overlap - const roomBookings = bookings.filter( - (b: Booking) => String(b.room_id) === String(room_id) + // Overlap (use helper) + const roomBookings = bookings.filter((b: Booking) => b.room_id === room_id); + const overlapErrors = validateRoomAvailability( + room_id, + start, + end, + roomBookings, + isEdit, + isEdit ? editBooking ?? null : null ); - const overlaps = roomBookings.some((b: Booking) => { - if (isEdit && b.id === editBooking?.id) { - return false; - } - return ( - new Date(start) < new Date(b.end_time) && - new Date(end) > new Date(b.start_time) - ); - }); - if (overlaps) { - newErrors.start = "Start/end time overlaps another booking."; - newErrors.end = "Start/end time overlaps another booking."; + if (overlapErrors.roomError) { + newErrors.room_id = overlapErrors.roomError; + } + if (overlapErrors.startError) { + newErrors.start = overlapErrors.startError; + } + if (overlapErrors.endError) { + newErrors.end = overlapErrors.endError; } // Invitees const inviteesError = validateInvitees(invitees, roomCapacity); @@ -261,7 +267,7 @@ const BookingForm: React.FC = ({ validateFields(); } function handleRoomChange(event: SelectChangeEvent) { - setRoomId(String(event.target.value)); + setRoomId(Number(event.target.value)); validateFields(); } @@ -278,7 +284,7 @@ const BookingForm: React.FC = ({ let bookingResult; if (!isEdit) { bookingResult = await createBooking({ - room_id, + room_id: Number(room_id), start_time: start, end_time: end, title, @@ -286,17 +292,20 @@ const BookingForm: React.FC = ({ }); } else if (editBooking) { const updatePayload = { - room_id, + room_id: Number(room_id), start_time: start, end_time: end, title, invitees, }; - logger.info("[BookingForm] updateBooking payload:", updatePayload); - bookingResult = await updateBooking(editBooking.id, updatePayload); + // logger.info("[BookingForm] updateBooking payload:", updatePayload); + bookingResult = await updateBooking( + Number(editBooking.id), + updatePayload + ); } if (onBookingSuccess && bookingResult) { - onBookingSuccess(); + onBookingSuccess(bookingResult); } onClose(); } catch (err: unknown) { @@ -551,7 +560,7 @@ const BookingForm: React.FC = ({ try { await deleteBooking(editBooking.id); if (onBookingSuccess) { - onBookingSuccess(); + onBookingSuccess(); // No booking param: just close } onClose(); } catch (err: unknown) { diff --git a/frontend/src/components/storybook-mocks.ts b/frontend/src/components/storybook-mocks.ts index 0085dc6e..88d24a20 100644 --- a/frontend/src/components/storybook-mocks.ts +++ b/frontend/src/components/storybook-mocks.ts @@ -9,7 +9,7 @@ export const mockRoom: ConferenceRoom = { }; export const mockBooking: Booking = { - id: "1", + id: 1, room_id: 1, start_time: new Date(Date.now() + 24 * 3600000 + 3 * 3600000).toISOString(), end_time: new Date(Date.now() + 24 * 3600000 + 4 * 3600000).toISOString(), diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index 315c556a..e682d690 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -22,15 +22,35 @@ * @property {string} BUSINESS_END - Business end time (e.g., '18:00') */ export const ENV = { - BACKEND_PROTOCOL: process.env.BACKEND_PROTOCOL || "http", - BACKEND_HOST: process.env.BACKEND_HOST || "localhost", - BACKEND_PORT: process.env.BACKEND_PORT || "8000", - FRONTEND_LOG_LEVEL: process.env.FRONTEND_LOG_LEVEL || "info", - NODE_ENV: process.env.FRONTEND_ENVIRONMENT || "development", + BACKEND_PROTOCOL: + process.env.REACT_APP_BACKEND_PROTOCOL || + process.env.BACKEND_PROTOCOL || + "http", + BACKEND_HOST: + process.env.REACT_APP_BACKEND_HOST || + process.env.BACKEND_HOST || + "localhost", + BACKEND_PORT: + process.env.REACT_APP_BACKEND_PORT || process.env.BACKEND_PORT || "8000", + FRONTEND_LOG_LEVEL: + process.env.REACT_APP_FRONTEND_LOG_LEVEL || + process.env.FRONTEND_LOG_LEVEL || + "info", + NODE_ENV: + process.env.REACT_APP_FRONTEND_ENVIRONMENT || + process.env.FRONTEND_ENVIRONMENT || + "development", DEFAULT_BOOKING_INTERVAL_MINUTES: - Number(process.env.DEFAULT_BOOKING_INTERVAL_MINUTES) || 30, - BUSINESS_START: process.env.BUSINESS_START || "08:00", - BUSINESS_END: process.env.BUSINESS_END || "18:00", + Number( + process.env.REACT_APP_DEFAULT_BOOKING_INTERVAL_MINUTES || + process.env.DEFAULT_BOOKING_INTERVAL_MINUTES + ) || 30, + BUSINESS_START: + process.env.REACT_APP_BUSINESS_START || + process.env.BUSINESS_START || + "08:00", + BUSINESS_END: + process.env.REACT_APP_BUSINESS_END || process.env.BUSINESS_END || "18:00", }; /** diff --git a/frontend/src/context/BookingContext.tsx b/frontend/src/context/BookingContext.tsx index 9c135f4e..cb8ccfba 100644 --- a/frontend/src/context/BookingContext.tsx +++ b/frontend/src/context/BookingContext.tsx @@ -114,12 +114,14 @@ export const BookingProvider = ({ children }: { children: ReactNode }) => { invitees?: string[]; }; const booking: Booking = { - id: String(singleData.booking_id), + id: + typeof singleData.booking_id === "number" + ? singleData.booking_id + : parseInt(singleData.booking_id as string, 10), room_id: - typeof singleData.room_id === "number" || - typeof singleData.room_id === "string" + typeof singleData.room_id === "number" ? singleData.room_id - : String(singleData.room_id), + : parseInt(singleData.room_id as string, 10), start_time: String(singleData.start_time), end_time: String(singleData.end_time), title: diff --git a/frontend/src/helpers/validation.ts b/frontend/src/helpers/validation.ts index df75d378..176deb9c 100644 --- a/frontend/src/helpers/validation.ts +++ b/frontend/src/helpers/validation.ts @@ -104,7 +104,7 @@ export function validateInvitees( * @returns Error object with roomError, startError, endError */ export function validateRoomAvailability( - room_id: string, + room_id: number, start: string, end: string, roomBookings: Booking[], @@ -113,7 +113,7 @@ export function validateRoomAvailability( ): { roomError?: string; startError?: string; endError?: string } { // Filter out the current booking if editing const filteredBookings = - isEdit && editBooking?.id + isEdit && editBooking?.id !== undefined ? roomBookings.filter((b: Booking) => b.id !== editBooking.id) : roomBookings; let startConflict = false; diff --git a/frontend/src/interfaces.ts b/frontend/src/interfaces.ts index dc8e12bb..85db473c 100644 --- a/frontend/src/interfaces.ts +++ b/frontend/src/interfaces.ts @@ -94,8 +94,8 @@ export interface User { } export interface Booking { - id: string; - room_id?: number | string; + id: number; + room_id?: number; room?: { name?: string }; start_time: string; end_time: string; diff --git a/frontend/src/pages/BookingPage.tsx b/frontend/src/pages/BookingPage.tsx index 88dbbcdf..b0c34fd1 100644 --- a/frontend/src/pages/BookingPage.tsx +++ b/frontend/src/pages/BookingPage.tsx @@ -40,7 +40,7 @@ import { useRooms } from "../context/RoomContext"; import { useBookings } from "../context/BookingContext"; // Utility/helper imports -import { logger } from "../utils/logger"; +// import { logger } from "../utils/logger"; import { getRoomClass } from "../helpers/calendar"; import { getInviteeName } from "../helpers/bookingList"; @@ -100,9 +100,7 @@ const BookingPage: React.FC = () => { return []; } const mappedEvents = bookings.map((booking: Booking) => { - const room = rooms.find( - (r: ConferenceRoom) => String(r.id) === String(booking.room_id) - ); + const room = rooms.find((r: ConferenceRoom) => r.id === booking.room_id); let roomName = room?.name; if (!roomName || roomName.trim() === "") { if ( @@ -110,11 +108,6 @@ const BookingPage: React.FC = () => { booking.room?.name.trim() !== "" ) { roomName = booking.room?.name; - } else if ( - typeof booking.room_id === "string" && - booking.room_id.trim() !== "" - ) { - roomName = booking.room_id as string; } else if (typeof booking.room_id === "number") { roomName = String(booking.room_id); } else { @@ -127,11 +120,7 @@ const BookingPage: React.FC = () => { : []; const start = new Date(booking.start_time); const end = new Date(booking.end_time); - const roomClass = getRoomClass( - typeof booking.room_id === "number" - ? booking.room_id - : parseInt(booking.room_id as string, 10) - ); + const roomClass = getRoomClass(booking.room_id ?? 0); return { id: booking.id, title: booking.title ?? "", @@ -160,7 +149,7 @@ const BookingPage: React.FC = () => { /** Set default room when rooms load */ useEffect(() => { - logger.debug("[BookingPage] Rooms updated", rooms); + // logger.debug("[BookingPage] Rooms updated", rooms); if (rooms.length > 0 && !selectedRoomId) { setSelectedRoomId(rooms[0].id); } @@ -289,18 +278,18 @@ const BookingPage: React.FC = () => { start = new Date(slotInfo.start); end = new Date(start.getTime() + 30 * 60000); } - logger.info("[BookingPage] Slot selected", { - start, - end, - room_id: selectedRoomId, - }); + // logger.info("[BookingPage] Slot selected", { + // start, + // end, + // room_id: selectedRoomId, + // }); setFormSlot({ start, end, room_id: selectedRoomId }); setEditBooking(null); setFormOpen(true); }; const handleSelectEvent = (event: BookingEvent) => { - logger.info("[BookingPage] Event selected", event); + // logger.info("[BookingPage] Event selected", event); const originalBooking = event.originalBooking; const eventIdStr = String(event.id); if (originalBooking && originalBooking.id) { @@ -323,8 +312,14 @@ const BookingPage: React.FC = () => { }); } else { setEditBooking({ - id: String(event.id), - room_id: event.room_id, + id: + typeof event.id === "number" + ? event.id + : parseInt(event.id as string, 10), + room_id: + typeof event.room_id === "number" + ? event.room_id + : parseInt(event.room_id as string, 10), room: event.room_name ? { name: event.room_name } : undefined, start_time: event.start ? event.start.toString() : "", end_time: event.end ? event.end.toString() : "", @@ -343,7 +338,7 @@ const BookingPage: React.FC = () => { const navigate = useNavigate(); const handleFormClose = (refresh = false, booking?: Booking) => { - logger.info("[BookingPage] Booking form closed", { refresh, booking }); + // logger.info("[BookingPage] Booking form closed", { refresh, booking }); setFormOpen(false); setFormSlot(null); setEditBooking(null); @@ -372,7 +367,7 @@ const BookingPage: React.FC = () => { variant="outlined" sx={{ ml: 2 }} onClick={() => { - logger.info("[BookingPage] Opening room modal"); + // logger.info("[BookingPage] Opening room modal"); setRoomModalOpen(true); }} disabled={!selectedRoomId} @@ -383,7 +378,7 @@ const BookingPage: React.FC = () => { { - logger.info("[BookingPage] Closing room modal"); + // logger.info("[BookingPage] Closing room modal"); setRoomModalOpen(false); }} room={ diff --git a/frontend/src/pages/ConfirmationPage.tsx b/frontend/src/pages/ConfirmationPage.tsx index 9f973f4f..81353930 100644 --- a/frontend/src/pages/ConfirmationPage.tsx +++ b/frontend/src/pages/ConfirmationPage.tsx @@ -7,7 +7,8 @@ */ // External imports -import React, { useState } from "react"; +// External imports +import React, { useState, useEffect } from "react"; import { useLocation, useNavigate } from "react-router-dom"; // MUI imports @@ -19,6 +20,7 @@ import { useQuery } from "@tanstack/react-query"; // Internal component imports import BookingConfirmation from "../components/BookingConfirmation"; import BookingForm from "../components/BookingForm"; +// import { logger } from "../utils/logger"; // API imports import { connectRoomsAvailabilityStream } from "../apis/sse"; @@ -51,17 +53,46 @@ const ConfirmationPage: React.FC = () => { const [booking, setBooking] = useState( location.state?.booking || null ); + // Track deleted state + const [deleted, setDeleted] = useState(false); + + useEffect(() => { + // If no booking, redirect to booking page + if (!booking) { + navigate("/booking"); + return; + } + + const bookingId = location.state?.booking?.id || booking?.id; + if (!bookingId) { + return; + } + // Always get latest booking from context + const latestBooking = bookings.find((b: Booking) => b.id === bookingId); + if (latestBooking) { + setBooking(latestBooking); + } + + const es = connectRoomsAvailabilityStream({ + onMessage: (data) => { + if (Array.isArray(data.bookings)) { + const updated = data.bookings.find( + (b: Booking) => b.id === bookingId + ); + if (updated) { + setBooking(updated); + } + } + }, + onError() { + // Optionally handle SSE errors + }, + }); - React.useEffect(() => { - // [ConfirmationPage] Mounted log removed return () => { - // [ConfirmationPage] Unmounted log removed + es.close(); }; - }, []); - - React.useEffect(() => { - // [ConfirmationPage] booking updated log removed - }, [booking]); + }, [bookings, location.state, booking?.id, navigate]); /** * Subscribe to SSE for live booking status updates. */ @@ -82,9 +113,7 @@ const ConfirmationPage: React.FC = () => { (b: Booking) => b.id === bookingId ); if (updated) { - setBooking((prev: Booking | null) => - prev ? { ...prev, ...updated } : updated - ); + setBooking(updated); } } }, @@ -115,8 +144,7 @@ const ConfirmationPage: React.FC = () => { }); if (!booking) { - // If no booking, redirect to booking page - navigate("/booking"); + // Already handled by useEffect, but return null to avoid rendering return null; } if (roomsLoading) { @@ -143,7 +171,7 @@ const ConfirmationPage: React.FC = () => { minHeight: "100vh", }} > - {editing ? ( + {editing && !deleted ? ( { start: booking.start_time, end: booking.end_time, }} - onBookingSuccess={() => { - setEditing(false); - // Optionally update booking state + onBookingSuccess={(resultBooking?: Booking) => { + if (!resultBooking) { + // Deleted + // logger.info("[ConfirmationPage] Booking deleted", booking); + setDeleted(true); + setEditing(false); + setBooking(null); + } else { + // logger.info( + // "[ConfirmationPage] Booking success (created or edited)", + // resultBooking + // ); + setEditing(false); + setBooking(resultBooking); + } }} /> ) : ( diff --git a/frontend/src/utils/logger.ts b/frontend/src/utils/logger.ts index 1662ab71..e0e8914c 100644 --- a/frontend/src/utils/logger.ts +++ b/frontend/src/utils/logger.ts @@ -11,7 +11,10 @@ import log, { LogLevelDesc } from "loglevel"; * Gets the log level from environment and sets it for loglevel. * @type {LogLevelDesc} */ -const level: LogLevelDesc = (process.env.FRONTEND_LOG_LEVEL || + +// Use REACT_APP_ prefix for Create React App compatibility +const level: LogLevelDesc = (process.env.REACT_APP_FRONTEND_LOG_LEVEL || + process.env.FRONTEND_LOG_LEVEL || "info") as LogLevelDesc; log.setLevel(level);