Rocky_Mountain_Vending/lib/google-calendar.ts

429 lines
10 KiB
TypeScript

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,
}
}
export function isGoogleCalendarConfigured() {
try {
getRequiredConfig()
return true
} catch {
return false
}
}
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<string, number> = {
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,
}
}