const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token" const GOOGLE_CALENDAR_API_BASE = "https://www.googleapis.com/calendar/v3" const DEFAULT_TIME_ZONE = "America/Denver" const DEFAULT_SLOT_MINUTES = 15 const DEFAULT_START_HOUR = 8 const DEFAULT_END_HOUR = 17 const OFFERABLE_WEEKDAYS = new Set([3, 4, 5]) type LocalDateTime = { year: number month: number day: number hour: number minute: number second: number weekday: number } type BusyInterval = { start: number end: number } function getTimeZone() { return process.env.GOOGLE_CALENDAR_TIMEZONE || DEFAULT_TIME_ZONE } function getSlotMinutes() { const value = Number.parseInt( process.env.GOOGLE_CALENDAR_CALLBACK_SLOT_MINUTES || "", 10 ) return Number.isFinite(value) && value > 0 ? value : DEFAULT_SLOT_MINUTES } function getCallbackHours() { const startHour = Number.parseInt( process.env.GOOGLE_CALENDAR_CALLBACK_START_HOUR || "", 10 ) const endHour = Number.parseInt( process.env.GOOGLE_CALENDAR_CALLBACK_END_HOUR || "", 10 ) return { startHour: Number.isFinite(startHour) && startHour >= 0 && startHour <= 23 ? startHour : DEFAULT_START_HOUR, endHour: Number.isFinite(endHour) && endHour >= 1 && endHour <= 24 ? endHour : DEFAULT_END_HOUR, } } function getRequiredConfig() { const clientId = String(process.env.GOOGLE_CALENDAR_CLIENT_ID || "").trim() const clientSecret = String( process.env.GOOGLE_CALENDAR_CLIENT_SECRET || "" ).trim() const refreshToken = String( process.env.GOOGLE_CALENDAR_REFRESH_TOKEN || "" ).trim() const calendarId = String(process.env.GOOGLE_CALENDAR_ID || "").trim() const missing = [ !clientId ? "GOOGLE_CALENDAR_CLIENT_ID" : null, !clientSecret ? "GOOGLE_CALENDAR_CLIENT_SECRET" : null, !refreshToken ? "GOOGLE_CALENDAR_REFRESH_TOKEN" : null, !calendarId ? "GOOGLE_CALENDAR_ID" : null, ].filter(Boolean) if (missing.length > 0) { throw new Error(`${missing.join(", ")} is not configured.`) } return { clientId, clientSecret, refreshToken, calendarId, } } function getLocalDateTime(date: Date, timeZone = getTimeZone()): LocalDateTime { const formatter = new Intl.DateTimeFormat("en-US", { timeZone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, weekday: "short", }) const parts = formatter.formatToParts(date) const values = Object.fromEntries(parts.map((part) => [part.type, part.value])) const weekdayMap: Record = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, } return { year: Number.parseInt(values.year || "0", 10), month: Number.parseInt(values.month || "0", 10), day: Number.parseInt(values.day || "0", 10), hour: Number.parseInt(values.hour || "0", 10), minute: Number.parseInt(values.minute || "0", 10), second: Number.parseInt(values.second || "0", 10), weekday: weekdayMap[values.weekday || "Sun"] ?? 0, } } function getTimeZoneOffsetMs(date: Date, timeZone = getTimeZone()) { const parts = getLocalDateTime(date, timeZone) const asUtc = Date.UTC( parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second ) return asUtc - date.getTime() } function zonedDateTimeToUtc( year: number, month: number, day: number, hour: number, minute: number, second = 0, timeZone = getTimeZone() ) { const utcGuess = new Date(Date.UTC(year, month - 1, day, hour, minute, second)) const offset = getTimeZoneOffsetMs(utcGuess, timeZone) return new Date(utcGuess.getTime() - offset) } function addDaysLocal(date: LocalDateTime, days: number) { const utcMidnight = Date.UTC(date.year, date.month - 1, date.day) const next = new Date(utcMidnight + days * 24 * 60 * 60 * 1000) return { year: next.getUTCFullYear(), month: next.getUTCMonth() + 1, day: next.getUTCDate(), } } function roundUpToSlot(date: Date, slotMinutes = getSlotMinutes()) { const rounded = new Date(date.getTime()) rounded.setUTCSeconds(0, 0) const intervalMs = slotMinutes * 60 * 1000 const remainder = rounded.getTime() % intervalMs if (remainder !== 0) { rounded.setTime(rounded.getTime() + (intervalMs - remainder)) } return rounded } function formatSlotLabel(startAt: Date, endAt: Date, timeZone = getTimeZone()) { const startFormatter = new Intl.DateTimeFormat("en-US", { timeZone, weekday: "short", month: "short", day: "numeric", hour: "numeric", minute: "2-digit", }) const endFormatter = new Intl.DateTimeFormat("en-US", { timeZone, hour: "numeric", minute: "2-digit", }) return `${startFormatter.format(startAt)} - ${endFormatter.format(endAt)}` } async function getGoogleAccessToken() { const config = getRequiredConfig() const body = new URLSearchParams({ client_id: config.clientId, client_secret: config.clientSecret, refresh_token: config.refreshToken, grant_type: "refresh_token", }) const response = await fetch(GOOGLE_TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body, }) const data = (await response.json().catch(() => ({}))) as { access_token?: string error?: string error_description?: string } if (!response.ok || !data.access_token) { throw new Error( data.error_description || data.error || "Failed to authenticate with Google Calendar." ) } return { accessToken: data.access_token, calendarId: config.calendarId, } } async function fetchBusyIntervals(startAt: Date, endAt: Date) { const { accessToken, calendarId } = await getGoogleAccessToken() const response = await fetch(`${GOOGLE_CALENDAR_API_BASE}/freeBusy`, { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ timeMin: startAt.toISOString(), timeMax: endAt.toISOString(), timeZone: getTimeZone(), items: [{ id: calendarId }], }), }) const data = (await response.json().catch(() => ({}))) as { calendars?: Record< string, { busy?: Array<{ start: string; end: string }> } > error?: { message?: string } } if (!response.ok) { throw new Error( data.error?.message || "Failed to fetch Google Calendar availability." ) } return (data.calendars?.[calendarId]?.busy || []) .map((entry) => ({ start: new Date(entry.start).getTime(), end: new Date(entry.end).getTime(), })) .filter((entry) => Number.isFinite(entry.start) && Number.isFinite(entry.end)) } function overlapsBusyWindow( startAt: Date, endAt: Date, busyIntervals: BusyInterval[] ) { const start = startAt.getTime() const end = endAt.getTime() return busyIntervals.some((busy) => start < busy.end && end > busy.start) } export async function listFutureCallbackSlots(limit = 3) { const timeZone = getTimeZone() const slotMinutes = getSlotMinutes() const { startHour, endHour } = getCallbackHours() const now = new Date() const nowLocal = getLocalDateTime(now, timeZone) const tomorrow = addDaysLocal(nowLocal, 1) const searchStart = zonedDateTimeToUtc( tomorrow.year, tomorrow.month, tomorrow.day, 0, 0, 0, timeZone ) const searchEnd = new Date(searchStart.getTime() + 21 * 24 * 60 * 60 * 1000) const busyIntervals = await fetchBusyIntervals(searchStart, searchEnd) const slots: Array<{ startAt: string endAt: string displayLabel: string dayLabel: string }> = [] for (let offset = 1; offset <= 21 && slots.length < limit; offset += 1) { const day = addDaysLocal(nowLocal, offset) const dayMarker = zonedDateTimeToUtc( day.year, day.month, day.day, 12, 0, 0, timeZone ) const weekday = getLocalDateTime(dayMarker, timeZone).weekday if (!OFFERABLE_WEEKDAYS.has(weekday)) { continue } for ( let minuteOffset = 0; minuteOffset < (endHour - startHour) * 60 && slots.length < limit; minuteOffset += slotMinutes ) { const hour = startHour + Math.floor(minuteOffset / 60) const minute = minuteOffset % 60 const slotStart = zonedDateTimeToUtc( day.year, day.month, day.day, hour, minute, 0, timeZone ) const slotEnd = new Date(slotStart.getTime() + slotMinutes * 60 * 1000) if (slotStart.getTime() <= now.getTime()) { continue } if (overlapsBusyWindow(slotStart, slotEnd, busyIntervals)) { continue } slots.push({ startAt: slotStart.toISOString(), endAt: slotEnd.toISOString(), displayLabel: formatSlotLabel(slotStart, slotEnd, timeZone), dayLabel: formatSlotLabel(slotStart, slotEnd, timeZone).split(" - ")[0], }) } } return slots } export async function createFollowupReminderEvent(args: { title: string description: string startAt: Date endAt: Date }) { const { accessToken, calendarId } = await getGoogleAccessToken() const timeZone = getTimeZone() const response = await fetch( `${GOOGLE_CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events`, { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ summary: args.title, description: args.description, start: { dateTime: args.startAt.toISOString(), timeZone, }, end: { dateTime: args.endAt.toISOString(), timeZone, }, }), } ) const data = (await response.json().catch(() => ({}))) as { id?: string htmlLink?: string error?: { message?: string } } if (!response.ok || !data.id) { throw new Error( data.error?.message || "Failed to create the Google Calendar reminder." ) } return { eventId: data.id, htmlLink: data.htmlLink || "", } } export function buildSameDayReminderWindow() { const slotMinutes = getSlotMinutes() const startAt = roundUpToSlot(new Date(), slotMinutes) const endAt = new Date(startAt.getTime() + slotMinutes * 60 * 1000) return { startAt, endAt, } }