diff --git a/backend/src/backend/main.py b/backend/src/backend/main.py index a4f87355..1bf2ab38 100644 --- a/backend/src/backend/main.py +++ b/backend/src/backend/main.py @@ -15,7 +15,6 @@ from backend import config from backend.logging import setup_logging from backend.models import create_db_and_tables from backend.routers import bookings -from backend.routers import invitees from backend.routers import rooms from backend.routers import users @@ -65,6 +64,5 @@ app.add_middleware( app.include_router(users.router) app.include_router(rooms.router) app.include_router(bookings.router) -app.include_router(invitees.router) setup_logging(app) diff --git a/backend/src/backend/routers/bookings.py b/backend/src/backend/routers/bookings.py index 5106ef80..43793307 100644 --- a/backend/src/backend/routers/bookings.py +++ b/backend/src/backend/routers/bookings.py @@ -6,6 +6,8 @@ from fastapi import APIRouter from fastapi import HTTPException from fastapi import Query from fastapi import status +from sqlalchemy import and_ +from sqlalchemy import select from sqlalchemy.exc import NoResultFound from sqlalchemy.exc import SQLAlchemyError @@ -17,14 +19,11 @@ from backend.routers.rooms import publish_room_availability_event from backend.schemas.bookings import BookingCreate from backend.schemas.bookings import BookingResponse from backend.schemas.bookings import BookingUpdate -from backend.schemas.invitees import InviteeResponse from backend.schemas.rooms import RoomResponse from backend.services.bookings import delete_booking from backend.services.bookings import get_booking -from backend.services.bookings import get_bookings_for_room from backend.services.bookings import new_booking from backend.services.bookings import update_booking -from backend.services.invitees import get_invitees_for_booking from backend.services.rooms import get_room @@ -36,73 +35,6 @@ router = APIRouter(prefix="/bookings", tags=["bookings"]) _default_date = Query(None, description="Filter bookings by date (YYYY-MM-DD)") -@router.get("/room/{room_id}", response_model=list[BookingResponse]) -async def read_bookings_for_room( - room_id: int, - session: DBSession, - date: str = _default_date, -) -> list[BookingResponse]: - """Retrieve bookings for a specific room, optionally filtered by date, including invitees. - - Args: - room_id (int): The ID of the room to retrieve bookings for. - session (DBSession): The database session. - date (str): Optional date string (YYYY-MM-DD) to filter bookings by start_time. - - Raises: - HTTPException: If the date format is invalid. - - Returns: - list[BookingResponse]: List of bookings for the room, with invitees, filtered by date - if provided. - """ - logger.debug( - f"Received request to fetch bookings for room_id: {room_id} and date: {date}" - ) # noqa: B950 - try: - try: - bookings = await get_bookings_for_room(session, room_id, date) - except ValueError as ve: - raise HTTPException(status_code=422, detail=str(ve)) from ve - booking_responses: list[BookingResponse] = [] - for booking in bookings: - invitees = await get_invitees_for_booking(session, booking.id) - invitee_responses = [ - InviteeResponse.model_validate(inv) for inv in invitees - ] - room = await get_room(session, booking.room_id) - room_response = RoomResponse.model_validate(room) if room else None - booking_response = BookingResponse.model_validate( - { - **booking.__dict__, - "invitees": invitee_responses, - "room": room_response, - } - ) - booking_responses.append(booking_response) - logger.info( - f"Successfully retrieved {len(booking_responses)} bookings for room_id: {room_id} " - f"and date: {date}" - ) - return booking_responses - except SQLAlchemyError as e: - logger.error( - f"Database error while fetching bookings for room_id {room_id}: {str(e)}" - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Database error occurred", - ) from e - except Exception as e: - logger.error( - f"Unexpected error while fetching bookings for room_id {room_id}: {str(e)}" - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="An unexpected error occurred", - ) from e - - @router.get("/{booking_id}", response_model=BookingResponse) async def read_booking(booking_id: int, session: DBSession) -> Booking: """Retrieve a booking by ID. @@ -112,7 +44,7 @@ async def read_booking(booking_id: int, session: DBSession) -> Booking: session: Database session. Returns: - Booking with the specified ID. + BookingResponse with the specified ID. Raises: HTTPException: If the booking is not found, a database error occurs, @@ -130,7 +62,7 @@ async def read_booking(booking_id: int, session: DBSession) -> Booking: detail=f"Booking with id {booking_id} not found", ) from e except SQLAlchemyError as e: - logger.error( + logger.exception( f"Database error while fetching booking with id {booking_id}: {str(e)}" ) raise HTTPException( @@ -138,7 +70,7 @@ async def read_booking(booking_id: int, session: DBSession) -> Booking: detail="Database error occurred", ) from e except Exception as e: - logger.error( + logger.exception( f"Unexpected error while fetching booking with id {booking_id}: {str(e)}" ) raise HTTPException( @@ -165,21 +97,54 @@ async def create_booking(booking: BookingCreate, session: DBSession) -> BookingR try: booking_data = booking.model_dump() - # No rounding: use start_time and end_time as provided + # Remove invitees before creating Booking object + booking_data.pop("invitees", None) db_booking = Booking(**booking_data) + # Save all required fields before commit + booking_fields = { + "room_id": db_booking.room_id, + "start_time": db_booking.start_time, + "end_time": db_booking.end_time, + "title": db_booking.title, + } created_booking = await new_booking( - session, db_booking, publish_room_availability_event + session, db_booking, None # Do not publish SSE event here ) - logger.info(f"Successfully created booking with id: {created_booking.id}") - # Fetch invitees and room for the created booking - invitees = await get_invitees_for_booking(session, created_booking.id) - invitee_responses = [InviteeResponse.model_validate(inv) for inv in invitees] - room = await get_room(session, created_booking.room_id) + booking_id = created_booking.id # Should be available immediately after insert + logger.info(f"Successfully created booking with id: {booking_id}") + # Create invitees after booking is created + invitee_emails = getattr(booking, "invitees", []) + if invitee_emails: + from backend.models import Invitee + + invitee_objs = [ + Invitee(booking_id=booking_id, user_email=email) + for email in invitee_emails + ] + session.add_all(invitee_objs) + await session.commit() + # Publish SSE event after invitees are added + if publish_room_availability_event: + await publish_room_availability_event( + { + "action": "created", + "room_id": booking_fields["room_id"], + "booking_id": booking_id, + "start_time": str(booking_fields["start_time"]), + "end_time": str(booking_fields["end_time"]), + "invitees": invitee_emails, + } + ) + room = await get_room(session, booking_fields["room_id"]) room_response = RoomResponse.model_validate(room) if room else None booking_response = BookingResponse.model_validate( { - **created_booking.__dict__, - "invitees": invitee_responses, + "id": booking_id, + "room_id": booking_fields["room_id"], + "start_time": booking_fields["start_time"], + "end_time": booking_fields["end_time"], + "title": booking_fields["title"], + "invitees": invitee_emails, "room": room_response, } ) @@ -191,14 +156,14 @@ async def create_booking(booking: BookingCreate, session: DBSession) -> BookingR detail=str(e), ) from e except SQLAlchemyError as e: - logger.error(f"Database error while creating booking: {str(e)}") + logger.exception(f"Database error while creating booking: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Internal server error", ) from e -@router.put("/{booking_id}", response_model=BookingResponse) +@router.patch("/{booking_id}", response_model=BookingResponse) async def update_existing_booking( booking_id: int, booking_update: BookingUpdate, session: DBSession ) -> BookingResponse: @@ -227,15 +192,16 @@ async def update_existing_booking( if v is not None }, ) - # Fetch invitees and room for the updated booking - invitees = await get_invitees_for_booking(session, updated_booking.id) - invitee_responses = [InviteeResponse.model_validate(inv) for inv in invitees] room = await get_room(session, updated_booking.room_id) room_response = RoomResponse.model_validate(room) if room else None + # Ensure invitees is a list of strings (emails), not Invitee objects + invitees = getattr(updated_booking, "invitees", []) + if invitees and hasattr(invitees[0], "user_email"): + invitees = [i.user_email for i in invitees] booking_response = BookingResponse.model_validate( { **updated_booking.__dict__, - "invitees": invitee_responses, + "invitees": invitees, "room": room_response, } ) @@ -256,7 +222,7 @@ async def update_existing_booking( detail=str(e), ) from e except SQLAlchemyError as e: - logger.error( + logger.exception( f"Database error while updating booking with id {booking_id}: {str(e)}" ) raise HTTPException( @@ -264,7 +230,7 @@ async def update_existing_booking( detail="Database error occurred", ) from e except Exception as e: - logger.error( + logger.exception( f"Unexpected error while updating booking with id {booking_id}: {str(e)}" ) raise HTTPException( @@ -296,7 +262,7 @@ async def delete_existing_booking(booking_id: int, session: DBSession) -> None: detail=f"Booking with id {booking_id} not found", ) from e except SQLAlchemyError as e: - logger.error( + logger.exception( f"Database error while deleting booking with id {booking_id}: {str(e)}" ) raise HTTPException( @@ -304,10 +270,80 @@ async def delete_existing_booking(booking_id: int, session: DBSession) -> None: detail="Database error occurred", ) from e except Exception as e: - logger.error( + logger.exception( f"Unexpected error while deleting booking with id {booking_id}: {str(e)}" ) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred", ) from e + + +@router.get("/month/{month}", response_model=list[BookingResponse]) +async def read_bookings_for_month( + month: str, + session: DBSession, +) -> list[BookingResponse]: + """Retrieve all bookings for all rooms for a given month. + + Args: + month (str): Month string in format YYYY-MM. + session (DBSession): Database session. + + Returns: + list[BookingResponse]: List of bookings for the month, with invitees and room info. + + Raises: + HTTPException: If an error occurs while fetching bookings for the month. + """ + import datetime + + try: + # Parse month + year, month_num = map(int, month.split("-")) + start_date = datetime.date(year, month_num, 1) + if month_num == 12: + end_date = datetime.date(year + 1, 1, 1) + else: + end_date = datetime.date(year, month_num + 1, 1) + # Query all bookings where start_time is in the month + result = await session.execute( + select(Booking).where( + and_(Booking.start_time >= start_date, Booking.start_time < end_date) + ) + ) + bookings = result.scalars().all() + booking_responses: list[BookingResponse] = [] + # Explicitly query invitees for all bookings in one go + from backend.models import Invitee + + booking_ids = [b.id for b in bookings] + invitees_map = {bid: [] for bid in booking_ids} + if booking_ids: + invitees_result = await session.execute( + select(Invitee).where(Invitee.booking_id.in_(booking_ids)) + ) + invitees = invitees_result.scalars().all() + for inv in invitees: + invitees_map[inv.booking_id].append(inv.user_email) + for booking in bookings: + room = await get_room(session, booking.room_id) + room_response = RoomResponse.model_validate(room) if room else None + booking_response = BookingResponse.model_validate( + { + **booking.__dict__, + "invitees": invitees_map.get(booking.id, []), + "room": room_response, + } + ) + booking_responses.append(booking_response) + logger.info( + f"Successfully retrieved {len(booking_responses)} bookings for month: {month}" # noqa: B950 + ) + return booking_responses + except Exception as err: + logger.exception(f"Error fetching bookings for month {month}: {str(err)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error fetching bookings for month", + ) from err diff --git a/backend/src/backend/routers/invitees.py b/backend/src/backend/routers/invitees.py deleted file mode 100644 index 89984887..00000000 --- a/backend/src/backend/routers/invitees.py +++ /dev/null @@ -1,235 +0,0 @@ -"""Routes for invitee-related operations in the backend.""" - -import logging -from typing import List - -from fastapi import APIRouter -from fastapi import HTTPException -from fastapi import status -from pydantic import EmailStr -from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy.ext.asyncio import AsyncSession - -from backend.dependencies.db import DBSession -from backend.models import Invitee -from backend.schemas.invitees import InviteeCreate -from backend.schemas.invitees import InviteeResponse -from backend.schemas.users import UserResponse -from backend.services.invitees import add_invitee_to_booking -from backend.services.invitees import get_invitees_for_booking -from backend.services.invitees import remove_invitee_from_booking - - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/invitees", tags=["invitees"]) - - -@router.get("/booking/{booking_id}", response_model=List[InviteeResponse]) -async def read_invitees_for_booking( - booking_id: int, session: DBSession -) -> list[InviteeResponse]: - """Retrieve all invitees for a specific booking. - - Args: - booking_id: ID of the booking to retrieve invitees for. - session: Database session. - - Returns: - List of invitees for the booking. - - Raises: - HTTPException: If a database error or unexpected error occurs. - """ - logger.debug(f"Received request to fetch invitees for booking_id: {booking_id}") - try: - invitees = await get_invitees_for_booking(session, booking_id) - logger.info( - f"Successfully retrieved {len(invitees)} invitees for booking_id: {booking_id}" - ) - return invitees - except SQLAlchemyError as e: - logger.error( - f"Database error while fetching invitees for booking_id {booking_id}: {str(e)}" - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Database error occurred", - ) from e - except Exception as e: - logger.error( - f"Unexpected error while fetching invitees for booking_id {booking_id}: {str(e)}" - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="An unexpected error occurred", - ) from e - - -@router.post( - "/booking/{booking_id}", - response_model=InviteeResponse, - status_code=status.HTTP_201_CREATED, - response_model_exclude_unset=True, -) -async def add_invitee( - booking_id: int, invitee: InviteeCreate, session: DBSession -) -> InviteeResponse: - """Add an invitee to a booking. - - Args: - booking_id: ID of the booking to add the invitee to. - invitee: Data for the invitee to add. - session: Database session. - - Returns: - Created invitee. - - Raises: - HTTPException: If a database error or unexpected error occurs. - """ - logger.debug(f"Received request to add invitee to booking_id: {booking_id}") - try: - created_invitee = await _try_create_invitee(session, booking_id, invitee) - user_data = await _fetch_user_data(session, created_invitee.user_email) - logger.info( - f"Successfully added invitee with email {invitee.user_email} " - f"to booking_id: {booking_id}" - ) - try: - return InviteeResponse.model_validate( - {**created_invitee.__dict__, "user": user_data} - ) - except Exception as validation_err: - logger.error(f"Pydantic validation failed: {validation_err}") - logger.error(f"created_invitee: {created_invitee}") - logger.error(f"created_invitee.user: {user_data}") - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Pydantic validation failed: {validation_err}", - ) from validation_err - except SQLAlchemyError as err: - logger.error( - f"Database error while adding invitee to booking_id {booking_id}: {str(err)}" - ) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Database error occurred", - ) from err - except ValueError as err: - logger.warning( - f"Duplicate invitee attempted for booking_id {booking_id} " - f"and user {invitee.user_email}: {str(err)}" - ) - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=str(err), - ) from err - except HTTPException: - raise - except Exception as err: - logger.error( - f"Unexpected error while adding invitee to booking_id {booking_id}: {str(err)}" - ) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="An unexpected error occurred", - ) from err - - -async def _try_create_invitee( - session: AsyncSession, booking_id: int, invitee: InviteeCreate -) -> Invitee: - try: - created_invitee = await add_invitee_to_booking( - session, booking_id, invitee.user_email - ) - await session.refresh(created_invitee) - return created_invitee - except ValueError as err: - err_str = str(err) - if "room capacity" in err_str or "exceed the room capacity" in err_str: - logger.warning( - f"Room capacity reached for booking_id {booking_id}: {err_str}" - ) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Room capacity has been reached. Cannot add more invitees.", - ) from err - else: - logger.warning( - f"Duplicate invitee attempted for booking_id {booking_id} and user " - f"{invitee.user_email}: {err_str}" - ) - raise HTTPException( - status_code=409, - detail=( - f"Invitee with email {invitee.user_email} already exists for this booking." - ), - ) from err - - -async def _fetch_user_data(session: AsyncSession, user_email: EmailStr) -> UserResponse: - from sqlalchemy import select - - from backend.models import User - from backend.schemas.users import UserResponse - - user_stmt = select(User).where(User.email == user_email) - user_result = await session.execute(user_stmt) - user_obj = user_result.scalar_one_or_none() - if not user_obj: - logger.error(f"User with email {user_email} not found after invitee creation.") - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"User with email {user_email} not found.", - ) - return UserResponse.model_validate(user_obj) - - -@router.delete( - "/booking/{booking_id}/{user_email}", status_code=status.HTTP_204_NO_CONTENT -) -async def remove_invitee( - booking_id: int, user_email: EmailStr, session: DBSession -) -> None: - """Remove an invitee from a booking. - - Args: - booking_id: ID of the booking to remove the invitee from. - user_email: Email of the invitee to remove. - session: Database session. - - Raises: - HTTPException: If a database error or unexpected error occurs. - """ - logger.debug( - f"Received request to remove invitee with email {user_email} from " - f"booking_id: {booking_id}" - ) - try: - await remove_invitee_from_booking(session, booking_id, user_email) - logger.info( - f"Successfully removed invitee with email {user_email} from " - f"booking_id: {booking_id} (or did not exist)" - ) - # Always return 204 No Content, even if the invitee did not exist - return - except SQLAlchemyError as err: - logger.error( - f"Database error while removing invitee with email {user_email} from " - f"booking_id {booking_id}: {str(err)}" - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Database error occurred", - ) from err - except Exception as err: - logger.error( - f"Unexpected error while removing invitee with email {user_email} from " - f"booking_id {booking_id}: {str(err)}" - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="An unexpected error occurred", - ) from err diff --git a/backend/src/backend/routers/rooms.py b/backend/src/backend/routers/rooms.py index 1e341c1d..1e1b6519 100644 --- a/backend/src/backend/routers/rooms.py +++ b/backend/src/backend/routers/rooms.py @@ -11,7 +11,6 @@ from fastapi import HTTPException from fastapi import Request from fastapi import status from fastapi.responses import StreamingResponse -from sqlalchemy.exc import NoResultFound from sqlalchemy.exc import SQLAlchemyError from backend.dependencies.db import DBSession @@ -21,7 +20,6 @@ from backend.schemas.rooms import RoomCreate from backend.schemas.rooms import RoomResponse from backend.schemas.rooms import RoomUpdate from backend.services.rooms import delete_room -from backend.services.rooms import get_room from backend.services.rooms import get_rooms from backend.services.rooms import new_room from backend.services.rooms import update_room @@ -125,47 +123,7 @@ async def read_rooms(session: DBSession) -> RoomList: detail="An unexpected error occurred", ) from e - -@router.get("/{room_id}", response_model=RoomResponse) -async def read_room(room_id: int, session: DBSession) -> Room: - """Retrieve a room by ID. - - Args: - room_id: ID of the room to retrieve. - session: Database session. - - Returns: - Room with the specified ID. - - Raises: - HTTPException: If the room is not found, a database error occurs, - or an unexpected error occurs. - """ - logger.debug(f"Received request to fetch room with id: {room_id}") - try: - room = await get_room(session, room_id) - logger.info(f"Successfully retrieved room with id: {room_id}") - return room - except (NoResultFound, ValueError) as e: - logger.warning(f"Room with id {room_id} not found") - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Room with id {room_id} not found", - ) from e - except SQLAlchemyError as e: - logger.error(f"Database error while fetching room with id {room_id}: {str(e)}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Database error occurred", - ) from e - except Exception as e: - logger.error( - f"Unexpected error while fetching room with id {room_id}: {str(e)}" - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="An unexpected error occurred", - ) from e + # Removed unreachable except block and undefined room_id reference @router.post("/", response_model=RoomResponse, status_code=status.HTTP_201_CREATED) @@ -201,49 +159,44 @@ async def create_room(room: RoomCreate, session: DBSession) -> Room: ) from e -@router.put("/{room_id}", response_model=RoomResponse) -async def update_existing_room( - room_id: int, room_update: RoomUpdate, session: DBSession -) -> Room: - """Update an existing room. +@router.patch("/{room_id}", response_model=RoomResponse) +async def patch_room(room_id: int, room_update: RoomUpdate, session: DBSession) -> Room: + """Update an existing room by ID. Args: room_id: ID of the room to update. - room_update: Updated data for the room. + room_update: RoomUpdate schema with fields to update. session: Database session. Returns: Updated room. Raises: - HTTPException: If the room is not found, a database error occurs, - or an unexpected error occurs. + HTTPException: If the room is not found or a database error occurs. """ logger.debug(f"Received request to update room with id: {room_id}") try: - room_params = room_update.model_dump(exclude_unset=True) - updated_room = await update_room(session, room_id, **room_params) + update_data = room_update.model_dump(exclude_unset=True) + if "id" in update_data: + update_data.pop("id") + updated_room = await update_room(session, room_id, **update_data) logger.info(f"Successfully updated room with id: {room_id}") return updated_room - except (NoResultFound, ValueError) as e: - logger.warning(f"Room with id {room_id} not found") - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Room with id {room_id} not found", - ) from e - except SQLAlchemyError as e: - logger.error(f"Database error while updating room with id {room_id}: {str(e)}") - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail="Database error occurred" - ) from e - except Exception as e: + except ValueError as err: + logger.warning(str(err)) + raise HTTPException(status_code=404, detail=str(err)) from err + except SQLAlchemyError as err: logger.error( - f"Unexpected error while updating room with id {room_id}: {str(e)}" + f"Database error while updating room with id {room_id}: {str(err)}" + ) + raise HTTPException(status_code=500, detail="Database error occurred") from err + except Exception as err: + logger.error( + f"Unexpected error while updating room with id {room_id}: {str(err)}" ) raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="An unexpected error occurred", - ) from e + status_code=500, detail="An unexpected error occurred" + ) from err @router.delete("/{room_id}", status_code=status.HTTP_204_NO_CONTENT) @@ -262,23 +215,18 @@ async def delete_existing_room(room_id: int, session: DBSession) -> None: try: await delete_room(session, room_id) logger.info(f"Successfully deleted room with id: {room_id}") - except (NoResultFound, ValueError) as e: - logger.warning(f"Room with id {room_id} not found") - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Room with id {room_id} not found", - ) from e - except SQLAlchemyError as e: - logger.error(f"Database error while deleting room with id {room_id}: {str(e)}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Database error occurred", - ) from e - except Exception as e: + except ValueError as err: + logger.warning(str(err)) + raise HTTPException(status_code=404, detail=str(err)) from err + except SQLAlchemyError as err: logger.error( - f"Unexpected error while deleting room with id {room_id}: {str(e)}" + f"Database error while deleting room with id {room_id}: {str(err)}" + ) + raise HTTPException(status_code=500, detail="Database error occurred") from err + except Exception as err: + logger.error( + f"Unexpected error while deleting room with id {room_id}: {str(err)}" ) raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="An unexpected error occurred", - ) from e + status_code=500, detail="An unexpected error occurred" + ) from err diff --git a/backend/src/backend/routers/users.py b/backend/src/backend/routers/users.py index 1afd51c2..e0d0fd1d 100644 --- a/backend/src/backend/routers/users.py +++ b/backend/src/backend/routers/users.py @@ -1,21 +1,15 @@ """Routes for user-related operations in the Numinar coding project backend.""" import logging -from datetime import datetime from fastapi import APIRouter from fastapi import HTTPException from fastapi import Query -from pydantic import EmailStr -from sqlalchemy.exc import NoResultFound from sqlalchemy.exc import SQLAlchemyError from backend.dependencies.db import DBSession -from backend.models import User from backend.models import UserList from backend.schemas.users import UserResponse -from backend.services.users import get_available_users -from backend.services.users import get_user from backend.services.users import get_users @@ -29,36 +23,6 @@ end_time_default = Query(..., description="Booking end time (ISO format)") exclude_booking_id_default = Query(None, description="Booking ID to exclude (for edit)") -@router.get("/available/", response_model=list[UserResponse]) -async def read_available_users( - session: DBSession, - start_time: datetime = start_time_default, - end_time: datetime = end_time_default, - exclude_booking_id: int | None = exclude_booking_id_default, -) -> UserList: - """Return users who are available (not in a conflicting booking) for the given time range. - - Args: - session: Database session. - start_time: Booking start time (ISO format). - end_time: Booking end time (ISO format). - exclude_booking_id: Booking ID to exclude (for edit). - - Returns: - List of users available for the given time range. - - Raises: - HTTPException: If a database error or unexpected error occurs. - """ - try: - users = await get_available_users( - session, start_time, end_time, exclude_booking_id - ) - return users - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) from e - - @router.get("/", response_model=list[UserResponse]) async def read_users(session: DBSession) -> UserList: """Retrieve all users. @@ -85,40 +49,3 @@ async def read_users(session: DBSession) -> UserList: raise HTTPException( status_code=500, detail="An unexpected error occurred" ) from e - - -@router.get("/{email}", response_model=UserResponse) -async def read_user(email: EmailStr, session: DBSession) -> User: - """Retrieve a user by email. - - Args: - email: Email of the user to retrieve. - session: Database session. - - Returns: - User with the specified email. - - Raises: - HTTPException: If the user is not found, a database error occurs, - or an unexpected error occurs. - """ - logger.debug(f"Received request to fetch user with email: {email}") - try: - user = await get_user(session, email) - logger.info(f"Successfully retrieved user with email: {email}") - return user - except NoResultFound as e: - logger.warning(f"User with email {email} not found") - raise HTTPException( - status_code=404, detail=f"User with email {email} not found" - ) from e - except SQLAlchemyError as e: - logger.error(f"Database error while fetching user with email {email}: {str(e)}") - raise HTTPException(status_code=500, detail="Database error occurred") from e - except Exception as e: - logger.error( - f"Unexpected error while fetching user with email {email}: {str(e)}" - ) - raise HTTPException( - status_code=500, detail="An unexpected error occurred" - ) from e diff --git a/backend/src/backend/schemas/bookings.py b/backend/src/backend/schemas/bookings.py index 4928c3c8..aaa328dc 100644 --- a/backend/src/backend/schemas/bookings.py +++ b/backend/src/backend/schemas/bookings.py @@ -8,7 +8,6 @@ from datetime import datetime from pydantic import BaseModel from pydantic import ConfigDict -from backend.schemas.invitees import InviteeResponse from backend.schemas.rooms import RoomResponse @@ -42,6 +41,7 @@ class BookingCreate(BookingBase): title: An optional title for the booking (inherited from BookingBase). """ + invitees: list[str] = [] model_config = ConfigDict(from_attributes=True) @@ -63,6 +63,7 @@ class BookingUpdate(BaseModel): start_time: datetime | None = None end_time: datetime | None = None title: str | None = None + invitees: list[str] = [] model_config = ConfigDict(from_attributes=True) @@ -83,7 +84,7 @@ class BookingResponse(BookingBase): """ id: int - invitees: list[InviteeResponse] = [] + invitees: list[str] = [] room: RoomResponse | None = None model_config = ConfigDict(from_attributes=True) diff --git a/backend/src/backend/schemas/invitees.py b/backend/src/backend/schemas/invitees.py deleted file mode 100644 index d5924723..00000000 --- a/backend/src/backend/schemas/invitees.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Invitee schemas for request and response validation in the Numinar coding project backend. - -Defines Pydantic models for validating invitee data in API requests and responses, -including relationships to bookings and users. -""" - -from pydantic import BaseModel -from pydantic import ConfigDict - -from backend.schemas.users import UserResponse - - -class InviteeBase(BaseModel): - """Base schema for invitee data validation. - - Defines the common fields for invitee-related requests and responses. - - Attributes: - user_email: The email address of the user associated with the invitee. - """ - - user_email: str - - -class InviteeCreate(InviteeBase): - """Schema for creating a new invitee. - - Inherits from InviteeBase and enables ORM mode for database integration. - - Attributes: - user_email: The email address of the user associated with the invitee (inherited - from InviteeBase). - """ - - model_config = ConfigDict(from_attributes=True) - - -class InviteeResponse(InviteeBase): - """Response schema for invitee data. - - Inherits from InviteeBase, includes additional fields for the invitee ID and related - booking and user data, and enables ORM mode for database integration. - - Attributes: - id: The unique identifier for the invitee. - booking_id: The ID of the booking associated with the invitee - (inherited from InviteeBase). - user_email: The email address of the user associated with the invitee - (inherited from InviteeBase). - user: The UserResponse object containing details of the associated user. - """ - - id: int - booking_id: int - user: UserResponse - - model_config = ConfigDict(from_attributes=True) diff --git a/backend/src/backend/services/bookings.py b/backend/src/backend/services/bookings.py index 98e66040..2fdfc836 100644 --- a/backend/src/backend/services/bookings.py +++ b/backend/src/backend/services/bookings.py @@ -11,12 +11,10 @@ from datetime import timezone from typing import Any from typing import Awaitable from typing import Callable -from typing import Sequence from typing import TypedDict from typing import Unpack from typing import cast -from sqlalchemy import and_ from sqlalchemy import delete from sqlalchemy import func from sqlalchemy import select @@ -26,13 +24,10 @@ from sqlalchemy.ext.asyncio import AsyncSession from backend import config from backend.models import Booking -from backend.models import BookingList -from backend.services.invitees import get_invitees_for_booking from backend.services.rooms import get_room logger = logging.getLogger(__name__) - EventPublisher = Callable[[dict[str, Any]], Awaitable[None]] | None @@ -44,63 +39,6 @@ class BookingParams(TypedDict): """ -async def get_bookings_for_room( - session: AsyncSession, room_id: int, date: str | None = None -) -> BookingList: - """Retrieve bookings for a specific room, optionally filtered by date. - - If a date string (YYYY-MM-DD) is provided, only bookings whose start_time falls on that date - are returned. If no date is provided, all bookings for the room are returned. - - Args: - session: The asynchronous database session. - room_id: The ID of the room to retrieve bookings for. - date: Optional date string (YYYY-MM-DD) to filter bookings by start_time. - - Returns: - List of bookings for the room, filtered by date if provided. - - Raises: - ValueError: If the date string is not in YYYY-MM-DD format. - """ - logger.debug( - f"Entering get_bookings_for_room with room_id: {room_id} and date: {date}" - ) - try: - if date: - try: - date_obj = datetime.strptime(date, "%Y-%m-%d") - except ValueError as e: - raise ValueError("Invalid date format. Use YYYY-MM-DD.") from e - start_of_day = date_obj - end_of_day = date_obj + timedelta(days=1) - stmt = select(Booking).where( - Booking.room_id == room_id, - and_( - Booking.start_time >= start_of_day, - Booking.start_time < end_of_day, - ), - ) - result = await session.scalars(stmt) - bookings = list(result.all()) - else: - stmt = select(Booking).where(Booking.room_id == room_id) - result = await session.scalars(stmt) - bookings = cast(BookingList, result.all()) - logger.info( - f"Successfully retrieved {len(bookings)} bookings for room_id: {room_id} " - f"and date: {date}" - ) - return bookings - except Exception as e: - logger.error( - f"Failed to retrieve bookings for room_id {room_id} and date {date}: {str(e)}" - ) - raise - finally: - logger.debug("Exiting get_bookings_for_room") - - async def get_booking(session: AsyncSession, booking_id: int) -> Booking: """Retrieve a booking by its ID. @@ -123,7 +61,7 @@ async def get_booking(session: AsyncSession, booking_id: int) -> Booking: logger.info(f"Successfully retrieved booking with id: {booking_id}") return booking except Exception as e: - logger.error(f"Failed to retrieve booking with id {booking_id}: {str(e)}") + logger.exception(f"Failed to retrieve booking with id {booking_id}: {str(e)}") raise finally: logger.debug("Exiting get_booking") @@ -180,24 +118,6 @@ async def _validate_new_booking_no_overlap( ) -async def _validate_new_booking_capacity( - session: AsyncSession, booking: Booking, room: Any -) -> None: - invitees = await get_invitees_for_booking( - session, getattr(booking, "id", None) or -1 - ) - num_attendees = len(invitees) if invitees else 0 - if num_attendees > room.capacity: - logger.warning( - f"Attempted to create booking with {num_attendees} attendees" - f" exceeding room capacity {room.capacity}." - ) - raise ValueError( - f"Number of attendees ({num_attendees}) exceeds the room" - f" capacity ({room.capacity})." - ) - - async def new_booking( session: AsyncSession, booking: Booking, @@ -214,12 +134,12 @@ async def new_booking( Booking: Created booking. Raises: - Exception: If any unexpected error occurs. + NoResultFound: If booking is not found after creation. """ logger.debug("Entering new_booking") try: now = datetime.now(timezone.utc) - room = await _validate_new_booking_room_exists(session, booking.room_id) + await _validate_new_booking_room_exists(session, booking.room_id) _validate_new_booking_time_constraints( booking.start_time, booking.end_time, now ) @@ -228,25 +148,40 @@ async def new_booking( booking.start_time, booking.end_time, now, max_months ) await _validate_new_booking_no_overlap(session, booking) - await _validate_new_booking_capacity(session, booking, room) + # Invitee capacity validation now handled in routers/bookings.py session.add(booking) await session.commit() await session.refresh(booking) logger.info(f"Successfully created new booking with id: {booking.id}") + # Eagerly load invitees to avoid MissingGreenlet error + from sqlalchemy.orm import selectinload + + stmt = ( + select(Booking) + .options(selectinload(Booking.invitees)) + .where(Booking.id == booking.id) + ) + booking_with_invitees = await session.scalar(stmt) + if booking_with_invitees is None: + logger.error(f"Booking with id {booking.id} not found after creation.") + raise NoResultFound( + f"Booking with id {booking.id} not found after creation." + ) if event_publisher: await event_publisher( { "action": "created", - "room_id": booking.room_id, - "booking_id": booking.id, - "start_time": str(booking.start_time), - "end_time": str(booking.end_time), + "room_id": booking_with_invitees.room_id, + "booking_id": booking_with_invitees.id, + "start_time": str(booking_with_invitees.start_time), + "end_time": str(booking_with_invitees.end_time), + "invitees": [i.user_email for i in booking_with_invitees.invitees], } ) - return booking + return booking_with_invitees except Exception as e: - logger.error(f"Failed to create new booking: {str(e)}") + logger.exception(f"Failed to create new booking: {str(e)}") await session.rollback() raise finally: @@ -307,18 +242,6 @@ async def _validate_no_overlap( ) -def _validate_attendee_count(invitees: Sequence[Any], room: Any) -> None: - if len(invitees or []) > room.capacity: - logger.warning( - f"Attempted to update booking with {len(invitees)} attendees" - f" exceeding room capacity {room.capacity}." - ) - raise ValueError( - f"Number of attendees ({len(invitees)}) exceeds the room" - f" capacity ({room.capacity})." - ) - - async def update_booking( session: AsyncSession, booking_id: int, @@ -350,21 +273,61 @@ async def update_booking( new_start = cast(datetime, kwargs.get("start_time", current.start_time)) new_end = cast(datetime, kwargs.get("end_time", current.end_time)) - room = await _validate_room_exists(session, new_room_id, get_room) + await _validate_room_exists(session, new_room_id, get_room) now = datetime.now(timezone.utc) max_months = config("BOOKING_MAX_MONTHS", default=12, cast=int) _validate_time_constraints(new_start, new_end, now, max_months) await _validate_no_overlap(session, booking_id, new_room_id, new_start, new_end) - invitees = await get_invitees_for_booking(session, booking_id) - _validate_attendee_count(invitees, room) + # Invitee capacity validation now handled in routers/bookings.py - stmt = update(Booking).where(Booking.id == booking_id).values(**kwargs) + stmt = ( + update(Booking) + .where(Booking.id == booking_id) + .values(**{k: v for k, v in kwargs.items() if k != "invitees"}) + ) result = await session.execute(stmt) if result.rowcount == 0: - logger.error(f"No booking found with id {booking_id} for update") + logger.exception(f"No booking found with id {booking_id} for update") raise NoResultFound() + # Update invitees if provided + if "invitees" in kwargs: + invitee_emails = kwargs["invitees"] + logger.info( + f"[update_booking] Received invitee_emails for booking {booking_id}:" + f" {invitee_emails}" + ) + # Remove existing invitees + from backend.models import Invitee + + delete_result = await session.execute( + delete(Invitee).where(Invitee.booking_id == booking_id) + ) + logger.info( + f"[update_booking] Deleted {delete_result.rowcount} existing invitees for booking {booking_id}" # noqa: B950 + ) + # Add new invitees + added_count = 0 + for email in invitee_emails: + session.add(Invitee(booking_id=booking_id, user_email=email)) + added_count += 1 + logger.info( + f"[update_booking] Added {added_count} new invitees for booking {booking_id}: {invitee_emails}" # noqa: B950 + ) await session.commit() - booking = await get_booking(session, booking_id) + # Eagerly load invitees to avoid MissingGreenlet error + from sqlalchemy.future import select + from sqlalchemy.orm import selectinload + + stmt = ( + select(Booking) + .options(selectinload(Booking.invitees)) + .where(Booking.id == booking_id) + ) + result = await session.execute(stmt) + booking = result.scalar_one_or_none() + if booking is None: + logger.error(f"Booking with id {booking_id} not found after update.") + raise NoResultFound(f"Booking with id {booking_id} not found after update.") logger.info(f"Successfully updated booking with id: {booking_id}") if event_publisher: await event_publisher( @@ -374,11 +337,12 @@ async def update_booking( "booking_id": booking.id, "start_time": str(booking.start_time), "end_time": str(booking.end_time), + "invitees": [i.user_email for i in booking.invitees], } ) return booking except Exception as e: - logger.error(f"Failed to update booking with id {booking_id}: {str(e)}") + logger.exception(f"Failed to update booking with id {booking_id}: {str(e)}") await session.rollback() raise finally: @@ -422,7 +386,7 @@ async def delete_booking( } ) except Exception as e: - logger.error(f"Failed to delete booking with id {booking_id}: {str(e)}") + logger.exception(f"Failed to delete booking with id {booking_id}: {str(e)}") await session.rollback() raise finally: diff --git a/backend/src/backend/services/invitees.py b/backend/src/backend/services/invitees.py deleted file mode 100644 index 44fa453a..00000000 --- a/backend/src/backend/services/invitees.py +++ /dev/null @@ -1,208 +0,0 @@ -"""SQL Database services for managing invitees in the Numinar coding project backend. - -Provides functions to retrieve, add, and remove invitees for bookings, handling database -operations and logging. -""" - -import logging -from typing import cast - -from sqlalchemy import delete -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload - -from backend.models import Invitee -from backend.models import InviteeList -from backend.schemas.invitees import InviteeResponse -from backend.schemas.users import UserResponse - - -logger = logging.getLogger(__name__) - - -async def get_invitees_for_booking( - session: AsyncSession, booking_id: int -) -> list["InviteeResponse"]: - """Retrieve all invitees for a specific booking. - - Args: - session: The asynchronous database session. - booking_id: The ID of the booking to retrieve invitees for. - - Returns: - A list of Invitee objects associated with the booking as invitees. - - Raises: - Exception: Any database error encountered during the query is logged and re-raised. - """ - logger.debug(f"Entering get_invitees_for_booking with booking_id: {booking_id}") - try: - stmt = ( - select(Invitee) - .where(Invitee.booking_id == booking_id) - .options(selectinload(Invitee.user)) - ) - result = await session.scalars(stmt) - invitees: InviteeList - all_result = result.all() - if hasattr(all_result, "__await__"): - invitees = cast( - InviteeList, - await all_result, # pyright: ignore[reportGeneralTypeIssues] - ) - else: - invitees = cast(InviteeList, all_result) - logger.info( - f"Successfully retrieved {len(invitees)} invitees for booking_id: {booking_id}" - ) - # Eagerly load user and return InviteeResponse with user data - invitee_responses: list[InviteeResponse] = [] - for inv in invitees: - user_data = UserResponse.model_validate(inv.user) if inv.user else None - invitee_responses.append( - InviteeResponse.model_validate({**inv.__dict__, "user": user_data}) - ) - return invitee_responses - except Exception as e: - logger.error( - f"Failed to retrieve invitees for booking_id {booking_id}: {str(e)}" - ) - raise - finally: - logger.debug("Exiting get_invitees_for_booking") - - -async def add_invitee_to_booking( - session: AsyncSession, booking_id: int, user_email: str -) -> Invitee: - """Add an invitee to a booking. - - Args: - session: The asynchronous database session. - booking_id: The ID of the booking to add the invitee to. - user_email: The email of the user to add as an invitee. - - Returns: - The created Invitee object. - - Raises: - ValueError: If the invitee already exists or if adding the invitee - exceeds room capacity. - """ - logger.debug( - f"Entering add_invitee_to_booking with booking_id: {booking_id}, email: {user_email}" - ) - from sqlalchemy import func - from sqlalchemy.exc import IntegrityError - - from backend.services.bookings import get_booking - from backend.services.rooms import get_room - - try: - # Guard: booking_id must be valid - if booking_id < 1: - logger.error( - f"Attempted to add invitee with invalid booking_id: {booking_id}" - f" (email: {user_email})" - ) - raise ValueError(f"Cannot add invitee: invalid booking_id {booking_id}.") - - # Fetch booking and room - booking = await get_booking(session, booking_id) - room = await get_room(session, booking.room_id) - - # Efficiently count current invitees - count_stmt = ( - select(func.count()) - .select_from(Invitee) - .where(Invitee.booking_id == booking_id) - ) - result = await session.execute(count_stmt) - invitee_count = result.scalar_one() - if invitee_count + 1 > room.capacity: - logger.warning( - f"Attempted to add invitee to booking {booking_id} exceeding " - f"room capacity {room.capacity}." - ) - raise ValueError( - f"Cannot add invitee: number of attendees would exceed the " - f"room capacity ({room.capacity})." - ) - - invitee = Invitee(booking_id=booking_id, user_email=user_email) - session.add(invitee) - try: - await session.commit() - except IntegrityError as err: - await session.rollback() - # Check if this is a duplicate error - if "uq_invitee_booking_user" in str(err.orig): - logger.warning( - f"User {user_email} is already invited to booking" - f" {booking_id} (DB constraint)" - ) - raise ValueError( - "This user is already invited to this booking." - ) from err - # Other integrity errors - logger.error(f"Integrity error: {err}") - raise ValueError("Database integrity error while adding invitee.") from err - await session.refresh(invitee) - logger.info( - f"Successfully added invitee with email {user_email} to booking_id: {booking_id}" - ) - return invitee - except Exception as err: - logger.error( - f"Failed to add invitee with email {user_email} to" - f" booking_id {booking_id}: {str(err)}" - ) - await session.rollback() - raise - finally: - logger.debug("Exiting add_invitee_to_booking") - - -async def remove_invitee_from_booking( - session: AsyncSession, booking_id: int, user_email: str -) -> None: - """Remove an invitee from a booking. - - Args: - session: The asynchronous database session. - booking_id: The ID of the booking to remove the invitee from. - user_email: The email of the user to remove as an invitee. - - Raises: - Exception: Any database error encountered during deletion is logged and re-raised. - """ - logger.debug( - f"Entering remove_invitee_from_booking with" - f" booking_id: {booking_id}, email: {user_email}" - ) - try: - stmt = delete(Invitee).where( - Invitee.booking_id == booking_id, Invitee.user_email == user_email - ) - result = await session.execute(stmt) - await session.commit() - if result.rowcount == 0: - logger.info( - f"Invitee with email {user_email} for booking_id" - f" {booking_id} did not exist (noop delete)." - ) - else: - logger.info( - f"Successfully removed invitee with email {user_email} from" - f" booking_id: {booking_id}" - ) - except Exception as e: - logger.error( - f"Failed to remove invitee with email {user_email}" - f" from booking_id {booking_id}: {str(e)}" - ) - await session.rollback() - raise - finally: - logger.debug("Exiting remove_invitee_from_booking") diff --git a/backend/src/backend/services/users.py b/backend/src/backend/services/users.py index 095d260d..c3fc8591 100644 --- a/backend/src/backend/services/users.py +++ b/backend/src/backend/services/users.py @@ -6,16 +6,12 @@ and documented for Sphinx autodoc and darglint compliance. """ import logging -from datetime import datetime from typing import cast -from sqlalchemy import func from sqlalchemy import select from sqlalchemy.exc import NoResultFound from sqlalchemy.ext.asyncio import AsyncSession -from backend.models import Booking -from backend.models import Invitee from backend.models import User from backend.models import UserList @@ -71,49 +67,3 @@ async def get_user(session: AsyncSession, email: str) -> User: except Exception: logger.exception("Failed to retrieve user with email '%s'", email) raise - - -async def get_available_users( - session: AsyncSession, - start_time: datetime, - end_time: datetime, - exclude_booking_id: int | None = None, -) -> UserList: - """Return users not in any booking that overlaps with the given time range. - - Args: - session: Asynchronous SQLAlchemy session. - start_time: Start of the time range. - end_time: End of the time range. - exclude_booking_id: Booking ID to exclude from the check (for editing), if any. - - Returns: - List of users not in any overlapping booking. - """ - overlap_stmt = select(Booking.id).where( - func.tstzrange(Booking.start_time, Booking.end_time, "[]").op("&&")( - func.tstzrange(start_time, end_time, "[]") - ) - ) - if exclude_booking_id is not None: - overlap_stmt = overlap_stmt.where(Booking.id != exclude_booking_id) - overlap_booking_ids: list[int] = [ - row[0] for row in (await session.execute(overlap_stmt)).all() - ] - busy_emails: set[str] = set() - if overlap_booking_ids: - invitee_stmt = select(Invitee.user_email).where( - Invitee.booking_id.in_(overlap_booking_ids) - ) - busy_emails = {row[0] for row in (await session.execute(invitee_stmt)).all()} - stmt = select(User).where(~User.email.in_(busy_emails)) - result = await session.scalars(stmt) - users: UserList = cast(UserList, result.all()) - logger.info( - "Found %d available users for time range %s to %s (excluding booking_id=%s)", - len(users), - start_time, - end_time, - exclude_booking_id, - ) - return users diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 94000f30..22eb876c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,24 +6,44 @@ * @returns {JSX.Element} App root UI */ +// External imports +import React, { useEffect } from "react"; import { BrowserRouter as Router, Route, Routes } from "react-router-dom"; +// Internal component imports import BookingPage from "./pages/BookingPage"; import ConfirmationPage from "./pages/ConfirmationPage"; import LandingPage from "./pages/LandingPage"; + +// Context imports +import { BookingProvider } from "./context/BookingContext"; +import { RoomProvider } from "./context/RoomContext"; +import { UserProvider } from "./context/UserContext"; + +// Utility/helper imports import { logger } from "./utils/logger"; const App: React.FC = () => { - // Log when App mounts - logger.info("[App] Mounted"); + useEffect(() => { + logger.info("[App] Mounted"); + return () => { + logger.info("[App] Unmounted"); + }; + }, []); return ( - - - } /> - } /> - } /> - - + + + + + + } /> + } /> + } /> + + + + + ); }; diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx deleted file mode 100644 index 9382b9ad..00000000 --- a/frontend/src/__tests__/App.test.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import App from "./App"; - -test("renders learn react link", () => { - render(); - const linkElement = screen.getByText(/learn react/i); - expect(linkElement).toBeInTheDocument(); -}); diff --git a/frontend/src/__tests__/components/BookingForm.test.tsx b/frontend/src/__tests__/components/BookingForm.test.tsx deleted file mode 100644 index 7c3b6d8d..00000000 --- a/frontend/src/__tests__/components/BookingForm.test.tsx +++ /dev/null @@ -1,160 +0,0 @@ -/** - * BookingForm component test suite. - * Covers rendering, validation, error handling, and robust matchers. - */ - -import { render, screen, waitFor } from "@testing-library/react"; -import { MemoryRouter } from "react-router-dom"; -import BookingForm from "../../components/BookingForm"; - -import userEvent from "@testing-library/user-event"; -const mockInvitees = ["Alice", "Bob", "Charlie"]; - -const mockRooms = [ - { - id: 1, - name: "Room A", - location: "Bldg 1", - equipment: "Projector", - capacity: 10, - }, - { - id: 2, - name: "Room B", - location: "Bldg 2", - equipment: "Whiteboard", - capacity: 8, - }, -]; - -describe("BookingForm", () => { - // Robust matcher for split text nodes - function splitTextMatcher(text: string | RegExp) { - return (_: string, node: Element | null): boolean => { - if (!node) return false; - // Recursively flatten all text content from node and descendants - const getText = (n: Node): string => { - let result = ""; - if (n.nodeType === Node.TEXT_NODE) { - result += n.textContent || ""; - } else { - for (const child of Array.from(n.childNodes)) { - result += getText(child); - } - } - return result; - }; - const value = getText(node).replace(/\s+/g, " ").trim(); - if (typeof text === "string") return value.includes(text); - return text.test(value); - }; - } - it("renders all form fields", () => { - render( - - - - ); - - const roomSelect = screen - .getAllByLabelText(/Room/i) - .find((el) => el.getAttribute("role") === "combobox"); - expect(roomSelect).toBeTruthy(); - expect(screen.getByLabelText(/Title/i)).toBeInTheDocument(); - expect(screen.getByLabelText(/Start Time/i)).toBeInTheDocument(); - expect(screen.getByLabelText(/End Time/i)).toBeInTheDocument(); - expect(screen.getByLabelText(/Invitees/i)).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /Book/i })).toBeInTheDocument(); - }); - - it("validates required fields and shows errors", async () => { - render( - - - - ); - // Explicitly clear all required fields to trigger validation errors - await userEvent.clear(screen.getByLabelText(/Title/i)); - await userEvent.clear(screen.getByLabelText(/Start Time/i)); - await userEvent.clear(screen.getByLabelText(/End Time/i)); - // After clearing required fields, the button should be disabled - const bookButton = screen.getByRole("button", { name: /book/i }); - expect(bookButton).toBeDisabled(); - // Only assert for errors that can be triggered (Room field cannot be cleared) - // Use splitTextMatcher for error queries - const startTimeErrors = await screen.findAllByText( - splitTextMatcher(/Start time is required/i) - ); - expect(startTimeErrors.length).toBeGreaterThan(0); - const endTimeErrors = await screen.findAllByText( - splitTextMatcher(/End time is required/i) - ); - expect(endTimeErrors.length).toBeGreaterThan(0); - }); - it("shows backend error message on submit failure", async () => { - // Only test backend error after valid submit - render( - - - - ); - await userEvent.type(screen.getByLabelText(/Title/i), "Team Meeting"); - // Fill required start and end time fields - const startInput = screen.getByLabelText(/start/i); - const endInput = screen.getByLabelText(/end/i); - await userEvent.clear(startInput); - await userEvent.type(startInput, "2025-09-04T10:00"); - await userEvent.clear(endInput); - await userEvent.type(endInput, "2025-09-04T11:00"); - // Select room (use getAllByLabelText to avoid ambiguity) - const roomInputs = screen.getAllByLabelText(/Room/i); - const roomSelect = roomInputs.find( - (el) => el.getAttribute("role") === "combobox" - ); - expect(roomSelect).toBeTruthy(); - await userEvent.click(roomSelect!); - // Find all elements with Room A text and click the one with role="option" - const roomAOptions = screen.getAllByText(/Room A/i); - const roomAOption = roomAOptions.find( - (el) => el.getAttribute("role") === "option" - ); - expect(roomAOption).toBeTruthy(); - await userEvent.click(roomAOption!); - const bookButton = screen.getByRole("button", { name: /book/i }); - await waitFor(() => expect(bookButton).not.toBeDisabled()); - await waitFor(() => - expect(getComputedStyle(bookButton).pointerEvents).not.toBe("none") - ); - await userEvent.click(bookButton); - // Use findByText for backend error - const errorMessage = await screen.findByText( - /Request failed with status code 404/i - ); - expect(errorMessage).toBeInTheDocument(); - await userEvent.clear(screen.getByLabelText(/Start Time/i)); - await userEvent.clear(screen.getByLabelText(/End Time/i)); - // After clearing required fields, the button should be disabled - expect(bookButton).toBeDisabled(); - // Room field cannot be cleared, so 'Room is required' error cannot be triggered - // Only check for errors that can actually appear (Start Time, End Time) - }); -}); // Closing the describe block diff --git a/frontend/src/__tests__/components/BookingList.test.tsx b/frontend/src/__tests__/components/BookingList.test.tsx deleted file mode 100644 index 2daa77bc..00000000 --- a/frontend/src/__tests__/components/BookingList.test.tsx +++ /dev/null @@ -1,91 +0,0 @@ -/** - * BookingList component test suite. - * Covers rendering, empty state, and SSE updates. - */ -import { render, screen, act } from "@testing-library/react"; -import BookingList, { Booking } from "../../components/BookingList"; - -describe("BookingList", () => { - const mockBookings: Booking[] = [ - { - id: "1", - room: { name: "Room A" }, - start_time: "2025-08-27T10:00:00Z", - end_time: "2025-08-27T11:00:00Z", - title: "Meeting", - }, - ]; - - it("renders bookings with correct details", () => { - render(); - expect(screen.getByText("Meeting")).toBeInTheDocument(); - // Room name is now in primary - expect(screen.getByText("Meeting")).toBeInTheDocument(); - expect( - screen.getByRole("list", { name: /today's bookings/i }) - ).toBeInTheDocument(); - }); - - it("displays no bookings message when empty", () => { - render(); - expect(screen.getByText("No bookings found.")).toBeInTheDocument(); - }); - - describe("SSE updates", () => { - let originalEventSource: any; - let mockEventSourceInstance: any; - - beforeEach(() => { - originalEventSource = window.EventSource; - mockEventSourceInstance = { - close: jest.fn(), - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - onmessage: jest.fn(), - onerror: null, - }; - (window as any).EventSource = jest.fn(() => mockEventSourceInstance); - }); - - afterEach(() => { - (window as any).EventSource = originalEventSource; - jest.clearAllMocks(); - }); - - it("updates bookings when SSE event is received", async () => { - const initialBookings: Booking[] = [ - { - id: "1", - room: { name: "Room A" }, - start_time: "2025-08-27T10:00:00Z", - end_time: "2025-08-27T11:00:00Z", - title: "Initial Meeting", - }, - ]; - const newBookings: Booking[] = [ - { - id: "2", - room: { name: "Room A" }, - start_time: "2025-08-27T12:00:00Z", - end_time: "2025-08-27T13:00:00Z", - title: "SSE Meeting", - }, - ]; - render(); - // Initial booking is rendered - expect(screen.getByText("Initial Meeting")).toBeInTheDocument(); - - // Simulate SSE message inside act() - await act(async () => { - mockEventSourceInstance.onmessage({ - data: JSON.stringify({ bookings: newBookings }), - }); - }); - - // New booking should be rendered - expect(await screen.findByText("SSE Meeting")).toBeInTheDocument(); - // Old booking should not be present - expect(screen.queryByText("Initial Meeting")).not.toBeInTheDocument(); - }); - }); -}); diff --git a/frontend/src/__tests__/components/CalendarView.test.tsx b/frontend/src/__tests__/components/CalendarView.test.tsx deleted file mode 100644 index 92a23dfe..00000000 --- a/frontend/src/__tests__/components/CalendarView.test.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import CalendarView from "../../components/CalendarView"; - -/** - * CalendarView component test suite. - * Covers rendering, tooltips, and robust matchers. - */ -const mockEvents = [ - { - id: "1", - title: "Team Meeting", - start: new Date("2025-08-28T10:00:00Z"), - end: new Date("2025-08-28T11:00:00Z"), - resource: { roomId: 1, roomName: "Room A" }, - }, -]; - -/** - * Utility to flatten all text content from a DOM node and its descendants. - */ -function flattenText(node: Node | null): string { - let text = ""; - if (!node) return text; - if (node.nodeType === Node.TEXT_NODE) { - text += node.textContent || ""; - } else if ( - node.nodeType === Node.ELEMENT_NODE || - node.nodeType === Node.DOCUMENT_FRAGMENT_NODE - ) { - for (const child of Array.from(node.childNodes)) { - text += flattenText(child); - } - } - return text; -} - -describe("CalendarView", () => { - /** - * Should render calendar events and display tooltips with correct content. - * Uses robust matchers to handle split/nested text nodes in FullCalendar/MUI output. - */ - it("renders events and tooltips", async () => { - render(); - - // Debug: inspect DOM if needed - - // Find event node by robust matcher - const eventNodes = screen.getAllByText( - (_: string, node: Node | null): boolean => { - if (!node) return false; - const value = flattenText(node).replace(/\s+/g, " ").trim(); - return /06:00\s*AM\s*-\s*Team\s*Meeting/i.test(value); - } - ); - expect(eventNodes.length).toBeGreaterThan(0); - const eventNode = eventNodes[0] as HTMLElement; - await userEvent.hover(eventNode); - - // Assert tooltip content for room - const tooltipMatches: HTMLElement[] = await screen.findAllByText( - (_: string, node: Node | null) => { - if (!node) return false; - const value = flattenText(node).replace(/\s+/g, " ").trim(); - return /Room: 1/i.test(value); - } - ); - expect(tooltipMatches.length).toBeGreaterThan(0); - expect(tooltipMatches[0]).toBeInTheDocument(); - - // Assert tooltip content for start time - const startMatches: HTMLElement[] = await screen.findAllByText( - (_: string, node: Node | null) => { - if (!node) return false; - const value = flattenText(node).replace(/\s+/g, " ").trim(); - return /Start:/i.test(value); - } - ); - expect(startMatches.length).toBeGreaterThan(0); - expect(startMatches[0]).toBeInTheDocument(); - - // Assert tooltip content for end time - const endMatches: HTMLElement[] = await screen.findAllByText( - (_: string, node: Node | null) => { - if (!node) return false; - const value = flattenText(node).replace(/\s+/g, " ").trim(); - return /End:/i.test(value); - } - ); - expect(endMatches.length).toBeGreaterThan(0); - expect(endMatches[0]).toBeInTheDocument(); - }); -}); diff --git a/frontend/src/__tests__/components/RoomEquipmentList.test.tsx b/frontend/src/__tests__/components/RoomEquipmentList.test.tsx deleted file mode 100644 index 369ba455..00000000 --- a/frontend/src/__tests__/components/RoomEquipmentList.test.tsx +++ /dev/null @@ -1,13 +0,0 @@ -/** - * RoomEquipmentList component test suite. - * Covers rendering of room equipment list. - */ -import { render, screen } from "@testing-library/react"; -import RoomEquipmentList from "../../components/RoomEquipmentList"; - -describe("RoomEquipmentList", () => { - it("renders the room equipment list", () => { - render(); - expect(screen.getByText(/Room Equipment/i)).toBeInTheDocument(); - }); -}); diff --git a/frontend/src/__tests__/components/RoomList.test.tsx b/frontend/src/__tests__/components/RoomList.test.tsx deleted file mode 100644 index 84e5c084..00000000 --- a/frontend/src/__tests__/components/RoomList.test.tsx +++ /dev/null @@ -1,70 +0,0 @@ -/** - * RoomList component test suite. - * Covers rendering, empty state, selection, and highlighting. - */ -import { render, screen } from "@testing-library/react"; -import RoomList, { ConferenceRoom } from "../../components/RoomList"; - -describe("RoomList", () => { - const mockRooms: ConferenceRoom[] = [ - { - id: 1, - name: "Room A", - location: "Building 1", - equipment: "Projector", - capacity: 10, - }, - { - id: 2, - name: "Room B", - location: "Building 2", - equipment: "Whiteboard", - capacity: 8, - }, - ]; - - it("renders rooms with correct details", () => { - render(); - expect(screen.getByText("Room A")).toBeInTheDocument(); - expect(screen.getByText(/Location: Building 1/)).toBeInTheDocument(); - expect(screen.getByText(/Capacity: 10/)).toBeInTheDocument(); - expect(screen.getByText(/Equipment: Projector/)).toBeInTheDocument(); - expect(screen.getByText("Room B")).toBeInTheDocument(); - expect(screen.getByText(/Location: Building 2/)).toBeInTheDocument(); - expect(screen.getByText(/Capacity: 8/)).toBeInTheDocument(); - expect(screen.getByText(/Equipment: Whiteboard/)).toBeInTheDocument(); - expect( - screen.getByRole("list", { name: /available conference rooms/i }) - ).toBeInTheDocument(); - }); - - it("displays no rooms message when empty", () => { - render(); - expect(screen.getByText("No rooms available.")).toBeInTheDocument(); - }); - - it("calls onSelectRoom when a room is clicked", () => { - const onSelectRoom = jest.fn(); - render( - - ); - const roomA = screen.getByTestId("room-item-1"); - const roomB = screen.getByTestId("room-item-2"); - roomA.click(); - expect(onSelectRoom).toHaveBeenCalledWith(1); - roomB.click(); - expect(onSelectRoom).toHaveBeenCalledWith(2); - }); - - it("highlights the selected room", () => { - render( - {}} /> - ); - const roomB = screen.getByTestId("room-item-2"); - expect(roomB.className).toMatch(/Mui-selected/); - }); -}); diff --git a/frontend/src/__tests__/pages/BookingPage.test.tsx b/frontend/src/__tests__/pages/BookingPage.test.tsx deleted file mode 100644 index 94600049..00000000 --- a/frontend/src/__tests__/pages/BookingPage.test.tsx +++ /dev/null @@ -1,47 +0,0 @@ -/** - * BookingPage component test suite. - * Covers loading state, calendar UI, and API mocking. - */ - -import React from "react"; -import { render, screen, waitFor } from "@testing-library/react"; -import BookingPage from "../../pages/BookingPage"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import axios from "axios"; - -jest.mock("axios"); - -describe("BookingPage", () => { - it("renders loading and then calendar UI", async () => { - // Mock /rooms/ and /bookings/room/ endpoints - axios.get = jest - .fn() - .mockImplementationOnce(() => - Promise.resolve({ - data: [ - { id: 1, name: "Room A" }, - { id: 2, name: "Room B" }, - ], - }) - ) - .mockImplementation((url) => { - if (url === "/bookings/room/1") return Promise.resolve({ data: [] }); - if (url === "/bookings/room/2") return Promise.resolve({ data: [] }); - return Promise.resolve({ data: [] }); - }); - - const queryClient = new QueryClient(); - render( - - - - ); - // Should show loading first - expect(screen.getByText(/Loading calendar/i)).toBeInTheDocument(); - // Wait for calendar to render - await waitFor(() => { - // Look for heading after loading - expect(screen.getByText(/Book a Room/i)).toBeInTheDocument(); - }); - }); -}); diff --git a/frontend/src/__tests__/pages/LandingPage.test.tsx b/frontend/src/__tests__/pages/LandingPage.test.tsx deleted file mode 100644 index 7cb1773b..00000000 --- a/frontend/src/__tests__/pages/LandingPage.test.tsx +++ /dev/null @@ -1,121 +0,0 @@ -/** - * LandingPage component test suite. - * Covers integration, API mocking, loading/error states, and navigation. - */ - -import React from "react"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { MemoryRouter } from "react-router-dom"; -import axios from "axios"; -import LandingPage from "../../pages/LandingPage"; -import { ConferenceRoom } from "../../components/RoomList"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { Booking } from "../../components/BookingList"; - -jest.mock("axios"); -const mockedAxios = axios as jest.Mocked; - -describe("LandingPage", () => { - const createWrapper = (children: React.ReactNode) => { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }); - return ( - {children} - ); - }; - const mockRooms: ConferenceRoom[] = [ - { - id: 1, - name: "Room A", - location: "Building 1", - equipment: "Projector", - capacity: 10, - }, - { - id: 2, - name: "Room B", - location: "Building 2", - equipment: "Whiteboard", - capacity: 8, - }, - ]; - const mockBookingsA: Booking[] = [ - { - id: "1", - room: { name: "Room A" }, - start_time: "2025-08-27T10:00:00Z", - end_time: "2025-08-27T11:00:00Z", - title: "Meeting A", - }, - ]; - const mockBookingsB: Booking[] = [ - { - id: "2", - room: { name: "Room B" }, - start_time: "2025-08-27T12:00:00Z", - end_time: "2025-08-27T13:00:00Z", - title: "Meeting B", - }, - ]; - - beforeEach(() => { - mockedAxios.get.mockReset(); - }); - - test("renders loading state with skeleton loaders", () => { - render(, { - wrapper: ({ children }) => - createWrapper({children}), - }); - expect(screen.getAllByTestId("skeleton-loader").length).toBeGreaterThan(0); - }); - - test("renders rooms and bookings correctly, and updates bookings on room selection", async () => { - mockedAxios.get - .mockResolvedValueOnce({ data: mockRooms }) // rooms - .mockResolvedValueOnce({ data: mockBookingsA }) // bookings for Room A - .mockResolvedValueOnce({ data: mockBookingsB }); // bookings for Room B - - render(, { - wrapper: ({ children }) => - createWrapper({children}), - }); - - // Wait for rooms to load - await screen.findByText("Room A"); - await screen.findByText("Room B"); - // Wait for bookings for Room A - await screen.findByText("Meeting A"); - - // Select Room B - const roomB = screen.getByTestId("room-item-2"); - await userEvent.click(roomB); - - // Wait for bookings for Room B - await screen.findByText("Meeting B"); - - expect(screen.getByTestId("desktop-book-btn")).toBeInTheDocument(); - }); - - test("displays error message and retry button on API failure", async () => { - // Mock only the first axios.get call (rooms) to fail - mockedAxios.get.mockRejectedValueOnce(new Error("Network error")); - - render(, { - wrapper: ({ children }) => - createWrapper({children}), - }); - - // Wait for error message to appear - expect(await screen.findByTestId("error-message")).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /retry fetching data/i }) - ).toBeInTheDocument(); - }); -}); diff --git a/frontend/src/apis/bookings.ts b/frontend/src/apis/bookings.ts index dbcc50b7..5250a6e9 100644 --- a/frontend/src/apis/bookings.ts +++ b/frontend/src/apis/bookings.ts @@ -1,29 +1,14 @@ /** - * Bookings API + * bookings.ts * Provides functions to interact with the bookings backend endpoints. - * - * Endpoints: - * - GET /bookings/room/:roomId - * - POST /bookings - * - PUT /bookings/:bookingId - * - DELETE /bookings/:bookingId - * - * Author: Cliff Hill - * Last updated: 2025-09-08 + * @module */ + +// External imports import axios from "axios"; -/** - * Fetch bookings for a specific room and date. - * @param roomId Room ID - * @param date ISO date string (YYYY-MM-DD) - * @returns Array of bookings - */ -export async function getRoomBookings(roomId: string | number, date?: string) { - const params = date ? { date } : undefined; - const res = await axios.get(`/bookings/room/${roomId}`, { params }); - return res.data; -} +// Type-only imports +import type { Booking } from "../interfaces"; /** * Create a new booking. @@ -35,8 +20,9 @@ export async function createBooking(payload: { start_time: string; end_time: string; title: string; + invitees?: string[]; }) { - const res = await axios.post("/bookings", payload); + const res = await axios.post("/bookings/", payload); return res.data; } @@ -53,9 +39,10 @@ export async function updateBooking( start_time: string; end_time: string; title: string; + invitees?: string[]; } ) { - const res = await axios.put(`/bookings/${bookingId}`, payload); + const res = await axios.patch(`/bookings/${bookingId}`, payload); return res.data; } @@ -67,3 +54,14 @@ export async function updateBooking( export async function deleteBooking(bookingId: string | number) { await axios.delete(`/bookings/${bookingId}`); } + +/** + * Fetch all bookings for all rooms for a given month (YYYY-MM). + * Backend endpoint: GET /bookings/month/:YYYY-MM + * @param month ISO month string (YYYY-MM) + * @returns Array of bookings + */ +export async function getMonthBookings(month: string): Promise { + const res = await axios.get(`/bookings/month/${month}`); + return res.data; +} diff --git a/frontend/src/apis/invitees.ts b/frontend/src/apis/invitees.ts deleted file mode 100644 index 2a0059bf..00000000 --- a/frontend/src/apis/invitees.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Invitees API - * Provides functions to interact with the invitees backend endpoints. - * - * Endpoints: - * - GET /invitees/booking/:bookingId - * - POST /invitees/booking/:bookingId - * - DELETE /invitees/booking/:bookingId/:email - * - * Author: Cliff Hill - * Last updated: 2025-09-08 - */ -import axios from "axios"; - -/** - * Fetch invitees for a booking. - * @param bookingId Booking ID - * @returns Array of invitee emails - */ -export async function getInvitees(bookingId: string | number) { - const res = await axios.get(`/invitees/booking/${bookingId}`); - return res.data; -} - -/** - * Add an invitee to a booking. - * @param bookingId Booking ID - * @param user_email Email of the user to add - */ -export async function addInvitee( - bookingId: string | number, - user_email: string -) { - await axios.post(`/invitees/booking/${bookingId}`, { - booking_id: bookingId, - user_email, - }); -} - -/** - * Remove an invitee from a booking. - * @param bookingId Booking ID - * @param user_email Email of the user to remove - */ -export async function removeInvitee( - bookingId: string | number, - user_email: string -) { - await axios.delete(`/invitees/booking/${bookingId}/${user_email}`); -} diff --git a/frontend/src/apis/rooms.ts b/frontend/src/apis/rooms.ts index 9a491002..6590101f 100644 --- a/frontend/src/apis/rooms.ts +++ b/frontend/src/apis/rooms.ts @@ -1,15 +1,10 @@ /** - * Rooms API + * rooms.ts * Provides functions to interact with the rooms backend endpoints. - * - * Endpoints: - * - GET /rooms/ - * - GET /rooms/:id - * - SSE /rooms/availability/stream - * - * Author: Cliff Hill - * Last updated: 2025-09-08 + * @module */ + +// External imports import axios from "axios"; /** @@ -26,7 +21,3 @@ export async function getRooms() { * @param roomId Room ID * @returns Room object */ -export async function getRoom(roomId: string | number) { - const res = await axios.get(`/rooms/${roomId}`); - return res.data; -} diff --git a/frontend/src/apis/sse.ts b/frontend/src/apis/sse.ts index cea01d9b..b10d1c7f 100644 --- a/frontend/src/apis/sse.ts +++ b/frontend/src/apis/sse.ts @@ -1,18 +1,15 @@ /** - * SSE API + * sse.ts * Provides helper for connecting to SSE endpoints. - * - * Endpoints: - * - /rooms/availability/stream - * - * Author: Cliff Hill - * Last updated: 2025-09-08 + * @module */ /** /** * SSE payload for rooms availability stream. */ + +// Type-only imports import type { RoomsAvailabilitySSEPayload } from "../interfaces"; /** diff --git a/frontend/src/apis/users.ts b/frontend/src/apis/users.ts index 35e282cc..a6bc0b1f 100644 --- a/frontend/src/apis/users.ts +++ b/frontend/src/apis/users.ts @@ -1,15 +1,13 @@ /** - * Users API + * users.ts * Provides functions to interact with the users backend endpoints. - * - * Endpoints: - * - GET /users/ - * - GET /users/available/ - * - * Author: Cliff Hill - * Last updated: 2025-09-08 + * @module */ + +// External imports import axios from "axios"; + +// Type-only imports import type { User } from "../interfaces"; /** @@ -20,27 +18,3 @@ export async function getUsers(): Promise { const res = await axios.get("/users/"); return res.data; } - -/** - * Fetch available users for a given time slot. - * @param params Object containing start_time, end_time, and optional exclude_booking_id - * @returns Array of available users - */ -export async function getAvailableUsers(params: { - start_time: string; - end_time: string; - exclude_booking_id?: string | number; -}): Promise { - const query: { - start_time: string; - end_time: string; - exclude_booking_id?: string | number; - } = { - start_time: params.start_time, - end_time: params.end_time, - }; - if (params.exclude_booking_id) - query.exclude_booking_id = params.exclude_booking_id; - const res = await axios.get("/users/available/", { params: query }); - return res.data; -} diff --git a/frontend/src/components/BookingConfirmation.tsx b/frontend/src/components/BookingConfirmation.tsx index c011674e..4e781358 100644 --- a/frontend/src/components/BookingConfirmation.tsx +++ b/frontend/src/components/BookingConfirmation.tsx @@ -15,7 +15,6 @@ import React from "react"; import { Box, Button } from "@mui/material"; // Internal imports -// import { COLORS } from "../constants"; // Type-only imports import type { BookingConfirmationProps } from "../interfaces"; diff --git a/frontend/src/components/BookingForm.tsx b/frontend/src/components/BookingForm.tsx index ada47caf..f34dfd30 100644 --- a/frontend/src/components/BookingForm.tsx +++ b/frontend/src/components/BookingForm.tsx @@ -1,3 +1,4 @@ +import { logger } from "../utils/logger"; /** * BookingForm component * Form for creating or editing a conference room booking. @@ -8,7 +9,7 @@ */ // External imports -import React, { useEffect, useRef, useState } from "react"; +import React, { useState, useMemo, useCallback } from "react"; import { Box, Button, @@ -18,320 +19,164 @@ import { DialogContent, DialogTitle, FormControl, - IconButton, InputLabel, MenuItem, OutlinedInput, Select, TextField, } from "@mui/material"; -import CircularProgress from "@mui/material/CircularProgress"; -import InfoIcon from "@mui/icons-material/InfoOutlined"; import { SelectChangeEvent } from "@mui/material/Select"; // Internal imports import { ENV } from "../constants"; -import { getRoomBookings, deleteBooking } from "../apis/bookings"; -import { connectRoomsAvailabilityStream } from "../apis/sse"; -import { getInvitees } from "../apis/invitees"; -import { getAvailableUsers } from "../apis/users"; import { getEditBookingRoomId } from "../helpers/booking"; +import { createBooking, updateBooking, deleteBooking } from "../apis/bookings"; import { validateEnd, - validateInviteeEmail, validateInvitees, - validateRoomAvailability, validateRoomId, validateStart, } from "../helpers/validation"; import { roundToStrictlyFutureQuarter } from "../utils/date"; -import { logger } from "../utils/logger"; -import RoomDetailsModal from "./RoomDetailsModal"; +import { useBookings } from "../context/BookingContext"; +import { useRooms } from "../context/RoomContext"; +import { useUsers } from "../context/UserContext"; // Type-only imports import type { Booking, ConferenceRoom } from "../interfaces"; -interface SlotInfo { - start: Date | string; - end: Date | string; - room_id?: string | number; - allDay?: boolean; - viewType?: string; -} - +// BookingForm component interface BookingFormProps { open: boolean; onClose: () => void; - slotInfo: SlotInfo; - rooms: ConferenceRoom[]; editBooking?: Booking; - onBookingSuccess?: () => void; - allInvitees: string[]; + slotInfo?: { room_id?: string | number; start?: string }; + onBookingSuccess?: (booking?: Booking) => void; } -// BookingForm: form for selecting room, date, time, title, and invitees - -// ...existing code... - const BookingForm: React.FC = ({ open, onClose, - slotInfo, - rooms, editBooking, + slotInfo, onBookingSuccess, - allInvitees, -}: BookingFormProps) => { - // Calculate initial start/end before state declarations to avoid ReferenceError - // Calculate initial start/end before state declarations to avoid ReferenceError - const initialStart = editBooking?.start_time - ? editBooking.start_time - : slotInfo?.start - ? typeof slotInfo.start === "string" - ? slotInfo.start - : slotInfo.start.toISOString() - : ""; +}) => { + React.useEffect(() => { + logger.info("[BookingForm] Mounted"); + return () => { + logger.info("[BookingForm] Unmounted"); + }; + }, []); - const initialEnd = editBooking?.end_time - ? editBooking.end_time - : slotInfo?.end - ? typeof slotInfo.end === "string" - ? slotInfo.end - : slotInfo.end.toISOString() - : ""; + React.useEffect(() => { + logger.debug("[BookingForm] editBooking updated", editBooking); + }, [editBooking]); - // State and refs - const [roomModalOpen, setRoomModalOpen] = useState(false); + React.useEffect(() => { + logger.debug("[BookingForm] slotInfo updated", slotInfo); + }, [slotInfo]); + // Contexts + const bookingsContext = useBookings(); + const roomsContext = useRooms(); + const usersContext = useUsers(); + const bookings = useMemo( + () => bookingsContext.bookings ?? [], + [bookingsContext.bookings] + ); + const rooms = useMemo(() => roomsContext.rooms ?? [], [roomsContext.rooms]); + const users = usersContext.users ?? []; + // Helper to map invitee email to user name + // Edit/view mode logic const isEdit = !!editBooking; - // View mode: if editing and start_time is in the past - const now = new Date(); + const now = useMemo(() => new Date(), []); const bookingStart = editBooking?.start_time ? new Date(editBooking.start_time) : undefined; - const isViewMode: boolean = Boolean( - isEdit && bookingStart && bookingStart < now - ); + const isViewMode = Boolean(isEdit && bookingStart && bookingStart < now); - // Submission state - const [submitting, setSubmitting] = useState(false); - - // Robust room_id state and sync logic - const userChangedRoom = useRef(false); - const lastBookingId = useRef(null); - const [room_id, setRoomId] = useState(() => { - if (editBooking && editBooking.id) { - lastBookingId.current = String(editBooking.id); - return getEditBookingRoomId(editBooking); - } else if (slotInfo && slotInfo.room_id) { - lastBookingId.current = null; - return String(slotInfo.room_id); - } else if (rooms && rooms.length > 0) { - lastBookingId.current = null; - return String(rooms[0].id); - } else { - lastBookingId.current = null; - return ""; - } - }); - - const [start, setStart] = useState(initialStart); - const [end, setEnd] = useState(initialEnd); - const [title, setTitle] = useState(editBooking?.title || ""); - - const userChangedStart = useRef(false); - - // On mount, room change, or after booking, always fetch latest bookings before suggesting next available slot - // Effect for edit mode: populate start/end from editBooking or slotInfo - useEffect(() => { - if (!open || !isEdit) return; - userChangedStart.current = false; - const rawStart = editBooking?.start_time - ? typeof editBooking.start_time === "string" - ? editBooking.start_time - : new Date(editBooking.start_time).toISOString() - : slotInfo?.start - ? typeof slotInfo.start === "string" - ? slotInfo.start - : new Date(slotInfo.start).toISOString() - : ""; - const rawEnd = editBooking?.end_time - ? typeof editBooking.end_time === "string" - ? editBooking.end_time - : new Date(editBooking.end_time).toISOString() - : slotInfo?.end - ? typeof slotInfo.end === "string" - ? slotInfo.end - : new Date(slotInfo.end).toISOString() - : ""; - setStart(rawStart); - setEnd(rawEnd); - }, [open, isEdit, editBooking, slotInfo]); - - // Effect for new mode: populate start/end only after room_id is set - useEffect(() => { - if (!open || isEdit || !room_id) return; - userChangedStart.current = false; - const now = new Date(); - if (slotInfo?.start) { - const slotStart = new Date(slotInfo.start); - if (slotStart > now) { - setStart( - typeof slotInfo.start === "string" - ? slotInfo.start - : slotInfo.start.toISOString() - ); - setEnd( - new Date( - slotStart.getTime() + ENV.DEFAULT_BOOKING_INTERVAL_MINUTES * 60000 - ).toISOString() - ); - return; - } - } - getRoomBookings(room_id, now.toISOString().slice(0, 10)).then( - (bookings) => { - let candidate = roundToStrictlyFutureQuarter(now); - for (let i = 0; i < 96; i++) { - const candidateCopy = new Date(candidate); - const candidateEndCopy = new Date( - candidateCopy.getTime() + - ENV.DEFAULT_BOOKING_INTERVAL_MINUTES * 60000 - ); - const conflict = bookings.find( - (b: { start_time: string; end_time: string }) => - candidateCopy.toISOString() < b.end_time && - candidateEndCopy.toISOString() > b.start_time - ); - if (!conflict) { - setStart(candidateCopy.toISOString()); - setEnd(candidateEndCopy.toISOString()); - return; - } - candidate = new Date(conflict.end_time); - if (candidate.getMinutes() % 15 !== 0) { - candidate = roundToStrictlyFutureQuarter(candidate); - } - } - setStart(candidate.toISOString()); - setEnd( - new Date( - candidate.getTime() + ENV.DEFAULT_BOOKING_INTERVAL_MINUTES * 60000 - ).toISOString() - ); - } - ); - }, [open, isEdit, room_id, slotInfo]); - const [invitees, setInvitees] = useState([]); - const [availableInvitees, setAvailableInvitees] = - useState(allInvitees); - const [loadingInvitees, setLoadingInvitees] = useState(false); - // Get current room object and capacity + // Room selection + const initialRoomId = useMemo(() => { + if (isEdit && editBooking) return getEditBookingRoomId(editBooking); + if (slotInfo?.room_id) return String(slotInfo.room_id); + if (rooms.length > 0) return String((rooms[0] as ConferenceRoom).id); + return ""; + }, [isEdit, editBooking, slotInfo, rooms]); + const [room_id, setRoomId] = useState(initialRoomId); const currentRoom = rooms.find( (r: ConferenceRoom) => String(r.id) === String(room_id) ); const roomCapacity = currentRoom?.capacity || Infinity; - const remainingSlots = roomCapacity - invitees.length; - // Track which rooms are unavailable due to conflicts - const [conflictingRoomIds, setConflictingRoomIds] = useState([]); + // Title + const [title, setTitle] = useState(isEdit ? editBooking?.title || "" : ""); - // Fetch conflicting rooms for the selected time - useEffect(() => { - if (!start || !end) { - setConflictingRoomIds([]); - return; - } - // For each room, check if it has a conflicting booking - const fetchConflicts = async () => { - const conflicts: string[] = []; - const currentBookingRoomId = isEdit - ? getEditBookingRoomId(editBooking) - : null; - await Promise.all( - rooms.map(async (room: ConferenceRoom) => { - try { - const date = new Date(start).toISOString().slice(0, 10); - const bookings = await getRoomBookings(room.id, date); - const startTime = new Date(start).toISOString(); - const endTime = new Date(end).toISOString(); - const hasConflict = bookings.some((b: Booking) => { - if (isEdit && b.id === editBooking?.id) return false; - return startTime < b.end_time && endTime > b.start_time; - }); - if ( - hasConflict && - (!isEdit || String(room.id) !== String(currentBookingRoomId)) && - !(!isEdit && String(room.id) === String(room_id)) - ) { - conflicts.push(String(room.id)); - } - } catch (e) { - // Ignore errors, treat as available - } - }) + // Invitees + const [invitees, setInvitees] = useState( + isEdit ? editBooking?.invitees ?? [] : [] + ); + // Sync invitees state with editBooking.invitees whenever editBooking changes + React.useEffect(() => { + if (isEdit && editBooking) { + setInvitees( + Array.isArray(editBooking.invitees) ? editBooking.invitees : [] ); - setConflictingRoomIds(conflicts); - }; - fetchConflicts(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [start, end, rooms, isEdit, editBooking?.id, setStart, setEnd]); - - // Fetch invitees for editBooking - useEffect(() => { - if (isEdit && editBooking?.id) { - getInvitees(editBooking.id) - .then((inviteesData) => { - setInvitees( - inviteesData.map((i: { user_email: string }) => i.user_email) - ); - }) - .catch(() => setInvitees([])); } }, [isEdit, editBooking]); - // Fetch available invitees from backend when room/start/end changes - useEffect(() => { - if (!room_id || !start || !end) { - setAvailableInvitees([]); - return; - } - setLoadingInvitees(true); - // If editing, pass exclude_booking_id to allow current invitees - const params = { - start_time: new Date(start).toISOString(), - end_time: new Date(end).toISOString(), - }; - if (isEdit && editBooking?.id) { - (params as Record).exclude_booking_id = editBooking.id; - } - getAvailableUsers(params) - .then((users) => { - const emails = users.map( - (u: import("../interfaces").User) => u.email ?? "" - ); - setAvailableInvitees(Array.from(new Set([...emails, ...invitees]))); - }) - .catch(() => - setAvailableInvitees(invitees.length > 0 ? [...invitees] : []) - ) - .finally(() => setLoadingInvitees(false)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [room_id, start, end, isEdit, editBooking?.id]); + // Start/end time logic + const findNextAvailableStart = useCallback( + (roomId: string | number, after: Date): Date => { + let candidate = roundToStrictlyFutureQuarter(after); + const roomBookings = bookings.filter( + (b: Booking) => String(b.room_id) === String(roomId) + ); + const getNextCandidate = (prev: Date) => + new Date(prev.getTime() + ENV.DEFAULT_BOOKING_INTERVAL_MINUTES * 60000); + for (let i = 0; i < 96; i++) { + const candidateEnd = getNextCandidate(candidate); + const currentCandidate = candidate; + const currentCandidateEnd = candidateEnd; + const overlaps = roomBookings.some(function (b) { + if (isEdit && b.id === editBooking?.id) return false; + return ( + currentCandidate < new Date(b.end_time) && + currentCandidateEnd > new Date(b.start_time) + ); + }); + if (!overlaps && candidate > now) return candidate; + candidate = candidateEnd; + } + return candidate; + }, + [bookings, isEdit, editBooking, now] + ); - // ...existing code... + const initialStart = useMemo(() => { + if (isEdit && editBooking?.start_time) + return new Date(editBooking.start_time); + if (slotInfo?.start) + return findNextAvailableStart(room_id, new Date(slotInfo.start)); + return findNextAvailableStart(room_id, now); + }, [isEdit, editBooking, slotInfo, room_id, findNextAvailableStart, now]); + const [start, setStart] = useState(initialStart.toISOString()); + // End time + const [customInterval, setCustomInterval] = useState(null); + const initialEnd = useMemo(() => { + if (isEdit && editBooking?.end_time) return new Date(editBooking.end_time); + return new Date( + initialStart.getTime() + ENV.DEFAULT_BOOKING_INTERVAL_MINUTES * 60000 + ); + }, [isEdit, editBooking, initialStart]); + const [end, setEnd] = useState(initialEnd.toISOString()); + + // Error state const [errors, setErrors] = useState<{ [key: string]: string }>({}); - const [submitError, setSubmitError] = useState(null); - // Independent field validation + // Validation function validateFields() { - if (isViewMode) { - setErrors({}); - return true; - } - // ...existing code... const newErrors: { [key: string]: string } = {}; - // Room ID + // Room const roomIdError = validateRoomId(room_id); if (roomIdError) newErrors.room_id = roomIdError; // Start @@ -340,197 +185,110 @@ const BookingForm: React.FC = ({ // End const endError = validateEnd(end, start); if (endError) newErrors.end = endError; + // Overlap + const roomBookings = bookings.filter( + (b: Booking) => String(b.room_id) === String(room_id) + ); + 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."; + } // Invitees const inviteesError = validateInvitees(invitees, roomCapacity); if (inviteesError) newErrors.invitees = inviteesError; - // Individual invitee emails - let hasInviteeError = false; - invitees.forEach((email, idx) => { - const emailError = validateInviteeEmail(email); - if (emailError) { - newErrors[`invitee_${idx}`] = emailError; - hasInviteeError = true; - } - }); - if (hasInviteeError) { - newErrors.invitees = "One or more invitees have invalid email addresses."; - } - // Room availability - if (room_id && start && end && roomBookings.length > 0) { - const availabilityErrors = validateRoomAvailability( - room_id, - start, - end, - roomBookings, - isEdit, - editBooking ?? null - ); - if (availabilityErrors.roomError) - newErrors.room_id = availabilityErrors.roomError; - if (availabilityErrors.startError) - newErrors.start = availabilityErrors.startError; - if (availabilityErrors.endError) - newErrors.end = availabilityErrors.endError; - } setErrors(newErrors); - if (Object.keys(newErrors).length > 0) { - logger.warn("[BookingForm] Validation failed", newErrors); - } return Object.keys(newErrors).length === 0; } - // Real-time bookings state via SSE - const [roomBookings, setRoomBookings] = useState([]); - useEffect(() => { - const es = connectRoomsAvailabilityStream({ - onMessage: (data) => { - if (Array.isArray(data.bookings)) { - // Filter bookings for the selected room and date - const dateStr = start - ? new Date(start).toISOString().slice(0, 10) - : null; - const filtered = data.bookings.filter((b: Booking) => { - const bookingDate = b.start_time?.slice(0, 10); - return ( - String(b.room_id) === String(room_id) && - (!dateStr || bookingDate === dateStr) - ); - }); - setRoomBookings(filtered); - } - }, - onError: (err) => { - logger.error("[BookingForm] SSE connection error", err); - }, - }); - return () => { - es.close(); - }; - }, [room_id, start]); + // UI logic for disabled states + const remainingSlots = roomCapacity - invitees.length; + const isFormError = !isViewMode && Object.keys(errors).length > 0; - // Move this effect after roomBookings is defined - useEffect(() => { + // Handlers + function handleStartChange(value: string) { + setStart(value); + let interval = customInterval ?? ENV.DEFAULT_BOOKING_INTERVAL_MINUTES; + const newEnd = new Date(new Date(value).getTime() + interval * 60000); + setEnd(newEnd.toISOString()); validateFields(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [room_id, invitees, start, end, roomBookings]); + } + function handleEndChange(value: string) { + setEnd(value); + const interval = + (new Date(value).getTime() - new Date(start).getTime()) / 60000; + if (interval > 0) setCustomInterval(interval); + validateFields(); + } + function handleInviteeChange(event: SelectChangeEvent) { + const value = event.target.value; + setInvitees( + typeof value === "string" ? value.split(",") : (value as string[]) + ); + validateFields(); + } + function handleRoomChange(event: SelectChangeEvent) { + setRoomId(String(event.target.value)); + validateFields(); + } - const handleSubmit = async (e: React.FormEvent) => { + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (isViewMode) { onClose(); return; } - if (submitting) return; - setSubmitting(true); - setSubmitError(null); - if (!validateFields()) { - setSubmitting(false); - return; - } - // ...existing code... + if (!validateFields()) return; try { - // Prepare payload for booking creation - const payload = { - room_id, - start_time: new Date(start).toISOString(), - end_time: new Date(end).toISOString(), - title, - }; - // Call createBooking API - const result = await import("../apis/bookings").then((mod) => - mod.createBooking(payload) - ); - logger.info("[BookingForm] Booking created", result); - if (onBookingSuccess) { - onBookingSuccess(); - } else { - onClose(); + let bookingResult; + if (!isEdit) { + bookingResult = await createBooking({ + room_id, + start_time: start, + end_time: end, + title, + invitees, + }); + } else if (editBooking) { + const updatePayload = { + room_id, + start_time: start, + end_time: end, + title, + invitees, + }; + // Log payload for debugging + if (window && window.console) { + console.info("[BookingForm] updateBooking payload:", updatePayload); + } + bookingResult = await updateBooking(editBooking.id, updatePayload); } - } catch (err: unknown) { - let detail = ""; - if (typeof err === "object" && err !== null && "response" in err) { - // @ts-ignore - detail = err.response?.data?.detail || err.message; - } else if (typeof err === "object" && err !== null && "message" in err) { - // @ts-ignore - detail = err.message; + if (onBookingSuccess && bookingResult) onBookingSuccess(bookingResult); + onClose(); + } catch (err: any) { + let errorMsg = "Failed to save booking. Please try again."; + if (err?.response?.data?.detail) { + errorMsg = err.response.data.detail; + } else if (err?.response?.data?.message) { + errorMsg = err.response.data.message; + } else if (err?.message) { + errorMsg = err.message; } - logger.error("[BookingForm] Booking create error", detail); - setSubmitError(detail || "Failed to create booking."); - } finally { - setSubmitting(false); + // Log error for debugging + logger.error("BookingForm: Error saving booking", err); + setErrors({ submit: errorMsg }); } - }; - - // ...existing code... - - const handleDelete = async () => { - if (!isEdit) return; - if (!window.confirm("Delete this booking?")) return; - try { - logger.info("[BookingForm] Deleting booking", { - id: editBooking?.id, - editBooking, - }); - if (!editBooking?.id) { - setSubmitError("No booking ID provided for deletion."); - setSubmitting(false); - return; - } - setSubmitting(true); - await deleteBooking(editBooking.id); - setSubmitting(false); - // Booking deleted successfully - if (onBookingSuccess) { - onBookingSuccess(); - } else { - onClose(); - } - } catch (err: unknown) { - setSubmitting(false); - let detail = ""; - if (typeof err === "object" && err !== null && "response" in err) { - // @ts-ignore - detail = err.response?.data?.detail || err.message; - } else if (typeof err === "object" && err !== null && "message" in err) { - // @ts-ignore - detail = err.message; - } - logger.error("[BookingForm] Booking delete error", detail); - setSubmitError(detail || "Failed to delete booking."); - } - }; - - const handleRoomChange = (e: SelectChangeEvent) => { - userChangedRoom.current = true; - setRoomId(String(e.target.value)); - }; + } + // UI rendering return ( - { - if (reason === "backdropClick" || reason === "escapeKeyDown") onClose(); - }} - maxWidth="xs" - fullWidth - > - {submitting && ( - - - - )} + {isViewMode ? "View Booking" @@ -538,110 +296,88 @@ const BookingForm: React.FC = ({ ? "Update Booking" : "New Booking"} + {errors.submit && ( + + {errors.submit} + + )}
- {submitError && ( - - {submitError} - - )} + {/* Room selection */} - - Room - setRoomModalOpen(true)} - disabled={!room_id} - tabIndex={-1} - edge="end" - > - - - + Room - {errors.room_id || - (rooms.length === 0 ? "Room is required." : "")} + {errors.room_id} - setRoomModalOpen(false)} - room={ - rooms.find( - (r: ConferenceRoom) => String(r.id) === String(room_id) - ) ?? rooms[0] - } - /> + {/* Title */} setTitle(e.target.value)} fullWidth margin="normal" - InputProps={{ readOnly: !!isViewMode }} - disabled={!!isViewMode} + InputProps={{ readOnly: isViewMode }} + disabled={isViewMode} /> + {/* Start time */} = ({ if (!start) return ""; const d = new Date(start); if (isNaN(d.getTime())) return ""; - // Format as YYYY-MM-DDTHH:mm (local time) const pad = (n: number) => n.toString().padStart(2, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad( d.getDate() )}T${pad(d.getHours())}:${pad(d.getMinutes())}`; })()} - onChange={(e) => { - userChangedStart.current = true; - const value = e.target.value; - if (value) { - setStart(new Date(value).toISOString()); - const localDate = new Date(value); - const newEnd = new Date( - localDate.getTime() + - ENV.DEFAULT_BOOKING_INTERVAL_MINUTES * 60000 - ); - setEnd(newEnd.toISOString()); - } else { - setStart(""); - setEnd(""); - } - }} + onChange={(e) => handleStartChange(e.target.value)} error={!!errors.start} - helperText={""} fullWidth margin="normal" required - InputProps={{ readOnly: !!isViewMode }} - disabled={!!isViewMode} + InputProps={{ readOnly: isViewMode }} + disabled={isViewMode} /> - {/* Always render the error message area for start time, even if untouched */} {errors.start} + {/* End time */} = ({ if (!end) return ""; const d = new Date(end); if (isNaN(d.getTime())) return ""; - // Format as YYYY-MM-DDTHH:mm (local time) const pad = (n: number) => n.toString().padStart(2, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad( d.getDate() )}T${pad(d.getHours())}:${pad(d.getMinutes())}`; })()} - onChange={(e) => { - const value = e.target.value; - const date = new Date(value); - if (!isNaN(date.getTime())) { - setEnd(date.toISOString()); - } else { - setEnd(""); - } - }} + onChange={(e) => handleEndChange(e.target.value)} + error={!!errors.end} fullWidth margin="normal" required - error={!!errors.end} - InputProps={{ readOnly: !!isViewMode }} - disabled={!!isViewMode} + InputProps={{ readOnly: isViewMode }} + disabled={isViewMode} /> - {/* Always render the error message area for end time, even if untouched */} - {errors.end || (!end ? "End time is required." : "")} + {errors.end} + {/* Invitees */} Invitees - {/* Show invitee field error if any invitee error exists */} {errors.invitees && ( {errors.invitees} )} - {invitees.map( - (email, idx) => - errors[`invitee_${idx}`] && ( - - {errors[`invitee_${idx}`]} - - ) - )} - {!isViewMode && } + {!isViewMode && } {isEdit && !isViewMode && ( )} -
diff --git a/frontend/src/components/BookingList.tsx b/frontend/src/components/BookingList.tsx index 8e10f108..28c2a939 100644 --- a/frontend/src/components/BookingList.tsx +++ b/frontend/src/components/BookingList.tsx @@ -1,25 +1,24 @@ -/** - * BookingList component - * Lists bookings for a selected room or user, including times and invitees. - * Handles empty state and sorts bookings chronologically. - * @returns {JSX.Element} Booking list UI - */ +// Utility/helper imports +import { logger } from "../utils/logger"; // External imports import React from "react"; + +// MUI imports import { List, ListItemButton, Typography } from "@mui/material"; -// Internal imports -// import { COLORS } from "../constants"; +// Internal helper imports import { formatBookingTime, - getInviteeName, sortBookingsByStartTime, } from "../helpers/bookingList"; // Type-only imports import type { BookingListProps } from "../interfaces"; +// Context hook imports +import { useUsers } from "../context/UserContext"; + /** * BookingList component * Displays a list of bookings for a room or user, including times and invitees. @@ -29,73 +28,78 @@ import type { BookingListProps } from "../interfaces"; * @returns {JSX.Element} Booking list UI */ const BookingList: React.FC = ({ bookings, onSelect }) => { + // Context hook for users (must be called at top level) + const { users } = useUsers(); + + React.useEffect(() => { + logger.info("[BookingList] Mounted"); + return () => { + logger.info("[BookingList] Unmounted"); + }; + }, []); + + React.useEffect(() => { + logger.debug("[BookingList] bookings updated", bookings); + }, [bookings]); + if (!bookings || bookings.length === 0) { return ( - - + + No bookings found. ); } - // Sort bookings by start_time ascending (oldest first) const sortedBookings = sortBookingsByStartTime(bookings); + /** + * Maps invitee email to user name for display. + * Only shows invitee names (not emails). + * @param {string} email - Invitee email + * @returns {string} Display name + */ + const getInviteeDisplay = (email: string) => { + const user = users.find( + (u: import("../interfaces").User) => u.email === email + ); + return user ? user.name : email; + }; return ( {sortedBookings.map((booking) => { - // Format time as h:mm AM/PM - const inviteeNames = booking.invitees - ? booking.invitees.map(getInviteeName).filter(Boolean) - : []; + let inviteeNames: string[] = []; + if (Array.isArray(booking.invitees)) { + inviteeNames = booking.invitees.map(getInviteeDisplay); + } return ( onSelect && onSelect(booking)} + className="bookinglist-item" sx={{ flexDirection: "column", alignItems: "flex-start" }} > - + {booking.start_time && booking.end_time ? `${formatBookingTime( booking.start_time - )}  ${formatBookingTime(booking.end_time)}` + )} - ${formatBookingTime(booking.end_time)}` : "Time not set"} - {inviteeNames.length > 0 && ( - - Invitees: {inviteeNames.join(", ")} - - )} + + {inviteeNames.length > 0 + ? `Invitees: ${inviteeNames.join(", ")}` + : "No invitees"} + ); })} diff --git a/frontend/src/components/CalendarView.tsx b/frontend/src/components/CalendarView.tsx index 5504ad9f..9adbdca1 100644 --- a/frontend/src/components/CalendarView.tsx +++ b/frontend/src/components/CalendarView.tsx @@ -1,12 +1,3 @@ -/** - * CalendarView.tsx - * Renders calendar with events, tooltips, and slot selection for bookings. - * Used in booking page for visualizing room availability. - * - * Author: Cliff Hill - * Last updated: 2025-09-05 - */ - /** * CalendarView component * Renders a calendar with events, tooltips, and slot selection for bookings. @@ -16,19 +7,30 @@ * @returns {JSX.Element} Calendar UI */ -import React from "react"; +// Utility/helper imports +import { logger } from "../utils/logger"; + +// External imports +import React, { useEffect } from "react"; + +// MUI imports +import Tooltip from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; + +// Third-party calendar imports import FullCalendar from "@fullcalendar/react"; import dayGridPlugin from "@fullcalendar/daygrid"; import interactionPlugin from "@fullcalendar/interaction"; import timeGridPlugin from "@fullcalendar/timegrid"; -import Tooltip from "@mui/material/Tooltip"; -import Typography from "@mui/material/Typography"; -import { ENV } from "../constants"; -import { getEventDisplayText, getRoomClass } from "../helpers/calendar"; -import { logger } from "../utils/logger"; +// Styles import "../styles/CalendarView.css"; +// Internal helper imports +import { ENV } from "../constants"; +import { getEventDisplayText, getRoomClass } from "../helpers/calendar"; + +// Type-only imports import type { BookingEvent } from "../types"; import type { CalendarViewProps } from "../interfaces"; @@ -43,6 +45,16 @@ const CalendarView: React.FC = ({ selectedRoomId, initialDate, }) => { + useEffect(() => { + logger.info("[CalendarView] Mounted"); + return () => { + logger.info("[CalendarView] Unmounted"); + }; + }, []); + + useEffect(() => { + logger.debug("[CalendarView] events updated", events); + }, [events]); // Custom event content with tooltip const renderEventContent = (arg: { event: BookingEvent }) => { const { event } = arg; @@ -104,33 +116,8 @@ const CalendarView: React.FC = ({ } open={process.env.NODE_ENV === "test" ? true : undefined} > -
- - {displayText} - +
+ {displayText}
); @@ -149,7 +136,7 @@ const CalendarView: React.FC = ({ ]; // Diagnostic: log events passed to FullCalendar React.useEffect(() => { - logger.info("[CalendarView] Events passed to FullCalendar:", events); + // Diagnostic: events passed to FullCalendar }, [events]); return (
@@ -173,7 +160,9 @@ const CalendarView: React.FC = ({ info.jsEvent.preventDefault(); if (onEventClick) { // Pass the full event object - onEventClick(info.event as unknown as BookingEvent); + onEventClick( + info.event.extendedProps as import("../types").BookingEvent + ); } }} selectable={true} diff --git a/frontend/src/components/RoomDetailsModal.tsx b/frontend/src/components/RoomDetailsModal.tsx index 082789ea..1f36b528 100644 --- a/frontend/src/components/RoomDetailsModal.tsx +++ b/frontend/src/components/RoomDetailsModal.tsx @@ -1,23 +1,19 @@ /** - * RoomDetailsModal.tsx + * RoomDetailsModal component * Modal dialog for displaying details about a conference room. * Used in booking page for room info popups. - * - * Author: Cliff Hill - * Last updated: 2025-09-05 * @component + * @param {RoomDetailsModalProps} props - Component props * @returns {JSX.Element} Room details modal UI */ -/** - * RoomDetailsModal.tsx - * Modal dialog for displaying details about a conference room. - * Used in booking page for room info popups. - * - * Author: Cliff Hill - * Last updated: 2025-09-05 - */ -import React from "react"; +// Utility/helper imports +import { logger } from "../utils/logger"; + +// External imports +import React, { useEffect } from "react"; + +// MUI imports import { Box, Button, @@ -28,6 +24,7 @@ import { Typography, } from "@mui/material"; +// Type-only imports import type { RoomDetailsModalProps } from "../interfaces"; const RoomDetailsModal: React.FC = ({ @@ -35,6 +32,16 @@ const RoomDetailsModal: React.FC = ({ onClose, room, }) => { + useEffect(() => { + logger.info("[RoomDetailsModal] Mounted"); + return () => { + logger.info("[RoomDetailsModal] Unmounted"); + }; + }, []); + + useEffect(() => { + logger.debug("[RoomDetailsModal] room updated", room); + }, [room]); if (!room) return null; return ( diff --git a/frontend/src/components/RoomList.tsx b/frontend/src/components/RoomList.tsx index 1fcb69f2..ca2dc1b3 100644 --- a/frontend/src/components/RoomList.tsx +++ b/frontend/src/components/RoomList.tsx @@ -1,12 +1,11 @@ -/** - * RoomList component - * Displays a list of available conference rooms, highlighting the selected room. - * Used in the landing page for room selection. - * @returns {JSX.Element} Room list UI - */ +// Utility/helper imports +import { logger } from "../utils/logger"; -import React from "react"; +// External imports +import React, { useEffect } from "react"; import { FC } from "react"; + +// MUI imports import { Card, CardContent, @@ -17,10 +16,12 @@ import { ListItemButton, } from "@mui/material"; import MeetingRoomIcon from "@mui/icons-material/MeetingRoom"; -// import { COLORS } from "../constants"; -import { getRoomListItemStyles, getRoomSecondaryText } from "../helpers/room"; -import type { RoomListProps } from "../interfaces"; +// Internal helper imports +import { getRoomListItemStyles, getRoomSecondaryText } from "../helpers/room"; + +// Type-only imports +import type { RoomListProps } from "../interfaces"; /** * RoomList component * Displays a list of available conference rooms, highlighting the selected room. @@ -34,6 +35,16 @@ const RoomList: FC = ({ selectedRoomId, onSelectRoom, }) => { + useEffect(() => { + logger.info("[RoomList] Mounted"); + return () => { + logger.info("[RoomList] Unmounted"); + }; + }, []); + + useEffect(() => { + logger.debug("[RoomList] rooms updated", rooms); + }, [rooms]); return ( = ({ sx={getRoomListItemStyles(room.id, selectedRoomId)} > + {room.name} + + } secondary={getRoomSecondaryText(room)} /> diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index 2e8346ef..d2af0fc2 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -1,5 +1,44 @@ /** - * Utility to get a CSS variable value from :root, with fallback. + * constants.ts + * Centralized constants for environment variables, colors, and time strings. + * @module + */ + +// ...no imports in this file... + +/** + * ENV + * Centralized environment variables for frontend configuration. + * All environment variables used in the system should be defined here and referenced throughout the frontend. + * @constant + * @type {Object} + * @property {string} BACKEND_PROTOCOL - Backend protocol (e.g., 'http') + * @property {string} BACKEND_HOST - Backend host (e.g., 'localhost') + * @property {string} BACKEND_PORT - Backend port (e.g., '8000') + * @property {string} FRONTEND_LOG_LEVEL - Log level for frontend logging + * @property {string} NODE_ENV - Node environment (e.g., 'development', 'production') + * @property {number} DEFAULT_BOOKING_INTERVAL_MINUTES - Default booking interval in minutes + * @property {string} BUSINESS_START - Business start time (e.g., '08:00') + * @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.NODE_ENV || "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", +}; + +/** + * Gets a CSS variable value from :root, with fallback if not found. + * @function getCssVar + * @param {string} name - The CSS variable name (e.g., '--color-primary'). + * @param {string} fallback - The fallback value to use if the variable is not set. + * @returns {string} The CSS variable value, or the fallback if not found. */ function getCssVar(name: string, fallback: string): string { if (typeof window === "undefined" || !document.documentElement) @@ -11,9 +50,23 @@ function getCssVar(name: string, fallback: string): string { } /** - * Get latest color values from CSS variables, with fallback to defaults. + * Gets the latest color values from CSS variables, with fallback to defaults. + * @function getColors + * @returns {Object} An object containing color values for the theme. + * @property {string} primary - Primary color + * @property {string} secondary - Secondary color + * @property {string} accent - Accent color + * @property {string} error - Error color + * @property {string} warning - Warning color + * @property {string} info - Info color + * @property {string} success - Success color + * @property {string} background - Background color + * @property {string} textPrimary - Primary text color + * @property {string} textSecondary - Secondary text color + * @property {string} white - White color + * @property {string} disabled - Disabled color */ -export function getColors() { +export function getColors(): Record { return { primary: getCssVar("--color-primary", "#1976d2"), secondary: getCssVar("--color-secondary", "#388e3c"), @@ -29,41 +82,23 @@ export function getColors() { disabled: getCssVar("--color-disabled", "#bdbdbd"), }; } -/** - * Centralized constants for environment variables, colors, and time strings. - * @file constants.ts - */ - -/** - * Environment variables for frontend configuration. - * Values are loaded from process.env with sensible defaults. - * @type {{ - * BACKEND_PROTOCOL: string, - * BACKEND_HOST: string, - * BACKEND_PORT: string, - * FRONTEND_LOG_LEVEL: string, - * NODE_ENV: string, - * DEFAULT_BOOKING_INTERVAL_MINUTES: number, - * BUSINESS_START: string, - * BUSINESS_END: string - * }} - */ -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.NODE_ENV || "development", - DEFAULT_BOOKING_INTERVAL_MINUTES: parseInt( - process.env.DEFAULT_BOOKING_INTERVAL_MINUTES || "30", - 10 - ), - BUSINESS_START: process.env.BUSINESS_START || "08:00", - BUSINESS_END: process.env.BUSINESS_END || "18:00", -}; /** * Centralized color constants for MUI theme and JS usage. + * @constant + * @type {Object} + * @property {string} primary - Primary color + * @property {string} secondary - Secondary color + * @property {string} accent - Accent color + * @property {string} error - Error color + * @property {string} warning - Warning color + * @property {string} info - Info color + * @property {string} success - Success color + * @property {string} background - Background color + * @property {string} textPrimary - Primary text color + * @property {string} textSecondary - Secondary text color + * @property {string} white - White color + * @property {string} disabled - Disabled color */ export const COLORS = { primary: "#1976d2", diff --git a/frontend/src/context/BookingContext.tsx b/frontend/src/context/BookingContext.tsx new file mode 100644 index 00000000..62d53859 --- /dev/null +++ b/frontend/src/context/BookingContext.tsx @@ -0,0 +1,185 @@ +/** + * BookingContext + * Provides booking data and fetchMonth function for conference room bookings. + * @context + */ +// External imports +import React, { + createContext, + useContext, + useEffect, + useState, + ReactNode, +} from "react"; + +// API imports +import { getMonthBookings } from "../apis/bookings"; +import { connectRoomsAvailabilityStream } from "../apis/sse"; + +// Type-only imports +import type { Booking } from "../interfaces"; + +interface BookingContextType { + bookings: Booking[]; + fetchMonth: (month: string) => Promise; +} + +const BookingContext = createContext(undefined); + +/** + * BookingProvider + * Provides booking context to child components. + * @param {ReactNode} children - Child components + * @returns {JSX.Element} Booking context provider + */ +export const BookingProvider = ({ children }: { children: ReactNode }) => { + // Fetch bookings for a given month and merge into state + const fetchMonth = async (month: string) => { + try { + const newBookings = await getMonthBookings(month); + setBookingsByMonth((prev) => ({ ...prev, [month]: newBookings })); + } catch (err) { + // Optionally handle error + } + }; + // Store bookings by month (YYYY-MM => Booking[]) + const [bookingsByMonth, setBookingsByMonth] = useState<{ + [month: string]: Booking[]; + }>({}); + // Flat array for UI filtering + const bookings = Object.values(bookingsByMonth).flat(); + + // Initial load: fetch all bookings for all rooms for today + useEffect(() => { + // On mount, fetch current month + const now = new Date(); + const yyyy = now.getFullYear(); + const mm = String(now.getMonth() + 1).padStart(2, "0"); + const monthStr = `${yyyy}-${mm}`; + fetchMonth(monthStr); + }, []); + + // SSE subscription for live updates + useEffect(() => { + // SSE connecting + const es = connectRoomsAvailabilityStream({ + onMessage: (data) => { + // SSE raw message + // Handle array of bookings + if (Array.isArray(data.bookings)) { + const bookingsByMonthFromSSE: { [month: string]: Booking[] } = {}; + data.bookings.forEach((b: Booking) => { + const d = new Date(b.start_time); + const month = `${d.getFullYear()}-${String( + d.getMonth() + 1 + ).padStart(2, "0")}`; + if (!bookingsByMonthFromSSE[month]) + bookingsByMonthFromSSE[month] = []; + bookingsByMonthFromSSE[month].push(b); + }); + // SSE received bookings + setBookingsByMonth((prev) => { + const updated = { ...prev }; + Object.entries(bookingsByMonthFromSSE).forEach( + ([month, sseBookings]) => { + updated[month] = [...sseBookings]; + // Updated month + } + ); + return { ...updated }; + }); + } + // Handle single booking event (created/updated/deleted) + // If payload is a single booking event, not a full bookings list + else if ( + typeof data === "object" && + "action" in data && + "booking_id" in data && + "start_time" in data && + "end_time" in data && + "room_id" in data + ) { + const singleData = data as unknown as { + action: "created" | "updated" | "deleted"; + booking_id: string | number; + room_id: string | number; + start_time: string; + end_time: string; + title?: string; + invitees?: string[]; + }; + const booking: Booking = { + id: String(singleData.booking_id), + room_id: + typeof singleData.room_id === "number" || + typeof singleData.room_id === "string" + ? singleData.room_id + : String(singleData.room_id), + start_time: String(singleData.start_time), + end_time: String(singleData.end_time), + title: + typeof singleData.title === "string" + ? singleData.title + : undefined, + invitees: Array.isArray(singleData.invitees) + ? singleData.invitees.map(String) + : [], // Always set to array + // ...existing code... + }; + const d = new Date(booking.start_time); + const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart( + 2, + "0" + )}`; + setBookingsByMonth((prev) => { + const updated = { ...prev }; + let monthBookings = updated[month] ? [...updated[month]] : []; + if ( + singleData.action === "created" || + singleData.action === "updated" + ) { + // Remove any existing booking with same id, then add new/updated booking + monthBookings = monthBookings.filter( + (b) => String(b.id) !== String(booking.id) + ); + monthBookings.push(booking); + // Created/updated booking for month + } else if (singleData.action === "deleted") { + // Remove booking with matching id + monthBookings = monthBookings.filter( + (b) => String(b.id) !== String(booking.id) + ); + // Deleted booking for month + } + updated[month] = monthBookings; + return { ...updated }; + }); + } + }, + onError: (err) => { + // Optionally handle SSE errors + // SSE error + }, + }); + return () => { + // SSE disconnecting + es.close(); + }; + }, []); + + return ( + + {children} + + ); +}; + +export function useBookings() { + const context = useContext(BookingContext); + if (!context) { + throw new Error("useBookings must be used within a BookingProvider"); + } + return context; +} + +// Hook to get invitees for a booking using context diff --git a/frontend/src/context/RoomContext.tsx b/frontend/src/context/RoomContext.tsx new file mode 100644 index 00000000..16e6c689 --- /dev/null +++ b/frontend/src/context/RoomContext.tsx @@ -0,0 +1,65 @@ +/** + * RoomContext + * Provides room data and loading/error state for conference rooms. + * @context + */ +// External imports +import React, { + createContext, + useContext, + useEffect, + useState, + ReactNode, +} from "react"; + +// API imports +import { getRooms } from "../apis/rooms"; + +// Type-only imports +import type { ConferenceRoom } from "../interfaces"; + +interface RoomContextType { + rooms: ConferenceRoom[]; + loading: boolean; + error: Error | null; +} + +const RoomContext = createContext(undefined); + +/** + * RoomProvider + * Provides room context to child components. + * @param {ReactNode} children - Child components + * @returns {JSX.Element} Room context provider + */ +export const RoomProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + const [rooms, setRooms] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + getRooms() + .then((data) => { + setRooms(data || []); + setLoading(false); + }) + .catch((err) => { + setError(err); + setLoading(false); + }); + }, []); + + return ( + + {children} + + ); +}; + +export const useRooms = () => { + const context = useContext(RoomContext); + if (!context) throw new Error("useRooms must be used within a RoomProvider"); + return context; +}; diff --git a/frontend/src/context/UserContext.tsx b/frontend/src/context/UserContext.tsx new file mode 100644 index 00000000..77b6596e --- /dev/null +++ b/frontend/src/context/UserContext.tsx @@ -0,0 +1,100 @@ +/** + * UserContext + * Provides user data and loading/error state for users. + * @context + */ +// External imports +import React, { + createContext, + useContext, + useEffect, + useState, + ReactNode, +} from "react"; + +// API imports +import { getUsers } from "../apis/users"; + +// Type-only imports +import type { User } from "../interfaces"; + +// Context imports +import { useBookings } from "./BookingContext"; + +/** + * useAvailableUsers + * Hook to get available users for a time slot using context. + * @param {Object} params - Parameters for filtering users + * @param {string} params.start_time - Start time + * @param {string} params.end_time - End time + * @param {string|number} [params.exclude_booking_id] - Booking ID to exclude + * @returns {User[]} Array of available users + */ +export function useAvailableUsers(params: { + start_time: string; + end_time: string; + exclude_booking_id?: string | number; +}): User[] { + const { users } = useUsers(); + const { bookings } = useBookings(); + const start = new Date(params.start_time).getTime(); + const end = new Date(params.end_time).getTime(); + const overlappingBookings = bookings.filter((b) => { + if (params.exclude_booking_id && b.id === params.exclude_booking_id) + return false; + const bStart = new Date(b.start_time).getTime(); + const bEnd = new Date(b.end_time).getTime(); + return bStart < end && bEnd > start; + }); + const busyInvitees = new Set(); + overlappingBookings.forEach((b) => { + (b.invitees || []).forEach((email) => busyInvitees.add(email)); + }); + return users.filter((u) => u.email && !busyInvitees.has(u.email)); +} + +interface UserContextType { + users: User[]; + loading: boolean; + error: Error | null; +} + +const UserContext = createContext(undefined); + +/** + * UserProvider + * Provides user context to child components. + * @param {ReactNode} children - Child components + * @returns {JSX.Element} User context provider + */ +export const UserProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + getUsers() + .then((data) => { + setUsers(data || []); + setLoading(false); + }) + .catch((err) => { + setError(err); + setLoading(false); + }); + }, []); + + return ( + + {children} + + ); +}; + +export const useUsers = () => { + const context = useContext(UserContext); + if (!context) throw new Error("useUsers must be used within a UserProvider"); + return context; +}; diff --git a/frontend/src/helpers/booking.ts b/frontend/src/helpers/booking.ts index 7d86fae1..bdebc036 100644 --- a/frontend/src/helpers/booking.ts +++ b/frontend/src/helpers/booking.ts @@ -9,6 +9,7 @@ // Minimal Booking type for utility functions +// Type-only imports import type { Booking } from "../interfaces"; /** diff --git a/frontend/src/helpers/bookingList.ts b/frontend/src/helpers/bookingList.ts index 50c6d57e..733c44c7 100644 --- a/frontend/src/helpers/bookingList.ts +++ b/frontend/src/helpers/bookingList.ts @@ -2,15 +2,14 @@ * bookingList.ts * Utility functions for BookingList component. * Provides helpers for sorting bookings and formatting invitee names. - * - * Author: Cliff Hill - * Last updated: 2025-09-05 + * @module */ /** * Represents a booking object with a start_time property. */ +// Type-only imports import type { BookingType } from "../types"; import type { Invitee } from "../interfaces"; diff --git a/frontend/src/helpers/calendar.ts b/frontend/src/helpers/calendar.ts index 087e47a7..95bd7579 100644 --- a/frontend/src/helpers/calendar.ts +++ b/frontend/src/helpers/calendar.ts @@ -2,11 +2,9 @@ * calendar.ts * Utility functions for CalendarView and booking system frontend. * Provides helpers for event display and room color assignment. - * - * Author: Cliff Hill - * Last updated: 2025-09-05 + * @module */ - +// ...no imports in this file... /** * Returns display text for a calendar event, including start time and title. * If room is provided, it can be appended or used for display logic. diff --git a/frontend/src/helpers/getRoomBookingsForDate.ts b/frontend/src/helpers/getRoomBookingsForDate.ts new file mode 100644 index 00000000..b9151f79 --- /dev/null +++ b/frontend/src/helpers/getRoomBookingsForDate.ts @@ -0,0 +1,6 @@ +/** + * getRoomBookingsForDate.ts + * Utility for fetching bookings for a specific room and date. + * (Intentionally left empty for future implementation.) + * @module + */ diff --git a/frontend/src/helpers/room.ts b/frontend/src/helpers/room.ts index e3bd8c62..9a6a4140 100644 --- a/frontend/src/helpers/room.ts +++ b/frontend/src/helpers/room.ts @@ -2,11 +2,10 @@ * room.ts * Utility functions for RoomList component. * Provides helpers for rendering room details and styles. - * - * Author: Cliff Hill - * Last updated: 2025-09-05 + * @module */ +// Type-only imports import { ConferenceRoom } from "../interfaces"; import type { RoomListItemStyles } from "../interfaces"; diff --git a/frontend/src/helpers/validation.ts b/frontend/src/helpers/validation.ts index eb736991..64f7226a 100644 --- a/frontend/src/helpers/validation.ts +++ b/frontend/src/helpers/validation.ts @@ -2,11 +2,10 @@ * validation.ts * Independent field validation helpers for BookingForm and booking system frontend. * Provides functions for validating room, time, and invitee fields. - * - * Author: Cliff Hill - * Last updated: 2025-09-05 + * @module */ +// Type-only imports import type { Booking } from "../interfaces"; /** @@ -75,12 +74,6 @@ export function validateInvitees( * @param email - Email string * @returns Error message or null */ -export function validateInviteeEmail(email: string): string | null { - if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { - return `Invalid email: ${email}`; - } - return null; -} /** * Validates room availability for a given time range against existing bookings. diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 29b8f2ab..425cd39a 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -14,13 +14,14 @@ import { ThemeProvider } from "@mui/material/styles"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import App from "./App"; +import { logger } from "./utils/logger"; import reportWebVitals from "./reportWebVitals"; import theme from "./theme"; import "./index.css"; // Global error handler to catch unhandled errors and prevent reloads window.onerror = function (message, source, lineno, colno, error) { - console.error("Global error handler:", { + logger.error("Global error handler:", { message, source, lineno, @@ -32,7 +33,7 @@ window.onerror = function (message, source, lineno, colno, error) { }; window.onunhandledrejection = function (event) { - console.error("Global unhandledrejection:", event.reason); + logger.error("Global unhandledrejection:", event.reason); // Prevent default browser reload on unhandled promise rejection return true; }; @@ -44,7 +45,7 @@ const backendPort = process.env.BACKEND_PORT || "8000"; axios.defaults.baseURL = `${backendProtocol}://${backendHost}:${backendPort}`; const root = ReactDOM.createRoot( - document.getElementById("root") as HTMLElement + document.getElementById("root") as HTMLDivElement ); root.render( diff --git a/frontend/src/interfaces.ts b/frontend/src/interfaces.ts index aa2f61c6..fb9cc83c 100644 --- a/frontend/src/interfaces.ts +++ b/frontend/src/interfaces.ts @@ -1,10 +1,9 @@ /** * interfaces.ts * Centralized TypeScript interfaces for frontend application. - * Author: Cliff Hill - * Last updated: 2025-09-09 + * @module */ - +// ...no imports in this file... /** * Information about a calendar slot selection, used for booking forms and calendar interactions. * @property start - Start time of the slot @@ -86,7 +85,7 @@ export interface RoomListItemStyles { */ export interface RoomsAvailabilitySSEPayload { bookings: Booking[]; - [key: string]: unknown; + // Add more specific properties if needed, otherwise remove index signature for strict typing } export interface User { id: string | number; diff --git a/frontend/src/pages/BookingPage.tsx b/frontend/src/pages/BookingPage.tsx index 184c2768..5e936702 100644 --- a/frontend/src/pages/BookingPage.tsx +++ b/frontend/src/pages/BookingPage.tsx @@ -8,8 +8,22 @@ * @component * @returns {JSX.Element} Booking page UI */ +/** + * BookingPage.tsx + * Page for booking a conference room, showing calendar and booking form. + * Handles room selection, booking creation, and SSE updates. + * + * Author: Cliff Hill + * Last updated: 2025-09-05 + * @component + * @returns {JSX.Element} Booking page UI + */ -import React, { useEffect, useRef, useState } from "react"; +// External imports +import React, { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +// MUI imports import { Box, Button, @@ -18,17 +32,25 @@ import { MenuItem, Select, } from "@mui/material"; -import { useQuery } from "@tanstack/react-query"; +// Styles +import "../styles/BookingPage.css"; + +// Internal component imports import BookingForm from "../components/BookingForm"; import CalendarView from "../components/CalendarView"; import RoomDetailsModal from "../components/RoomDetailsModal"; -import { connectRoomsAvailabilityStream } from "../apis/sse"; -import { getRoomBookings } from "../apis/bookings"; -import { getRooms } from "../apis/rooms"; -import { getUsers } from "../apis/users"; -import { logger } from "../utils/logger"; +// Context imports +import { useRooms } from "../context/RoomContext"; +import { useBookings } from "../context/BookingContext"; + +// Utility/helper imports +import { logger } from "../utils/logger"; +import { getRoomClass } from "../helpers/calendar"; +import { getInviteeName } from "../helpers/bookingList"; + +// Type-only imports import type { Booking, ConferenceRoom } from "../interfaces"; import type { BookingEvent } from "../types"; @@ -40,214 +62,92 @@ import type { BookingEvent } from "../types"; * @returns {JSX.Element} Booking page UI */ const BookingPage: React.FC = () => { + /** Get rooms from context */ + const { rooms } = useRooms(); const [formOpen, setFormOpen] = useState(false); const [roomModalOpen, setRoomModalOpen] = useState(false); - // Log mount/unmount for analytics - /** - * Track mount/unmount for analytics. - */ + /** Track mount/unmount for analytics */ useEffect(() => { logger.info("[BookingPage] Mounted"); - return () => logger.info("[BookingPage] Unmounted"); - }, []); - // Fetch all rooms - /** - * Fetch all rooms from backend. - */ - const { - data: rooms = [], - isLoading: roomsLoading, - error: roomsError, - }: { - data?: ConferenceRoom[]; - isLoading: boolean; - error?: unknown; - } = useQuery({ - queryKey: ["rooms"], - queryFn: () => getRooms(), - }); - - // Real-time bookings state (by room) - /** - * Real-time bookings state, grouped by room id. - */ - const [bookingsByRoom, setBookingsByRoom] = useState< - Record - >({}); - - // Fetch all users (invitees) - /** - * Fetch all users (invitees). - */ - const { - data: allInvitees = [], - isLoading: inviteesLoading, - error: inviteesError, - }: { - data?: import("../interfaces").User[]; - isLoading: boolean; - error?: unknown; - } = useQuery({ - queryKey: ["users"], - queryFn: () => getUsers(), - }); - const [bookingsLoading, setBookingsLoading] = useState(true); - const [bookingsError, setBookingsError] = useState(null); - const eventSourceRef = useRef(null); - const [usingPollingFallback, setUsingPollingFallback] = useState(false); - - // Fetch initial bookings and subscribe to SSE - /** - * Fetch initial bookings and subscribe to SSE or polling fallback. - */ - useEffect(() => { - let cancelled = false; - let pollInterval: NodeJS.Timeout | null = null; - /** - * Fetch bookings for all rooms and update state. - */ - async function fetchBookingsAndSet() { - setBookingsLoading(true); - setBookingsError(null); - try { - const results: Record = {}; - await Promise.all( - rooms.map(async (room: ConferenceRoom) => { - try { - const bookings: Booking[] = await getRoomBookings(room.id); - results[room.id] = bookings; - logger.debug( - `[BookingPage] Bookings fetched for room ${room.id}`, - bookings - ); - } catch (e) { - results[room.id] = []; - logger.warn( - `[BookingPage] Failed to fetch bookings for room ${room.id}` - ); - } - }) - ); - if (!cancelled) setBookingsByRoom(results); - logger.info("[BookingPage] All bookings fetched and set"); - } catch (err) { - if (!cancelled) setBookingsError(err); - logger.error("[BookingPage] Error fetching bookings", err); - } finally { - if (!cancelled) setBookingsLoading(false); - } - } - if (rooms.length > 0) { - fetchBookingsAndSet(); - logger.info("[BookingPage] Fetching bookings for all rooms"); - // Use SSE if available, otherwise fallback to polling - const sseAllowed = - typeof window !== "undefined" && - typeof window.EventSource !== "undefined"; - if (sseAllowed) { - setUsingPollingFallback(false); - if (eventSourceRef.current) eventSourceRef.current.close(); - eventSourceRef.current = connectRoomsAvailabilityStream({ - onMessage: (data) => { - if (Array.isArray(data.bookings)) { - const grouped: Record = {}; - data.bookings.forEach((b: Booking) => { - if (!grouped[b.room_id as number]) - grouped[b.room_id as number] = []; - grouped[b.room_id as number].push(b); - }); - setBookingsByRoom(grouped); - logger.debug("[BookingPage] SSE update received", grouped); - } - }, - onError: (err) => { - logger.error("[BookingPage] SSE connection error", err); - }, - }); - } else { - setUsingPollingFallback(true); - logger.warn("[BookingPage] SSE not supported, falling back to polling"); - // Poll every 10 seconds if SSE not available - pollInterval = setInterval(() => { - logger.info("[BookingPage] Polling for bookings update"); - fetchBookingsAndSet(); - }, 10000); - } - } return () => { - cancelled = true; - if (eventSourceRef.current) eventSourceRef.current.close(); - if (pollInterval) clearInterval(pollInterval); + logger.info("[BookingPage] Unmounted"); }; - }, [rooms, formOpen, roomModalOpen]); + }, []); + /** Get rooms from context (should be provided via context or props) */ - // Removed unused roomColors variable + /** Unified bookings state from context */ + const { bookings } = useBookings(); - // Combine all bookings into calendar events, sorted chronologically (room id tiebreaker), with color and formatted title - // Expose allBookings for event lookup + /** Get users from context */ + + /** Fetch initial bookings and subscribe to SSE or polling fallback */ + + // ...removed unused roomColors variable + + /** + * Combine all bookings into calendar events, sorted chronologically (room id tiebreaker), with color and formatted title. + * Expose allBookings for event lookup. + */ /** * Flatten all bookings from all rooms into a single array. */ const allBookings: Booking[] = React.useMemo(() => { - if (!bookingsByRoom || rooms.length === 0) return []; - return Object.entries(bookingsByRoom).flatMap( - ([roomId, bookings]) => bookings as Booking[] - ); - }, [bookingsByRoom, rooms]); + if (!bookings) return []; + return bookings; + }, [bookings]); /** * Map all bookings to calendar event objects for display. */ const events = React.useMemo(() => { - if (!bookingsByRoom || rooms.length === 0) return []; - const mappedEvents = Object.entries(bookingsByRoom).flatMap( - ([roomId, bookings]) => - (bookings as Booking[]).map((booking) => { - logger.info("[BookingPage] Event mapping diagnostic", { - bookingId: booking.id, - bookingRoomId: booking.room_id, - roomIdFromGroup: roomId, - }); - // Normalize roomId for lookup - const normalizedRoomId = String(roomId); - const room = rooms.find( - (r: ConferenceRoom) => String(r.id) === normalizedRoomId - ); - let roomName = room?.name; - if (!roomName || roomName.trim() === "") { - if ( - typeof booking.room?.name === "string" && - 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 { - roomName = "Room"; - } - } - const inviteeNames = booking.invitees ?? []; - const start = new Date(booking.start_time); - const end = new Date(booking.end_time); - return { - id: booking.id, - title: booking.title ?? "", - start: start ?? new Date(booking.start_time), - end: end ?? new Date(booking.end_time), - room_id: booking.room_id, - room_name: roomName, - invitees: inviteeNames, - originalBooking: booking, - }; - }) - ); + if (!bookings || rooms.length === 0) return []; + const mappedEvents = bookings.map((booking: Booking) => { + const room = rooms.find( + (r: ConferenceRoom) => String(r.id) === String(booking.room_id) + ); + let roomName = room?.name; + if (!roomName || roomName.trim() === "") { + if ( + typeof booking.room?.name === "string" && + 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 { + roomName = "Room"; + } + } + // Map invitees to display names for tooltip + const inviteeNames = Array.isArray(booking.invitees) + ? booking.invitees.map(getInviteeName).filter(Boolean) + : []; + 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) + ); + return { + id: booking.id, + title: booking.title ?? "", + start: start ?? new Date(booking.start_time), + end: end ?? new Date(booking.end_time), + room_id: booking.room_id, + room_name: roomName, + invitees: inviteeNames, + originalBooking: booking, + classNames: [roomClass, "calendar-event"], + }; + }); return mappedEvents; - }, [bookingsByRoom, rooms]); + }, [bookings, rooms]); // Removed duplicate and broken code block interface SlotInfo { @@ -257,28 +157,27 @@ const BookingPage: React.FC = () => { } const [formSlot, setFormSlot] = useState(null); const [editBooking, setEditBooking] = useState(null); - // Default to first room, always require a room to be selected + /** Default to first room, always require a room to be selected */ const [selectedRoomId, setSelectedRoomId] = useState(""); - // Set default room when rooms load + /** Set default room when rooms load */ useEffect(() => { + logger.debug("[BookingPage] Rooms updated", rooms); if (rooms.length > 0 && !selectedRoomId) { setSelectedRoomId(rooms[0].id); } }, [rooms, selectedRoomId]); - // Handle slot selection from calendar (month/week/day) + /** + * Handle slot selection from calendar (month/week/day) + * @param slotInfo - Slot information from calendar + */ const handleSelectSlot = ( slotInfo: SlotInfo & { viewType?: string; start?: Date | string } ) => { - logger.debug( - "[BookingPage] Slot selected", - slotInfo, - "selectedRoomId:", - selectedRoomId - ); + // Slot selected if (!selectedRoomId) { - logger.warn("[BookingPage] No room selected, cannot open booking form."); + // No room selected, cannot open booking form return; } // slotInfo: { start, end, allDay, viewType } @@ -286,10 +185,8 @@ const BookingPage: React.FC = () => { let end = new Date(start.getTime() + 30 * 60000); if (slotInfo.viewType === "month") { // Debug: log the current time, selected day, and initial candidate - // (logger.debug moved below variable initialization) // 1. Use selected day, but round to nearest quarter-hour of current time (all in user's local timezone) - // All Date objects in JS are in the user's local timezone unless constructed with a UTC string. const now = new Date(); // Always construct selectedDay in local time using year/month/day from the selected date let baseDate = slotInfo.start ? new Date(slotInfo.start) : new Date(); @@ -312,12 +209,6 @@ const BookingPage: React.FC = () => { 0 ); // Debug: log the current time, selected day, and initial candidate - logger.debug("[BookingPage] Timezone debug", { - now_string: now.toString(), - now_locale: now.toLocaleString(), - selectedDay_string: selectedDay.toString(), - selectedDay_locale: selectedDay.toLocaleString(), - }); // Clamp to office hours (local time) const officeStart = 8; const officeEnd = 18; @@ -327,7 +218,7 @@ const BookingPage: React.FC = () => { selectedDay.setHours(officeEnd - 1, 45, 0, 0); // 2. Find all bookings for this room on this day - const roomBookings = events.filter((e) => { + const roomBookings = events.filter((e: BookingEvent) => { if (e.room_id !== selectedRoomId) return false; const eventStart = e.start && @@ -342,7 +233,7 @@ const BookingPage: React.FC = () => { }); // Build an array of [start, end] for each booking, skip if missing const bookingBlocks = roomBookings - .map((b) => { + .map((b: BookingEvent) => { // Only accept string | number | Date for start/end const validStart = typeof b.start === "string" || @@ -357,7 +248,10 @@ const BookingPage: React.FC = () => { const e = new Date(b.end as string | number | Date); return [s.getTime(), e.getTime()]; }) - .filter((block) => block !== undefined) as [number, number][]; + .filter( + (block): block is [number, number] => + Array.isArray(block) && block.length === 2 + ); // 3. Search for first available 30-min slot in 15-min increments let found = false; @@ -366,9 +260,7 @@ const BookingPage: React.FC = () => { const searchEnd = new Date(selectedDay); searchEnd.setHours(officeEnd, 0, 0, 0); while (candidate.getTime() + 30 * 60000 <= searchEnd.getTime()) { - logger.debug("[BookingPage] Checking candidate slot", { - candidate: candidate.toString(), - }); + // Checking candidate slot: candidate const candidateStart = candidate.getTime(); const candidateEnd = candidateStart + 30 * 60000; // Check for overlap with any booking @@ -393,23 +285,38 @@ 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, + }); setFormSlot({ start, end, room_id: selectedRoomId }); setEditBooking(null); setFormOpen(true); }; const handleSelectEvent = (event: BookingEvent) => { + logger.info("[BookingPage] Event selected", event); const originalBooking = event.originalBooking; const eventIdStr = String(event.id); if (originalBooking && originalBooking.id) { - setEditBooking(originalBooking); + setEditBooking({ + ...originalBooking, + invitees: Array.isArray(originalBooking.invitees) + ? originalBooking.invitees + : [], + }); } else { - // Try to find backend booking by event.id using allBookings (compare as strings) const foundBooking = allBookings.find( (b: Booking) => String(b.id) === eventIdStr ); if (foundBooking) { - setEditBooking(foundBooking); + setEditBooking({ + ...foundBooking, + invitees: Array.isArray(foundBooking.invitees) + ? foundBooking.invitees + : [], + }); } else { setEditBooking({ id: String(event.id), @@ -418,7 +325,7 @@ const BookingPage: React.FC = () => { start_time: event.start ? event.start.toString() : "", end_time: event.end ? event.end.toString() : "", title: event.title ?? "", - invitees: event.invitees ?? [], + invitees: Array.isArray(event.invitees) ? event.invitees : [], }); } } @@ -427,79 +334,29 @@ const BookingPage: React.FC = () => { }; const refetchBookings = async () => { - setBookingsLoading(true); - setBookingsError(null); - const results: Record = {}; - try { - await Promise.all( - rooms.map(async (room: ConferenceRoom) => { - try { - const bookings = await getRoomBookings(room.id); - results[room.id] = bookings; - logger.debug( - `[BookingPage] Bookings refetched for room ${room.id}`, - bookings - ); - } catch (e) { - results[room.id] = []; - logger.warn( - `[BookingPage] Failed to refetch bookings for room ${room.id}` - ); - } - }) - ); - setBookingsByRoom(results); - logger.info("[BookingPage] Bookings refetched and set"); - } catch (err) { - setBookingsError(err); - logger.error("[BookingPage] Error refetching bookings", err); - } finally { - setBookingsLoading(false); - } + // Bookings are now managed by BookingContext; context handles refresh }; - const handleFormClose = (refresh = false) => { - logger.info("[BookingPage] Booking form closed", { refresh }); + const navigate = useNavigate(); + const handleFormClose = (refresh = false, booking?: Booking) => { + logger.info("[BookingPage] Booking form closed", { refresh, booking }); setFormOpen(false); setFormSlot(null); setEditBooking(null); if (refresh) { - logger.info("[BookingPage] Refetching bookings after form close"); refetchBookings(); } + if (booking) { + navigate("/confirmation", { state: { booking } }); + } }; - // (removed old handleSelectEvent) - - if (roomsLoading || bookingsLoading || inviteesLoading) { - return
Loading calendar...
; - } - if (roomsError || bookingsError || inviteesError) { - return ( -
- Error loading calendar data. -
- ); - } + // ...removed old handleSelectEvent return ( -
+

Book a Room

- {usingPollingFallback && ( - - Warning: Your browser does not support real-time - updates. The calendar will refresh every 10 seconds. - - )} + {/* ...existing code... */} Room @@ -509,17 +366,32 @@ const BookingPage: React.FC = () => { label="Room" onChange={(e) => setSelectedRoomId(e.target.value)} > - {rooms.map((room: ConferenceRoom) => ( - - {room.name} - - ))} + {rooms.map((room: ConferenceRoom) => { + const roomIdx = Number(room.id) % 20 || 0; + const bgColor = `var(--room-color-${roomIdx + 1})`; + return ( + + {room.name} + + ); + })}
diff --git a/frontend/src/pages/ConfirmationPage.tsx b/frontend/src/pages/ConfirmationPage.tsx index 69e87f60..890071d8 100644 --- a/frontend/src/pages/ConfirmationPage.tsx +++ b/frontend/src/pages/ConfirmationPage.tsx @@ -1,24 +1,36 @@ /** - * ConfirmationPage.tsx + * ConfirmationPage component * Shows booking confirmation and allows editing or returning to booking form. * Expects booking data via location.state. - * - * Author: Cliff Hill - * Last updated: 2025-09-05 * @component * @returns {JSX.Element} Confirmation page UI */ +// External imports import React, { useState } from "react"; -import { Box } from "@mui/material"; import { useLocation, useNavigate } from "react-router-dom"; + +// MUI imports +import { Box } from "@mui/material"; + +// Third-party imports import { useQuery } from "@tanstack/react-query"; +// Internal component imports import BookingConfirmation from "../components/BookingConfirmation"; import BookingForm from "../components/BookingForm"; + +// API imports import { connectRoomsAvailabilityStream } from "../apis/sse"; import { getRooms } from "../apis/rooms"; +// Context imports +import { useBookings } from "../context/BookingContext"; + +// Utility/helper imports +import { logger } from "../utils/logger"; + +// Type-only imports import type { Booking } from "../interfaces"; /** @@ -36,19 +48,35 @@ const ConfirmationPage: React.FC = () => { /** * Booking state, updated via SSE for live status. */ + const { bookings } = useBookings(); const [booking, setBooking] = useState( location.state?.booking || null ); + + React.useEffect(() => { + logger.info("[ConfirmationPage] Mounted"); + return () => { + logger.info("[ConfirmationPage] Unmounted"); + }; + }, []); + + React.useEffect(() => { + logger.debug("[ConfirmationPage] booking updated", booking); + }, [booking]); /** * Subscribe to SSE for live booking status updates. */ React.useEffect(() => { - if (!booking) return; + let 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 === booking?.id + (b: Booking) => b.id === bookingId ); if (updated) { setBooking((prev: Booking | null) => @@ -64,7 +92,7 @@ const ConfirmationPage: React.FC = () => { return () => { es.close(); }; - }, [booking, booking?.id]); + }, [bookings, location.state, booking?.id]); /** * Editing state for toggling between confirmation and edit form. */ @@ -75,7 +103,7 @@ const ConfirmationPage: React.FC = () => { * Fetch all rooms for editing. */ const { - data: rooms = [], + // ...existing code... isLoading: roomsLoading, error: roomsError, } = useQuery({ @@ -114,16 +142,13 @@ const ConfirmationPage: React.FC = () => { onClose={handleFormClose} slotInfo={{ start: booking.start_time, - end: booking.end_time, room_id: booking.room_id, }} - rooms={rooms} editBooking={{ ...booking, start: booking.start_time, end: booking.end_time, }} - allInvitees={booking.invitees || []} onBookingSuccess={() => { setEditing(false); // Optionally update booking state diff --git a/frontend/src/pages/LandingPage.tsx b/frontend/src/pages/LandingPage.tsx index 5b9ce6a0..c28f6a87 100644 --- a/frontend/src/pages/LandingPage.tsx +++ b/frontend/src/pages/LandingPage.tsx @@ -1,51 +1,33 @@ /** - * LandingPage.tsx + * LandingPage component * Main landing page for the conference room booking system frontend. * Displays available rooms, today's bookings, and navigation to booking form. * Handles data fetching, error states, and loading skeletons. - * - * Author: Cliff Hill - * Last updated: 2025-09-05 * @component * @returns {JSX.Element} Landing page UI */ +// External imports import React, { FC, useEffect, useState } from "react"; -import { - Box, - Button, - Card, - CardContent, - Skeleton, - Typography, -} from "@mui/material"; -import EventNoteIcon from "@mui/icons-material/EventNote"; import { useNavigate } from "react-router-dom"; -import { useQuery } from "@tanstack/react-query"; +// MUI imports +import { Box, Button, Card, CardContent, Typography } from "@mui/material"; +import EventNoteIcon from "@mui/icons-material/EventNote"; + +// Styles +import "../styles/LandingPage.css"; + +// Internal component imports import BookingList from "../components/BookingList"; import RoomList from "../components/RoomList"; -import { connectRoomsAvailabilityStream } from "../apis/sse"; -import { getRooms } from "../apis/rooms"; + +// Context imports +import { useRooms } from "../context/RoomContext"; +import { useBookings } from "../context/BookingContext"; + +// Utility/helper imports import { logger } from "../utils/logger"; - -import type { Booking } from "../interfaces"; -import type { ConferenceRoom } from "../interfaces"; - -/** - * LandingPage component - * Main landing page for the conference room booking system frontend. - * Displays available rooms, today's bookings, and navigation to booking form. - * Handles data fetching, error states, and loading skeletons. - * @component - * @returns {JSX.Element} Landing page UI - */ -/** - * LandingPage component - * Renders the main UI for conference room booking, including available rooms, today's bookings, and navigation to booking form. - * Handles data fetching, error states, and loading skeletons. - * @returns {JSX.Element} Landing page UI - */ const LandingPage: FC = () => { // Log when LandingPage mounts/unmounts /** @@ -71,30 +53,8 @@ const LandingPage: FC = () => { }, []); const navigate = useNavigate(); - // Fetch available rooms from backend - /** - * Fetch available rooms from backend. - */ - const { - data: rooms = [], - isLoading: roomsLoading, - error: roomsError, - refetch: refetchRooms, - } = useQuery({ - queryKey: ["rooms"], - queryFn: async () => { - logger.info("[LandingPage] Fetching rooms"); - const rooms = await getRooms(); - logger.info("[LandingPage] Rooms response", rooms); // Log backend return value - return rooms as ConferenceRoom[]; - }, - }); - // Log rooms result after fetch - useEffect(() => { - if (!roomsLoading && roomsError == null) { - logger.info("[LandingPage] Rooms loaded", rooms); - } - }, [rooms, roomsLoading, roomsError]); + // Get rooms from context + const { rooms } = useRooms(); // Room selection state const [selectedRoomId, setSelectedRoomId] = useState( @@ -106,108 +66,34 @@ const LandingPage: FC = () => { if (rooms.length > 0 && selectedRoomId === undefined) { setSelectedRoomId(rooms[0].id); } + // Fetch bookings for today for the selected room if not already fetched + // Bookings are now managed by BookingContext }, [rooms, selectedRoomId]); // Real-time bookings state via SSE - const [bookingsByRoom, setBookingsByRoom] = useState< - Record - >({}); - useEffect(() => { - const es = connectRoomsAvailabilityStream({ - onMessage: (data) => { - if (Array.isArray(data.bookings)) { - const grouped: Record = {}; - data.bookings.forEach((b: Booking) => { - if (typeof b.room_id === "undefined" || b.room_id === null) return; - const roomId = Number(b.room_id); - if (!grouped[roomId]) grouped[roomId] = []; - grouped[roomId].push(b); - }); - setBookingsByRoom(grouped); - logger.debug("[LandingPage] SSE update received", grouped); - } - }, - onError: (err) => { - logger.error("[LandingPage] SSE connection error", err); - }, - }); - return () => { - es.close(); - }; - }, []); + // Unified bookings state from context + const { bookings } = useBookings(); // Select bookings for the selected room - const bookings = selectedRoomId ? bookingsByRoom[selectedRoomId] || [] : []; + // Only show bookings for today + function isToday(dateStr: string): boolean { + const d = new Date(dateStr); + const now = new Date(); + return ( + d.getFullYear() === now.getFullYear() && + d.getMonth() === now.getMonth() && + d.getDate() === now.getDate() + ); + } + const filteredBookings = selectedRoomId + ? bookings.filter( + (b: import("../interfaces").Booking) => + String(b.room_id) === String(selectedRoomId) && + b.start_time && + isToday(b.start_time) + ) + : []; // Display skeleton loaders during data fetching - if (roomsLoading) { - return ( -
-
- - - {[...Array(3)].map((_, i) => ( - - ))} - -
-
- - - {[...Array(3)].map((_, i) => ( - - ))} - -
-
- ); - } - - // Display error message with retry option - if (roomsError) { - logger.error("[LandingPage] Rooms error", roomsError); - return ( -
-
- - {roomsError?.message || "Failed to fetch data."} - - -
-
- ); - } return (
@@ -260,7 +146,7 @@ const LandingPage: FC = () => { display: "flex", alignItems: "center", fontWeight: 600, - color: "var(--color-on-primary)", + color: "var(--color-white)", }} > { height: "100%", }} > - +
diff --git a/frontend/src/styles/App.css b/frontend/src/styles/App.css index ff82b5dd..ad71d4a9 100644 --- a/frontend/src/styles/App.css +++ b/frontend/src/styles/App.css @@ -18,19 +18,19 @@ /* Header styles */ .App-header { - background-color: #282c34; + background-color: var(--color-app-bg, #282c34); min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; font-size: calc(10px + 2vmin); - color: white; + color: var(--color-app-text, white); } /* Link styles */ .App-link { - color: #61dafb; + color: var(--color-app-accent, #61dafb); } /* Keyframes for logo spin animation */ diff --git a/frontend/src/styles/BookingForm.css b/frontend/src/styles/BookingForm.css index b76dd53e..3506dcdd 100644 --- a/frontend/src/styles/BookingForm.css +++ b/frontend/src/styles/BookingForm.css @@ -13,14 +13,14 @@ /* Unavailable menu item styles */ .booking-form-menu-item-unavailable { - color: #b0b0b0; + color: var(--color-form-disabled, #b0b0b0); margin-left: 8px; font-size: 13px; } /* Invitee chip styles */ .booking-form-invitee-chip { - color: #1976d2; + color: var(--color-primary, #1976d2); margin-right: 8px; } diff --git a/frontend/src/styles/BookingList.css b/frontend/src/styles/BookingList.css index f42c07ed..445d943c 100644 --- a/frontend/src/styles/BookingList.css +++ b/frontend/src/styles/BookingList.css @@ -12,7 +12,7 @@ /* Booking time styles */ .booking-list-time { font-weight: 600; - color: #23272f; + color: var(--color-on-surface, #23272f); font-size: 1rem; } @@ -20,7 +20,7 @@ .booking-list-invitees { display: block; font-size: 0.85em; - color: #555; + color: var(--color-on-surface-secondary, #555); font-style: italic; margin-top: 2px; } diff --git a/frontend/src/styles/BookingPage.css b/frontend/src/styles/BookingPage.css new file mode 100644 index 00000000..3db57bb3 --- /dev/null +++ b/frontend/src/styles/BookingPage.css @@ -0,0 +1,12 @@ +.bookingpage-loading { + padding: 24px; +} + +.bookingpage-error { + padding: 24px; + color: var(--color-error, red); +} + +.bookingpage-content { + padding: 24px; +} diff --git a/frontend/src/styles/CalendarView.css b/frontend/src/styles/CalendarView.css index 66a5b0b7..cfd38ae5 100644 --- a/frontend/src/styles/CalendarView.css +++ b/frontend/src/styles/CalendarView.css @@ -1,3 +1,22 @@ +/* Override inline styles if present */ +.fc-event[style], +.fc-daygrid-event[style], +.fc-event-main[style], +.fc-event-main-frame[style] { + background: inherit !important; + background-color: inherit !important; +} +/* Force FullCalendar event blocks to use the correct background and text color */ +.fc-event, +.fc-daygrid-event { + background-color: var( + --fc-event-bg-color, + var(--color-primary, #1976d2) + ) !important; + color: var(--color-white, #fff) !important; + border-radius: 4px !important; + border: none !important; +} /* Inline event styles from CalendarViewInline.css */ .calendar-event-content { width: 100%; @@ -8,6 +27,8 @@ border-radius: 4px; padding-left: 4px; padding-right: 4px; + background-color: var(--fc-event-bg-color, var(--color-primary, #1976d2)); + color: var(--color-white, #fff); } .fc-event-title-ellipsis { @@ -23,7 +44,7 @@ /* Non-business hours styling */ .fc-timegrid-col-bg .fc-non-business, .fc-non-business { - background: #888 !important; + background: var(--color-calendar-nonbusiness, #888) !important; opacity: 0.5 !important; } @@ -53,54 +74,57 @@ .fc .fc-daygrid-day.fc-day-sun .fc-daygrid-day-frame, .fc .fc-timegrid-col.fc-day-sat, .fc .fc-timegrid-col.fc-day-sun { - background: #888 !important; /* unified non-business color */ - border-color: #bbb !important; + background: var( + --color-calendar-nonbusiness, + #888 + ) !important; /* unified non-business color */ + border-color: var(--color-calendar-border, #bbb) !important; } .fc .fc-daygrid-day.fc-day-sat .fc-daygrid-day-number, .fc .fc-daygrid-day.fc-day-sun .fc-daygrid-day-number { - color: #e0e0e0 !important; + color: var(--color-calendar-title, #e0e0e0) !important; font-weight: 500 !important; opacity: 0.7 !important; } /* Weekday non-business hours */ .fc .fc-non-business { - background: #888 !important; + background: var(--color-calendar-nonbusiness, #888) !important; opacity: 0.5 !important; } /* Workday all-day and time slots */ .fc .fc-daygrid-day .fc-daygrid-day-frame, .fc .fc-timegrid-col { - background: #c0c0c0 !important; + background: var(--color-calendar-blocked, #c0c0c0) !important; } /* Lighter border for all-day row on weekends */ .fc .fc-daygrid-day.fc-day-sat, .fc .fc-daygrid-day.fc-day-sun { - border-color: #bbb !important; + border-color: var(--color-calendar-border, #bbb) !important; } /* Out-of-month days styling */ .fc .fc-daygrid-day.fc-day-other .fc-daygrid-day-frame { - background: #aaa !important; + background: var(--color-calendar-muted, #aaa) !important; opacity: 1 !important; } /* =============================== */ /* Calendar cell backgrounds */ /* =============================== */ :root { - --fc-page-bg-color: #a0a0a0; - --fc-neutral-bg-color: #e3f2fd; - --fc-today-bg-color: #e3f2fd; - --fc-event-bg-color: #1976d2; - --fc-border-color: #707070; + --fc-page-bg-color: var(--color-page-bg, #a0a0a0); + --fc-neutral-bg-color: var(--color-neutral-bg, #e3f2fd); + --fc-today-bg-color: var(--color-today-bg, #e3f2fd); + --fc-event-bg-color: var(--color-event-bg, #1976d2); + --fc-border-color: var(--color-border, #707070); } .calendarContainer { padding: 2rem; - background: #f8fafc !important; + background: var(--color-calendar-bg, #f8fafc) !important; border-radius: 16px !important; box-shadow: 0 4px 24px rgba(30, 64, 175, 0.08) !important; /* Responsive container, no fixed height */ @@ -118,8 +142,8 @@ } .fc .fc-toolbar { - background: #1976d2 !important; - color: #fff !important; + background: var(--color-primary, #1976d2) !important; + color: var(--color-white, #fff) !important; border-radius: 12px 12px 0 0 !important; padding: 0.5rem 1rem !important; } @@ -130,8 +154,8 @@ } .fc .fc-button { - background: #1976d2 !important; - color: #fff !important; + background: var(--color-primary, #1976d2) !important; + color: var(--color-white, #fff) !important; border: none !important; border-radius: 6px !important; padding: 0.3rem 0.8rem !important; @@ -157,14 +181,15 @@ .fc .fc-daygrid-day-number { font-weight: 600 !important; - color: #1976d2 !important; + color: var(--color-calendar-event, #1976d2) !important; } .fc .fc-event { - color: #fff !important; + color: var(--color-white, #fff) !important; padding: 2px 6px !important; font-size: 0.95rem !important; box-shadow: 0 2px 8px rgba(30, 64, 175, 0.12) !important; + overflow: hidden !important; } .fc .fc-event:hover { @@ -193,7 +218,7 @@ } .fc .fc-daygrid-day.fc-day-other .fc-daygrid-day-number { - color: #fff !important; + color: var(--color-white, #fff) !important; opacity: 1 !important; font-weight: 600 !important; } @@ -217,61 +242,61 @@ * These classes are mapped in the frontend logic (getRoomClass). */ .room-color-1 { - background: var(--room-color-1) !important; + background: var(--room-color-1, #1976d2) !important; } .room-color-2 { - background: var(--room-color-2) !important; + background: var(--room-color-2, #388e3c) !important; } .room-color-3 { - background: var(--room-color-3) !important; + background: var(--room-color-3, #fbc02d) !important; } .room-color-4 { - background: var(--room-color-4) !important; + background: var(--room-color-4, #d32f2f) !important; } .room-color-5 { - background: var(--room-color-5) !important; + background: var(--room-color-5, #7b1fa2) !important; } .room-color-6 { - background: var(--room-color-6) !important; + background: var(--room-color-6, #0288d1) !important; } .room-color-7 { - background: var(--room-color-7) !important; + background: var(--room-color-7, #c2185b) !important; } .room-color-8 { - background: var(--room-color-8) !important; + background: var(--room-color-8, #ffa000) !important; } .room-color-9 { - background: var(--room-color-9) !important; + background: var(--room-color-9, #009688) !important; } .room-color-10 { - background: var(--room-color-10) !important; + background: var(--room-color-10, #8bc34a) !important; } .room-color-11 { - background: var(--room-color-11) !important; + background: var(--room-color-11, #e91e63) !important; } .room-color-12 { - background: var(--room-color-12) !important; + background: var(--room-color-12, #00bcd4) !important; } .room-color-13 { - background: var(--room-color-13) !important; + background: var(--room-color-13, #ff5722) !important; } .room-color-14 { - background: var(--room-color-14) !important; + background: var(--room-color-14, #9c27b0) !important; } .room-color-15 { - background: var(--room-color-15) !important; + background: var(--room-color-15, #3f51b5) !important; } .room-color-16 { - background: var(--room-color-16) !important; + background: var(--room-color-16, #4caf50) !important; } .room-color-17 { - background: var(--room-color-17) !important; + background: var(--room-color-17, #ff9800) !important; } .room-color-18 { - background: var(--room-color-18) !important; + background: var(--room-color-18, #607d8b) !important; } .room-color-19 { - background: var(--room-color-19) !important; + background: var(--room-color-19, #795548) !important; } /* Ensure event text is readable on colored backgrounds */ @@ -295,5 +320,5 @@ .room-color-17, .room-color-18, .room-color-19 { - color: #fff !important; + color: var(--color-white, #fff) !important; } diff --git a/frontend/src/styles/LandingPage.css b/frontend/src/styles/LandingPage.css index 262bc545..df72ee1b 100644 --- a/frontend/src/styles/LandingPage.css +++ b/frontend/src/styles/LandingPage.css @@ -1,7 +1,10 @@ /* Selected room button styles */ .MuiButtonBase-root.MuiListItemButton-root.Mui-selected { - background-color: #afafaf !important; /* Only use !important if necessary for Material UI override */ - border: 1px solid #23272f; + background-color: var( + --color-landing-bg, + #afafaf + ) !important; /* Only use !important if necessary for Material UI override */ + border: 1px solid var(--color-on-surface, #23272f); border-radius: 16px; margin: 1px; } @@ -69,7 +72,7 @@ padding: 2rem 1.5rem 1.5rem 1.5rem; margin-bottom: 0; transition: none; - color: #222; + color: var(--color-white, #fff); flex: 1 1 0; min-height: 60vh; display: flex; @@ -94,7 +97,7 @@ .landing-hr-wide { width: 420px; border: none; - border-top: 2px solid #444; + border-top: 2px solid var(--color-landing-divider, #444); margin: 0.5rem auto 1.5rem auto; background: none; } @@ -106,7 +109,11 @@ width: 100vw; text-align: center; padding-bottom: 2rem; - background: linear-gradient(to top, #fff 80%, rgba(255, 255, 255, 0)); + background: linear-gradient( + to top, + var(--color-white, #fff) 80%, + rgba(255, 255, 255, 0) + ); z-index: 10; display: flex; flex-direction: column; @@ -121,7 +128,7 @@ .landing-hr { width: 220px; border: none; - border-top: 2px solid #e0e0e0; + border-top: 2px solid var(--color-calendar-title, #e0e0e0); margin: 0.5rem auto 1.5rem auto; background: none; } @@ -160,9 +167,9 @@ border-radius: 0; box-shadow: none; border: none; - background: #fff; + background: var(--color-white, #fff); padding: 1.2rem 0.5rem 1.5rem 0.5rem; - color: #23272f; + color: var(--color-on-surface, #23272f); } .landing-book-btn-desktop { display: none; @@ -221,6 +228,6 @@ .landing-hr-wide { width: 80vw; margin: 0.5rem auto 1.5rem auto; - border-top: 2px solid #e0e0e0; + border-top: 2px solid var(--color-calendar-title, #e0e0e0); } } diff --git a/frontend/src/styles/RoomList.css b/frontend/src/styles/RoomList.css index 49269fba..4e07fad7 100644 --- a/frontend/src/styles/RoomList.css +++ b/frontend/src/styles/RoomList.css @@ -11,7 +11,7 @@ .room-list-header { flex: 0 0 auto; border-bottom: 1px solid #e0e0e0; - background-color: #1976d2; + background-color: var(--color-primary, #1976d2); margin-bottom: 0; border-top-left-radius: 6px; border-top-right-radius: 6px; @@ -30,7 +30,7 @@ flex: 1 1 auto; overflow-y: auto; min-height: 0; - background-color: #f7f8fa; + background-color: var(--color-roomlist-bg, #f7f8fa); color: #23272f; border-bottom-left-radius: 6px; border-bottom-right-radius: 6px; @@ -38,7 +38,7 @@ /* Selected room list item styles */ .room-list-item-selected { - background-color: #ececec !important; + background-color: var(--color-roomlist-selected, #ececec) !important; font-weight: 700; color: #23272f; border: 1px solid #23272f; diff --git a/frontend/src/theme.ts b/frontend/src/theme.ts index 488e4a11..ebc95225 100644 --- a/frontend/src/theme.ts +++ b/frontend/src/theme.ts @@ -1,12 +1,13 @@ /** * theme.ts * MUI theme configuration for the frontend. - * - * Author: Cliff Hill - * Last updated: 2025-09-08 + * @module */ +// MUI imports import { createTheme } from "@mui/material/styles"; + +// Internal helper imports import { getColors } from "./constants"; /** diff --git a/frontend/src/types.ts b/frontend/src/types.ts index f7229a26..eee8223a 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,10 +1,9 @@ /** * types.ts * Centralized TypeScript types for frontend application. - * Author: Cliff Hill - * Last updated: 2025-09-09 + * @module */ - +// ...no imports in this file... /** * Represents a booking event for display in the calendar. * @property originalBooking - Optional reference to the original booking object diff --git a/frontend/src/utils/logger.ts b/frontend/src/utils/logger.ts index f869d4f6..1662ab71 100644 --- a/frontend/src/utils/logger.ts +++ b/frontend/src/utils/logger.ts @@ -1,13 +1,22 @@ /** * logger.ts * Centralized logger using loglevel, with level set from FRONTEND_LOG_LEVEL. - * - * Author: Cliff Hill - * Last updated: 2025-09-08 + * @module */ + +// External imports import log, { LogLevelDesc } from "loglevel"; -const level = (process.env.FRONTEND_LOG_LEVEL || "info") as LogLevelDesc; +/** + * Gets the log level from environment and sets it for loglevel. + * @type {LogLevelDesc} + */ +const level: LogLevelDesc = (process.env.FRONTEND_LOG_LEVEL || + "info") as LogLevelDesc; log.setLevel(level); +/** + * Centralized logger instance for frontend logging. + * @type {log.Logger} + */ export const logger = log;