From 7d649a1e0f1e0425e5dc4fa711c2c392bb065e01 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Wed, 4 Mar 2026 14:28:07 +0000 Subject: [PATCH] fix(bookings): add UTC conversion to admin and reschedule endpoints admin_create_booking, admin_update_booking, and reschedule_booking stored naive datetimes without converting to UTC, causing +2h offset on calendar after the frontend started treating all DB times as UTC. Co-Authored-By: Claude Opus 4.5 --- backend/app/api/bookings.py | 102 +++++++++++------------------------- 1 file changed, 31 insertions(+), 71 deletions(-) diff --git a/backend/app/api/bookings.py b/backend/app/api/bookings.py index 04f3d7c..393ce45 100644 --- a/backend/app/api/bookings.py +++ b/backend/app/api/bookings.py @@ -1066,14 +1066,16 @@ def admin_update_booking( ) # Update fields (only if provided) + # Convert datetimes from admin timezone to UTC for storage + user_timezone = current_admin.timezone or "UTC" # type: ignore[attr-defined] if data.title is not None: booking.title = data.title # type: ignore[assignment] if data.description is not None: booking.description = data.description # type: ignore[assignment] if data.start_datetime is not None: - booking.start_datetime = data.start_datetime # type: ignore[assignment] + booking.start_datetime = convert_to_utc(data.start_datetime, user_timezone) # type: ignore[assignment] if data.end_datetime is not None: - booking.end_datetime = data.end_datetime # type: ignore[assignment] + booking.end_datetime = convert_to_utc(data.end_datetime, user_timezone) # type: ignore[assignment] # Re-validate booking rules # Use booking owner's timezone for validation @@ -1240,15 +1242,18 @@ def reschedule_booking( old_start = booking.start_datetime old_end = booking.end_datetime - # Validate new time slot - # Use booking owner's timezone for validation - user_timezone = (booking.user.timezone or "UTC") if booking.user else "UTC" + # Convert input times from admin timezone to UTC + user_timezone = current_admin.timezone or "UTC" # type: ignore[attr-defined] + start_datetime_utc = convert_to_utc(data.start_datetime, user_timezone) + end_datetime_utc = convert_to_utc(data.end_datetime, user_timezone) + + # Validate new time slot (using UTC times) booking_user_id = int(booking.user_id) if booking.user_id else 0 errors = validate_booking_rules( db=db, space_id=int(booking.space_id), # type: ignore[arg-type] - start_datetime=data.start_datetime, - end_datetime=data.end_datetime, + start_datetime=start_datetime_utc, + end_datetime=end_datetime_utc, user_id=booking_user_id, exclude_booking_id=booking.id, # Exclude self from overlap check user_timezone=user_timezone, @@ -1260,9 +1265,9 @@ def reschedule_booking( detail=", ".join(errors), ) - # Update times - booking.start_datetime = data.start_datetime # type: ignore[assignment] - booking.end_datetime = data.end_datetime # type: ignore[assignment] + # Update times (already UTC) + booking.start_datetime = start_datetime_utc # type: ignore[assignment] + booking.end_datetime = end_datetime_utc # type: ignore[assignment] # Sync with Google Calendar if event exists if booking.google_calendar_event_id and booking.user_id: @@ -1351,65 +1356,20 @@ def admin_create_booking( detail="User not found", ) - # Validate booking rules (we need to check overlap with approved bookings only) - # For admin direct booking, we need custom validation: - # 1. Duration limits - # 2. Working hours - # 3. Overlap with approved bookings only (not pending) - from app.models.settings import Settings - from sqlalchemy import and_ + # Convert input times from admin timezone to UTC + user_timezone = current_admin.timezone or "UTC" # type: ignore[attr-defined] + start_datetime_utc = convert_to_utc(booking_data.start_datetime, user_timezone) + end_datetime_utc = convert_to_utc(booking_data.end_datetime, user_timezone) - errors = [] - - # Fetch settings - settings = db.query(Settings).filter(Settings.id == 1).first() - if not settings: - settings = Settings( - id=1, - min_duration_minutes=30, - max_duration_minutes=480, - working_hours_start=8, - working_hours_end=20, - max_bookings_per_day_per_user=3, - min_hours_before_cancel=2, - ) - db.add(settings) - db.commit() - db.refresh(settings) - - # a) Validate duration in range - duration_minutes = (booking_data.end_datetime - booking_data.start_datetime).total_seconds() / 60 - if ( - duration_minutes < settings.min_duration_minutes - or duration_minutes > settings.max_duration_minutes - ): - errors.append( - f"Durata rezervării trebuie să fie între {settings.min_duration_minutes} " - f"și {settings.max_duration_minutes} minute" - ) - - # b) Validate working hours - if ( - booking_data.start_datetime.hour < settings.working_hours_start - or booking_data.end_datetime.hour > settings.working_hours_end - ): - errors.append( - f"Rezervările sunt permise doar între {settings.working_hours_start}:00 " - f"și {settings.working_hours_end}:00" - ) - - # c) Check for overlapping approved bookings only - overlapping_bookings = db.query(Booking).filter( - Booking.space_id == booking_data.space_id, - Booking.status == "approved", # Only check approved bookings - and_( - Booking.start_datetime < booking_data.end_datetime, - Booking.end_datetime > booking_data.start_datetime, - ), - ).first() - - if overlapping_bookings: - errors.append("Spațiul este deja rezervat în acest interval") + # Validate booking rules (using UTC times, same as create_booking) + errors = validate_booking_rules( + db=db, + space_id=booking_data.space_id, + user_id=target_user_id, + start_datetime=start_datetime_utc, + end_datetime=end_datetime_utc, + user_timezone=user_timezone, + ) if errors: raise HTTPException( @@ -1417,12 +1377,12 @@ def admin_create_booking( detail=errors[0], # Return first error ) - # Create booking with approved status + # Create booking with approved status (UTC times) booking = Booking( user_id=target_user_id, space_id=booking_data.space_id, - start_datetime=booking_data.start_datetime, - end_datetime=booking_data.end_datetime, + start_datetime=start_datetime_utc, + end_datetime=end_datetime_utc, title=booking_data.title, description=booking_data.description, status="approved", # Direct approval, bypass pending state