""" Google Services — Calendar, Maps, and Gmail integrations. """ import os import base64 import json import math import uuid from datetime import datetime, timedelta from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from email.mime.base import MIMEBase from email import encoders from google.oauth2 import service_account from google.oauth2.credentials import Credentials from googleapiclient.discovery import build import googlemaps import config from core import settings_manager as settings def get_secret(key: str) -> str: """Retrieve a secret from environment variables.""" return os.environ.get(key) # ============================================================ # Google Calendar # ============================================================ def get_calendar_service(): """Initialize and return a Google Calendar API service client. Uses GOOGLE_CALENDAR_CREDENTIALS from secrets (base64-encoded service account JSON). """ credentials_b64 = get_secret("GOOGLE_CALENDAR_CREDENTIALS") credentials_json = base64.b64decode(credentials_b64).decode("utf-8") credentials_info = json.loads(credentials_json) credentials = service_account.Credentials.from_service_account_info( credentials_info, scopes=["https://www.googleapis.com/auth/calendar"] ) service = build("calendar", "v3", credentials=credentials) return service def create_calendar_event(summary: str, description: str, location: str, start_datetime: str, end_datetime: str) -> str: """Create an event on the shared Google Calendar. Args: summary: Event title, e.g., "[Level 1] Robert Williams — John Moustoukas, MD" description: Event details (addresses, contact info) location: Visit address start_datetime: ISO format datetime string end_datetime: ISO format datetime string Returns: The created event's Google Calendar event ID """ service = get_calendar_service() calendar_id = get_secret("SHARED_CALENDAR_ID") event = { "summary": summary, "description": description, "location": location, "start": { "dateTime": start_datetime, "timeZone": settings.get_timezone(), }, "end": { "dateTime": end_datetime, "timeZone": settings.get_timezone(), }, } created_event = service.events().insert(calendarId=calendar_id, body=event).execute() return created_event["id"] def delete_calendar_event(event_id: str) -> bool: """Delete an event from the shared Google Calendar. Returns True if successful. """ try: service = get_calendar_service() calendar_id = get_secret("SHARED_CALENDAR_ID") service.events().delete(calendarId=calendar_id, eventId=event_id).execute() return True except Exception: return False def get_calendar_events(date: str) -> list[dict]: """Fetch all events from the shared calendar for a given date (YYYY-MM-DD). Returns list of event dicts with 'summary', 'start', 'end', 'id'. """ service = get_calendar_service() calendar_id = get_secret("SHARED_CALENDAR_ID") # Create time bounds for the given date date_obj = datetime.strptime(date, "%Y-%m-%d") time_min = date_obj.isoformat()+"Z"# + "T00:00:00Z" claude messed up here time_max = (date_obj + timedelta(days=1)).isoformat()+"Z"# + "T00:00:00Z" print(time_min, time_max) events_result = service.events().list( calendarId=calendar_id, timeMin=time_min, timeMax=time_max, singleEvents=True, orderBy="startTime" ).execute() events = events_result.get("items", []) return [ { "id": event["id"], "summary": event.get("summary", ""), "start": event["start"].get("dateTime", event["start"].get("date")), "end": event["end"].get("dateTime", event["end"].get("date")), } for event in events ] # ============================================================ # Google Maps # ============================================================ def get_maps_client(): """Initialize and return a Google Maps client. Uses GOOGLE_MAPS_API_KEY from secrets. """ return googlemaps.Client(key=get_secret("GOOGLE_MAPS_API_KEY")) def calculate_travel_time(origin_address: str, destination_address: str, arrival_by: datetime) -> dict: """Calculate driving travel time between two addresses using traffic-aware two-pass estimation. Pass 1: Get rough travel duration (no departure_time). Pass 2: Estimate departure = arrival_by - rough duration, then re-query with departure_time to get duration_in_traffic. Final recommended departure = arrival_by - traffic-aware duration. Args: origin_address: Full address string destination_address: Full address string arrival_by: When the provider must arrive (appointment start datetime) Returns: Dict with 'travel_minutes' (int) and 'recommended_departure' (datetime). Returns travel_minutes=0 if same address. Returns travel_minutes=-1 if the API call fails. """ if origin_address.strip().lower() == destination_address.strip().lower(): return {"travel_minutes": 0, "recommended_departure": arrival_by} try: client = get_maps_client() # Pass 1: rough estimate (no traffic) result = client.distance_matrix( origins=[origin_address], destinations=[destination_address], mode="driving" ) element = result["rows"][0]["elements"][0] if element["status"] != "OK": return {"travel_minutes": -1, "recommended_departure": arrival_by} rough_seconds = element["duration"]["value"] rough_minutes = math.ceil(rough_seconds / 60) rough_departure = arrival_by - timedelta(minutes=rough_minutes) # Pass 2: traffic-aware estimate using rough departure time result = client.distance_matrix( origins=[origin_address], destinations=[destination_address], mode="driving", departure_time=rough_departure ) element = result["rows"][0]["elements"][0] if element["status"] != "OK": return {"travel_minutes": rough_minutes, "recommended_departure": arrival_by - timedelta(minutes=rough_minutes)} traffic_seconds = element.get("duration_in_traffic", element["duration"])["value"] traffic_minutes = math.ceil(traffic_seconds / 60) recommended_departure = arrival_by - timedelta(minutes=traffic_minutes) return {"travel_minutes": traffic_minutes, "recommended_departure": recommended_departure} except Exception: return {"travel_minutes": -1, "recommended_departure": arrival_by} def geocode_address(address: str) -> tuple[float, float] | None: """Convert an address string to (latitude, longitude). Returns None if geocoding fails. """ try: client = get_maps_client() result = client.geocode(address) if not result: return None location = result[0]["geometry"]["location"] return (location["lat"], location["lng"]) except Exception: return None def calculate_travel_times_batch(origin: str, destinations: list[str], arrival_by: datetime) -> list[dict]: """Calculate travel times from one origin to multiple destinations using traffic-aware two-pass estimation. Pass 1: Get rough durations for all destinations (no departure_time). Pass 2: Use the max rough duration to compute a single departure_time for the batch, then re-query to get duration_in_traffic per destination. Returns a list of dicts with 'travel_minutes' and 'recommended_departure', in the same order as destinations. """ if not destinations: return [] try: client = get_maps_client() # Pass 1: rough estimates (no traffic) result = client.distance_matrix( origins=[origin], destinations=destinations, mode="driving" ) rough_seconds_list = [] for element in result["rows"][0]["elements"]: if element["status"] == "OK": rough_seconds_list.append(element["duration"]["value"]) else: rough_seconds_list.append(-1) # Use max rough duration to compute a single departure_time for the batch valid_seconds = [s for s in rough_seconds_list if s >= 0] if not valid_seconds: return [{"travel_minutes": -1, "recommended_departure": arrival_by} for _ in destinations] max_rough_minutes = math.ceil(max(valid_seconds) / 60) rough_departure = arrival_by - timedelta(minutes=max_rough_minutes) # Pass 2: traffic-aware estimates result = client.distance_matrix( origins=[origin], destinations=destinations, mode="driving", departure_time=rough_departure ) travel_results = [] for element in result["rows"][0]["elements"]: if element["status"] == "OK": traffic_seconds = element.get("duration_in_traffic", element["duration"])["value"] traffic_minutes = math.ceil(traffic_seconds / 60) travel_results.append({ "travel_minutes": traffic_minutes, "recommended_departure": arrival_by - timedelta(minutes=traffic_minutes), }) else: travel_results.append({"travel_minutes": -1, "recommended_departure": arrival_by}) return travel_results except Exception: return [{"travel_minutes": -1, "recommended_departure": arrival_by} for _ in destinations] # ============================================================ # Gmail # ============================================================ def _build_ics(appointment_details: dict, method: str = "REQUEST") -> str: """Build an .ics calendar string from appointment details. Args: appointment_details: Dict with date, start_time, end_time, patient_name, provider_name, address. method: VCALENDAR METHOD — "REQUEST" for new/updated, "CANCEL" for cancellations. """ appt_date = appointment_details.get("date", "") start_time = appointment_details.get("start_time", "00:00") end_time = appointment_details.get("end_time", "00:00") patient_name = appointment_details.get("patient_name", "") provider_name = appointment_details.get("provider_name", "") address = appointment_details.get("address", "") dt_start = datetime.strptime(f"{appt_date} {start_time}", "%Y-%m-%d %H:%M") dt_end = datetime.strptime(f"{appt_date} {end_time}", "%Y-%m-%d %H:%M") now = datetime.utcnow() uid = uuid.uuid5(uuid.NAMESPACE_URL, f"{appt_date}-{start_time}-{patient_name}-{provider_name}") return ( "BEGIN:VCALENDAR\r\n" "VERSION:2.0\r\n" "PRODID:-//SOS Care Now//Scheduling//EN\r\n" f"METHOD:{method}\r\n" "BEGIN:VEVENT\r\n" f"UID:{uid}\r\n" f"DTSTAMP:{now:%Y%m%dT%H%M%SZ}\r\n" f"DTSTART;TZID={settings.get_timezone()}:{dt_start:%Y%m%dT%H%M%S}\r\n" f"DTEND;TZID={settings.get_timezone()}:{dt_end:%Y%m%dT%H%M%S}\r\n" f"SUMMARY:{config.ORG_NAME} — {patient_name} with {provider_name}\r\n" f"LOCATION:{address}\r\n" f"STATUS:{'CANCELLED' if method == 'CANCEL' else 'CONFIRMED'}\r\n" "END:VEVENT\r\n" "END:VCALENDAR\r\n" ) def get_gmail_service(): """Initialize and return a Gmail API service client. Uses GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, and GMAIL_REFRESH_TOKEN from secrets. The refresh token is exchanged for a short-lived access token by the underlying google-auth library on each request. """ creds = Credentials.from_authorized_user_info( { "client_id": get_secret("GMAIL_CLIENT_ID"), "client_secret": get_secret("GMAIL_CLIENT_SECRET"), "refresh_token": get_secret("GMAIL_REFRESH_TOKEN"), }, scopes=["https://www.googleapis.com/auth/gmail.send"], ) return build("gmail", "v1", credentials=creds, cache_discovery=False) def send_email(to_address: str, subject: str, body: str, ics_content: str | None = None) -> bool: """Send an email via the Gmail API. Uses GMAIL_ADDRESS as the From header and the OAuth credentials in GMAIL_CLIENT_ID / GMAIL_CLIENT_SECRET / GMAIL_REFRESH_TOKEN. If ics_content is provided, attaches it as a .ics calendar invite. Returns True if sent successfully. """ try: gmail_address = get_secret("GMAIL_ADDRESS") message = MIMEMultipart() message["From"] = gmail_address message["To"] = to_address message["Subject"] = subject message.attach(MIMEText(body, "plain")) if ics_content: ics_part = MIMEBase("text", "calendar", method="REQUEST", name="invite.ics") ics_part.set_payload(ics_content.encode("utf-8")) encoders.encode_base64(ics_part) ics_part.add_header("Content-Disposition", "attachment", filename="invite.ics") message.attach(ics_part) raw = base64.urlsafe_b64encode(message.as_bytes()).decode("utf-8") service = get_gmail_service() service.users().messages().send(userId="me", body={"raw": raw}).execute() return True except Exception: return False def send_scheduling_notification(provider_email: str, patient_email: str, appointment_details: dict) -> dict: """Send scheduling confirmation emails to both the provider and patient. Args: provider_email: Provider's email patient_email: Patient's email appointment_details: Dict with keys: patient_name, provider_name, date, start_time, end_time, visit_level, address Returns: Dict with 'provider_sent': bool, 'patient_sent': bool """ patient_name = appointment_details.get("patient_name", "Patient") provider_name = appointment_details.get("provider_name", "Provider") date = appointment_details.get("date", "") start_time = appointment_details.get("start_time", "") end_time = appointment_details.get("end_time", "") visit_level = appointment_details.get("visit_level", "") address = appointment_details.get("address", "") recommended_departure = appointment_details.get("recommended_departure_time", "") # Email to provider provider_subject = f"{config.ORG_NAME} - New Appointment Scheduled" departure_line = f"\nRecommended Departure Time: {recommended_departure}" if recommended_departure else "" provider_body = f"""Hello {provider_name}, A new appointment has been scheduled: Patient: {patient_name} Date: {date} Time: {start_time} - {end_time} Visit Level: {visit_level} Address: {address}{departure_line} Please ensure you arrive on time. Best regards, {config.ORG_NAME} """ # Email to patient patient_subject = f"{config.ORG_NAME} - Appointment Confirmation" patient_body = f"""Hello {patient_name}, Your appointment has been confirmed: Provider: {provider_name} Date: {date} Time: {start_time} - {end_time} Your provider will visit you at: {address} If you need to reschedule or cancel, please contact us as soon as possible. Best regards, {config.ORG_NAME} """ ics = _build_ics(appointment_details) provider_sent = send_email(provider_email, provider_subject, provider_body, ics) if provider_email else False patient_sent = send_email(patient_email, patient_subject, patient_body, ics) if patient_email else False return {"provider_sent": provider_sent, "patient_sent": patient_sent} def send_cancellation_notification(provider_email: str, patient_email: str, appointment_details: dict) -> dict: """Send cancellation notification emails to both the provider and patient. Returns: Dict with 'provider_sent': bool, 'patient_sent': bool """ patient_name = appointment_details.get("patient_name", "Patient") provider_name = appointment_details.get("provider_name", "Provider") date = appointment_details.get("date", "") start_time = appointment_details.get("start_time", "") end_time = appointment_details.get("end_time", "") # Email to provider provider_subject = f"{config.ORG_NAME} - Appointment Cancelled" provider_body = f"""Hello {provider_name}, The following appointment has been cancelled: Patient: {patient_name} Date: {date} Time: {start_time} - {end_time} This time slot is now available for other appointments. Best regards, {config.ORG_NAME} """ # Email to patient patient_subject = f"{config.ORG_NAME} - Appointment Cancellation Confirmation" patient_body = f"""Hello {patient_name}, Your appointment has been cancelled: Provider: {provider_name} Date: {date} Time: {start_time} - {end_time} If you would like to reschedule, please contact us. Best regards, {config.ORG_NAME} """ ics = _build_ics(appointment_details, method="CANCEL") provider_sent = send_email(provider_email, provider_subject, provider_body, ics) if provider_email else False patient_sent = send_email(patient_email, patient_subject, patient_body, ics) if patient_email else False return {"provider_sent": provider_sent, "patient_sent": patient_sent}