Version: 2.0
Date: 2025-11-30
Status: Production Ready
RoomManager is a robust room-capacity guard and assignment layer designed to prevent overbooking in clinic scheduling workflows. It integrates with Jane App (or simulated Jane data) to ensure that appointments never exceed available room capacity, while respecting practitioner-room preferences.
- ✅ Capacity Enforcement: Never allows more simultaneous appointments than available rooms
- ✅ Atomic Booking Operations: Race-condition-safe concurrent booking support
- ✅ Practitioner-Room Preferences: Assigns preferred rooms when available, falls back to any free room
- ✅ Comprehensive Logging: Full audit trail of all bookings, assignments, and conflicts
- ✅ Concurrent Booking Support: Handles simultaneous booking attempts safely
- ✅ Test Suite: Comprehensive automated tests covering all edge cases
-
RoomManager (
backend/room_manager.py)- Main room assignment logic
- Capacity checking
- Practitioner preference handling
- Atomic booking operations
-
RoomAssignment (
backend/room_assignment.py)- Low-level room finding algorithms
- Locking mechanisms
- Assignment record management
-
Database Schema
rooms: Room definitionsappointments_cache: Cached appointments from Janeassignments: Room-to-appointment mappingspractitioner_room_preferences: Practitioner preferencesevents_log: Audit trail
Jane App (or Simulated Data)
↓
Webhook/Polling → Appointment Created
↓
RoomManager.assign_room()
↓
1. Acquire Lock (atomic)
2. Check Capacity
3. Find Best Room (respect preferences)
4. Create Assignment
5. Log Event
6. Release Lock
↓
Assignment Result → Jane App (notes update)
Get list of available rooms for a time slot.
Parameters:
time_slot_start(datetime): Start of time slottime_slot_end(datetime): End of time slotbuffer_minutes(int): Buffer time between appointments (default: 0)
Returns: List of available room dictionaries
Example:
from room_manager import RoomManager
from datetime import datetime, timezone
manager = RoomManager("clinic-001")
start = datetime(2025, 12, 1, 10, 0, tzinfo=timezone.utc)
end = datetime(2025, 12, 1, 11, 0, tzinfo=timezone.utc)
available = await manager.get_available_rooms(start, end)
# Returns: [{"id": "...", "name": "Room A", ...}, ...]Check if there is capacity for a new appointment.
Parameters:
time_slot_start(datetime): Start of time slottime_slot_end(datetime): End of time slotbuffer_minutes(int): Buffer time between appointments
Returns: Tuple of (has_capacity, total_rooms, occupied_rooms)
Example:
has_capacity, total, occupied = await manager.check_capacity(start, end)
if not has_capacity:
print(f"No capacity: {occupied}/{total} rooms occupied")Atomically assign a room to an appointment.
Parameters:
practitioner_id(str): Jane practitioner IDappointment_id(str): Appointment IDappointment_start(datetime): Appointment start timeappointment_end(datetime): Appointment end timeassigned_by(str): Who is making the assignment (default: "system")source(str): Source of assignment (default: "api")buffer_minutes(int): Buffer time between appointments (default: 0)allow_preferred_fallback(bool): If preferred unavailable, use any free room (default: True)
Returns: Dictionary with assignment result:
{
"success": bool,
"room_id": str | None,
"room_name": str | None,
"action": str, # "assigned", "rejected", "waitlisted"
"reason": str | None,
"preferred_room_used": bool,
"capacity_info": {
"total": int,
"occupied": int,
"available": int
},
"assignment_id": str | None
}Example:
result = await manager.assign_room(
practitioner_id="practitioner-123",
appointment_id="apt-456",
appointment_start=start,
appointment_end=end,
assigned_by="admin",
source="webhook"
)
if result["success"]:
print(f"Assigned {result['room_name']} to appointment")
if result["preferred_room_used"]:
print("Used practitioner's preferred room")
else:
print(f"Rejected: {result['reason']}")Release a room assignment (e.g., on cancellation).
Parameters:
appointment_id(str): Appointment IDactor(str): Who is releasing the room (default: "system")
Returns: Dictionary with release result
Example:
result = await manager.release_room("apt-456", actor="admin")
if result["success"]:
print(f"Released {result['room_name']}")Simulate concurrent booking attempts (useful for testing).
Parameters:
requests(List[Dict]): List of booking requests
Returns: List of assignment results
Example:
requests = [
{
"appointment_id": "apt-1",
"practitioner_id": "practitioner-1",
"start": datetime(...),
"end": datetime(...),
"assigned_by": "test",
"source": "simulation"
},
# ... more requests
]
results = await manager.simulate_concurrent_booking(requests)
successful = [r for r in results if r["success"]]
print(f"{len(successful)}/{len(results)} bookings succeeded")Use the preferences API to set practitioner-room preferences:
POST /admin/preferences/practitioner-room
{
"clinic_id": "clinic-001",
"practitioner_id": "practitioner-123",
"room_id": "room-uuid",
"priority": 1 # 1 = preferred, 2 = acceptable, 3 = avoid
}- Preferred Room Available: Assigns preferred room
- Preferred Room Occupied: Falls back to any available room (if
allow_preferred_fallback=True) - No Preference Set: Uses standard room assignment algorithm (minimum overlaps)
- Priority 1: Preferred room (highest priority)
- Priority 2: Acceptable room
- Priority 3: Avoid room (not currently used, reserved for future)
- Pre-Booking Check: Before assignment, checks if
occupied_rooms < total_rooms - Atomic Operation: Check and assignment happen inside a database lock
- Rejection: If capacity exceeded, booking is rejected with clear error message
- Total Rooms: Count of active, non-blocked rooms at appointment time
- Occupied Rooms: Count of distinct rooms with overlapping appointments
- Available:
total_rooms - occupied_rooms
Scenario 1: Capacity Available
- 3 rooms total
- 2 rooms occupied
- New booking → ✅ Allowed (2 < 3)
Scenario 2: Capacity Exceeded
- 3 rooms total
- 3 rooms occupied
- New booking → ❌ Rejected (3 >= 3)
Scenario 3: Concurrent Bookings
- 3 rooms total
- 2 rooms occupied
- 2 simultaneous booking requests
- Result: Only 1 succeeds (atomic lock ensures this)
All events are logged to events_log table:
BOOKING_SUCCESS: Room successfully assignedBOOKING_REJECTED: Booking rejected (capacity exceeded)ROOM_ASSIGNED: Room assignment createdROOM_RELEASED: Room assignment removedPREFERENCE_CREATED: Practitioner-room preference setPREFERENCE_DELETED: Practitioner-room preference removed
{
"clinic_id": "clinic-001",
"event_type": "BOOKING_SUCCESS",
"appointment_id": "apt-456",
"actor": "system",
"details": {
"room_id": "room-uuid",
"room_name": "Room A",
"practitioner_id": "practitioner-123",
"preferred_room_used": true,
"source": "webhook",
"time_slot": {
"start": "2025-12-01T10:00:00Z",
"end": "2025-12-01T11:00:00Z"
}
},
"created_at": "2025-12-01T10:00:00Z"
}-- Get all booking events for a clinic
SELECT * FROM events_log
WHERE clinic_id = 'clinic-001'
AND event_type IN ('BOOKING_SUCCESS', 'BOOKING_REJECTED')
ORDER BY created_at DESC;
-- Get conflicts (rejected bookings)
SELECT * FROM events_log
WHERE clinic_id = 'clinic-001'
AND event_type = 'BOOKING_REJECTED'
AND details->>'reason' = 'capacity_exceeded';# Install test dependencies
pip install pytest pytest-asyncio
# Run all tests
pytest backend/tests/test_room_manager.py -v
# Run specific test
pytest backend/tests/test_room_manager.py::test_concurrent_bookings_race_condition -vThe test suite covers:
- ✅ Basic capacity (N rooms, N appointments)
- ✅ Capacity exceeded ((N+1) appointments blocked)
- ✅ Practitioner preferred room assignment
- ✅ Preferred room fallback when occupied
- ✅ Overlapping appointments detection
- ✅ Adjacent appointments (no gap)
- ✅ Cancellation freeing room
- ✅ Concurrent bookings (race conditions)
- ✅ Get available rooms
- ✅ Check capacity
Set TEST_DATABASE_URL environment variable:
export TEST_DATABASE_URL="postgresql://user:pass@localhost/test_roommanager"Since we don't have an active Jane account, the system works with simulated Jane data structures. The integration is designed to work seamlessly when real Jane data is provided.
| Jane Field | RoomManager Field |
|---|---|
appointment.id |
appointments_cache.id |
appointment.start_at |
appointments_cache.start_at |
appointment.end_at |
appointments_cache.end_at |
appointment.practitioner.id |
appointments_cache.practitioner_id |
appointment.status |
appointments_cache.status |
- Export Jane schedule as CSV
- Import into RoomManager
- Process appointments
- Export "safe schedule" with room assignments
Pros:
- Simple, no API required
- Works with any Jane subscription tier
Cons:
- Manual process
- Not real-time
- Requires reconciliation
- Subscribe to Jane calendar feed (iCal)
- Parse calendar events
- Trigger RoomManager on updates
- Reconcile with Jane schedule
Pros:
- Automated
- Near real-time
Cons:
- Privacy/security concerns
- Fragile (calendar format changes)
- May miss some events
- Configure Jane webhooks
- Receive appointment events
- Process in RoomManager
- Update Jane with room assignments
Pros:
- Real-time
- Reliable
- Official integration
Cons:
- Requires Jane API access
- May not be available
For Production:
- Start with Strategy 1 (Manual CSV sync) for validation
- Move to Strategy 2 (Calendar subscribe) for automation
- Upgrade to Strategy 3 (Webhooks) if Jane API becomes available
For Development/Testing:
- Use simulated data (as currently implemented)
- Test with realistic Jane-like data structures
-
Rooms are Interchangeable: All rooms can be used for any appointment type
- Exception: Practitioner preferences may indicate specialization
-
1:1 Room:Appointment Ratio: Each room can hold 1 appointment at a time
- Future: Could support multi-appointment rooms (group therapy)
-
Appointments Come from Jane: All appointments originate from Jane App
- Reality: Some appointments may be created directly in Jane (bypassing webhooks)
-
Time Zones: All times normalized to UTC
- Assumption: Jane provides UTC times
-
No Official Jane API: Integration relies on unofficial methods
- Risk: May miss appointments created directly in Jane
- Mitigation: Polling/reconciliation jobs
-
Webhook Reliability: Webhooks may be delayed or missed
- Risk: Appointments may not be processed immediately
- Mitigation: Polling fallback
-
State Synchronization: RoomManager state may drift from Jane
- Risk: Room assignments may not reflect in Jane
- Mitigation: Reconciliation jobs, manual sync
-
Force Assign Override: Admins can override capacity checks
- Risk: May create double-bookings
- Mitigation: Validation warnings, audit logging
- Set Practitioner Preferences: Configure preferred rooms for each practitioner
- Monitor Conflicts: Regularly check
BOOKING_REJECTEDevents - Reconcile Daily: Compare RoomManager assignments with Jane schedule
- Review Logs: Check audit trail for unusual patterns
- Always Use RoomManager: Don't bypass capacity checks
- Handle Rejections: Provide clear error messages to users
- Log Everything: All booking operations should be logged
- Test Concurrency: Use
simulate_concurrent_bookingfor testing - Monitor Capacity: Track capacity metrics over time
- Validate Data: Ensure Jane data is properly formatted
- Handle Errors: Gracefully handle Jane API failures
- Reconcile Regularly: Run daily reconciliation jobs
- Monitor Sync: Track sync status and failures
Issue: Bookings Rejected Despite Available Rooms
Cause: Capacity check may be too strict, or rooms may be blocked
Solution:
- Check room status:
SELECT * FROM rooms WHERE clinic_id = '...' - Check room blocks:
SELECT * FROM rooms WHERE block_until > NOW() - Verify capacity: Use
check_capacity()to debug
Issue: Preferred Room Not Assigned
Cause: Preferred room may be occupied or preference not set
Solution:
- Check preferences:
GET /admin/preferences/practitioner-room?practitioner_id=... - Check room availability: Use
get_available_rooms() - Verify preference priority: Should be
priority = 1
Issue: Concurrent Bookings Both Succeed
Cause: Lock may not be working correctly
Solution:
- Check lock implementation: Verify PostgreSQL advisory locks
- Check lock timeout: May need to increase retry delay
- Review logs: Check for lock acquisition failures
Issue: Appointments Missing from RoomManager
Cause: Webhook may have failed or appointment created directly in Jane
Solution:
- Check webhook logs: Review
events_logfor webhook events - Run reconciliation: Compare Jane schedule with RoomManager
- Enable polling: Use polling fallback to catch missed events
- Multi-Appointment Rooms: Support for group therapy rooms (capacity > 1)
- Equipment Requirements: Room assignments based on equipment needs
- Accessibility Requirements: Consider accessibility when assigning rooms
- Waitlist Auto-Assignment: Automatically assign rooms when they free up
- Real-Time Capacity Display: Show available rooms in UI
- Advanced Conflict Resolution: Suggest alternative times when capacity exceeded
- Batch Operations: Assign multiple appointments at once
- Bulk Preference Import: Import preferences from CSV
- Capacity Forecasting: Predict capacity for future dates
- Analytics Endpoints: Room utilization metrics
For issues, questions, or contributions:
- Documentation: See this file and
CODE_REVIEW_REPORT.md - Tests: Run test suite to verify functionality
- Logs: Check
events_logtable for audit trail - API: Use
/healthendpoint to verify system status
- ✅ Added capacity enforcement (pre-booking validation)
- ✅ Implemented practitioner-room preferences
- ✅ Enhanced atomic booking operations
- ✅ Added comprehensive test suite
- ✅ Improved logging and audit trail
- ✅ Added concurrent booking simulation
- ✅ Created API endpoints for preferences
- Basic room assignment
- Webhook handling
- Database schema
from room_manager import RoomManager
from datetime import datetime, timezone
# Initialize manager
manager = RoomManager("clinic-001")
# Create appointment (from Jane webhook)
appointment_data = {
"id": "apt-123",
"practitioner_id": "practitioner-456",
"start_at": "2025-12-01T10:00:00Z",
"end_at": "2025-12-01T11:00:00Z"
}
# Assign room
result = await manager.assign_room(
practitioner_id=appointment_data["practitioner_id"],
appointment_id=appointment_data["id"],
appointment_start=datetime.fromisoformat(appointment_data["start_at"].replace("Z", "+00:00")),
appointment_end=datetime.fromisoformat(appointment_data["end_at"].replace("Z", "+00:00")),
assigned_by="webhook",
source="jane_webhook"
)
if result["success"]:
print(f"✅ Assigned {result['room_name']} to appointment")
if result["preferred_room_used"]:
print(" Used practitioner's preferred room")
else:
print(f"❌ Rejected: {result['reason']}")
print(f" Capacity: {result['capacity_info']['occupied']}/{result['capacity_info']['total']}")
# Later: Cancel appointment
release_result = await manager.release_room("apt-123", actor="admin")
if release_result["success"]:
print(f"✅ Released {release_result['room_name']}")