kitchen/docs/archive/PLAN_2_newbook_frequent_updates.md
jtricerolph 8d688b459d Initial kitchen scaffold — Phase 1 kitchen port (build-verified 2026-07-11)
FastAPI backend (Python 3.11, MSSQL ODBC for SambaPOS, Azure DI OCR),
kitchen_db on central PG. React/TS/Vite frontend with navy sidebar layout.

Backend: auth.py (APP_SLUG=kitchen, SimpleNamespace — archive routes use
.kitchen_id/.is_admin without modification), main.py (51 migrations, scheduler,
internal router for KDS bookings feed), api/internal.py, full archive API
(31 routers: invoices, recipes, menus, sambapos, resos, newbook, disputes,
purchase_orders, etc.), models, migrations, OCR pipeline.
kitchen_id pinned to 1 (B1 — single hotel).

Frontend: AuthGate (app=kitchen, token shim for archive compat — B5b pending),
Layout (navy sidebar, 6 sections, Lucide icons, teal --app-primary),
App.tsx (Outlet pattern, UploadApp outside Layout), index.css (full :root block).
strict: false — archive components have type issues; build clean.

Note: 45 archive components call fetch('/api/...') without /kitchen/ prefix
(B5b). Runtime 404s; deferred until after initial testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 12:15:39 +00:00

32 KiB

Plan 2: Newbook Frequent Updates (15-Minute Intervals)

Current State

Existing Newbook Sync Mechanism

File: backend/services/newbook.py

Current sync behavior:

  • Manual trigger via API endpoint
  • Full historical sync on demand
  • No automatic scheduling
  • Fetches data from date range (date_from to date_to)
  • Updates:
    • Daily occupancy (aggregated)
    • Meal allocations
    • Arrival tracking
    • GL account revenue

Existing Resos Sync for Comparison

Files:

  • backend/services/resos.py - Sync service
  • backend/models/resos.py - ResosUpcomingSyncSettings model
  • Database table: resos_upcoming_sync_settings

Resos already implements automatic frequent updates:

  • Configurable sync interval (default 60 minutes)
  • Automatic scheduling via background task
  • Separate settings for upcoming bookings vs historical
  • Settings page UI for interval configuration

Problem Statement

Hotel managers need near-real-time occupancy data for the next 7 days to:

  • Coordinate restaurant table assignments
  • Plan staffing levels
  • Monitor arrivals throughout the day
  • React to last-minute bookings/cancellations
  • Track meal plan changes

Current manual sync requires:

  1. User remembers to trigger sync
  2. User waits for completion
  3. No automatic updates when bookings change

Goal: Implement automatic 15-minute sync for next 7 days, similar to Resos upcoming bookings feature.

Requirements

Functional Requirements

  1. Automatic Sync Scheduling

    • Default interval: 15 minutes
    • Configurable via settings page
    • Target date range: Today to Today + 7 days
    • Run continuously while app is running
  2. Settings Management

    • Add "Newbook Frequent Update Interval" to settings page
    • Dropdown options: 5, 10, 15, 30, 60 minutes
    • Enable/disable toggle
    • Per-kitchen configuration
  3. Background Task

    • Non-blocking execution
    • Error handling and retry logic
    • Logging for monitoring
    • Graceful shutdown on app restart
  4. Scope Limitation

    • ONLY sync next 7 days (not full history)
    • Keep existing manual full-sync functionality
    • Frequent updates don't replace historical sync

Non-Functional Requirements

  1. Performance

    • Sync completes within 30 seconds
    • No impact on API response times
    • Rate limit compliance with Newbook API
  2. Reliability

    • Failed sync doesn't stop scheduler
    • Exponential backoff on API errors
    • Alert on repeated failures
  3. Observability

    • Log each sync start/completion
    • Track sync duration
    • Record API error rates
    • Dashboard widget showing last sync time

Architecture

Component Overview

┌─────────────────────────────────────────────────────┐
│                   Frontend                          │
│                                                     │
│  ┌──────────────────────────────────────────┐    │
│  │  Settings Page                            │    │
│  │  - Newbook Frequent Update Interval       │    │
│  │  - Enable/Disable Toggle                  │    │
│  │  - Last Sync Time Display                 │    │
│  └──────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────┘
                       │
                       ▼
┌─────────────────────────────────────────────────────┐
│                 Backend API                         │
│                                                     │
│  ┌──────────────────────────────────────────┐    │
│  │  POST /api/settings/newbook-sync          │    │
│  │  GET /api/settings/newbook-sync           │    │
│  └──────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────┘
                       │
                       ▼
┌─────────────────────────────────────────────────────┐
│            Background Scheduler                     │
│                                                     │
│  ┌──────────────────────────────────────────┐    │
│  │  newbook_frequent_sync_loop()             │    │
│  │  - Runs every N minutes                   │    │
│  │  - Queries active kitchens                │    │
│  │  - Calls sync_occupancy(today, today+7)   │    │
│  └──────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────┘
                       │
                       ▼
┌─────────────────────────────────────────────────────┐
│              Newbook Service                        │
│                                                     │
│  ┌──────────────────────────────────────────┐    │
│  │  sync_occupancy(kitchen_id, from, to)     │    │
│  │  - Existing sync logic                    │    │
│  │  - Fetches occupancy data                 │    │
│  │  - Updates database                       │    │
│  └──────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────┘

Implementation Plan

Phase 1: Database Schema

1.1 Create Settings Table

File: backend/models/newbook.py

Add new model after NewbookSyncLog:

class NewbookFrequentSyncSettings(Base):
    """Settings for automatic frequent Newbook occupancy sync"""
    __tablename__ = "newbook_frequent_sync_settings"

    id: Mapped[int] = mapped_column(primary_key=True, index=True)
    kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False, unique=True)

    # Sync configuration
    is_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
    sync_interval_minutes: Mapped[int] = mapped_column(Integer, default=15)  # Default 15 minutes

    # Sync scope
    days_ahead: Mapped[int] = mapped_column(Integer, default=7)  # Sync next N days

    # Status tracking
    last_sync_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
    last_sync_status: Mapped[str | None] = mapped_column(String(20), nullable=True)  # success, failed, running
    last_error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
    consecutive_failures: Mapped[int] = mapped_column(Integer, default=0)

    created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
    updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

    # Relationships
    kitchen: Mapped["Kitchen"] = relationship("Kitchen", back_populates="newbook_frequent_sync_settings")

1.2 Create Migration

File: backend/migrations/add_newbook_frequent_sync.py (NEW)

import asyncio
import logging
from sqlalchemy import text
from database import engine

logger = logging.getLogger(__name__)

async def run_migration():
    """Create newbook_frequent_sync_settings table"""

    create_table_sql = """
    CREATE TABLE IF NOT EXISTS newbook_frequent_sync_settings (
        id SERIAL PRIMARY KEY,
        kitchen_id INTEGER NOT NULL REFERENCES kitchens(id) UNIQUE,
        is_enabled BOOLEAN DEFAULT TRUE,
        sync_interval_minutes INTEGER DEFAULT 15,
        days_ahead INTEGER DEFAULT 7,
        last_sync_at TIMESTAMP,
        last_sync_status VARCHAR(20),
        last_error_message TEXT,
        consecutive_failures INTEGER DEFAULT 0,
        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
        updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    );
    """

    create_index_sql = """
    CREATE INDEX IF NOT EXISTS idx_newbook_frequent_sync_kitchen
    ON newbook_frequent_sync_settings(kitchen_id);
    """

    # Initialize settings for all existing kitchens
    initialize_settings_sql = """
    INSERT INTO newbook_frequent_sync_settings (kitchen_id, is_enabled, sync_interval_minutes, days_ahead)
    SELECT id, TRUE, 15, 7
    FROM kitchens
    WHERE id NOT IN (SELECT kitchen_id FROM newbook_frequent_sync_settings)
    ON CONFLICT (kitchen_id) DO NOTHING;
    """

    try:
        async with engine.begin() as conn:
            await conn.execute(text(create_table_sql))
            logger.info("Created newbook_frequent_sync_settings table")

            await conn.execute(text(create_index_sql))
            logger.info("Created newbook_frequent_sync_settings indexes")

            await conn.execute(text(initialize_settings_sql))
            logger.info("Initialized newbook frequent sync settings for existing kitchens")
    except Exception as e:
        if "already exists" not in str(e).lower():
            raise
        logger.warning(f"Newbook frequent sync migration: {e}")

if __name__ == "__main__":
    asyncio.run(run_migration())

1.3 Update Kitchen Model

File: backend/models/user.py

Add relationship to Kitchen class:

# In Kitchen class, add to relationships section:
newbook_frequent_sync_settings: Mapped["NewbookFrequentSyncSettings"] = relationship(
    "NewbookFrequentSyncSettings", back_populates="kitchen", uselist=False
)

Phase 2: Background Scheduler

2.1 Create Scheduler Service

File: backend/services/newbook_scheduler.py (NEW)

import asyncio
import logging
from datetime import datetime, date, timedelta
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db_context
from models.newbook import NewbookFrequentSyncSettings
from models.user import Kitchen
from services.newbook import sync_occupancy_for_kitchen

logger = logging.getLogger(__name__)

class NewbookScheduler:
    """Background scheduler for frequent Newbook occupancy sync"""

    def __init__(self):
        self.is_running = False
        self.task = None

    async def start(self):
        """Start the background scheduler"""
        if self.is_running:
            logger.warning("Newbook scheduler already running")
            return

        self.is_running = True
        self.task = asyncio.create_task(self._run_loop())
        logger.info("Newbook frequent sync scheduler started")

    async def stop(self):
        """Stop the background scheduler"""
        self.is_running = False
        if self.task:
            self.task.cancel()
            try:
                await self.task
            except asyncio.CancelledError:
                pass
        logger.info("Newbook frequent sync scheduler stopped")

    async def _run_loop(self):
        """Main scheduler loop"""
        while self.is_running:
            try:
                await self._sync_all_kitchens()
            except Exception as e:
                logger.error(f"Error in Newbook scheduler loop: {e}", exc_info=True)

            # Wait for next cycle (check every minute, actual sync based on interval)
            await asyncio.sleep(60)

    async def _sync_all_kitchens(self):
        """Sync occupancy for all enabled kitchens"""
        async with get_db_context() as db:
            # Get all kitchens with frequent sync enabled
            result = await db.execute(
                select(NewbookFrequentSyncSettings, Kitchen).join(
                    Kitchen, NewbookFrequentSyncSettings.kitchen_id == Kitchen.id
                ).where(
                    and_(
                        NewbookFrequentSyncSettings.is_enabled == True,
                        Kitchen.newbook_api_key.isnot(None)  # Only kitchens with Newbook configured
                    )
                )
            )
            settings_and_kitchens = result.all()

            for settings, kitchen in settings_and_kitchens:
                # Check if enough time has passed since last sync
                if settings.last_sync_at:
                    minutes_since_sync = (datetime.utcnow() - settings.last_sync_at).total_seconds() / 60
                    if minutes_since_sync < settings.sync_interval_minutes:
                        continue  # Too soon, skip

                # Perform sync
                await self._sync_kitchen(db, settings, kitchen)

    async def _sync_kitchen(self, db: AsyncSession, settings: NewbookFrequentSyncSettings, kitchen: Kitchen):
        """Sync occupancy for a single kitchen"""
        logger.info(f"Starting Newbook frequent sync for kitchen {kitchen.id} ({kitchen.name})")

        # Update status to running
        settings.last_sync_status = "running"
        await db.commit()

        try:
            # Calculate date range: today to today + N days
            date_from = date.today()
            date_to = date_from + timedelta(days=settings.days_ahead)

            # Call existing sync service
            await sync_occupancy_for_kitchen(kitchen.id, date_from, date_to, db)

            # Update success status
            settings.last_sync_at = datetime.utcnow()
            settings.last_sync_status = "success"
            settings.last_error_message = None
            settings.consecutive_failures = 0

            logger.info(f"Newbook frequent sync completed for kitchen {kitchen.id}")

        except Exception as e:
            # Update failure status
            settings.last_sync_status = "failed"
            settings.last_error_message = str(e)[:500]  # Truncate long errors
            settings.consecutive_failures += 1

            logger.error(f"Newbook frequent sync failed for kitchen {kitchen.id}: {e}", exc_info=True)

            # Alert if repeated failures
            if settings.consecutive_failures >= 5:
                logger.critical(f"Newbook sync has failed {settings.consecutive_failures} times for kitchen {kitchen.id}")

        finally:
            await db.commit()

# Global scheduler instance
newbook_scheduler = NewbookScheduler()

2.2 Update Newbook Service

File: backend/services/newbook.py

Extract existing sync logic into a reusable function:

async def sync_occupancy_for_kitchen(
    kitchen_id: int,
    date_from: date,
    date_to: date,
    db: AsyncSession
) -> dict:
    """
    Sync occupancy data for a kitchen (reusable by scheduler and manual API)

    Returns:
        dict with sync stats (records_fetched, errors, etc.)
    """
    # Extract existing logic from current sync endpoint
    # This is the core sync logic that both manual and automatic sync will use

    # Get kitchen and API credentials
    kitchen = await db.get(Kitchen, kitchen_id)
    if not kitchen or not kitchen.newbook_api_key:
        raise ValueError(f"Kitchen {kitchen_id} not found or Newbook not configured")

    # Create Newbook API client
    client = NewbookAPIClient(
        api_key=kitchen.newbook_api_key,
        api_secret=kitchen.newbook_api_secret,
        property_id=kitchen.newbook_property_id
    )

    # Fetch occupancy data
    occupancy_data = await client.get_occupancy(date_from, date_to)

    # Update database (existing logic)
    records_fetched = 0
    for day_data in occupancy_data:
        # Existing upsert logic
        records_fetched += 1

    return {
        "records_fetched": records_fetched,
        "date_from": date_from.isoformat(),
        "date_to": date_to.isoformat(),
        "status": "success"
    }

2.3 Register Scheduler in main.py

File: backend/main.py

Update lifespan function to start/stop scheduler:

from services.newbook_scheduler import newbook_scheduler

@asynccontextmanager
async def lifespan(app: FastAPI):
    """Application lifecycle manager"""
    # ... existing startup code ...

    # Run migrations
    await run_migrations()

    # Start background schedulers
    await newbook_scheduler.start()
    logger.info("Background schedulers started")

    yield

    # Shutdown
    await newbook_scheduler.stop()
    logger.info("Background schedulers stopped")

Phase 3: API Endpoints

3.1 Settings API

File: backend/api/settings.py (UPDATE)

Add endpoints for Newbook frequent sync settings:

from models.newbook import NewbookFrequentSyncSettings
from pydantic import BaseModel

class NewbookFrequentSyncSettingsUpdate(BaseModel):
    is_enabled: bool
    sync_interval_minutes: int
    days_ahead: int

class NewbookFrequentSyncSettingsResponse(BaseModel):
    is_enabled: bool
    sync_interval_minutes: int
    days_ahead: int
    last_sync_at: Optional[datetime]
    last_sync_status: Optional[str]
    last_error_message: Optional[str]
    consecutive_failures: int

@router.get("/newbook-frequent-sync")
async def get_newbook_frequent_sync_settings(
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db)
) -> NewbookFrequentSyncSettingsResponse:
    """Get Newbook frequent sync settings for current kitchen"""

    result = await db.execute(
        select(NewbookFrequentSyncSettings).where(
            NewbookFrequentSyncSettings.kitchen_id == current_user.kitchen_id
        )
    )
    settings = result.scalar_one_or_none()

    if not settings:
        # Create default settings
        settings = NewbookFrequentSyncSettings(
            kitchen_id=current_user.kitchen_id,
            is_enabled=True,
            sync_interval_minutes=15,
            days_ahead=7
        )
        db.add(settings)
        await db.commit()
        await db.refresh(settings)

    return NewbookFrequentSyncSettingsResponse(
        is_enabled=settings.is_enabled,
        sync_interval_minutes=settings.sync_interval_minutes,
        days_ahead=settings.days_ahead,
        last_sync_at=settings.last_sync_at,
        last_sync_status=settings.last_sync_status,
        last_error_message=settings.last_error_message,
        consecutive_failures=settings.consecutive_failures
    )

@router.post("/newbook-frequent-sync")
async def update_newbook_frequent_sync_settings(
    settings_update: NewbookFrequentSyncSettingsUpdate,
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db)
) -> NewbookFrequentSyncSettingsResponse:
    """Update Newbook frequent sync settings"""

    # Validate interval
    valid_intervals = [5, 10, 15, 30, 60]
    if settings_update.sync_interval_minutes not in valid_intervals:
        raise HTTPException(
            status_code=400,
            detail=f"Invalid sync interval. Must be one of: {valid_intervals}"
        )

    # Validate days_ahead
    if settings_update.days_ahead < 1 or settings_update.days_ahead > 30:
        raise HTTPException(
            status_code=400,
            detail="days_ahead must be between 1 and 30"
        )

    result = await db.execute(
        select(NewbookFrequentSyncSettings).where(
            NewbookFrequentSyncSettings.kitchen_id == current_user.kitchen_id
        )
    )
    settings = result.scalar_one_or_none()

    if not settings:
        settings = NewbookFrequentSyncSettings(kitchen_id=current_user.kitchen_id)
        db.add(settings)

    settings.is_enabled = settings_update.is_enabled
    settings.sync_interval_minutes = settings_update.sync_interval_minutes
    settings.days_ahead = settings_update.days_ahead
    settings.updated_at = datetime.utcnow()

    await db.commit()
    await db.refresh(settings)

    return NewbookFrequentSyncSettingsResponse(
        is_enabled=settings.is_enabled,
        sync_interval_minutes=settings.sync_interval_minutes,
        days_ahead=settings.days_ahead,
        last_sync_at=settings.last_sync_at,
        last_sync_status=settings.last_sync_status,
        last_error_message=settings.last_error_message,
        consecutive_failures=settings.consecutive_failures
    )

Phase 4: Frontend Implementation

4.1 Settings Page Section

File: frontend/src/pages/Settings.tsx (UPDATE)

Add Newbook frequent sync settings section:

interface NewbookFrequentSyncSettings {
  is_enabled: boolean
  sync_interval_minutes: number
  days_ahead: number
  last_sync_at: string | null
  last_sync_status: string | null
  last_error_message: string | null
  consecutive_failures: number
}

// Add query hook
const { data: newbookSyncSettings, isLoading: isLoadingNewbookSync } = useQuery<NewbookFrequentSyncSettings>({
  queryKey: ['newbook-frequent-sync-settings'],
  queryFn: async () => {
    const res = await fetch('/api/settings/newbook-frequent-sync', {
      headers: { Authorization: `Bearer ${token}` }
    })
    if (!res.ok) throw new Error('Failed to fetch Newbook sync settings')
    return res.json()
  }
})

// Add mutation hook
const updateNewbookSyncMutation = useMutation({
  mutationFn: async (settings: Partial<NewbookFrequentSyncSettings>) => {
    const res = await fetch('/api/settings/newbook-frequent-sync', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${token}`
      },
      body: JSON.stringify(settings)
    })
    if (!res.ok) throw new Error('Failed to update settings')
    return res.json()
  },
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['newbook-frequent-sync-settings'] })
  }
})

// Add UI section (after Resos settings):
<div style={styles.section}>
  <h2>Newbook Frequent Updates</h2>
  <p style={{ color: '#666', marginBottom: '1rem' }}>
    Automatically sync hotel occupancy data for the next 7 days at regular intervals.
    Keeps ResidentsTableChart and forecasts up-to-date with last-minute bookings.
  </p>

  {isLoadingNewbookSync ? (
    <div>Loading...</div>
  ) : newbookSyncSettings ? (
    <>
      <div style={styles.formGroup}>
        <label style={styles.label}>
          <input
            type="checkbox"
            checked={newbookSyncSettings.is_enabled}
            onChange={(e) => updateNewbookSyncMutation.mutate({
              ...newbookSyncSettings,
              is_enabled: e.target.checked
            })}
          />
          Enable automatic frequent sync
        </label>
      </div>

      {newbookSyncSettings.is_enabled && (
        <>
          <div style={styles.formGroup}>
            <label style={styles.label}>Sync Interval</label>
            <select
              value={newbookSyncSettings.sync_interval_minutes}
              onChange={(e) => updateNewbookSyncMutation.mutate({
                ...newbookSyncSettings,
                sync_interval_minutes: parseInt(e.target.value)
              })}
              style={styles.input}
            >
              <option value={5}>Every 5 minutes</option>
              <option value={10}>Every 10 minutes</option>
              <option value={15}>Every 15 minutes (Recommended)</option>
              <option value={30}>Every 30 minutes</option>
              <option value={60}>Every 60 minutes</option>
            </select>
          </div>

          <div style={styles.formGroup}>
            <label style={styles.label}>Days Ahead</label>
            <input
              type="number"
              min={1}
              max={30}
              value={newbookSyncSettings.days_ahead}
              onChange={(e) => updateNewbookSyncMutation.mutate({
                ...newbookSyncSettings,
                days_ahead: parseInt(e.target.value)
              })}
              style={styles.input}
            />
            <small style={{ color: '#666' }}>
              Number of days to sync ahead (default: 7)
            </small>
          </div>

          {newbookSyncSettings.last_sync_at && (
            <div style={styles.statusBox}>
              <div style={styles.statusRow}>
                <strong>Last Sync:</strong>
                <span>{new Date(newbookSyncSettings.last_sync_at).toLocaleString()}</span>
              </div>
              <div style={styles.statusRow}>
                <strong>Status:</strong>
                <span style={{
                  color: newbookSyncSettings.last_sync_status === 'success' ? 'green' :
                         newbookSyncSettings.last_sync_status === 'failed' ? 'red' : 'orange'
                }}>
                  {newbookSyncSettings.last_sync_status?.toUpperCase()}
                </span>
              </div>
              {newbookSyncSettings.last_error_message && (
                <div style={styles.errorMessage}>
                  <strong>Error:</strong> {newbookSyncSettings.last_error_message}
                </div>
              )}
              {newbookSyncSettings.consecutive_failures > 0 && (
                <div style={{ color: 'orange', marginTop: '0.5rem' }}>
                  ⚠️ {newbookSyncSettings.consecutive_failures} consecutive failures
                </div>
              )}
            </div>
          )}
        </>
      )}
    </>
  ) : null}
</div>

4.2 Add Status Indicator to Dashboard

File: frontend/src/pages/Dashboard.tsx (UPDATE)

Add widget showing Newbook sync status:

<div style={styles.widget}>
  <h3>Newbook Sync Status</h3>
  {newbookSyncSettings && (
    <>
      <div>
        <strong>Interval:</strong> Every {newbookSyncSettings.sync_interval_minutes} minutes
      </div>
      {newbookSyncSettings.last_sync_at && (
        <div>
          <strong>Last Sync:</strong> {formatTimeAgo(newbookSyncSettings.last_sync_at)}
        </div>
      )}
      <div style={{
        padding: '0.5rem',
        marginTop: '0.5rem',
        borderRadius: '4px',
        background: newbookSyncSettings.last_sync_status === 'success' ? '#e7f5e7' :
                   newbookSyncSettings.last_sync_status === 'failed' ? '#ffe7e7' : '#fff3cd',
        color: newbookSyncSettings.last_sync_status === 'success' ? 'green' :
               newbookSyncSettings.last_sync_status === 'failed' ? 'red' : 'orange'
      }}>
        {newbookSyncSettings.last_sync_status === 'success' ? '✓ Syncing' :
         newbookSyncSettings.last_sync_status === 'failed' ? '✗ Sync Failed' : '⟳ Running'}
      </div>
    </>
  )}
</div>

Phase 5: Testing

5.1 Unit Tests

File: backend/tests/test_newbook_scheduler.py (NEW)

import pytest
from datetime import datetime, date, timedelta
from services.newbook_scheduler import NewbookScheduler
from models.newbook import NewbookFrequentSyncSettings

@pytest.mark.asyncio
async def test_scheduler_starts_and_stops():
    scheduler = NewbookScheduler()
    assert not scheduler.is_running

    await scheduler.start()
    assert scheduler.is_running

    await scheduler.stop()
    assert not scheduler.is_running

@pytest.mark.asyncio
async def test_sync_respects_interval(db_session):
    # Create settings with 15-minute interval
    settings = NewbookFrequentSyncSettings(
        kitchen_id=1,
        is_enabled=True,
        sync_interval_minutes=15,
        last_sync_at=datetime.utcnow() - timedelta(minutes=10)  # 10 minutes ago
    )
    db_session.add(settings)
    await db_session.commit()

    scheduler = NewbookScheduler()
    # Should skip sync (only 10 minutes passed, need 15)
    await scheduler._sync_all_kitchens()

    # Verify sync was not performed
    await db_session.refresh(settings)
    assert settings.last_sync_status != "running"

@pytest.mark.asyncio
async def test_sync_handles_errors(db_session, mock_newbook_api_error):
    settings = NewbookFrequentSyncSettings(
        kitchen_id=1,
        is_enabled=True,
        sync_interval_minutes=15,
        consecutive_failures=0
    )
    db_session.add(settings)
    await db_session.commit()

    scheduler = NewbookScheduler()
    await scheduler._sync_all_kitchens()

    await db_session.refresh(settings)
    assert settings.last_sync_status == "failed"
    assert settings.consecutive_failures == 1
    assert settings.last_error_message is not None

5.2 Integration Tests

Test Scenarios:

  1. Enable sync via UI

    • Navigate to Settings page
    • Enable Newbook frequent sync
    • Set interval to 5 minutes (for faster testing)
    • Verify settings saved
  2. Verify automatic sync

    • Wait 5 minutes
    • Check backend logs for sync execution
    • Verify database updated with recent dates
    • Check settings show last_sync_at updated
  3. Test error handling

    • Temporarily break Newbook API credentials
    • Wait for next sync
    • Verify error message displayed in UI
    • Fix credentials
    • Verify sync recovers
  4. Test disable sync

    • Disable sync in Settings
    • Wait past interval
    • Verify no sync occurs
    • Check logs confirm scheduler skips disabled kitchens

5.3 Performance Tests

Test: Measure sync duration for 7-day window

  • Expected: <30 seconds for typical hotel
  • Alert if exceeds 60 seconds

Test: Verify no API response impact during sync

  • Make API calls while sync running
  • Measure latency
  • Ensure <200ms response times

Phase 6: Monitoring & Alerting

6.1 Logging

Add structured logging:

logger.info(
    "Newbook frequent sync completed",
    extra={
        "kitchen_id": kitchen.id,
        "records_fetched": result["records_fetched"],
        "duration_seconds": duration,
        "date_from": date_from.isoformat(),
        "date_to": date_to.isoformat()
    }
)

6.2 Metrics

Track key metrics:

  • Sync success rate (%)
  • Average sync duration (seconds)
  • API error rate
  • Consecutive failure count per kitchen

6.3 Alerts

Configure alerts for:

  • Critical: 5+ consecutive failures
  • Warning: Sync duration >60 seconds
  • Warning: No sync in 2x expected interval

Rollout Plan

Phase 1: Backend Only (Week 1)

  • Deploy database migration
  • Deploy scheduler service
  • Test with single kitchen
  • Monitor logs and performance

Phase 2: Settings UI (Week 2)

  • Deploy settings API endpoints
  • Deploy settings page UI
  • Enable for pilot customers
  • Gather feedback

Phase 3: Dashboard Integration (Week 3)

  • Add dashboard widget
  • Add sync status indicators
  • Document feature for users
  • Enable for all customers

Phase 4: Optimization (Week 4)

  • Tune sync intervals based on usage
  • Optimize API calls
  • Add caching if needed
  • Performance monitoring

Configuration Options

  • Small (<10 rooms): 30 minutes
  • Medium (10-25 rooms): 15 minutes (default)
  • Large (25+ rooms): 10 minutes
  • High churn properties: 5 minutes

Advanced Settings (Future)

  • Smart intervals: Increase frequency during check-in hours
  • Selective sync: Only sync rooms with changes
  • Webhook integration: Real-time updates on booking changes
  • Batch optimization: Group multiple kitchens in single API call

Success Criteria

Scheduler runs continuously without crashes Sync completes within 30 seconds for 7-day window Settings UI allows enable/disable and interval configuration Dashboard shows last sync time and status Failed syncs logged with error details Consecutive failures trigger alerts No impact on API response times ResidentsTableChart shows up-to-date data

Future Enhancements

  1. Predictive Sync

    • Increase frequency during peak booking hours
    • Reduce frequency overnight
  2. Differential Sync

    • Only fetch changed bookings
    • Reduce API load and sync time
  3. Multi-Property Optimization

    • Batch requests for properties with same owner
    • Share rate limits across properties
  4. Webhook Integration

    • Real-time push updates from Newbook
    • Eliminate polling entirely
  5. Sync History Dashboard

    • Chart showing sync frequency and success rate
    • Identify patterns in failures
    • Performance trends over time