diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts index e82d5325..c7718f5b 100644 --- a/lib/calendarClient.ts +++ b/lib/calendarClient.ts @@ -8,6 +8,7 @@ import logger from "@lib/logger"; const log = logger.getChildLogger({ prefix: ["[lib] calendarClient"] }); import { CalDavCalendar } from "./integrations/CalDav/CalDavCalendarAdapter"; +import { AppleCalendar } from "./integrations/Apple/AppleCalendarAdapter"; // eslint-disable-next-line @typescript-eslint/no-var-requires const { google } = require("googleapis"); @@ -521,6 +522,8 @@ const calendars = (withCredentials): CalendarApiAdapter[] => return MicrosoftOffice365Calendar(cred); case "caldav_calendar": return new CalDavCalendar(cred); + case "apple_calendar": + return new AppleCalendar(cred); default: return; // unknown credential, could be legacy? In any case, ignore } diff --git a/lib/integrations.ts b/lib/integrations.ts index 76e96e55..5c2754b6 100644 --- a/lib/integrations.ts +++ b/lib/integrations.ts @@ -8,6 +8,8 @@ export function getIntegrationName(name: string) { return "Zoom"; case "caldav_calendar": return "CalDav Server"; + case "apple_calendar": + return "Apple Calendar"; default: return "Unknown"; } diff --git a/lib/integrations/Apple/AppleCalendarAdapter.ts b/lib/integrations/Apple/AppleCalendarAdapter.ts new file mode 100644 index 00000000..9263b304 --- /dev/null +++ b/lib/integrations/Apple/AppleCalendarAdapter.ts @@ -0,0 +1,355 @@ +import { IntegrationCalendar, CalendarApiAdapter, CalendarEvent } from "../../calendarClient"; +import { symmetricDecrypt } from "@lib/crypto"; +import { + createAccount, + fetchCalendars, + fetchCalendarObjects, + getBasicAuthHeaders, + createCalendarObject, + updateCalendarObject, + deleteCalendarObject, +} from "tsdav"; +import { Credential } from "@prisma/client"; +import ICAL from "ical.js"; +import { createEvent, DurationObject, Attendee, Person } from "ics"; +import dayjs from "dayjs"; +import { v4 as uuidv4 } from "uuid"; +import { stripHtml } from "../../emails/helpers"; +import logger from "@lib/logger"; + +const log = logger.getChildLogger({ prefix: ["[[lib] apple calendar"] }); + +type EventBusyDate = Record<"start" | "end", Date>; + +export class AppleCalendar implements CalendarApiAdapter { + private url: string; + private credentials: Record; + private headers: Record; + private readonly integrationName: string = "apple_calendar"; + + constructor(credential: Credential) { + const decryptedCredential = JSON.parse( + symmetricDecrypt(credential.key, process.env.CALENDSO_ENCRYPTION_KEY) + ); + const username = decryptedCredential.username; + const password = decryptedCredential.password; + + this.url = "https://caldav.icloud.com"; + + this.credentials = { + username, + password, + }; + + this.headers = getBasicAuthHeaders({ + username, + password, + }); + } + + convertDate(date: string): number[] { + return dayjs(date) + .utc() + .toArray() + .slice(0, 6) + .map((v, i) => (i === 1 ? v + 1 : v)); + } + + getDuration(start: string, end: string): DurationObject { + return { + minutes: dayjs(end).diff(dayjs(start), "minute"), + }; + } + + getAttendees(attendees: Person[]): Attendee[] { + return attendees.map(({ email, name }) => ({ name, email, partstat: "NEEDS-ACTION" })); + } + + async createEvent(event: CalendarEvent): Promise> { + try { + const calendars = await this.listCalendars(); + const uid = uuidv4(); + const { error, value: iCalString } = await createEvent({ + uid, + startInputType: "utc", + start: this.convertDate(event.startTime), + duration: this.getDuration(event.startTime, event.endTime), + title: event.title, + description: stripHtml(event.description ?? ""), + location: event.location, + organizer: { email: event.organizer.email, name: event.organizer.name }, + attendees: this.getAttendees(event.attendees), + }); + + if (error) { + log.debug("Error creating iCalString"); + return {}; + } + + if (!iCalString) { + log.debug("Error creating iCalString"); + return {}; + } + + await Promise.all( + calendars.map((calendar) => { + return createCalendarObject({ + calendar: { + url: calendar.externalId, + }, + filename: `${uid}.ics`, + iCalString: iCalString, + headers: this.headers, + }); + }) + ); + + return { + uid, + id: uid, + }; + } catch (reason) { + console.error(reason); + throw reason; + } + } + + async updateEvent(uid: string, event: CalendarEvent): Promise { + try { + const calendars = await this.listCalendars(); + const events = []; + + for (const cal of calendars) { + const calEvents = await this.getEvents(cal.externalId, null, null, [`${cal.externalId}${uid}.ics`]); + + for (const ev of calEvents) { + events.push(ev); + } + } + + const { error, value: iCalString } = await createEvent({ + uid, + startInputType: "utc", + start: this.convertDate(event.startTime), + duration: this.getDuration(event.startTime, event.endTime), + title: event.title, + description: stripHtml(event.description ?? ""), + location: event.location, + organizer: { email: event.organizer.email, name: event.organizer.name }, + attendees: this.getAttendees(event.attendees), + }); + + if (error) { + log.debug("Error creating iCalString"); + return {}; + } + + const eventsToUpdate = events.filter((event) => event.uid === uid); + + return await Promise.all( + eventsToUpdate.map((event) => { + return updateCalendarObject({ + calendarObject: { + url: event.url, + data: iCalString, + etag: event?.etag, + }, + headers: this.headers, + }); + }) + ); + } catch (reason) { + console.error(reason); + throw reason; + } + } + + async deleteEvent(uid: string): Promise { + try { + const calendars = await this.listCalendars(); + const events = []; + + for (const cal of calendars) { + const calEvents = await this.getEvents(cal.externalId, null, null, [`${cal.externalId}${uid}.ics`]); + + for (const ev of calEvents) { + events.push(ev); + } + } + + const eventsToUpdate = events.filter((event) => event.uid === uid); + + await Promise.all( + eventsToUpdate.map((event) => { + return deleteCalendarObject({ + calendarObject: { + url: event.url, + etag: event?.etag, + }, + headers: this.headers, + }); + }) + ); + } catch (reason) { + console.error(reason); + throw reason; + } + } + + async getAvailability( + dateFrom: string, + dateTo: string, + selectedCalendars: IntegrationCalendar[] + ): Promise { + try { + 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 Promise.resolve([]); + } + + return ( + selectedCalendarIds.length === 0 + ? this.listCalendars().then((calendars) => calendars.map((calendar) => calendar.externalId)) + : Promise.resolve(selectedCalendarIds) + ).then(async (ids: string[]) => { + if (ids.length === 0) { + return Promise.resolve([]); + } + + return ( + await Promise.all( + ids.map(async (calId) => { + return (await this.getEvents(calId, dateFrom, dateTo)).map((event) => { + return { + start: event.startDate, + end: event.endDate, + }; + }); + }) + ) + ).flatMap((event) => event); + }); + } catch (reason) { + log.error(reason); + throw reason; + } + } + + async listCalendars(): Promise { + try { + const account = await this.getAccount(); + const calendars = await fetchCalendars({ + account, + headers: this.headers, + }); + + return calendars + .filter((calendar) => { + return calendar.components?.includes("VEVENT"); + }) + .map((calendar) => ({ + externalId: calendar.url, + name: calendar.displayName ?? "", + primary: false, + integration: this.integrationName, + })); + } catch (reason) { + console.error(reason); + throw reason; + } + } + + async getEvents( + calId: string, + dateFrom: string | null, + dateTo: string | null, + objectUrls: string[] | null + ): Promise { + try { + const objects = await fetchCalendarObjects({ + calendar: { + url: calId, + }, + objectUrls: objectUrls ? objectUrls : undefined, + timeRange: + dateFrom && dateTo + ? { + start: dayjs(dateFrom).utc().format("YYYY-MM-DDTHH:mm:ss[Z]"), + end: dayjs(dateTo).utc().format("YYYY-MM-DDTHH:mm:ss[Z]"), + } + : undefined, + headers: this.headers, + }); + + const events = + objects && + objects?.length > 0 && + objects + .map((object) => { + if (object?.data) { + const jcalData = ICAL.parse(object.data); + const vcalendar = new ICAL.Component(jcalData); + const vevent = vcalendar.getFirstSubcomponent("vevent"); + const event = new ICAL.Event(vevent); + + const calendarTimezone = vcalendar.getFirstSubcomponent("vtimezone") + ? vcalendar.getFirstSubcomponent("vtimezone").getFirstPropertyValue("tzid") + : ""; + + const startDate = calendarTimezone + ? dayjs(event.startDate).tz(calendarTimezone) + : new Date(event.startDate.toUnixTime() * 1000); + const endDate = calendarTimezone + ? dayjs(event.endDate).tz(calendarTimezone) + : new Date(event.endDate.toUnixTime() * 1000); + + return { + uid: event.uid, + etag: object.etag, + url: object.url, + summary: event.summary, + description: event.description, + location: event.location, + sequence: event.sequence, + startDate, + endDate, + duration: { + weeks: event.duration.weeks, + days: event.duration.days, + hours: event.duration.hours, + minutes: event.duration.minutes, + seconds: event.duration.seconds, + isNegative: event.duration.isNegative, + }, + organizer: event.organizer, + attendees: event.attendees.map((a) => a.getValues()), + recurrenceId: event.recurrenceId, + timezone: calendarTimezone, + }; + } + }) + .filter((e) => e != null); + + return events; + } catch (reason) { + console.error(reason); + throw reason; + } + } + + private async getAccount() { + const account = await createAccount({ + account: { + serverUrl: this.url, + accountType: "caldav", + credentials: this.credentials, + }, + headers: this.headers, + }); + + return account; + } +} diff --git a/lib/integrations/Apple/components/AddAppleIntegration.tsx b/lib/integrations/Apple/components/AddAppleIntegration.tsx new file mode 100644 index 00000000..5ab5fe8a --- /dev/null +++ b/lib/integrations/Apple/components/AddAppleIntegration.tsx @@ -0,0 +1,50 @@ +import React from "react"; + +type Props = { + onSubmit: () => void; +}; + +export const ADD_APPLE_INTEGRATION_FORM_TITLE = "addAppleIntegration"; + +const AddAppleIntegration = React.forwardRef((props, ref) => { + const onSubmit = (event) => { + event.preventDefault(); + event.stopPropagation(); + + props.onSubmit(); + }; + + return ( +
+
+ + +
+
+ + +
+
+ ); +}); + +AddAppleIntegration.displayName = "AddAppleIntegrationForm"; +export default AddAppleIntegration; diff --git a/pages/api/integrations/apple/add.ts b/pages/api/integrations/apple/add.ts new file mode 100644 index 00000000..5de5f11e --- /dev/null +++ b/pages/api/integrations/apple/add.ts @@ -0,0 +1,52 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getSession } from "next-auth/client"; +import prisma from "../../../../lib/prisma"; +import { symmetricEncrypt } from "@lib/crypto"; +import logger from "@lib/logger"; +import { AppleCalendar } from "@lib/integrations/Apple/AppleCalendarAdapter"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "POST") { + // Check that user is authenticated + const session = await getSession({ req: req }); + + if (!session) { + res.status(401).json({ message: "You must be logged in to do this" }); + return; + } + + const { username, password } = req.body; + // Get user + await prisma.user.findFirst({ + where: { + email: session.user.email, + }, + select: { + id: true, + }, + }); + + try { + const dav = new AppleCalendar({ + id: 0, + type: "apple_calendar", + key: symmetricEncrypt(JSON.stringify({ username, password }), process.env.CALENDSO_ENCRYPTION_KEY), + userId: session.user.id, + }); + + await dav.listCalendars(); + await prisma.credential.create({ + data: { + type: "apple_calendar", + key: symmetricEncrypt(JSON.stringify({ username, password }), process.env.CALENDSO_ENCRYPTION_KEY), + userId: session.user.id, + }, + }); + } catch (reason) { + logger.error("Could not add this caldav account", reason); + return res.status(500).json({ message: "Could not add this caldav account" }); + } + + return res.status(200).json({}); + } +} diff --git a/pages/integrations/index.tsx b/pages/integrations/index.tsx index 058f07de..8de0b500 100644 --- a/pages/integrations/index.tsx +++ b/pages/integrations/index.tsx @@ -12,6 +12,9 @@ import AddCalDavIntegration, { ADD_CALDAV_INTEGRATION_FORM_TITLE, } from "@lib/integrations/CalDav/components/AddCalDavIntegration"; import { getSession } from "@lib/auth"; +import AddAppleIntegration, { + ADD_APPLE_INTEGRATION_FORM_TITLE, +} from "@lib/integrations/Apple/components/AddAppleIntegration"; export type Integration = { installed: boolean; @@ -34,6 +37,10 @@ export default function Home({ integrations }: Props) { const [isAddCalDavIntegrationDialogOpen, setIsAddCalDavIntegrationDialogOpen] = useState(false); const [addCalDavError, setAddCalDavError] = useState<{ message: string } | null>(null); + const addAppleIntegrationRef = useRef(null); + const [isAddAppleIntegrationDialogOpen, setIsAddAppleIntegrationDialogOpen] = useState(false); + const [addAppleError, setAddAppleError] = useState<{ message: string } | null>(null); + useEffect(loadCalendars, [integrations]); function loadCalendars() { @@ -51,6 +58,12 @@ export default function Home({ integrations }: Props) { return; } + if (type === "apple_calendar") { + setAddAppleError(null); + setIsAddAppleIntegrationDialogOpen(true); + return; + } + fetch("/api/integrations/" + type.replace("_", "") + "/add") .then((response) => response.json()) .then((data) => (window.location.href = data.url)); @@ -72,6 +85,21 @@ export default function Home({ integrations }: Props) { }); }; + const handleAddAppleIntegration = async ({ username, password }) => { + const requestBody = JSON.stringify({ + username, + password, + }); + + return await fetch("/api/integrations/apple/add", { + method: "POST", + body: requestBody, + headers: { + "Content-Type": "application/json", + }, + }); + }; + function calendarSelectionHandler(calendar) { return (selected) => { const i = selectableCalendars.findIndex((c) => c.externalId === calendar.externalId); @@ -104,6 +132,8 @@ export default function Home({ integrations }: Props) { return "integrations/outlook.svg"; case "caldav_calendar": return "integrations/caldav.svg"; + case "apple_calendar": + return "integrations/apple-calendar.svg"; default: return ""; } @@ -221,6 +251,25 @@ export default function Home({ integrations }: Props) { } }; + const handleAddAppleIntegrationSaveButtonPress = async () => { + const form = addAppleIntegrationRef.current.elements; + const password = form.password.value; + const username = form.username.value; + + try { + setAddAppleError(null); + const addAppleIntegrationResponse = await handleAddAppleIntegration({ username, password }); + if (addAppleIntegrationResponse.ok) { + setIsAddAppleIntegrationDialogOpen(false); + } else { + const j = await addAppleIntegrationResponse.json(); + setAddAppleError({ message: j.message }); + } + } catch (reason) { + console.error(reason); + } + }; + const ConnectCalDavServerDialog = useCallback(() => { return ( { + return ( + setIsAddAppleIntegrationDialogOpen(isOpen)}> + + +
+ {addAppleError && ( +

+ Error: + {addAppleError.message} +

+ )} + +
+
+ + { + setIsAddAppleIntegrationDialogOpen(false); + }} + as="button" + className="btn btn-white mx-2"> + Cancel + +
+
+
+ ); + }, [isAddAppleIntegrationDialogOpen, addAppleError]); + if (loading) { return ; } @@ -366,6 +458,7 @@ export default function Home({ integrations }: Props) { + ); @@ -441,6 +534,14 @@ export async function getServerSideProps(context) { imageSrc: "integrations/caldav.svg", description: "For personal and business calendars", }, + { + installed: true, + type: "apple_calendar", + credential: credentials.find((integration) => integration.type === "apple_calendar") || null, + title: "Apple Calendar", + imageSrc: "integrations/apple-calendar.svg", + description: "For personal and business calendars", + }, ]; return { diff --git a/public/integrations/apple-calendar.svg b/public/integrations/apple-calendar.svg new file mode 100644 index 00000000..b5c73afd --- /dev/null +++ b/public/integrations/apple-calendar.svg @@ -0,0 +1,60 @@ + + + + + + + + + + +]> + + + + + + + + + + + + + + + + + + + + + +