"""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 ), }