diff --git a/packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts b/packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts index 0ea84b6369..01f1d00291 100644 --- a/packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts +++ b/packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts @@ -3,6 +3,7 @@ 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"; @@ -117,12 +118,12 @@ export class GoogleCalendarSubscriptionAdapter implements ICalendarSubscriptionP }; if (!syncToken) { - const now = dayjs(); + const now = dayjs().startOf("day"); // first sync or unsync (3 months) - const threeMonths = now.add(3, "month"); + const monthsAhead = now.add(CalendarCacheEventService.MONTHS_AHEAD, "month").endOf("day"); const timeMinISO = now.toISOString(); - const timeMaxISO = threeMonths.toISOString(); + const timeMaxISO = monthsAhead.toISOString(); params.timeMin = timeMinISO; params.timeMax = timeMaxISO; } else { @@ -151,8 +152,21 @@ export class GoogleCalendarSubscriptionAdapter implements ICalendarSubscriptionP } 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((event) => typeof event.id === "string" && !!event.id) + .filter(filterEventsWithoutId) + .filter(filterEventsByDateRange) .map((event) => { // empty or opaque is busy const busy = !event.transparency || event.transparency === "opaque"; diff --git a/packages/features/calendar-subscription/adapters/__tests__/GoogleCalendarSubscriptionAdapter.test.ts b/packages/features/calendar-subscription/adapters/__tests__/GoogleCalendarSubscriptionAdapter.test.ts index 9ff5fb664d..15e2f1434b 100644 --- a/packages/features/calendar-subscription/adapters/__tests__/GoogleCalendarSubscriptionAdapter.test.ts +++ b/packages/features/calendar-subscription/adapters/__tests__/GoogleCalendarSubscriptionAdapter.test.ts @@ -2,11 +2,25 @@ import "../__mocks__/CalendarAuth"; import { beforeEach, describe, expect, test, vi } from "vitest"; +import dayjs from "@calcom/dayjs"; import type { SelectedCalendar } from "@calcom/prisma/client"; import type { CredentialForCalendarServiceWithEmail } from "@calcom/types/Credential"; import { GoogleCalendarSubscriptionAdapter } from "../GoogleCalendarSubscription.adapter"; +const addMonthsFromNow = (months: number) => { + const date = dayjs(); + return date.add(months, "months").startOf("day"); +}; + +const today = dayjs().startOf("day"); + +const oneWeekFromNow = today.add(7, "days"); + +const eventEndTime = oneWeekFromNow.add(1, "hours"); + +const channelExpirationDate = new Date(Date.now() + 86400000); + vi.mock("uuid", () => ({ v4: vi.fn().mockReturnValue("test-uuid"), })); @@ -30,16 +44,16 @@ const mockSelectedCalendar: SelectedCalendar = { watchAttempts: 0, maxAttempts: 3, unwatchAttempts: 0, - createdAt: new Date(), - updatedAt: new Date(), + createdAt: today.toDate(), + updatedAt: today.toDate(), channelId: "test-channel-id", channelKind: "web_hook", channelResourceId: "test-resource-id", channelResourceUri: "test-resource-uri", - channelExpiration: new Date(Date.now() + 86400000), - syncSubscribedAt: new Date(), + channelExpiration: channelExpirationDate, + syncSubscribedAt: today.toDate(), syncToken: "test-sync-token", - syncedAt: new Date(), + syncedAt: today.toDate(), syncErrorAt: null, syncErrorCount: 0, }; @@ -176,7 +190,7 @@ describe("GoogleCalendarSubscriptionAdapter", () => { id: "test-channel-id", resourceId: "test-resource-id", resourceUri: "test-resource-uri", - expiration: "1640995200000", + expiration: String(channelExpirationDate.getTime()), }, }; @@ -202,17 +216,18 @@ describe("GoogleCalendarSubscriptionAdapter", () => { id: "test-channel-id", resourceId: "test-resource-id", resourceUri: "test-resource-uri", - expiration: new Date(1640995200000), + expiration: channelExpirationDate, }); }); test("should handle expiration as ISO string", async () => { + const mockExpirationDate = new Date(Date.now() + 10000000); const mockWatchResponse = { data: { id: "test-channel-id", resourceId: "test-resource-id", resourceUri: "test-resource-uri", - expiration: "2023-12-01T10:00:00Z", + expiration: mockExpirationDate.toISOString(), }, }; @@ -220,7 +235,7 @@ describe("GoogleCalendarSubscriptionAdapter", () => { const result = await adapter.subscribe(mockSelectedCalendar, mockCredential); - expect(result.expiration).toEqual(new Date("2023-12-01T10:00:00Z")); + expect(result.expiration).toEqual(mockExpirationDate); }); test("should handle missing expiration", async () => { @@ -265,6 +280,19 @@ describe("GoogleCalendarSubscriptionAdapter", () => { }); describe("fetchEvents", () => { + const commonEventData = { + iCalUID: "event-1@cal.com", + summary: "Test Event", + description: "Test Description", + location: "Test Location", + status: "confirmed", + transparency: "opaque", + kind: "calendar#event", + etag: "test-etag", + created: today.toISOString(), + updated: today.toISOString(), + }; + test("should fetch events with sync token", async () => { const mockEventsResponse = { data: { @@ -272,23 +300,14 @@ describe("GoogleCalendarSubscriptionAdapter", () => { items: [ { id: "event-1", - iCalUID: "event-1@cal.com", - summary: "Test Event", - description: "Test Description", - location: "Test Location", + ...commonEventData, start: { - dateTime: "2023-12-01T10:00:00Z", + dateTime: oneWeekFromNow.toISOString(), timeZone: "UTC", }, end: { - dateTime: "2023-12-01T11:00:00Z", + dateTime: eventEndTime.toISOString(), }, - status: "confirmed", - transparency: "opaque", - kind: "calendar#event", - etag: "test-etag", - created: "2023-12-01T09:00:00Z", - updated: "2023-12-01T09:30:00Z", }, ], }, @@ -312,8 +331,8 @@ describe("GoogleCalendarSubscriptionAdapter", () => { { id: "event-1", iCalUID: "event-1@cal.com", - start: new Date("2023-12-01T10:00:00Z"), - end: new Date("2023-12-01T11:00:00Z"), + start: oneWeekFromNow.toDate(), + end: eventEndTime.toDate(), busy: true, summary: "Test Event", description: "Test Description", @@ -325,14 +344,14 @@ describe("GoogleCalendarSubscriptionAdapter", () => { timeZone: "UTC", recurringEventId: null, originalStartDate: null, - createdAt: new Date("2023-12-01T09:00:00Z"), - updatedAt: new Date("2023-12-01T09:30:00Z"), + createdAt: today.toDate(), + updatedAt: today.toDate(), }, ], }); }); - test("should fetch events without sync token (initial sync)", async () => { + test("should fetch events without sync token (initial sync) with a 3-month range", async () => { const calendarWithoutSyncToken = { ...mockSelectedCalendar, syncToken: null, @@ -349,34 +368,37 @@ describe("GoogleCalendarSubscriptionAdapter", () => { const result = await adapter.fetchEvents(calendarWithoutSyncToken, mockCredential); + const expectedTimeMin = today.startOf("day").toISOString(); + const expectedTimeMax = addMonthsFromNow(3).endOf("day").toISOString(); + expect(mockClient.events.list).toHaveBeenCalledWith({ calendarId: "test@example.com", pageToken: undefined, singleEvents: true, - timeMin: expect.any(String), - timeMax: expect.any(String), + timeMin: expectedTimeMin, + timeMax: expectedTimeMax, }); expect(result.syncToken).toBe("initial-sync-token"); }); test("should handle all-day events", async () => { + const allDayStart = oneWeekFromNow.startOf("day"); + const allDayEnd = oneWeekFromNow.endOf("day"); + const mockEventsResponse = { data: { nextSyncToken: "new-sync-token", items: [ { id: "event-1", - iCalUID: "event-1@cal.com", - summary: "All Day Event", + ...commonEventData, start: { - date: "2023-12-01", + date: allDayStart.toISOString().split("T")[0], }, end: { - date: "2023-12-02", + date: allDayEnd.toISOString().split("T")[0], }, - status: "confirmed", - transparency: "opaque", }, ], }, @@ -387,8 +409,8 @@ describe("GoogleCalendarSubscriptionAdapter", () => { const result = await adapter.fetchEvents(mockSelectedCalendar, mockCredential); expect(result.items[0].isAllDay).toBe(true); - expect(result.items[0].start).toEqual(new Date("2023-12-01")); - expect(result.items[0].end).toEqual(new Date("2023-12-02")); + expect(result.items[0].start).toEqual(new Date(allDayStart.toISOString().split("T")[0])); + expect(result.items[0].end).toEqual(new Date(allDayEnd.toISOString().split("T")[0])); }); test("should handle free events (transparent)", async () => { @@ -398,15 +420,14 @@ describe("GoogleCalendarSubscriptionAdapter", () => { items: [ { id: "event-1", - iCalUID: "event-1@cal.com", + ...commonEventData, summary: "Free Event", start: { - dateTime: "2023-12-01T10:00:00Z", + dateTime: oneWeekFromNow.toISOString(), }, end: { - dateTime: "2023-12-01T11:00:00Z", + dateTime: eventEndTime.toISOString(), }, - status: "confirmed", transparency: "transparent", }, ], @@ -421,6 +442,9 @@ describe("GoogleCalendarSubscriptionAdapter", () => { }); test("should handle pagination", async () => { + const date2 = new Date(oneWeekFromNow.toDate()); + date2.setDate(date2.getDate() + 1); + const mockEventsResponse1 = { data: { nextPageToken: "page-2", @@ -428,8 +452,8 @@ describe("GoogleCalendarSubscriptionAdapter", () => { { id: "event-1", summary: "Event 1", - start: { dateTime: "2023-12-01T10:00:00Z" }, - end: { dateTime: "2023-12-01T11:00:00Z" }, + start: { dateTime: oneWeekFromNow.toISOString() }, + end: { dateTime: eventEndTime.toISOString() }, }, ], }, @@ -442,8 +466,8 @@ describe("GoogleCalendarSubscriptionAdapter", () => { { id: "event-2", summary: "Event 2", - start: { dateTime: "2023-12-01T12:00:00Z" }, - end: { dateTime: "2023-12-01T13:00:00Z" }, + start: { dateTime: date2.toISOString() }, + end: { dateTime: new Date(date2.getTime() + 3600000).toISOString() }, }, ], }, @@ -468,19 +492,19 @@ describe("GoogleCalendarSubscriptionAdapter", () => { { id: "event-1", summary: "Valid Event", - start: { dateTime: "2023-12-01T10:00:00Z" }, - end: { dateTime: "2023-12-01T11:00:00Z" }, + start: { dateTime: oneWeekFromNow.toISOString() }, + end: { dateTime: eventEndTime.toISOString() }, }, { summary: "Invalid Event", - start: { dateTime: "2023-12-01T12:00:00Z" }, - end: { dateTime: "2023-12-01T13:00:00Z" }, + start: { dateTime: oneWeekFromNow.toISOString() }, + end: { dateTime: eventEndTime.toISOString() }, }, { id: "", summary: "Empty ID Event", - start: { dateTime: "2023-12-01T14:00:00Z" }, - end: { dateTime: "2023-12-01T15:00:00Z" }, + start: { dateTime: oneWeekFromNow.toISOString() }, + end: { dateTime: eventEndTime.toISOString() }, }, ], }, diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts index bece5e1420..fd1f44d310 100644 --- a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts @@ -83,9 +83,7 @@ export class CalendarCacheEventRepository implements ICalendarCacheEventReposito async deleteStale() { return this.prismaClient.calendarCacheEvent.deleteMany({ where: { - end: { - lte: new Date(), - }, + end: { lte: new Date() }, }, }); } diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts index 906103c587..220ca53297 100644 --- a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts @@ -9,6 +9,7 @@ const log = logger.getSubLogger({ prefix: ["CalendarCacheEventService"] }); * Service to handle calendar cache */ export class CalendarCacheEventService { + static MONTHS_AHEAD: number = 3; constructor( private deps: { calendarCacheEventRepository: ICalendarCacheEventRepository; diff --git a/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventRepository.test.ts b/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventRepository.test.ts index 88f1450dbe..d2f2328039 100644 --- a/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventRepository.test.ts +++ b/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventRepository.test.ts @@ -239,9 +239,7 @@ describe("CalendarCacheEventRepository", () => { expect(mockPrismaClient.calendarCacheEvent.deleteMany).toHaveBeenCalledWith({ where: { - end: { - lte: expect.any(Date), - }, + end: { lte: expect.any(Date) }, }, }); }); diff --git a/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventService.test.ts b/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventService.test.ts index 88b93174c2..a6727757d7 100644 --- a/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventService.test.ts +++ b/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventService.test.ts @@ -284,7 +284,7 @@ describe("CalendarCacheEventService", () => { description: null, location: null, status: "confirmed", - isAllDay: undefined, + isAllDay: false, timeZone: null, recurringEventId: null, originalStartDate: null, @@ -306,7 +306,7 @@ describe("CalendarCacheEventService", () => { summary: null, description: null, location: null, - isAllDay: undefined, + isAllDay: false, timeZone: null, originalStartTime: null, recurringEventId: null, @@ -346,9 +346,8 @@ describe("CalendarCacheEventService", () => { expect(CalendarCacheEventService.isCalendarTypeSupported("unknown_calendar")).toBe(false); }); - test("should return false for null or undefined", () => { + test("should return false for null", () => { expect(CalendarCacheEventService.isCalendarTypeSupported(null)).toBe(false); - expect(CalendarCacheEventService.isCalendarTypeSupported(undefined)).toBe(false); }); }); }); diff --git a/packages/prisma/migrations/20251017161715_cleanup_future_events/migration.sql b/packages/prisma/migrations/20251017161715_cleanup_future_events/migration.sql new file mode 100644 index 0000000000..2f5944a705 --- /dev/null +++ b/packages/prisma/migrations/20251017161715_cleanup_future_events/migration.sql @@ -0,0 +1,4 @@ +-- Deletes the future (years ahead) events generated by Google Calendar recurring events +DELETE + FROM "CalendarCacheEvent" + WHERE start > NOW() + interval '3 months';