diff --git a/apps/web/pages/api/cron/calendar-cache-cleanup.ts b/apps/web/pages/api/cron/calendar-cache-cleanup.ts new file mode 100644 index 0000000000..8959de0d11 --- /dev/null +++ b/apps/web/pages/api/cron/calendar-cache-cleanup.ts @@ -0,0 +1,16 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import prisma from "@calcom/prisma"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const deleted = await prisma.calendarCache.deleteMany({ + where: { + // Delete all cache entries that expired before now + expiresAt: { + lte: new Date(Date.now()), + }, + }, + }); + + res.json({ ok: true, count: deleted.count }); +} diff --git a/apps/web/vercel.json b/apps/web/vercel.json new file mode 100644 index 0000000000..641f68e58b --- /dev/null +++ b/apps/web/vercel.json @@ -0,0 +1,8 @@ +{ + "crons": [ + { + "path": "/api/cron/calendar-cache-cleanup", + "schedule": "0 5 * * *" + } + ] +} diff --git a/packages/app-store/googlecalendar/lib/CalendarService.test.ts b/packages/app-store/googlecalendar/lib/CalendarService.test.ts new file mode 100644 index 0000000000..05658bc400 --- /dev/null +++ b/packages/app-store/googlecalendar/lib/CalendarService.test.ts @@ -0,0 +1,101 @@ +import prismaMock from "../../../../tests/libs/__mocks__/prisma"; + +import { afterEach, expect, test, vi } from "vitest"; + +import CalendarService from "./CalendarService"; + +afterEach(() => { + vi.resetAllMocks(); +}); + +vi.mock("@calcom/features/flags/server/utils", () => ({ + getFeatureFlagMap: vi.fn().mockResolvedValue({ + "calendar-cache": true, + }), +})); + +vi.mock("./getGoogleAppKeys", () => ({ + getGoogleAppKeys: vi.fn().mockResolvedValue({ + client_id: "xxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com", + client_secret: "xxxxxxxxxxxxxxxxxx", + redirect_uris: ["http://localhost:3000/api/integrations/googlecalendar/callback"], + }), +})); + +const googleTestCredential = { + scope: "https://www.googleapis.com/auth/calendar.events", + token_type: "Bearer", + expiry_date: 1625097600000, + access_token: "", + refresh_token: "", +}; + +const testCredential = { + appId: "test", + id: 1, + invalid: false, + key: googleTestCredential, + type: "test", + userId: 1, + user: { email: "example@cal.com" }, + teamId: 1, +}; + +const testSelectedCalendar = { + userId: 1, + integration: "google_calendar", + externalId: "example@cal.com", +}; + +const testFreeBusyResponse = { + kind: "calendar#freeBusy", + timeMax: "2024-01-01T20:59:59.000Z", + timeMin: "2023-11-30T20:00:00.000Z", + calendars: { + "example@cal.com": { + busy: [ + { end: "2023-12-01T19:00:00Z", start: "2023-12-01T18:00:00Z" }, + { end: "2023-12-04T19:00:00Z", start: "2023-12-04T18:00:00Z" }, + ], + }, + "xxxxxxxxxxxxxxxxxxxxxxxxxx@group.calendar.google.com": { busy: [] }, + }, +}; + +const calendarCacheResponse = { + key: "dummy", + expiresAt: new Date(), + credentialId: 1, + value: testFreeBusyResponse, +}; + +test("Calendar Cache is being called", async () => { + prismaMock.calendarCache.findUnique + // First call won't have a cache + .mockResolvedValueOnce(null) + // Second call will have a cache + .mockResolvedValueOnce(calendarCacheResponse); + + // prismaMock.calendarCache.create.mock. + const calendarService = new CalendarService(testCredential); + // @ts-expect-error authedCalendar is a private method, hence the TS error + vi.spyOn(calendarService, "authedCalendar").mockReturnValue( + // @ts-expect-error trust me bro + { + freebusy: { + query: vi.fn().mockReturnValue({ + data: testFreeBusyResponse, + }), + }, + } + ); + + await calendarService.getAvailability(new Date().toISOString(), new Date().toISOString(), [ + testSelectedCalendar, + ]); + await calendarService.getAvailability(new Date().toISOString(), new Date().toISOString(), [ + testSelectedCalendar, + ]); + expect(prismaMock.calendarCache.findUnique).toHaveBeenCalled(); + expect(prismaMock.calendarCache.upsert).toHaveBeenCalledOnce(); +}); diff --git a/packages/app-store/googlecalendar/lib/CalendarService.ts b/packages/app-store/googlecalendar/lib/CalendarService.ts index 32796ae948..c34c62c376 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.ts @@ -4,6 +4,7 @@ import type { calendar_v3 } from "googleapis"; import { google } from "googleapis"; import { MeetLocationType } from "@calcom/app-store/locations"; +import { getFeatureFlagMap } from "@calcom/features/flags/server/utils"; import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser"; import type CalendarService from "@calcom/lib/CalendarService"; import logger from "@calcom/lib/logger"; @@ -24,6 +25,36 @@ interface GoogleCalError extends Error { code?: number; } +const ONE_MINUTE_MS = 60 * 1000; +const CACHING_TIME = ONE_MINUTE_MS; + +/** Expand the start date to the start of the month */ +function getTimeMin(timeMin: string) { + const dateMin = new Date(timeMin); + return new Date(dateMin.getFullYear(), dateMin.getMonth(), 1, 0, 0, 0, 0).toISOString(); +} + +/** Expand the end date to the end of the month */ +function getTimeMax(timeMax: string) { + const dateMax = new Date(timeMax); + return new Date(dateMax.getFullYear(), dateMax.getMonth() + 1, 0, 0, 0, 0, 0).toISOString(); +} + +/** + * Enable or disable the expanded cache + * TODO: Make this configurable + * */ +const ENABLE_EXPANDED_CACHE = true; + +/** + * By expanding the cache to whole months, we can save round trips to the third party APIs. + * In this case we already have the data in the database, so we can just return it. + */ +function handleMinMax(min: string, max: string) { + if (!ENABLE_EXPANDED_CACHE) return { timeMin: min, timeMax: max }; + return { timeMin: getTimeMin(min), timeMax: getTimeMax(max) }; +} + export default class GoogleCalendarService implements Calendar { private integrationName = ""; private auth: { getToken: () => Promise }; @@ -32,6 +63,7 @@ export default class GoogleCalendarService implements Calendar { constructor(credential: CredentialPayload) { this.integrationName = "google_calendar"; + this.credential = credential; this.auth = this.googleAuth(credential); this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] }); this.credential = credential; @@ -86,6 +118,15 @@ export default class GoogleCalendarService implements Calendar { }; }; + private authedCalendar = async () => { + const myGoogleAuth = await this.auth.getToken(); + const calendar = google.calendar({ + version: "v3", + auth: myGoogleAuth, + }); + return calendar; + }; + private getAttendees = (event: CalendarEvent) => { // When rescheduling events we know the external id of the calendar so we can just look for it in the destinationCalendar array. const selectedHostDestinationCalendar = event.destinationCalendar?.find( @@ -129,209 +170,236 @@ export default class GoogleCalendarService implements Calendar { }; async createEvent(calEventRaw: CalendarEvent, credentialId: number): Promise { - return new Promise(async (resolve, reject) => { - const myGoogleAuth = await this.auth.getToken(); - const payload: calendar_v3.Schema$Event = { - summary: calEventRaw.title, - description: getRichDescription(calEventRaw), - start: { - dateTime: calEventRaw.startTime, - timeZone: calEventRaw.organizer.timeZone, - }, - end: { - dateTime: calEventRaw.endTime, - timeZone: calEventRaw.organizer.timeZone, - }, - attendees: this.getAttendees(calEventRaw), - reminders: { - useDefault: true, - }, - guestsCanSeeOtherGuests: !!calEventRaw.seatsPerTimeSlot ? calEventRaw.seatsShowAttendees : true, - }; + const payload: calendar_v3.Schema$Event = { + summary: calEventRaw.title, + description: getRichDescription(calEventRaw), + start: { + dateTime: calEventRaw.startTime, + timeZone: calEventRaw.organizer.timeZone, + }, + end: { + dateTime: calEventRaw.endTime, + timeZone: calEventRaw.organizer.timeZone, + }, + attendees: this.getAttendees(calEventRaw), + reminders: { + useDefault: true, + }, + guestsCanSeeOtherGuests: !!calEventRaw.seatsPerTimeSlot ? calEventRaw.seatsShowAttendees : true, + }; - if (calEventRaw.location) { - payload["location"] = getLocation(calEventRaw); - } + if (calEventRaw.location) { + payload["location"] = getLocation(calEventRaw); + } - if (calEventRaw.conferenceData && calEventRaw.location === MeetLocationType) { - payload["conferenceData"] = calEventRaw.conferenceData; - } - const calendar = google.calendar({ - version: "v3", + if (calEventRaw.conferenceData && calEventRaw.location === MeetLocationType) { + payload["conferenceData"] = calEventRaw.conferenceData; + } + const calendar = await this.authedCalendar(); + // Find in calEventRaw.destinationCalendar the one with the same credentialId + + const selectedCalendar = + calEventRaw.destinationCalendar?.find((cal) => cal.credentialId === credentialId)?.externalId || + "primary"; + + try { + const event = await calendar.events.insert({ + calendarId: selectedCalendar, + requestBody: payload, + conferenceDataVersion: 1, + sendUpdates: "none", }); - // Find in calEventRaw.destinationCalendar the one with the same credentialId - const selectedCalendar = - calEventRaw.destinationCalendar?.find((cal) => cal.credentialId === credentialId)?.externalId || - "primary"; - - calendar.events.insert( - { - auth: myGoogleAuth, + if (event && event.data.id && event.data.hangoutLink) { + await calendar.events.patch({ + // Update the same event but this time we know the hangout link calendarId: selectedCalendar, - requestBody: payload, - conferenceDataVersion: 1, - sendUpdates: "none", - }, - function (error, event) { - if (error || !event?.data) { - console.error("There was an error contacting google calendar service: ", error); - return reject(error); - } + eventId: event.data.id || "", + requestBody: { + description: getRichDescription({ + ...calEventRaw, + additionalInformation: { hangoutLink: event.data.hangoutLink }, + }), + }, + }); + } - if (event && event.data.id && event.data.hangoutLink) { - calendar.events.patch({ - // Update the same event but this time we know the hangout link - calendarId: selectedCalendar, - auth: myGoogleAuth, - eventId: event.data.id || "", - requestBody: { - description: getRichDescription({ - ...calEventRaw, - additionalInformation: { hangoutLink: event.data.hangoutLink }, - }), - }, - }); - } - return resolve({ - uid: "", - ...event.data, - id: event.data.id || "", - additionalInfo: { - hangoutLink: event.data.hangoutLink || "", - }, - type: "google_calendar", - password: "", - url: "", - iCalUID: event.data.iCalUID, - }); - } - ); - }); + return { + uid: "", + ...event.data, + id: event.data.id || "", + additionalInfo: { + hangoutLink: event.data.hangoutLink || "", + }, + type: "google_calendar", + password: "", + url: "", + iCalUID: event.data.iCalUID, + }; + } catch (error) { + console.error("There was an error contacting google calendar service: ", error); + throw error; + } } async updateEvent(uid: string, event: CalendarEvent, externalCalendarId: string): Promise { - return new Promise(async (resolve, reject) => { - const myGoogleAuth = await this.auth.getToken(); - const payload: calendar_v3.Schema$Event = { - summary: event.title, - description: getRichDescription(event), - start: { - dateTime: event.startTime, - timeZone: event.organizer.timeZone, - }, - end: { - dateTime: event.endTime, - timeZone: event.organizer.timeZone, - }, - attendees: this.getAttendees(event), - reminders: { - useDefault: true, - }, - guestsCanSeeOtherGuests: !!event.seatsPerTimeSlot ? event.seatsShowAttendees : true, - }; + const payload: calendar_v3.Schema$Event = { + summary: event.title, + description: getRichDescription(event), + start: { + dateTime: event.startTime, + timeZone: event.organizer.timeZone, + }, + end: { + dateTime: event.endTime, + timeZone: event.organizer.timeZone, + }, + attendees: this.getAttendees(event), + reminders: { + useDefault: true, + }, + guestsCanSeeOtherGuests: !!event.seatsPerTimeSlot ? event.seatsShowAttendees : true, + }; - if (event.location) { - payload["location"] = getLocation(event); - } + if (event.location) { + payload["location"] = getLocation(event); + } - if (event.conferenceData && event.location === MeetLocationType) { - payload["conferenceData"] = event.conferenceData; - } + if (event.conferenceData && event.location === MeetLocationType) { + payload["conferenceData"] = event.conferenceData; + } - const calendar = google.calendar({ - version: "v3", - auth: myGoogleAuth, + const calendar = await this.authedCalendar(); + + const selectedCalendar = externalCalendarId + ? externalCalendarId + : event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId; + + try { + const evt = await calendar.events.update({ + calendarId: selectedCalendar, + eventId: uid, + sendNotifications: true, + sendUpdates: "none", + requestBody: payload, + conferenceDataVersion: 1, }); - const selectedCalendar = externalCalendarId - ? externalCalendarId - : event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId; - - calendar.events.update( - { - auth: myGoogleAuth, + if (evt && evt.data.id && evt.data.hangoutLink && event.location === MeetLocationType) { + calendar.events.patch({ + // Update the same event but this time we know the hangout link calendarId: selectedCalendar, - eventId: uid, - sendNotifications: true, - sendUpdates: "none", - requestBody: payload, - conferenceDataVersion: 1, - }, - function (err, evt) { - if (err) { - console.error("There was an error contacting google calendar service: ", err); - return reject(err); - } - - if (evt && evt.data.id && evt.data.hangoutLink && event.location === MeetLocationType) { - calendar.events.patch({ - // Update the same event but this time we know the hangout link - calendarId: selectedCalendar, - auth: myGoogleAuth, - eventId: evt.data.id || "", - requestBody: { - description: getRichDescription({ - ...event, - additionalInformation: { hangoutLink: evt.data.hangoutLink }, - }), - }, - }); - return resolve({ - uid: "", - ...evt.data, - id: evt.data.id || "", - additionalInfo: { - hangoutLink: evt.data.hangoutLink || "", - }, - type: "google_calendar", - password: "", - url: "", - iCalUID: evt.data.iCalUID, - }); - } - return resolve(evt?.data); - } - ); - }); + eventId: evt.data.id || "", + requestBody: { + description: getRichDescription({ + ...event, + additionalInformation: { hangoutLink: evt.data.hangoutLink }, + }), + }, + }); + return { + uid: "", + ...evt.data, + id: evt.data.id || "", + additionalInfo: { + hangoutLink: evt.data.hangoutLink || "", + }, + type: "google_calendar", + password: "", + url: "", + iCalUID: evt.data.iCalUID, + }; + } + return evt?.data; + } catch (error) { + console.error("There was an error contacting google calendar service: ", error); + throw error; + } } async deleteEvent(uid: string, event: CalendarEvent, externalCalendarId?: string | null): Promise { - return new Promise(async (resolve, reject) => { - const myGoogleAuth = await this.auth.getToken(); - const calendar = google.calendar({ - version: "v3", - auth: myGoogleAuth, + const calendar = await this.authedCalendar(); + const defaultCalendarId = "primary"; + const calendarId = externalCalendarId + ? externalCalendarId + : event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId; + + try { + const event = await calendar.events.delete({ + calendarId: calendarId ? calendarId : defaultCalendarId, + eventId: uid, + sendNotifications: false, + sendUpdates: "none", }); + return event?.data; + } catch (error) { + const err = error as GoogleCalError; + /** + * 410 is when an event is already deleted on the Google cal before on cal.com + * 404 is when the event is on a different calendar + */ + if (err.code === 410) return; + console.error("There was an error contacting google calendar service: ", err); + if (err.code === 404) return; + throw err; + } + } - const defaultCalendarId = "primary"; - const calendarId = externalCalendarId - ? externalCalendarId - : event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId; - - calendar.events.delete( - { - auth: myGoogleAuth, - calendarId: calendarId ? calendarId : defaultCalendarId, - eventId: uid, - sendNotifications: false, - sendUpdates: "none", + async getCacheOrFetchAvailability(args: { + timeMin: string; + timeMax: string; + items: { id: string }[]; + }): Promise { + const calendar = await this.authedCalendar(); + const flags = await getFeatureFlagMap(prisma); + if (!flags["calendar-cache"]) { + this.log.warn("Calendar Cache is disabled - Skipping"); + const { timeMin, timeMax, items } = args; + const apires = await calendar.freebusy.query({ + requestBody: { timeMin, timeMax, items }, + }); + return apires.data; + } + const { timeMin: _timeMin, timeMax: _timeMax, items } = args; + const { timeMin, timeMax } = handleMinMax(_timeMin, _timeMax); + const key = JSON.stringify({ timeMin, timeMax, items }); + const cached = await prisma.calendarCache.findUnique({ + where: { + credentialId_key: { + credentialId: this.credential.id, + key, }, - function (err: GoogleCalError | null, event) { - if (err) { - /** - * 410 is when an event is already deleted on the Google cal before on cal.com - * 404 is when the event is on a different calendar - */ - if (err.code === 410) return resolve(); - console.error("There was an error contacting google calendar service: ", err); - if (err.code === 404) return resolve(); - return reject(err); - } - return resolve(event?.data); - } - ); + expiresAt: { gte: new Date(Date.now()) }, + }, }); + + if (cached) return cached.value as unknown as calendar_v3.Schema$FreeBusyResponse; + + const apires = await calendar.freebusy.query({ + requestBody: { timeMin, timeMax, items }, + }); + + // Skipping await to respond faster + prisma.calendarCache.upsert({ + where: { + credentialId_key: { + credentialId: this.credential.id, + key, + }, + }, + update: { + value: JSON.parse(JSON.stringify(apires.data)), + expiresAt: new Date(Date.now() + CACHING_TIME), + }, + create: { + value: JSON.parse(JSON.stringify(apires.data)), + credentialId: this.credential.id, + key, + expiresAt: new Date(Date.now() + CACHING_TIME), + }, + }); + + return apires.data; } async getAvailability( @@ -339,96 +407,65 @@ export default class GoogleCalendarService implements Calendar { dateTo: string, selectedCalendars: IntegrationCalendar[] ): Promise { - return new Promise(async (resolve, reject) => { - const myGoogleAuth = await this.auth.getToken(); - const calendar = google.calendar({ - version: "v3", - auth: myGoogleAuth, + const calendar = await this.authedCalendar(); + const selectedCalendarIds = selectedCalendars + .filter((e) => e.integration === this.integrationName) + .map((e) => e.externalId); + if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) { + // Only calendars of other integrations selected + return []; + } + async function getCalIds() { + if (selectedCalendarIds.length !== 0) return selectedCalendarIds; + const cals = await calendar.calendarList.list({ fields: "items(id)" }); + if (!cals.data.items) return []; + return cals.data.items.reduce((c, cal) => (cal.id ? [...c, cal.id] : c), [] as string[]); + } + + try { + const calsIds = await getCalIds(); + const freeBusyData = await this.getCacheOrFetchAvailability({ + timeMin: dateFrom, + timeMax: dateTo, + items: calsIds.map((id) => ({ id })), }); - const selectedCalendarIds = selectedCalendars - .filter((e) => e.integration === this.integrationName) - .map((e) => e.externalId); - if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) { - // Only calendars of other integrations selected - resolve([]); - return; - } - - (selectedCalendarIds.length === 0 - ? calendar.calendarList - .list({ - fields: "items(id)", - }) - .then((cals) => cals.data.items?.map((cal) => cal.id).filter(Boolean) || []) - : Promise.resolve(selectedCalendarIds) - ) - .then((calsIds) => { - calendar.freebusy.query( - { - requestBody: { - timeMin: dateFrom, - timeMax: dateTo, - items: calsIds.map((id) => ({ id: id })), - }, - }, - (err, apires) => { - if (err) return reject(err); - // If there's no calendar we just skip - if (!apires?.data.calendars) return resolve([]); - const result = Object.values(apires.data.calendars).reduce((c, i) => { - i.busy?.forEach((busyTime) => { - c.push({ - start: busyTime.start || "", - end: busyTime.end || "", - }); - }); - return c; - }, [] as Prisma.PromiseReturnType); - resolve(result); - } - ); - }) - .catch((err) => { - this.log.error("There was an error contacting google calendar service: ", err); - - reject(err); + if (!freeBusyData?.calendars) throw new Error("No response from google calendar"); + const result = Object.values(freeBusyData.calendars).reduce((c, i) => { + i.busy?.forEach((busyTime) => { + c.push({ + start: busyTime.start || "", + end: busyTime.end || "", + }); }); - }); + return c; + }, [] as Prisma.PromiseReturnType); + return result; + } catch (error) { + this.log.error("There was an error contacting google calendar service: ", error); + throw error; + } } async listCalendars(): Promise { - return new Promise(async (resolve, reject) => { - const myGoogleAuth = await this.auth.getToken(); - const calendar = google.calendar({ - version: "v3", - auth: myGoogleAuth, - }); - - calendar.calendarList - .list({ - fields: "items(id,summary,primary,accessRole)", - }) - .then((cals) => { - resolve( - cals.data.items?.map((cal) => { - const calendar: IntegrationCalendar = { - externalId: cal.id ?? "No id", - integration: this.integrationName, - name: cal.summary ?? "No name", - primary: cal.primary ?? false, - readOnly: !(cal.accessRole === "writer" || cal.accessRole === "owner") && true, - email: cal.id ?? "", - }; - return calendar; - }) || [] - ); - }) - .catch((err: Error) => { - this.log.error("There was an error contacting google calendar service: ", err); - - reject(err); - }); - }); + const calendar = await this.authedCalendar(); + try { + const cals = await calendar.calendarList.list({ fields: "items(id,summary,primary,accessRole)" }); + if (!cals.data.items) return []; + return cals.data.items.map( + (cal) => + ({ + externalId: cal.id ?? "No id", + integration: this.integrationName, + name: cal.summary ?? "No name", + primary: cal.primary ?? false, + readOnly: !(cal.accessRole === "writer" || cal.accessRole === "owner") && true, + email: cal.id ?? "", + } satisfies IntegrationCalendar) + ); + } catch (error) { + this.log.error("There was an error contacting google calendar service: ", error); + throw error; + } } } diff --git a/packages/features/flags/config.ts b/packages/features/flags/config.ts index 2a7ecf0280..50e61214a9 100644 --- a/packages/features/flags/config.ts +++ b/packages/features/flags/config.ts @@ -3,6 +3,7 @@ * Maybe later on we can add string variants or numeric ones **/ export type AppFlags = { + "calendar-cache": boolean; emails: boolean; insights: boolean; teams: boolean; diff --git a/packages/prisma/migrations/20230907002853_add_calendar_cache/migration.sql b/packages/prisma/migrations/20230907002853_add_calendar_cache/migration.sql new file mode 100644 index 0000000000..37a5e90e80 --- /dev/null +++ b/packages/prisma/migrations/20230907002853_add_calendar_cache/migration.sql @@ -0,0 +1,26 @@ +-- CreateTable +CREATE TABLE + "CalendarCache" ( + "key" TEXT NOT NULL, + "value" JSONB NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "credentialId" INTEGER NOT NULL, + CONSTRAINT "CalendarCache_pkey" PRIMARY KEY ("credentialId", "key") + ); + +-- CreateIndex +CREATE UNIQUE INDEX "CalendarCache_credentialId_key_key" ON "CalendarCache" ("credentialId", "key"); + +-- AddForeignKey +ALTER TABLE "CalendarCache" ADD CONSTRAINT "CalendarCache_credentialId_fkey" FOREIGN KEY ("credentialId") REFERENCES "Credential" ("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Add Feature Flag +INSERT INTO + "Feature" (slug, enabled, description, "type") +VALUES + ( + 'calendar-cache', + false, + 'Enable Third Party Calendar Cache - Cache third party calendar events to reduce the number of API calls to third party calendar providers.', + 'OPERATIONAL' + ) ON CONFLICT (slug) DO NOTHING; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index ffcf80e83b..e3ac72463d 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -139,6 +139,7 @@ model Credential { destinationCalendars DestinationCalendar[] selectedCalendars SelectedCalendar[] invalid Boolean? @default(false) + CalendarCache CalendarCache[] @@index([userId]) @@index([appId]) @@ -923,3 +924,15 @@ view BookingTimeStatus { timeStatus String? eventParentId Int? } + +model CalendarCache { + // The key would be the unique URL that is requested by the user + key String + value Json + expiresAt DateTime + credentialId Int + credential Credential? @relation(fields: [credentialId], references: [id], onDelete: Cascade) + + @@id([credentialId, key]) + @@unique([credentialId, key]) +}