From 0abff9f56268c0c9d2507acd7b981bc3442e23e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20M=C3=B3ricz?= Date: Tue, 23 Jan 2024 13:30:40 +0100 Subject: [PATCH] feat: Add ICS Feed support (#13337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: ics-feed proof of concept pt. 1 * feat: ics-feedcalendar fixes * fix: ics feed i18n and icon issues * fix: remove debug log * chore: revert yarn.lock * fix: remove debug log --------- Co-authored-by: Omar López Co-authored-by: Alex van Andel --- apps/web/public/static/locales/en/common.json | 1 + apps/web/public/static/locales/hu/common.json | 1 + packages/app-store/_pages/setup/index.tsx | 1 + packages/app-store/apps.metadata.generated.ts | 2 + packages/app-store/apps.server.generated.ts | 1 + .../app-store/ics-feedcalendar/DESCRIPTION.md | 1 + .../app-store/ics-feedcalendar/api/add.ts | 60 ++++ .../app-store/ics-feedcalendar/api/index.ts | 1 + .../app-store/ics-feedcalendar/config.json | 14 + packages/app-store/ics-feedcalendar/index.ts | 2 + .../ics-feedcalendar/lib/CalendarService.ts | 272 ++++++++++++++++++ .../app-store/ics-feedcalendar/lib/index.ts | 1 + .../app-store/ics-feedcalendar/package.json | 18 ++ .../ics-feedcalendar/pages/setup/index.tsx | 124 ++++++++ .../ics-feedcalendar/static/icon.svg | 87 ++++++ packages/app-store/index.ts | 1 + 16 files changed, 587 insertions(+) create mode 100644 packages/app-store/ics-feedcalendar/DESCRIPTION.md create mode 100644 packages/app-store/ics-feedcalendar/api/add.ts create mode 100644 packages/app-store/ics-feedcalendar/api/index.ts create mode 100644 packages/app-store/ics-feedcalendar/config.json create mode 100644 packages/app-store/ics-feedcalendar/index.ts create mode 100644 packages/app-store/ics-feedcalendar/lib/CalendarService.ts create mode 100644 packages/app-store/ics-feedcalendar/lib/index.ts create mode 100644 packages/app-store/ics-feedcalendar/package.json create mode 100644 packages/app-store/ics-feedcalendar/pages/setup/index.tsx create mode 100644 packages/app-store/ics-feedcalendar/static/icon.svg diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 0406219a1c..f6e2cd88ac 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -302,6 +302,7 @@ "welcome_to_calcom": "Welcome to {{appName}}", "welcome_instructions": "Tell us what to call you and let us know what timezone you’re in. You’ll be able to edit this later.", "connect_caldav": "Connect to CalDav (Beta)", + "connect_ics_feed": "Connect to an ICS Feed", "connect": "Connect", "try_for_free": "Try it for free", "create_booking_link_with_calcom": "Create your own booking link with {{appName}}", diff --git a/apps/web/public/static/locales/hu/common.json b/apps/web/public/static/locales/hu/common.json index 81f6011537..40abc1e14f 100644 --- a/apps/web/public/static/locales/hu/common.json +++ b/apps/web/public/static/locales/hu/common.json @@ -70,6 +70,7 @@ "current_time": "Jelenlegi idő", "welcome": "Üdvözöljük", "connect_caldav": "Csatlakozás a CalDav szerverhez", + "connect_ics_feed": "Csatlakozás egy ICS feedhez", "connect": "Csatlakozás", "try_for_free": "Próbáld ki ingyen", "who": "Ki", diff --git a/packages/app-store/_pages/setup/index.tsx b/packages/app-store/_pages/setup/index.tsx index e9345f8c90..b04c4ec51e 100644 --- a/packages/app-store/_pages/setup/index.tsx +++ b/packages/app-store/_pages/setup/index.tsx @@ -9,6 +9,7 @@ export const AppSetupMap = { "exchange2013-calendar": dynamic(() => import("../../exchange2013calendar/pages/setup")), "exchange2016-calendar": dynamic(() => import("../../exchange2016calendar/pages/setup")), "caldav-calendar": dynamic(() => import("../../caldavcalendar/pages/setup")), + "ics-feed": dynamic(() => import("../../ics-feedcalendar/pages/setup")), zapier: dynamic(() => import("../../zapier/pages/setup")), make: dynamic(() => import("../../make/pages/setup")), closecom: dynamic(() => import("../../closecom/pages/setup")), diff --git a/packages/app-store/apps.metadata.generated.ts b/packages/app-store/apps.metadata.generated.ts index e18667b7ef..067b406cdc 100644 --- a/packages/app-store/apps.metadata.generated.ts +++ b/packages/app-store/apps.metadata.generated.ts @@ -30,6 +30,7 @@ import { metadata as googlevideo__metadata_ts } from "./googlevideo/_metadata"; import gtm_config_json from "./gtm/config.json"; import { metadata as hubspot__metadata_ts } from "./hubspot/_metadata"; import { metadata as huddle01video__metadata_ts } from "./huddle01video/_metadata"; +import ics_feedcalendar_config_json from "./ics-feedcalendar/config.json"; import intercom_config_json from "./intercom/config.json"; import { metadata as jitsivideo__metadata_ts } from "./jitsivideo/_metadata"; import { metadata as larkcalendar__metadata_ts } from "./larkcalendar/_metadata"; @@ -111,6 +112,7 @@ export const appStoreMetadata = { gtm: gtm_config_json, hubspot: hubspot__metadata_ts, huddle01video: huddle01video__metadata_ts, + "ics-feedcalendar": ics_feedcalendar_config_json, intercom: intercom_config_json, jitsivideo: jitsivideo__metadata_ts, larkcalendar: larkcalendar__metadata_ts, diff --git a/packages/app-store/apps.server.generated.ts b/packages/app-store/apps.server.generated.ts index 8c8e5170f5..b34cd5370c 100644 --- a/packages/app-store/apps.server.generated.ts +++ b/packages/app-store/apps.server.generated.ts @@ -30,6 +30,7 @@ export const apiHandlers = { gtm: import("./gtm/api"), hubspot: import("./hubspot/api"), huddle01video: import("./huddle01video/api"), + "ics-feedcalendar": import("./ics-feedcalendar/api"), intercom: import("./intercom/api"), jitsivideo: import("./jitsivideo/api"), larkcalendar: import("./larkcalendar/api"), diff --git a/packages/app-store/ics-feedcalendar/DESCRIPTION.md b/packages/app-store/ics-feedcalendar/DESCRIPTION.md new file mode 100644 index 0000000000..2538807718 --- /dev/null +++ b/packages/app-store/ics-feedcalendar/DESCRIPTION.md @@ -0,0 +1 @@ +{DESCRIPTION} diff --git a/packages/app-store/ics-feedcalendar/api/add.ts b/packages/app-store/ics-feedcalendar/api/add.ts new file mode 100644 index 0000000000..1ae3d89049 --- /dev/null +++ b/packages/app-store/ics-feedcalendar/api/add.ts @@ -0,0 +1,60 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { symmetricEncrypt } from "@calcom/lib/crypto"; +import logger from "@calcom/lib/logger"; +import prisma from "@calcom/prisma"; + +import getInstalledAppPath from "../../_utils/getInstalledAppPath"; +import appConfig from "../config.json"; +import { CalendarService } from "../lib"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "POST") { + const { urls } = req.body; + // Get user + const user = await prisma.user.findFirstOrThrow({ + where: { + id: req.session?.user?.id, + }, + select: { + id: true, + email: true, + }, + }); + + const data = { + type: appConfig.type, + key: symmetricEncrypt(JSON.stringify({ urls }), process.env.CALENDSO_ENCRYPTION_KEY || ""), + userId: user.id, + teamId: null, + appId: appConfig.slug, + invalid: false, + }; + + try { + const dav = new CalendarService({ + id: 0, + ...data, + user: { email: user.email }, + }); + const listedCals = await dav.listCalendars(); + + if (listedCals.length !== urls.length) { + throw new Error(`Listed cals and URLs mismatch: ${listedCals.length} vs. ${urls.length}`); + } + + await prisma.credential.create({ + data, + }); + } catch (e) { + logger.error("Could not add ICS feeds", e); + return res.status(500).json({ message: "Could not add ICS feeds" }); + } + + return res.status(200).json({ url: getInstalledAppPath({ variant: "calendar", slug: "ics-feed" }) }); + } + + if (req.method === "GET") { + return res.status(200).json({ url: "/apps/ics-feed/setup" }); + } +} diff --git a/packages/app-store/ics-feedcalendar/api/index.ts b/packages/app-store/ics-feedcalendar/api/index.ts new file mode 100644 index 0000000000..4c0d2ead01 --- /dev/null +++ b/packages/app-store/ics-feedcalendar/api/index.ts @@ -0,0 +1 @@ +export { default as add } from "./add"; diff --git a/packages/app-store/ics-feedcalendar/config.json b/packages/app-store/ics-feedcalendar/config.json new file mode 100644 index 0000000000..d05abae701 --- /dev/null +++ b/packages/app-store/ics-feedcalendar/config.json @@ -0,0 +1,14 @@ +{ + "name": "ICS Feed", + "title": "ICS Feed", + "slug": "ics-feed", + "type": "ics-feed_calendar", + "logo": "icon.svg", + "variant": "calendar", + "categories": ["calendar"], + "publisher": "Cal.com, Inc.", + "email": "help@cal.com", + "description": "Import events from an ICS Feed into Cal.com.", + "isTemplate": false, + "__createdUsingCli": true +} diff --git a/packages/app-store/ics-feedcalendar/index.ts b/packages/app-store/ics-feedcalendar/index.ts new file mode 100644 index 0000000000..e2e9d7b029 --- /dev/null +++ b/packages/app-store/ics-feedcalendar/index.ts @@ -0,0 +1,2 @@ +export * as api from "./api"; +export * as lib from "./lib"; diff --git a/packages/app-store/ics-feedcalendar/lib/CalendarService.ts b/packages/app-store/ics-feedcalendar/lib/CalendarService.ts new file mode 100644 index 0000000000..ca9821107f --- /dev/null +++ b/packages/app-store/ics-feedcalendar/lib/CalendarService.ts @@ -0,0 +1,272 @@ +/* eslint-disable @typescript-eslint/triple-slash-reference */ +/// +import ICAL from "ical.js"; + +import dayjs from "@calcom/dayjs"; +import { symmetricDecrypt } from "@calcom/lib/crypto"; +import type { + Calendar, + IntegrationCalendar, + EventBusyDate, + CalendarEvent, + NewCalendarEventType, +} from "@calcom/types/Calendar"; +import type { CredentialPayload } from "@calcom/types/Credential"; + +// for Apple's Travel Time feature only (for now) +const getTravelDurationInSeconds = (vevent: ICAL.Component) => { + const travelDuration: ICAL.Duration = vevent.getFirstPropertyValue("x-apple-travel-duration"); + if (!travelDuration) return 0; + + // we can't rely on this being a valid duration and it's painful to check, so just try and catch if anything throws + try { + const travelSeconds = travelDuration.toSeconds(); + // integer validation as we can never be sure with ical.js + if (!Number.isInteger(travelSeconds)) return 0; + return travelSeconds; + } catch (e) { + return 0; + } +}; + +const applyTravelDuration = (event: ICAL.Event, seconds: number) => { + if (seconds <= 0) return event; + // move event start date back by the specified travel time + event.startDate.second -= seconds; + return event; +}; + +const CALENDSO_ENCRYPTION_KEY = process.env.CALENDSO_ENCRYPTION_KEY || ""; + +export default class ICSFeedCalendarService implements Calendar { + private urls: string[] = []; + protected integrationName = "ics-feed_calendar"; + + constructor(credential: CredentialPayload) { + const { urls } = JSON.parse(symmetricDecrypt(credential.key as string, CALENDSO_ENCRYPTION_KEY)); + this.urls = urls; + } + + createEvent(_event: CalendarEvent, _credentialId: number): Promise { + throw new Error("createEvent called on read-only ICS feed"); + } + + deleteEvent(_uid: string, _event: CalendarEvent, _externalCalendarId?: string): Promise { + throw new Error("deleteEvent called on read-only ICS feed"); + } + + updateEvent( + _uid: string, + _event: CalendarEvent, + _externalCalendarId?: string + ): Promise { + throw new Error("updateEvent called on read-only ICS feed"); + } + + fetchCalendars = async (): Promise<{ url: string; vcalendar: ICAL.Component }[]> => { + const reqPromises = await Promise.allSettled(this.urls.map((x) => fetch(x).then((y) => [x, y]))); + const reqs = reqPromises + .filter((x) => x.status === "fulfilled") + .map((x) => (x as PromiseFulfilledResult<[string, Response]>).value); + const res = await Promise.all(reqs.map((x) => x[1].text().then((y) => [x[0], y]))); + return res + .map((x) => { + try { + const jcalData = ICAL.parse(x[1]); + return { + url: x[0], + vcalendar: new ICAL.Component(jcalData), + }; + } catch (e) { + console.error("Error parsing calendar object: ", e); + return null; + } + }) + .filter((x) => x !== null) as { url: string; vcalendar: ICAL.Component }[]; + }; + + /** + * getUserTimezoneFromDB() retrieves the timezone of a user from the database. + * + * @param {number} id - The user's unique identifier. + * @returns {Promise} - A Promise that resolves to the user's timezone or "Europe/London" as a default value if the timezone is not found. + */ + getUserTimezoneFromDB = async (id: number): Promise => { + const prisma = await import("@calcom/prisma").then((mod) => mod.default); + const user = await prisma.user.findUnique({ + where: { + id, + }, + select: { + timeZone: true, + }, + }); + return user?.timeZone; + }; + + /** + * getUserId() extracts the user ID from the first calendar in an array of IntegrationCalendars. + * + * @param {IntegrationCalendar[]} selectedCalendars - An array of IntegrationCalendars. + * @returns {number | null} - The user ID associated with the first calendar in the array, or null if the array is empty or the user ID is not found. + */ + getUserId = (selectedCalendars: IntegrationCalendar[]): number | null => { + if (selectedCalendars.length === 0) { + return null; + } + return selectedCalendars[0].userId || null; + }; + + async getAvailability( + dateFrom: string, + dateTo: string, + selectedCalendars: IntegrationCalendar[] + ): Promise { + const startISOString = new Date(dateFrom).toISOString(); + + const calendars = await this.fetchCalendars(); + + const userId = this.getUserId(selectedCalendars); + // we use the userId from selectedCalendars to fetch the user's timeZone from the database primarily for all-day events without any timezone information + const userTimeZone = userId ? await this.getUserTimezoneFromDB(userId) : "Europe/London"; + const events: { start: string; end: string }[] = []; + + calendars.forEach(({ vcalendar }) => { + const vevents = vcalendar.getAllSubcomponents("vevent"); + vevents.forEach((vevent) => { + // if event status is free or transparent, DON'T return (unlike usual getAvailability) + // + // commented out because a lot of public ICS feeds that describe stuff like + // public holidays have them marked as transparent. if that is explicitly + // added to cal.com as an ICS feed, it should probably not be ignored. + // if (vevent?.getFirstPropertyValue("transp") === "TRANSPARENT") return; + + const event = new ICAL.Event(vevent); + const dtstart: { [key: string]: string } | undefined = vevent?.getFirstPropertyValue("dtstart"); + const timezone = dtstart ? dtstart["timezone"] : undefined; + // We check if the dtstart timezone is in UTC which is actually represented by Z instead, but not recognized as that in ICAL.js as UTC + const isUTC = timezone === "Z"; + const tzid: string | undefined = vevent?.getFirstPropertyValue("tzid") || isUTC ? "UTC" : timezone; + // In case of icalendar, when only tzid is available without vtimezone, we need to add vtimezone explicitly to take care of timezone diff + if (!vcalendar.getFirstSubcomponent("vtimezone")) { + const timezoneToUse = tzid || userTimeZone; + if (timezoneToUse) { + try { + const timezoneComp = new ICAL.Component("vtimezone"); + timezoneComp.addPropertyWithValue("tzid", timezoneToUse); + const standard = new ICAL.Component("standard"); + + // get timezone offset + const tzoffsetfrom = dayjs(event.startDate.toJSDate()).tz(timezoneToUse).format("Z"); + const tzoffsetto = dayjs(event.endDate.toJSDate()).tz(timezoneToUse).format("Z"); + + // set timezone offset + standard.addPropertyWithValue("tzoffsetfrom", tzoffsetfrom); + standard.addPropertyWithValue("tzoffsetto", tzoffsetto); + // provide a standard dtstart + standard.addPropertyWithValue("dtstart", "1601-01-01T00:00:00"); + timezoneComp.addSubcomponent(standard); + vcalendar.addSubcomponent(timezoneComp); + } catch (e) { + // Adds try-catch to ensure the code proceeds when Apple Calendar provides non-standard TZIDs + console.log("error in adding vtimezone", e); + } + } else { + console.error("No timezone found"); + } + } + const vtimezone = vcalendar.getFirstSubcomponent("vtimezone"); + + // mutate event to consider travel time + applyTravelDuration(event, getTravelDurationInSeconds(vevent)); + + if (event.isRecurring()) { + let maxIterations = 365; + if (["HOURLY", "SECONDLY", "MINUTELY"].includes(event.getRecurrenceTypes())) { + console.error(`Won't handle [${event.getRecurrenceTypes()}] recurrence`); + return; + } + + const start = dayjs(dateFrom); + const end = dayjs(dateTo); + const startDate = ICAL.Time.fromDateTimeString(startISOString); + startDate.hour = event.startDate.hour; + startDate.minute = event.startDate.minute; + startDate.second = event.startDate.second; + const iterator = event.iterator(startDate); + let current: ICAL.Time; + let currentEvent; + let currentStart = null; + let currentError; + + while ( + maxIterations > 0 && + (currentStart === null || currentStart.isAfter(end) === false) && + // this iterator was poorly implemented, normally done is expected to be + // returned + (current = iterator.next()) + ) { + maxIterations -= 1; + + try { + // @see https://github.com/mozilla-comm/ical.js/issues/514 + currentEvent = event.getOccurrenceDetails(current); + } catch (error) { + if (error instanceof Error && error.message !== currentError) { + currentError = error.message; + } + } + if (!currentEvent) return; + // do not mix up caldav and icalendar! For the recurring events here, the timezone + // provided is relevant, not as pointed out in https://datatracker.ietf.org/doc/html/rfc4791#section-9.6.5 + // where recurring events are always in utc (in caldav!). Thus, apply the time zone here. + if (vtimezone) { + const zone = new ICAL.Timezone(vtimezone); + currentEvent.startDate = currentEvent.startDate.convertToZone(zone); + currentEvent.endDate = currentEvent.endDate.convertToZone(zone); + } + currentStart = dayjs(currentEvent.startDate.toJSDate()); + + if (currentStart.isBetween(start, end) === true) { + events.push({ + start: currentStart.toISOString(), + end: dayjs(currentEvent.endDate.toJSDate()).toISOString(), + }); + } + } + if (maxIterations <= 0) { + console.warn("could not find any occurrence for recurring event in 365 iterations"); + } + return; + } + + if (vtimezone) { + const zone = new ICAL.Timezone(vtimezone); + event.startDate = event.startDate.convertToZone(zone); + event.endDate = event.endDate.convertToZone(zone); + } + + return events.push({ + start: dayjs(event.startDate.toJSDate()).toISOString(), + end: dayjs(event.endDate.toJSDate()).toISOString(), + }); + }); + }); + + return Promise.resolve(events); + } + + async listCalendars(): Promise { + const vcals = await this.fetchCalendars(); + + return vcals.map(({ url, vcalendar }) => { + const name: string = vcalendar.getFirstPropertyValue("x-wr-calname"); + return { + name, + readOnly: true, + externalId: url, + integrationName: this.integrationName, + }; + }); + } +} diff --git a/packages/app-store/ics-feedcalendar/lib/index.ts b/packages/app-store/ics-feedcalendar/lib/index.ts new file mode 100644 index 0000000000..e168c149df --- /dev/null +++ b/packages/app-store/ics-feedcalendar/lib/index.ts @@ -0,0 +1 @@ +export { default as CalendarService } from "./CalendarService"; diff --git a/packages/app-store/ics-feedcalendar/package.json b/packages/app-store/ics-feedcalendar/package.json new file mode 100644 index 0000000000..f9cbd94d04 --- /dev/null +++ b/packages/app-store/ics-feedcalendar/package.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "private": true, + "name": "@calcom/ics-feed", + "version": "0.0.0", + "main": "./index.ts", + "dependencies": { + "@calcom/core": "*", + "@calcom/lib": "*", + "@calcom/prisma": "*", + "@calcom/ui": "*", + "react-hook-form": "^7.43.3" + }, + "devDependencies": { + "@calcom/types": "*" + }, + "description": "Import events from an ICS Feed into Cal.com." +} diff --git a/packages/app-store/ics-feedcalendar/pages/setup/index.tsx b/packages/app-store/ics-feedcalendar/pages/setup/index.tsx new file mode 100644 index 0000000000..bc1208a4d2 --- /dev/null +++ b/packages/app-store/ics-feedcalendar/pages/setup/index.tsx @@ -0,0 +1,124 @@ +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { Toaster } from "react-hot-toast"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Alert, Button, Form, TextField } from "@calcom/ui"; +import { Plus, Trash } from "@calcom/ui/components/icon"; + +export default function ICSFeedSetup() { + const { t } = useLocale(); + const router = useRouter(); + const form = useForm({ + defaultValues: {}, + }); + + const [urls, setUrls] = useState([""]); + const [errorMessage, setErrorMessage] = useState(""); + const [errorActionUrl, setErrorActionUrl] = useState(""); + + return ( +
+
+
+
+ {/* eslint-disable @next/next/no-img-element */} + ICS Feed +
+
+

{t("connect_ics_feed")}

+
{t("credentials_stored_encrypted")}
+
+
{ + setErrorMessage(""); + const res = await fetch("/api/integrations/ics-feed/add", { + method: "POST", + body: JSON.stringify({ urls }), + headers: { + "Content-Type": "application/json", + }, + }); + const json = await res.json(); + if (!res.ok) { + setErrorMessage(json?.message || t("something_went_wrong")); + if (json.actionUrl) { + setErrorActionUrl(json.actionUrl); + } + } else { + router.push(json.url); + } + }}> +
+ {urls.map((url, i) => ( +
+ { + const newVal = e.target.value as string; + setUrls((urls) => urls.map((x, ii) => (ii === i ? newVal : x))); + }} + placeholder="https://example.com/calendar.ics" + /> + {i !== 0 ? ( + + ) : null} +
+ ))} +
+ + + + {errorMessage && ( + + Go to Admin + + ) : undefined + } + className="my-4" + /> + )} +
+ + +
+ +
+
+
+
+ +
+ ); +} diff --git a/packages/app-store/ics-feedcalendar/static/icon.svg b/packages/app-store/ics-feedcalendar/static/icon.svg new file mode 100644 index 0000000000..6b7ae62b33 --- /dev/null +++ b/packages/app-store/ics-feedcalendar/static/icon.svg @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/packages/app-store/index.ts b/packages/app-store/index.ts index c00d34704e..6a83795e39 100644 --- a/packages/app-store/index.ts +++ b/packages/app-store/index.ts @@ -10,6 +10,7 @@ const appStore = { googlevideo: () => import("./googlevideo"), hubspot: () => import("./hubspot"), huddle01video: () => import("./huddle01video"), + "ics-feedcalendar": () => import("./ics-feedcalendar"), jitsivideo: () => import("./jitsivideo"), larkcalendar: () => import("./larkcalendar"), office365calendar: () => import("./office365calendar"),