- Dashboard redesign as command center with filters, quick actions, inline approve/reject - Reusable components: BookingRow, BookingFilters, ActionMenu, BookingPreviewModal, BookingEditModal - Calendar: drag & drop reschedule, eventClick preview modal, grid/list toggle - Mobile: segmented control bookings/calendar toggle, compact pills, responsive layout - Collapsible filters with active count badge - Smart menu positioning with Teleport - Calendar/list bidirectional data sync - Navigation: unified History page, removed AdminPending - Google Calendar OAuth integration - Dark mode contrast improvements, breadcrumb navigation - useLocalStorage composable for state persistence Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
278 lines
8.7 KiB
Python
278 lines
8.7 KiB
Python
"""Google Calendar integration endpoints."""
|
|
import urllib.parse
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
from fastapi.responses import RedirectResponse
|
|
from google_auth_oauthlib.flow import Flow
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.config import settings
|
|
from app.core.deps import get_current_user, get_db
|
|
from app.models.google_calendar_token import GoogleCalendarToken
|
|
from app.models.user import User
|
|
from app.services.google_calendar_service import (
|
|
create_oauth_state,
|
|
decrypt_token,
|
|
encrypt_token,
|
|
revoke_google_token,
|
|
sync_all_bookings,
|
|
verify_oauth_state,
|
|
)
|
|
|
|
router = APIRouter()
|
|
|
|
GOOGLE_SCOPES = [
|
|
"https://www.googleapis.com/auth/calendar",
|
|
"https://www.googleapis.com/auth/calendar.events",
|
|
]
|
|
|
|
|
|
def _get_client_config() -> dict:
|
|
"""Build Google OAuth client configuration."""
|
|
return {
|
|
"web": {
|
|
"client_id": settings.google_client_id,
|
|
"client_secret": settings.google_client_secret,
|
|
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
|
"token_uri": "https://oauth2.googleapis.com/token",
|
|
"redirect_uris": [settings.google_redirect_uri],
|
|
}
|
|
}
|
|
|
|
|
|
@router.get("/integrations/google/connect")
|
|
def connect_google(
|
|
current_user: Annotated[User, Depends(get_current_user)],
|
|
) -> dict[str, str]:
|
|
"""
|
|
Start Google OAuth flow.
|
|
|
|
Returns an authorization URL with a signed state parameter for CSRF prevention.
|
|
The state encodes the user's identity so the callback can identify the user
|
|
without requiring an auth header (since it's a browser redirect from Google).
|
|
"""
|
|
if not settings.google_client_id or not settings.google_client_secret:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="Google Calendar integration not configured",
|
|
)
|
|
|
|
# Create signed state with user_id for CSRF prevention
|
|
state = create_oauth_state(int(current_user.id)) # type: ignore[arg-type]
|
|
|
|
try:
|
|
flow = Flow.from_client_config(
|
|
_get_client_config(),
|
|
scopes=GOOGLE_SCOPES,
|
|
redirect_uri=settings.google_redirect_uri,
|
|
)
|
|
|
|
authorization_url, _ = flow.authorization_url(
|
|
access_type="offline",
|
|
include_granted_scopes="true",
|
|
prompt="consent",
|
|
state=state,
|
|
)
|
|
|
|
return {"authorization_url": authorization_url, "state": state}
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to start OAuth flow: {str(e)}",
|
|
)
|
|
|
|
|
|
@router.get("/integrations/google/callback")
|
|
def google_callback(
|
|
db: Annotated[Session, Depends(get_db)],
|
|
code: Annotated[str | None, Query()] = None,
|
|
state: Annotated[str | None, Query()] = None,
|
|
error: Annotated[str | None, Query()] = None,
|
|
) -> RedirectResponse:
|
|
"""
|
|
Handle Google OAuth callback.
|
|
|
|
This endpoint receives the browser redirect from Google after authorization.
|
|
User identity is verified via the signed state parameter (no auth header needed).
|
|
After processing, redirects to the frontend settings page with status.
|
|
"""
|
|
frontend_settings = f"{settings.frontend_url}/settings"
|
|
|
|
# Handle user denial or Google error
|
|
if error:
|
|
msg = urllib.parse.quote(error)
|
|
return RedirectResponse(
|
|
url=f"{frontend_settings}?google_calendar=error&message={msg}"
|
|
)
|
|
|
|
if not code or not state:
|
|
return RedirectResponse(
|
|
url=f"{frontend_settings}?google_calendar=error&message=Missing+code+or+state"
|
|
)
|
|
|
|
# Verify state and extract user_id (CSRF protection)
|
|
user_id = verify_oauth_state(state)
|
|
if user_id is None:
|
|
return RedirectResponse(
|
|
url=f"{frontend_settings}?google_calendar=error&message=Invalid+or+expired+state"
|
|
)
|
|
|
|
# Verify user exists
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
if not user:
|
|
return RedirectResponse(
|
|
url=f"{frontend_settings}?google_calendar=error&message=User+not+found"
|
|
)
|
|
|
|
if not settings.google_client_id or not settings.google_client_secret:
|
|
return RedirectResponse(
|
|
url=f"{frontend_settings}?google_calendar=error&message=Not+configured"
|
|
)
|
|
|
|
try:
|
|
flow = Flow.from_client_config(
|
|
_get_client_config(),
|
|
scopes=GOOGLE_SCOPES,
|
|
redirect_uri=settings.google_redirect_uri,
|
|
state=state,
|
|
)
|
|
|
|
# Exchange authorization code for tokens
|
|
flow.fetch_token(code=code)
|
|
credentials = flow.credentials
|
|
|
|
# Encrypt tokens for secure database storage
|
|
encrypted_access = encrypt_token(credentials.token)
|
|
encrypted_refresh = (
|
|
encrypt_token(credentials.refresh_token)
|
|
if credentials.refresh_token
|
|
else ""
|
|
)
|
|
|
|
# Store or update tokens
|
|
token_record = (
|
|
db.query(GoogleCalendarToken)
|
|
.filter(GoogleCalendarToken.user_id == user_id)
|
|
.first()
|
|
)
|
|
|
|
if token_record:
|
|
token_record.access_token = encrypted_access # type: ignore[assignment]
|
|
token_record.refresh_token = encrypted_refresh # type: ignore[assignment]
|
|
token_record.token_expiry = credentials.expiry # type: ignore[assignment]
|
|
else:
|
|
token_record = GoogleCalendarToken(
|
|
user_id=user_id, # type: ignore[arg-type]
|
|
access_token=encrypted_access,
|
|
refresh_token=encrypted_refresh,
|
|
token_expiry=credentials.expiry,
|
|
)
|
|
db.add(token_record)
|
|
|
|
db.commit()
|
|
|
|
return RedirectResponse(
|
|
url=f"{frontend_settings}?google_calendar=connected"
|
|
)
|
|
except Exception as e:
|
|
msg = urllib.parse.quote(str(e))
|
|
return RedirectResponse(
|
|
url=f"{frontend_settings}?google_calendar=error&message={msg}"
|
|
)
|
|
|
|
|
|
@router.post("/integrations/google/disconnect")
|
|
def disconnect_google(
|
|
db: Annotated[Session, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(get_current_user)],
|
|
) -> dict[str, str]:
|
|
"""
|
|
Disconnect Google Calendar and revoke access.
|
|
|
|
Attempts to revoke the token with Google, then removes stored tokens.
|
|
Token revocation is best-effort - local tokens are always deleted.
|
|
"""
|
|
token_record = (
|
|
db.query(GoogleCalendarToken)
|
|
.filter(GoogleCalendarToken.user_id == current_user.id)
|
|
.first()
|
|
)
|
|
|
|
if token_record:
|
|
# Attempt to revoke the token with Google (best-effort)
|
|
try:
|
|
access_token = decrypt_token(token_record.access_token)
|
|
revoke_google_token(access_token)
|
|
except Exception:
|
|
pass # Delete locally even if revocation fails
|
|
|
|
db.delete(token_record)
|
|
db.commit()
|
|
|
|
return {"message": "Google Calendar disconnected"}
|
|
|
|
|
|
@router.post("/integrations/google/sync")
|
|
def sync_google_calendar(
|
|
db: Annotated[Session, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(get_current_user)],
|
|
) -> dict:
|
|
"""
|
|
Manually sync all approved future bookings to Google Calendar.
|
|
|
|
Creates calendar events for bookings that don't have one yet,
|
|
and updates existing events for bookings that do.
|
|
|
|
Returns a summary of sync results (created/updated/failed counts).
|
|
"""
|
|
# Check if connected
|
|
token_record = (
|
|
db.query(GoogleCalendarToken)
|
|
.filter(GoogleCalendarToken.user_id == current_user.id)
|
|
.first()
|
|
)
|
|
|
|
if not token_record:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Google Calendar not connected. Please connect first.",
|
|
)
|
|
|
|
result = sync_all_bookings(db, int(current_user.id)) # type: ignore[arg-type]
|
|
|
|
if "error" in result:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=result["error"],
|
|
)
|
|
|
|
return result
|
|
|
|
|
|
@router.get("/integrations/google/status")
|
|
def google_status(
|
|
db: Annotated[Session, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(get_current_user)],
|
|
) -> dict[str, bool | str | None]:
|
|
"""
|
|
Check Google Calendar connection status.
|
|
|
|
Returns whether user has connected their Google Calendar account
|
|
and when the current token expires.
|
|
"""
|
|
token_record = (
|
|
db.query(GoogleCalendarToken)
|
|
.filter(GoogleCalendarToken.user_id == current_user.id)
|
|
.first()
|
|
)
|
|
|
|
return {
|
|
"connected": token_record is not None,
|
|
"expires_at": (
|
|
token_record.token_expiry.isoformat()
|
|
if token_record and token_record.token_expiry
|
|
else None
|
|
),
|
|
}
|