Files
calendar/packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts
Volnei MunhozGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1eb6ff3e65 chore: calendar cache optmize (#24533)
* wip

* fix: regenerate Prisma types and fix GoogleCalendarSubscriptionAdapter tests

- Run yarn prisma generate to regenerate Prisma client with missing types (WorkflowType enum)
- Fix addMonthsFromNow helper to properly return the calculated date
- Update GoogleCalendarSubscriptionAdapter to use startOf/endOf day for time ranges
- Update test expectations to use Date objects instead of dayjs objects
- Fix all-day event test to use future date instead of today
- All tests now passing (3599 passed)

Co-Authored-By: Volnei Munhoz <[email protected]>

* fix tests

* parametrize months ahead

* improve names

* Improve based on reviews

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-17 17:20:08 +00:00

217 lines
7.1 KiB
TypeScript

import type { calendar_v3 } from "@googleapis/calendar";
import { v4 as uuid } from "uuid";
import { CalendarAuth } from "@calcom/app-store/googlecalendar/lib/CalendarAuth";
import dayjs from "@calcom/dayjs";
import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService";
import logger from "@calcom/lib/logger";
import type { SelectedCalendar } from "@calcom/prisma/client";
import type {
ICalendarSubscriptionPort,
CalendarSubscriptionResult,
CalendarSubscriptionEvent,
CalendarSubscriptionEventItem,
CalendarCredential,
} from "../lib/CalendarSubscriptionPort.interface";
const log = logger.getSubLogger({ prefix: ["GoogleCalendarSubscriptionAdapter"] });
/**
* Google Calendar Subscription Adapter
*
* This adapter uses the Google Calendar API to create and manage calendar subscriptions
* @see https://developers.google.com/google-apps/calendar/quickstart/nodejs
*/
export class GoogleCalendarSubscriptionAdapter implements ICalendarSubscriptionPort {
private GOOGLE_WEBHOOK_TOKEN = process.env.GOOGLE_WEBHOOK_TOKEN;
private GOOGLE_WEBHOOK_URL = `${
process.env.GOOGLE_WEBHOOK_URL || process.env.NEXT_PUBLIC_WEBAPP_URL
}/api/webhooks/calendar-subscription/google_calendar`;
async validate(request: Request): Promise<boolean> {
const token = request?.headers?.get("X-Goog-Channel-Token");
if (!this.GOOGLE_WEBHOOK_TOKEN) {
log.warn("GOOGLE_WEBHOOK_TOKEN not configured");
return false;
}
if (token !== this.GOOGLE_WEBHOOK_TOKEN) {
log.warn("Invalid webhook token");
return false;
}
return true;
}
async extractChannelId(request: Request): Promise<string | null> {
const channelId = request?.headers?.get("X-Goog-Channel-ID");
if (!channelId) {
log.warn("Missing channel ID in webhook");
return null;
}
return channelId;
}
async subscribe(
selectedCalendar: SelectedCalendar,
credential: CalendarCredential
): Promise<CalendarSubscriptionResult> {
log.debug("Attempt to subscribe to Google Calendar", { externalId: selectedCalendar.externalId });
const MONTH_IN_SECONDS = 60 * 60 * 24 * 30;
const client = await this.getClient(credential);
const result = await client.events.watch({
calendarId: selectedCalendar.externalId,
requestBody: {
id: uuid(),
type: "web_hook",
address: this.GOOGLE_WEBHOOK_URL,
token: this.GOOGLE_WEBHOOK_TOKEN,
params: {
ttl: MONTH_IN_SECONDS.toFixed(0),
},
},
});
const e = result.data?.expiration;
const expiration = e ? new Date(/^\d+$/.test(e) ? +e : e) : null;
return {
provider: "google_calendar",
id: result.data.id,
resourceId: result.data.resourceId,
resourceUri: result.data.resourceUri,
expiration,
};
}
async unsubscribe(selectedCalendar: SelectedCalendar, credential: CalendarCredential): Promise<void> {
log.debug("Attempt to unsubscribe from Google Calendar", { externalId: selectedCalendar.externalId });
const client = await this.getClient(credential);
await client.channels
.stop({
requestBody: {
id: selectedCalendar.channelId as string,
resourceId: selectedCalendar.channelResourceId as string,
},
})
.catch((err) => {
log.error("Error unsubscribing from Google Calendar", err);
throw err;
});
}
async fetchEvents(
selectedCalendar: SelectedCalendar,
credential: CalendarCredential
): Promise<CalendarSubscriptionEvent> {
log.info("Attempt to fetch events from Google Calendar", { externalId: selectedCalendar.externalId });
const client = await this.getClient(credential);
let syncToken = selectedCalendar.syncToken || undefined;
let pageToken;
const params: calendar_v3.Params$Resource$Events$List = {
calendarId: selectedCalendar.externalId,
pageToken,
singleEvents: true,
};
if (!syncToken) {
const now = dayjs().startOf("day");
// first sync or unsync (3 months)
const monthsAhead = now.add(CalendarCacheEventService.MONTHS_AHEAD, "month").endOf("day");
const timeMinISO = now.toISOString();
const timeMaxISO = monthsAhead.toISOString();
params.timeMin = timeMinISO;
params.timeMax = timeMaxISO;
} else {
// incremental sync
params.syncToken = syncToken;
}
const events: calendar_v3.Schema$Event[] = [];
do {
const { data }: { data: calendar_v3.Schema$Events } = await client.events.list(params);
syncToken = data.nextSyncToken || syncToken;
pageToken = data.nextPageToken ?? null;
if (pageToken) {
params.pageToken = pageToken;
}
events.push(...(data.items || []));
} while (pageToken);
return {
provider: "google_calendar",
syncToken: syncToken || null,
items: this.parseEvents(events),
};
}
private parseEvents(events: calendar_v3.Schema$Event[]): CalendarSubscriptionEventItem[] {
const now = dayjs().startOf("day");
const monthsAhead = now.add(CalendarCacheEventService.MONTHS_AHEAD, "month").endOf("day");
function filterEventsWithoutId(event: calendar_v3.Schema$Event) {
return typeof event.id === "string" && !!event.id;
}
function filterEventsByDateRange(event: calendar_v3.Schema$Event) {
const start = dayjs(event.start?.dateTime || event.start?.date);
return !start.isBefore(now) && !start.isAfter(monthsAhead);
}
return events
.filter(filterEventsWithoutId)
.filter(filterEventsByDateRange)
.map((event) => {
// empty or opaque is busy
const busy = !event.transparency || event.transparency === "opaque";
const start = event.start?.dateTime
? new Date(event.start.dateTime)
: event.start?.date
? new Date(event.start.date)
: new Date();
const end = event.end?.dateTime
? new Date(event.end.dateTime)
: event.end?.date
? new Date(event.end.date)
: new Date();
return {
id: event.id as string,
iCalUID: event.iCalUID ?? null,
start,
end,
busy,
summary: event.summary ?? null,
description: event.description ?? null,
location: event.location ?? null,
kind: event.kind ?? null,
etag: event.etag ?? null,
status: event.status ?? null,
isAllDay: typeof event.start?.date === "string" && !event.start?.dateTime ? true : false,
timeZone: event.start?.timeZone ?? null,
recurringEventId: event.recurringEventId ?? null,
originalStartDate: event.originalStartTime?.dateTime
? new Date(event.originalStartTime.dateTime)
: event.originalStartTime?.date
? new Date(event.originalStartTime.date)
: null,
createdAt: event.created ? new Date(event.created) : null,
updatedAt: event.updated ? new Date(event.updated) : null,
};
});
}
private async getClient(credential: CalendarCredential) {
const auth = new CalendarAuth(credential);
return await auth.getClient();
}
}