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>
This commit is contained in:
jtricerolph 2026-07-12 12:15:39 +00:00
commit 8d688b459d
10003 changed files with 1928395 additions and 0 deletions

View file

@ -0,0 +1,972 @@
# 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`:
```python
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)
```python
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:
```python
# 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)
```python
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:
```python
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:
```python
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:
```python
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:
```typescript
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:
```typescript
<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)
```python
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:
```python
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
### Recommended Intervals by Property Size
- **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