420 lines
10 KiB
TypeScript
420 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,
|
|
}
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|