App is working, now for code cleanup and redoing the tests.

Signed-off-by: Cliff Hill <xlorep@darkhelm.org>
This commit is contained in:
2025-09-18 20:09:20 -04:00
parent 24d207f916
commit 5aa3c909c2
55 changed files with 1622 additions and 3020 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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:

View File

@@ -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")

View File

@@ -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

View File

@@ -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 (
<Router>
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/booking" element={<BookingPage />} />
<Route path="/confirmation" element={<ConfirmationPage />} />
</Routes>
</Router>
<RoomProvider>
<UserProvider>
<BookingProvider>
<Router>
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/booking" element={<BookingPage />} />
<Route path="/confirmation" element={<ConfirmationPage />} />
</Routes>
</Router>
</BookingProvider>
</UserProvider>
</RoomProvider>
);
};

View File

@@ -1,8 +0,0 @@
import { render, screen } from "@testing-library/react";
import App from "./App";
test("renders learn react link", () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

View File

@@ -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(
<MemoryRouter>
<BookingForm
open={true}
onClose={jest.fn()}
slotInfo={{}}
rooms={mockRooms}
allInvitees={[]}
/>
</MemoryRouter>
);
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(
<MemoryRouter>
<BookingForm
open={true}
onClose={jest.fn()}
slotInfo={{}}
rooms={mockRooms}
allInvitees={[]}
/>
</MemoryRouter>
);
// 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(
<MemoryRouter>
<BookingForm
open={true}
onClose={jest.fn()}
rooms={mockRooms}
allInvitees={mockInvitees}
slotInfo={{}}
onBookingSuccess={jest.fn()}
/>
</MemoryRouter>
);
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

View File

@@ -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(<BookingList bookings={mockBookings} />);
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(<BookingList bookings={[]} />);
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(<BookingList bookings={initialBookings} />);
// 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();
});
});
});

View File

@@ -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(<CalendarView events={mockEvents} initialDate="2025-08-28" />);
// 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();
});
});

View File

@@ -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(<RoomEquipmentList />);
expect(screen.getByText(/Room Equipment/i)).toBeInTheDocument();
});
});

View File

@@ -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(<RoomList rooms={mockRooms} />);
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(<RoomList rooms={[]} />);
expect(screen.getByText("No rooms available.")).toBeInTheDocument();
});
it("calls onSelectRoom when a room is clicked", () => {
const onSelectRoom = jest.fn();
render(
<RoomList
rooms={mockRooms}
selectedRoomId={2}
onSelectRoom={onSelectRoom}
/>
);
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(
<RoomList rooms={mockRooms} selectedRoomId={2} onSelectRoom={() => {}} />
);
const roomB = screen.getByTestId("room-item-2");
expect(roomB.className).toMatch(/Mui-selected/);
});
});

View File

@@ -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(
<QueryClientProvider client={queryClient}>
<BookingPage />
</QueryClientProvider>
);
// 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();
});
});
});

View File

@@ -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<typeof axios>;
describe("LandingPage", () => {
const createWrapper = (children: React.ReactNode) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
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(<LandingPage />, {
wrapper: ({ children }) =>
createWrapper(<MemoryRouter>{children}</MemoryRouter>),
});
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(<LandingPage />, {
wrapper: ({ children }) =>
createWrapper(<MemoryRouter>{children}</MemoryRouter>),
});
// 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(<LandingPage />, {
wrapper: ({ children }) =>
createWrapper(<MemoryRouter>{children}</MemoryRouter>),
});
// Wait for error message to appear
expect(await screen.findByTestId("error-message")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /retry fetching data/i })
).toBeInTheDocument();
});
});

View File

@@ -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<Booking[]> {
const res = await axios.get(`/bookings/month/${month}`);
return res.data;
}

View File

@@ -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}`);
}

View File

@@ -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;
}

View File

@@ -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";
/**

View File

@@ -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<User[]> {
const res = await axios.get<User[]>("/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<User[]> {
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<User[]>("/users/available/", { params: query });
return res.data;
}

View File

@@ -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";

File diff suppressed because it is too large Load Diff

View File

@@ -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<BookingListProps> = ({ 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 (
<List sx={{ mt: 0.5 }}>
<ListItemButton
sx={{
flexDirection: "column",
alignItems: "flex-start",
py: 2,
px: 2,
}}
disabled
>
<Typography
sx={{
fontWeight: 600,
color: "var(--color-text-primary)",
fontSize: "1rem",
width: "100%",
}}
>
<ListItemButton className="bookinglist-empty" disabled>
<Typography className="bookinglist-item-title">
No bookings found.
</Typography>
</ListItemButton>
</List>
);
}
// 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 (
<List sx={{ mt: 0.5 }}>
{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 (
<ListItemButton
key={booking.id}
onClick={() => onSelect && onSelect(booking)}
className="bookinglist-item"
sx={{ flexDirection: "column", alignItems: "flex-start" }}
>
<span
style={{
fontWeight: 600,
color: "var(--color-text-primary)",
fontSize: "1rem",
}}
>
<span className="bookinglist-item-title">
{booking.start_time && booking.end_time
? `${formatBookingTime(
booking.start_time
)}  ${formatBookingTime(booking.end_time)}`
)} - ${formatBookingTime(booking.end_time)}`
: "Time not set"}
</span>
{inviteeNames.length > 0 && (
<span
style={{
display: "block",
fontSize: "0.85em",
color: "var(--color-text-secondary)",
fontStyle: "italic",
marginTop: 2,
}}
>
Invitees: {inviteeNames.join(", ")}
</span>
)}
<span
className="bookinglist-invitees"
style={{
marginTop: 4,
fontSize: "0.92em",
fontWeight: 400,
color: "#666",
}}
>
{inviteeNames.length > 0
? `Invitees: ${inviteeNames.join(", ")}`
: "No invitees"}
</span>
</ListItemButton>
);
})}

View File

@@ -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<CalendarViewProps> = ({
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<CalendarViewProps> = ({
}
open={process.env.NODE_ENV === "test" ? true : undefined}
>
<div
className={roomClass}
style={{
width: "100%",
height: "100%",
cursor: "pointer",
display: "flex",
alignItems: "center",
borderRadius: 4,
paddingLeft: 4,
paddingRight: 4,
}}
>
<span
className="fc-event-title-ellipsis"
style={{
display: "block",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
width: "100%",
minWidth: 0,
fontSize: "inherit",
}}
>
{displayText}
</span>
<div className={`${roomClass} calendar-event`}>
<span className="calendar-event-title">{displayText}</span>
</div>
</Tooltip>
);
@@ -149,7 +136,7 @@ const CalendarView: React.FC<CalendarViewProps> = ({
];
// Diagnostic: log events passed to FullCalendar
React.useEffect(() => {
logger.info("[CalendarView] Events passed to FullCalendar:", events);
// Diagnostic: events passed to FullCalendar
}, [events]);
return (
<div className="calendarContainer">
@@ -173,7 +160,9 @@ const CalendarView: React.FC<CalendarViewProps> = ({
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}

View File

@@ -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<RoomDetailsModalProps> = ({
@@ -35,6 +32,16 @@ const RoomDetailsModal: React.FC<RoomDetailsModalProps> = ({
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 (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>

View File

@@ -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<RoomListProps> = ({
selectedRoomId,
onSelectRoom,
}) => {
useEffect(() => {
logger.info("[RoomList] Mounted");
return () => {
logger.info("[RoomList] Unmounted");
};
}, []);
useEffect(() => {
logger.debug("[RoomList] rooms updated", rooms);
}, [rooms]);
return (
<Card
sx={{
@@ -102,7 +113,20 @@ const RoomList: FC<RoomListProps> = ({
sx={getRoomListItemStyles(room.id, selectedRoomId)}
>
<ListItemText
primary={room.name}
primary={
<span
style={{
color: `var(--room-color-${
(Number(room.id) % 20) + 1
})`,
fontWeight: room.id === selectedRoomId ? 700 : 500,
fontSize: room.id === selectedRoomId ? "1.08em" : "1em",
letterSpacing: 0.5,
}}
>
{room.name}
</span>
}
secondary={getRoomSecondaryText(room)}
/>
</ListItemButton>

View File

@@ -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<string, string> {
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",

View File

@@ -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<void>;
}
const BookingContext = createContext<BookingContextType | undefined>(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 (
<BookingContext.Provider value={{ bookings, fetchMonth }}>
{children}
</BookingContext.Provider>
);
};
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

View File

@@ -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<RoomContextType | undefined>(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<ConferenceRoom[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
getRooms()
.then((data) => {
setRooms(data || []);
setLoading(false);
})
.catch((err) => {
setError(err);
setLoading(false);
});
}, []);
return (
<RoomContext.Provider value={{ rooms, loading, error }}>
{children}
</RoomContext.Provider>
);
};
export const useRooms = () => {
const context = useContext(RoomContext);
if (!context) throw new Error("useRooms must be used within a RoomProvider");
return context;
};

View File

@@ -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<string>();
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<UserContextType | undefined>(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<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
getUsers()
.then((data) => {
setUsers(data || []);
setLoading(false);
})
.catch((err) => {
setError(err);
setLoading(false);
});
}, []);
return (
<UserContext.Provider value={{ users, loading, error }}>
{children}
</UserContext.Provider>
);
};
export const useUsers = () => {
const context = useContext(UserContext);
if (!context) throw new Error("useUsers must be used within a UserProvider");
return context;
};

View File

@@ -9,6 +9,7 @@
// Minimal Booking type for utility functions
// Type-only imports
import type { Booking } from "../interfaces";
/**

View File

@@ -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";

View File

@@ -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.

View File

@@ -0,0 +1,6 @@
/**
* getRoomBookingsForDate.ts
* Utility for fetching bookings for a specific room and date.
* (Intentionally left empty for future implementation.)
* @module
*/

View File

@@ -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";

View File

@@ -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.

View File

@@ -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(
<React.StrictMode>

View File

@@ -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;

View File

@@ -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<number, Booking[]>
>({});
// 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<unknown>(null);
const eventSourceRef = useRef<EventSource | null>(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<number, Booking[]> = {};
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<number, Booking[]> = {};
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<SlotInfo | null>(null);
const [editBooking, setEditBooking] = useState<Booking | null>(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<string | number>("");
// 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<number, Booking[]> = {};
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 <div style={{ padding: 24 }}>Loading calendar...</div>;
}
if (roomsError || bookingsError || inviteesError) {
return (
<div style={{ padding: 24, color: "red" }}>
Error loading calendar data.
</div>
);
}
// ...removed old handleSelectEvent
return (
<div style={{ padding: 24 }}>
<div className="bookingpage-content">
<h2>Book a Room</h2>
{usingPollingFallback && (
<Box
mb={2}
sx={{
background: "#fff3cd",
color: "#856404",
border: "1px solid #ffeeba",
borderRadius: 2,
p: 2,
}}
>
<strong>Warning:</strong> Your browser does not support real-time
updates. The calendar will refresh every 10 seconds.
</Box>
)}
{/* ...existing code... */}
<Box display="flex" alignItems="center" mb={2}>
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel id="room-select-label">Room</InputLabel>
@@ -509,17 +366,32 @@ const BookingPage: React.FC = () => {
label="Room"
onChange={(e) => setSelectedRoomId(e.target.value)}
>
{rooms.map((room: ConferenceRoom) => (
<MenuItem key={room.id} value={room.id}>
{room.name}
</MenuItem>
))}
{rooms.map((room: ConferenceRoom) => {
const roomIdx = Number(room.id) % 20 || 0;
const bgColor = `var(--room-color-${roomIdx + 1})`;
return (
<MenuItem
key={room.id}
value={room.id}
sx={{
backgroundColor: bgColor,
color: "#fff",
fontWeight: 500,
}}
>
{room.name}
</MenuItem>
);
})}
</Select>
</FormControl>
<Button
variant="outlined"
sx={{ ml: 2 }}
onClick={() => setRoomModalOpen(true)}
onClick={() => {
logger.info("[BookingPage] Opening room modal");
setRoomModalOpen(true);
}}
disabled={!selectedRoomId}
>
View Room & Equipment Details
@@ -527,7 +399,10 @@ const BookingPage: React.FC = () => {
</Box>
<RoomDetailsModal
open={roomModalOpen}
onClose={() => setRoomModalOpen(false)}
onClose={() => {
logger.info("[BookingPage] Closing room modal");
setRoomModalOpen(false);
}}
room={
rooms.find((r: ConferenceRoom) => r.id === selectedRoomId) ?? rooms[0]
}
@@ -543,18 +418,25 @@ const BookingPage: React.FC = () => {
open={formOpen}
onClose={() => handleFormClose(true)}
slotInfo={
formSlot ?? {
start: new Date(),
end: new Date(),
room_id: selectedRoomId,
}
formSlot
? {
...formSlot,
start:
typeof formSlot.start === "string"
? formSlot.start
: formSlot.start instanceof Date
? formSlot.start.toISOString()
: new Date().toISOString(),
}
: {
start: new Date().toISOString(),
room_id: selectedRoomId,
}
}
rooms={rooms}
editBooking={editBooking ?? undefined}
allInvitees={allInvitees.map(
(u: import("../interfaces").User) => u.email ?? u.name
)}
onBookingSuccess={() => handleFormClose(true)}
onBookingSuccess={(booking?: Booking) =>
handleFormClose(true, booking)
}
/>
)}
</div>

View File

@@ -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<Booking | null>(
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

View File

@@ -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<number | undefined>(
@@ -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<number, Booking[]>
>({});
useEffect(() => {
const es = connectRoomsAvailabilityStream({
onMessage: (data) => {
if (Array.isArray(data.bookings)) {
const grouped: Record<number, Booking[]> = {};
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 (
<div className="landing-root">
<div className="landing-section">
<Box sx={{ p: 2 }}>
<Skeleton
variant="text"
width="50%"
data-testid="skeleton-loader"
/>
{[...Array(3)].map((_, i) => (
<Skeleton
key={i}
variant="rectangular"
height={60}
sx={{ mb: 1 }}
data-testid="skeleton-loader"
/>
))}
</Box>
</div>
<div className="landing-section">
<Box sx={{ p: 2 }}>
<Skeleton
variant="text"
width="50%"
data-testid="skeleton-loader"
/>
{[...Array(3)].map((_, i) => (
<Skeleton
key={i}
variant="rectangular"
height={60}
sx={{ mb: 1 }}
data-testid="skeleton-loader"
/>
))}
</Box>
</div>
</div>
);
}
// Display error message with retry option
if (roomsError) {
logger.error("[LandingPage] Rooms error", roomsError);
return (
<div
className="landing-root"
style={{ alignItems: "center", justifyContent: "center" }}
>
<div style={{ width: "100%", textAlign: "center" }}>
<Typography color="error" data-testid="error-message">
{roomsError?.message || "Failed to fetch data."}
</Typography>
<Button
variant="contained"
onClick={() => {
refetchRooms();
}}
sx={{ mt: 2 }}
aria-label="Retry fetching data"
>
Retry
</Button>
</div>
</div>
);
}
return (
<div className="landing-root">
@@ -260,7 +146,7 @@ const LandingPage: FC = () => {
display: "flex",
alignItems: "center",
fontWeight: 600,
color: "var(--color-on-primary)",
color: "var(--color-white)",
}}
>
<EventNoteIcon
@@ -285,7 +171,7 @@ const LandingPage: FC = () => {
height: "100%",
}}
>
<BookingList bookings={bookings} />
<BookingList bookings={filteredBookings} />
</Box>
</Card>
</div>

View File

@@ -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 */

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,12 @@
.bookingpage-loading {
padding: 24px;
}
.bookingpage-error {
padding: 24px;
color: var(--color-error, red);
}
.bookingpage-content {
padding: 24px;
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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";
/**

View File

@@ -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

View File

@@ -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;