/**
* 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
}