OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-booking / lib / gcal.js

276 lines · javascript ref:
/**
 * Google Calendar Client
 *
 * Handles OAuth2 token lifecycle, FreeBusy API queries, and caching.
 * Accepts injected httpClient for testing (defaults to bare-fetch).
 * All outbound calls wrapped in try/catch — never blocks slot calculation.
 */

const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'
const GOOGLE_FREEBUSY_URL = 'https://www.googleapis.com/calendar/v3/freeBusy'
const GOOGLE_CALENDARLIST_URL = 'https://www.googleapis.com/calendar/v3/users/me/calendarList'
const SCOPE = 'https://www.googleapis.com/auth/calendar.freebusy https://www.googleapis.com/auth/calendar.calendarlist.readonly'

export class GoogleCalendarClient {
  constructor (options = {}) {
    this.clientId = options.clientId || ''
    this.clientSecret = options.clientSecret || ''
    this.httpClient = options.httpClient || null
    this.cacheTTL = options.cacheTTL != null ? options.cacheTTL : 5 * 60 * 1000
  }

  /**
   * Get the HTTP client (lazy-load bare-fetch if not injected)
   */
  async _fetch (url, opts) {
    if (this.httpClient) return this.httpClient(url, opts)
    if (!this._bareFetch) {
      const mod = await import('bare-fetch')
      this._bareFetch = mod.default || mod
    }
    return this._bareFetch(url, opts)
  }

  /**
   * Build Google OAuth2 authorization URL
   */
  getAuthUrl (redirectUri, state) {
    const params = new URLSearchParams({
      client_id: this.clientId,
      redirect_uri: redirectUri,
      response_type: 'code',
      scope: SCOPE,
      access_type: 'offline',
      prompt: 'consent',
      state
    })
    return GOOGLE_AUTH_URL + '?' + params.toString()
  }

  /**
   * Exchange authorization code for tokens
   */
  async exchangeCode (code, redirectUri) {
    const body = new URLSearchParams({
      code,
      client_id: this.clientId,
      client_secret: this.clientSecret,
      redirect_uri: redirectUri,
      grant_type: 'authorization_code'
    })

    const res = await this._fetch(GOOGLE_TOKEN_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: body.toString()
    })

    const data = await res.json()
    if (!res.ok) throw new Error(data.error_description || data.error || 'Token exchange failed')

    return {
      accessToken: data.access_token,
      refreshToken: data.refresh_token,
      expiresIn: data.expires_in,
      scope: data.scope
    }
  }

  /**
   * Refresh an expired access token
   */
  async refreshAccessToken (refreshToken) {
    const body = new URLSearchParams({
      refresh_token: refreshToken,
      client_id: this.clientId,
      client_secret: this.clientSecret,
      grant_type: 'refresh_token'
    })

    const res = await this._fetch(GOOGLE_TOKEN_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: body.toString()
    })

    const data = await res.json()
    if (!res.ok) throw new Error(data.error_description || data.error || 'Token refresh failed')

    return {
      accessToken: data.access_token,
      expiresIn: data.expires_in
    }
  }

  /**
   * Get a valid access token for a provider (refresh if expired)
   */
  async getValidToken (db, providerId) {
    const entry = await db.get(`gcal:provider:${providerId}`)
    if (!entry || !entry.value.connected) return null

    const gcal = entry.value
    const now = Date.now()

    // Token still valid (with 5-min buffer)
    if (gcal.accessToken && gcal.tokenExpiry && now < gcal.tokenExpiry - 300000) {
      return gcal.accessToken
    }

    // Refresh needed
    if (!gcal.refreshToken) return null

    try {
      const refreshed = await this.refreshAccessToken(gcal.refreshToken)
      gcal.accessToken = refreshed.accessToken
      gcal.tokenExpiry = now + (refreshed.expiresIn * 1000)
      await db.put(`gcal:provider:${providerId}`, gcal)
      return gcal.accessToken
    } catch (err) {
      console.error('[GCal] Token refresh failed for', providerId, err.message)
      // Mark as disconnected if refresh fails (token revoked)
      gcal.connected = false
      await db.put(`gcal:provider:${providerId}`, gcal)
      return null
    }
  }

  /**
   * Fetch free/busy blocks from Google Calendar API
   * Returns merged array of { start, end } (UTC ms timestamps)
   */
  async fetchFreeBusy (accessToken, calendarIds, timeMin, timeMax) {
    const items = calendarIds.map(id => ({ id }))

    const res = await this._fetch(GOOGLE_FREEBUSY_URL, {
      method: 'POST',
      headers: {
        Authorization: 'Bearer ' + accessToken,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ timeMin, timeMax, items })
    })

    const data = await res.json()
    if (!res.ok) throw new Error(data.error?.message || 'FreeBusy query failed')

    return mergeBusyBlocks(data)
  }

  /**
   * Get busy blocks for a specific date, with caching.
   * Returns array of { start, end } in milliseconds-of-day (local time).
   */
  async getBusyBlocksForDate (db, providerId, date, calendarIds) {
    // Check cache
    const cacheKey = `gcal:cache:${providerId}:${date}`
    const cached = await db.get(cacheKey)

    if (cached && Date.now() - cached.value.fetchedAt < this.cacheTTL) {
      return cached.value.busy
    }

    // Fetch from Google
    const accessToken = await this.getValidToken(db, providerId)
    if (!accessToken) return []

    try {
      // Query for the full day in UTC
      const timeMin = date + 'T00:00:00Z'
      const timeMax = date + 'T23:59:59Z'

      const busyUtc = await this.fetchFreeBusy(accessToken, calendarIds, timeMin, timeMax)

      // Convert UTC timestamps to ms-of-day
      // For simplicity, treat as UTC day boundaries (timezone offset applied later if needed)
      const dateStart = new Date(date + 'T00:00:00Z').getTime()
      const busy = busyUtc.map(block => ({
        start: Math.max(0, block.start - dateStart),
        end: Math.min(24 * 60 * 60 * 1000, block.end - dateStart)
      })).filter(b => b.end > 0 && b.start < 24 * 60 * 60 * 1000)

      // Cache the result
      await db.put(cacheKey, { busy, fetchedAt: Date.now() })

      return busy
    } catch (err) {
      console.error('[GCal] FreeBusy fetch failed for', providerId, date, err.message)
      return []
    }
  }

  /**
   * Fetch list of calendars for selection UI
   */
  async fetchCalendarList (accessToken) {
    const res = await this._fetch(GOOGLE_CALENDARLIST_URL + '?minAccessRole=freeBusyReader', {
      headers: { Authorization: 'Bearer ' + accessToken }
    })

    const data = await res.json()
    if (!res.ok) throw new Error(data.error?.message || 'Calendar list failed')

    return (data.items || []).map(cal => ({
      id: cal.id,
      summary: cal.summary || cal.id,
      primary: cal.primary || false
    }))
  }

  /**
   * Disconnect — remove tokens and cached data
   */
  async disconnect (db, providerId) {
    await db.del(`gcal:provider:${providerId}`)

    // Clear all cached busy blocks
    const delKeys = []
    for await (const entry of db.createReadStream({
      gte: `gcal:cache:${providerId}:`,
      lte: `gcal:cache:${providerId}:~`
    })) {
      delKeys.push(entry.key)
    }
    for (const key of delKeys) {
      await db.del(key)
    }
  }
}

/**
 * Merge busy blocks from all calendars in a FreeBusy response.
 * Returns sorted, non-overlapping array of { start, end } (UTC ms).
 */
export function mergeBusyBlocks (freeBusyResponse) {
  const allBusy = []

  const calendars = freeBusyResponse.calendars || {}
  for (const calData of Object.values(calendars)) {
    for (const block of calData.busy || []) {
      allBusy.push({
        start: new Date(block.start).getTime(),
        end: new Date(block.end).getTime()
      })
    }
  }

  if (allBusy.length === 0) return allBusy

  // Sort by start time
  allBusy.sort((a, b) => a.start - b.start)

  // Merge overlapping blocks
  const merged = [allBusy[0]]
  for (let i = 1; i < allBusy.length; i++) {
    const last = merged[merged.length - 1]
    if (allBusy[i].start <= last.end) {
      last.end = Math.max(last.end, allBusy[i].end)
    } else {
      merged.push(allBusy[i])
    }
  }

  return merged
}