# Book a Meeting Design

## Context

The dashboard already shows a "Book a meeting" card ("Reads free slots,
suggests times and sends a calendar invite once confirmed") with a provider
dropdown offering Google Calendar, Google Meet, Zoom, and Microsoft Teams. It
is currently "Coming soon" and toggled off.

Investigation found:

- **No backend code exists for this feature anywhere in `strategist-ai`.** No
  OAuth client, no API integration, no chat tool, for any of the four
  providers. A repo-wide search for "zoom", "teams", "calendar" (excluding
  unrelated Firestore/GCP hits) turned up nothing.
- **The OAuth *connect* flow already works, and tenants have used it.**
  Staging's `galaxiq_tenants` database has `ACTIVE` rows in several tenants'
  per-schema `integrations` table for `google-calendar`, `teams`, and `zoom`,
  each with a real `accessToken`/`refreshToken` (`authType: oauth2`) and an
  `expiresAt` about an hour after connection. This was written by a
  *different* service — not this repo — using the same generic
  connect-and-store-tokens mechanism already used for Twitter, LinkedIn,
  Facebook, Instagram, TikTok, and Pinterest.
- **Nothing reads these tokens or acts on them.** The gap this spec closes is
  entirely on the "do something with an already-connected account" side, not
  the OAuth handshake.

This project is scoped to **Google Calendar and Microsoft Teams only.**
Zoom is excluded: it has no calendar or free/busy API of its own, and every
alternative for supporting it (a new local bookings table, or treating a
tenant's Zoom selection as invalid without a paired calendar connection) was
judged not worth the complexity for this phase. Google Meet is out of scope
throughout — it was never asked for.

## Goal

Give the chatbot a real `book_meeting` capability for a tenant whose active
meeting provider is Google Calendar or Microsoft Teams: check free/busy live
against the provider's calendar, book a slot immediately when the visitor
picks one, and cancel a previously booked meeting on request — all through
new AI chat tools that plug into the same `TOOL_SCHEMAS`/`TOOL_EXECUTORS`
pattern `send_email` and `create_or_update_ticket` already use.

## Architecture

No new database table. Both supported providers have a real calendar with a
free/busy API and a queryable event list, so the provider's own calendar is
the single source of truth for availability, for what was booked, and for
what to cancel — read and written live on every call, the same way
`get_mailchimp_connection` reads a tenant's Mailchimp connection fresh on
every `send_email` rather than caching it.

```
visitor: "can I book a call tomorrow afternoon?"
  -> model calls check_availability(date_range)
       -> execute_check_availability reads tenant's active book_meeting
          provider from tools_settings
       -> app/services/integrations/{google_calendar,teams}.py:
          get_connection(tenant_id) -- reads + refreshes the OAuth token
       -> provider's free/busy API, returns open slots
  -> model relays 2-3 slots to the visitor in chat
visitor: "2pm works"
  -> model calls book_meeting(start_time, visitor_email, visitor_name)
       -> re-checks that slot is still free (race-condition guard: another
          visitor could have taken it between suggestion and pick)
       -> provider's create-event API, with the visitor as an attendee
          (this is what sends the calendar invite)
       -> returns confirmation + any meeting link the provider gives back
          (Google Calendar can attach a Meet link; Teams events created
          with isOnlineMeeting=true get a Teams join link automatically)
visitor: "actually cancel that"
  -> model calls cancel_meeting(visitor_email, approximate time or none)
       -> provider's calendar search (by attendee email, optionally
          narrowed by time) to find the event
       -> provider's delete-event API
```

## Components

### `app/services/integrations/google_calendar.py` (new)

Mirrors `mailchimp.py`'s shape exactly:

```python
def get_google_calendar_connection(tenant_id: str) -> dict | None:
    """This tenant's active Google Calendar connection, refreshed if its
    access token has expired. Returns {"access_token", "calendar_email",
    "timezone"} -- everything the calendar calls below need. `timezone` is
    read from calendars.get("primary").timeZone on the same trip, using the
    business's own configured timezone rather than guessing one from an
    address field or asking the visitor's browser -- there is nowhere else
    in this codebase a tenant's business timezone is stored, and Google
    already has the authoritative answer for a calendar we are already
    calling. None covers every reason it cannot be used: no connection, a
    disconnected one, or a refresh failure (revoked consent)."""

def check_availability(connection: dict, start: datetime, end: datetime) -> list[dict]:
    """Calls Google Calendar's freebusy.query. Returns open slots within
    [start, end], not busy blocks -- callers want what they can offer, not
    what to avoid. Slot times are returned in connection["timezone"] so the
    model relays them in the business's own local time."""

def create_event(connection: dict, start: datetime, end: datetime,
                 visitor_email: str, visitor_name: str) -> dict:
    """Calls events.insert with the visitor as an attendee (sendUpdates=all
    is what makes Calendar actually email them the invite) and
    conferenceDataVersion=1 for an auto-attached Meet link. Returns
    {"event_id", "meeting_link"}."""

def cancel_event(connection: dict, visitor_email: str, event_id: str = None,
                 near_time: datetime = None) -> bool:
    """event_id is used directly if the caller has it (nothing in this
    phase persists one between calls, so this is the near_time path in
    practice -- see cancel_meeting's tool description). Otherwise searches
    events.list scoped to attendee=visitor_email and, if given, a narrow
    window around near_time, and deletes the single unambiguous match.
    Returns False (does not guess) if zero or more than one event
    matches."""
```

### `app/services/integrations/teams.py` (new)

Same four functions, over Microsoft Graph instead of the Calendar API.
`get_teams_connection` reads timezone from `GET /me/mailboxSettings` ->
`timeZone` the same way the Google module reads it from `calendars.get`.

- `check_availability` -> `POST /me/calendarView` or `/me/calendar/getSchedule`.
- `create_event` -> `POST /me/events` with `isOnlineMeeting: true`,
  `onlineMeetingProvider: "teamsForBusiness"` -- this one call both books the
  calendar slot and creates the Teams meeting; Graph returns the join URL on
  the created event's `onlineMeeting.joinUrl`.
- `cancel_event` -> `GET /me/events?$filter=...` to find it, `DELETE
  /me/events/{id}` to remove it.

### Token refresh (new — nothing in the repo does this today)

Both `get_google_calendar_connection` and its Teams equivalent check
`expiresAt` before returning a connection; if expired, they call the
provider's token-refresh endpoint using the stored `refreshToken`, then
write the new `accessToken`/`expiresAt` back to the same `integrations` row
before returning. A refresh failure (revoked consent) returns `None`, same
as "never connected" -- the caller doesn't need to tell those apart.

### `app/services/chat/tools.py` (extended)

Three new entries in `TOOL_SCHEMAS`/`TOOL_EXECUTORS`, same pattern as
`send_email`:

- **`check_availability`** — params: `date_range` (a natural description the
  model resolves to a start/end, e.g. "tomorrow afternoon"). Returns a short
  list of open slots for the model to relay in chat. Never books anything.
- **`book_meeting`** — params: `start_time`, `visitor_email`, `visitor_name`.
  Re-validates the slot is still free before creating the event (closes the
  gap between suggesting a slot and the visitor picking it). Books
  immediately on a valid, still-free slot — no separate human-approval step
  (per the immediate-booking decision this spec is written against).
- **`cancel_meeting`** — params: `visitor_email`, optional `approximate_time`.
  Description tells the model: ask for the visitor's email and, if they
  don't recall the exact time, roughly when it was, since the cancel lookup
  is a search, not an ID lookup, and needs enough to disambiguate.

Each executor's first step is identical to `execute_send_email`'s: check the
feature switch (`book_meeting`) and that a provider is actually connected,
resolve which provider (`google_calendar` or `teams`) via
`get_tool_settings(tenant_id)["providers"]["book_meeting"]`, and return a
clear `{"status": "error", "detail": ...}` if either check fails, before
touching any provider API.

### `app/services/chat/tools_settings.py` (extended)

- Add `"book_meeting": "book_meeting"` to `FEATURE_TOOLS` (it is currently
  absent, which is the entire reason the dashboard toggle does nothing today
  — see the module's own docstring).
- Add `"book_meeting": ("google_calendar", "teams")` to `PROVIDER_OPTIONS`.
- Add `"book_meeting": None` to `PROVIDER_DEFAULTS` (nothing is
  pre-selected — same reasoning as `product_recommendation`: don't show a
  tenant as configured for something they never chose).
- Add a connection gate in `save_tool_settings`, mirroring the existing
  Mailchimp gate exactly:

```python
if (providers or {}).get("book_meeting") in ("google_calendar", "teams"):
    provider = providers["book_meeting"]
    connected = (get_google_calendar_connection(tenant_id) if provider == "google_calendar"
                else get_teams_connection(tenant_id))
    if not connected:
        raise ValueError(
            f"Connect {provider.replace('_', ' ').title()} under Integrations "
            f"before selecting it as your meeting provider.")
```

## Data flow / error handling

- **No connection selected, or the selected provider's connection is
  missing/revoked**: every tool returns `{"status": "error", "detail":
  "Connect Google Calendar or Microsoft Teams to book meetings."}` before
  calling any provider API — same shape as `send_email`'s "Connect Mailchimp"
  error.
- **Race condition between suggesting and booking a slot**: `book_meeting`
  re-checks free/busy for the exact requested slot immediately before
  creating the event. If it is no longer free, returns an error telling the
  model to offer alternatives rather than silently booking a conflict.
- **Cancel with an ambiguous match** (zero or multiple events found):
  returns an error naming the ambiguity so the model can ask a clarifying
  question, rather than guessing which event to delete.
- **Provider API failure** (rate limit, transient 5xx): caught and returned
  as a generic `{"status": "error", "detail": "Could not book that meeting
  right now."}`, mirroring `send_via_mandrill`'s "never raise for a delivery
  failure" contract — vendor names and raw error text never reach the model
  or the visitor.
- **Token refresh failure** (revoked consent): treated identically to "never
  connected" — the tenant needs to reconnect under Integrations either way.

## Testing

Every provider call is mocked in tests, following `test_mailchimp_email.py`'s
pattern (`get_google_calendar_connection`/`get_teams_connection` patched
directly rather than mocking `httpx`/the Graph SDK at a lower level, except
in the provider modules' own unit tests, which mock the HTTP layer the same
way `test_mailchimp_email.py` mocks `httpx.post`). Coverage needed:

- Each provider module: connection read, token-refresh-on-expiry, refresh
  failure -> None, free/busy parsing, event creation, event search +
  cancellation (including the zero-match and multiple-match cases).
- Each new chat tool: feature-off, no-connection, happy path, the
  slot-no-longer-free race case, provider-failure-is-caught-not-raised.
- `tools_settings.py`: the new `PROVIDER_OPTIONS`/`PROVIDER_DEFAULTS`
  entries, and the connection gate (both providers, both the connected and
  not-connected cases).

## Out of scope (this phase)

- **Zoom.** No free/busy API of its own; would need either a paired
  calendar connection or a new local bookings table, both deferred.
- **Google Meet.** Never requested; the "Google Calendar" option already
  attaches a Meet link via `conferenceDataVersion=1`, which covers the
  practical need without treating Meet as a separate provider.
- **Human-approval-before-booking flow.** This phase books immediately on a
  valid slot; a merchant-approval step would be a separate, later feature.
- **A merchant-facing "my bookings" list/dashboard.** Nothing in this phase
  persists bookings outside the provider's own calendar, so there is no new
  data to build a dashboard view over yet.
- **Meeting duration configuration.** Fixed at 30 minutes for phase one;
  making it tenant-configurable is a small, separate follow-up once the
  core flow works.
- **Rescheduling** (as distinct from cancel + book again). Not requested;
  cancel_meeting + book_meeting already cover the same outcome in two calls.

## Open questions

1. **Meeting duration**: confirmed above as 30 minutes fixed for phase one —
   flagged here in case that default is wrong for how these businesses
   actually meet.
