Skip to content

Commit 577004b

Browse files
committed
feat: add Proton Calendar integration
1 parent 180ede2 commit 577004b

19 files changed

Lines changed: 353 additions & 4 deletions

File tree

apps/web/components/apps/AppSetupPage.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export const AppSetupMap = {
99
"exchange2016-calendar": dynamic(() => import("@calcom/web/components/apps/exchange2016calendar/Setup")),
1010
"caldav-calendar": dynamic(() => import("@calcom/web/components/apps/caldavcalendar/Setup")),
1111
"ics-feed": dynamic(() => import("@calcom/web/components/apps/ics-feedcalendar/Setup")),
12+
"proton-calendar": dynamic(() => import("@calcom/web/components/apps/protoncalendar/Setup")),
1213
make: dynamic(() => import("@calcom/web/components/apps/make/Setup")),
1314
sendgrid: dynamic(() => import("@calcom/web/components/apps/sendgrid/Setup")),
1415
stripe: dynamic(() => import("@calcom/web/components/apps/stripepayment/Setup")),
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { useRouter } from "next/navigation";
2+
import { useState } from "react";
3+
import { useForm } from "react-hook-form";
4+
import { Toaster } from "sonner";
5+
6+
import { useLocale } from "@calcom/lib/hooks/useLocale";
7+
import { Alert } from "@calcom/ui/components/alert";
8+
import { Button } from "@calcom/ui/components/button";
9+
import { Form, TextField } from "@calcom/ui/components/form";
10+
import { PlusIcon, TrashIcon } from "@coss/ui/icons";
11+
12+
export default function ProtonCalendarSetup() {
13+
const { t } = useLocale();
14+
const router = useRouter();
15+
const form = useForm({
16+
defaultValues: {},
17+
});
18+
19+
const [urls, setUrls] = useState<string[]>([""]);
20+
const [errorMessage, setErrorMessage] = useState("");
21+
22+
return (
23+
<div className="bg-emphasis flex h-screen">
24+
<div className="bg-default m-auto rounded p-5 md:w-[560px] md:p-10">
25+
<div className="flex flex-col stack-y-5 md:flex-row md:space-x-5 md:stack-y-0">
26+
<div>
27+
{/* eslint-disable @next/next/no-img-element */}
28+
<img
29+
src="/api/app-store/protoncalendar/icon.svg"
30+
alt="Proton Calendar"
31+
className="h-12 w-12 max-w-2xl"
32+
/>
33+
</div>
34+
<div className="flex w-10/12 flex-col">
35+
<h1 className="text-default">{t("connect_proton_calendar")}</h1>
36+
<div className="mt-1 text-sm">{t("credentials_stored_encrypted")}</div>
37+
<div className="my-2 mt-3">
38+
<Form
39+
form={form}
40+
handleSubmit={async (_) => {
41+
setErrorMessage("");
42+
43+
try {
44+
const res = await fetch("/api/integrations/protoncalendar/add", {
45+
method: "POST",
46+
body: JSON.stringify({ urls }),
47+
headers: {
48+
"Content-Type": "application/json",
49+
},
50+
});
51+
const json = await res.json().catch(() => ({}));
52+
53+
if (!res.ok) {
54+
setErrorMessage(json?.message || t("something_went_wrong"));
55+
return;
56+
}
57+
58+
router.push(json.url);
59+
} catch {
60+
setErrorMessage(t("something_went_wrong"));
61+
}
62+
}}>
63+
<fieldset className="stack-y-2" disabled={form.formState.isSubmitting}>
64+
{urls.map((url, i) => (
65+
<div key={i} className="flex w-full items-center gap-2">
66+
<TextField
67+
required
68+
type="text"
69+
label={t("calendar_url")}
70+
value={url}
71+
containerClassName={`w-full ${i === 0 ? "mr-6" : ""}`}
72+
onChange={(e) => {
73+
const newVal = e.target.value as string;
74+
setUrls((urls) => urls.map((x, ii) => (ii === i ? newVal : x)));
75+
}}
76+
placeholder="webcal://calendar.proton.me/api/calendar/v1/url/..."
77+
/>
78+
{i !== 0 ? (
79+
<button
80+
type="button"
81+
aria-label={t("remove")}
82+
className="mb-2 h-min text-sm"
83+
onClick={() => setUrls((urls) => urls.filter((_, ii) => i !== ii))}>
84+
<TrashIcon size={16} />
85+
</button>
86+
) : null}
87+
</div>
88+
))}
89+
</fieldset>
90+
91+
<button
92+
className="text-sm"
93+
type="button"
94+
onClick={() => {
95+
setUrls((urls) => urls.concat(""));
96+
}}>
97+
{t("add")} <PlusIcon className="inline" size={16} />
98+
</button>
99+
100+
{errorMessage && <Alert severity="error" title={errorMessage} className="my-4" />}
101+
102+
<div className="mt-5 justify-end space-x-2 rtl:space-x-reverse sm:mt-4 sm:flex">
103+
<Button type="button" color="secondary" onClick={() => router.back()}>
104+
{t("cancel")}
105+
</Button>
106+
<Button type="submit" loading={form.formState.isSubmitting}>
107+
{t("save")}
108+
</Button>
109+
</div>
110+
</Form>
111+
</div>
112+
</div>
113+
</div>
114+
</div>
115+
<Toaster position="bottom-right" />
116+
</div>
117+
);
118+
}

packages/app-store/apps.metadata.generated.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ import pipedream_config_json from "./pipedream/config.json";
7474
import pipedrive_crm_config_json from "./pipedrive-crm/config.json";
7575
import plausible_config_json from "./plausible/config.json";
7676
import posthog_config_json from "./posthog/config.json";
77+
import protoncalendar_config_json from "./protoncalendar/config.json";
7778
import qr_code_config_json from "./qr_code/config.json";
7879
import raycast_config_json from "./raycast/config.json";
7980
import retell_ai_config_json from "./retell-ai/config.json";
@@ -186,6 +187,7 @@ export const appStoreMetadata = {
186187
"pipedrive-crm": pipedrive_crm_config_json,
187188
plausible: plausible_config_json,
188189
posthog: posthog_config_json,
190+
protoncalendar: protoncalendar_config_json,
189191
qr_code: qr_code_config_json,
190192
raycast: raycast_config_json,
191193
"retell-ai": retell_ai_config_json,

packages/app-store/apps.server.generated.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export const apiHandlers = {
5555
"pipedrive-crm": import("./pipedrive-crm/api"),
5656
plausible: import("./plausible/api"),
5757
posthog: import("./posthog/api"),
58+
protoncalendar: import("./protoncalendar/api"),
5859
qr_code: import("./qr_code/api"),
5960
riverside: import("./riverside/api"),
6061
roam: import("./roam/api"),

packages/app-store/calendar.services.generated.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,6 @@ export const CalendarServiceMap =
1616
"ics-feedcalendar": import("./ics-feedcalendar/lib/CalendarService"),
1717
larkcalendar: import("./larkcalendar/lib/CalendarService"),
1818
office365calendar: import("./office365calendar/lib/CalendarService"),
19+
protoncalendar: import("./protoncalendar/lib/CalendarService"),
1920
zohocalendar: import("./zohocalendar/lib/CalendarService"),
2021
};

packages/app-store/ics-feedcalendar/lib/CalendarService.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,14 @@ const applyTravelDuration = (event: ICAL.Event, seconds: number) => {
4040

4141
const CALENDSO_ENCRYPTION_KEY = process.env.CALENDSO_ENCRYPTION_KEY || "";
4242

43-
class ICSFeedCalendarService implements Calendar {
43+
export class ICSFeedCalendarService implements Calendar {
4444
private urls: string[] = [];
45-
protected integrationName = "ics-feed_calendar";
45+
protected integrationName: string;
4646

47-
constructor(credential: CredentialPayload) {
47+
constructor(credential: CredentialPayload, integrationName = "ics-feed_calendar") {
4848
const { urls } = JSON.parse(symmetricDecrypt(credential.key as string, CALENDSO_ENCRYPTION_KEY));
4949
this.urls = urls;
50+
this.integrationName = integrationName;
5051
}
5152

5253
createEvent(_event: CalendarEvent, _credentialId: number): Promise<NewCalendarEventType> {
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Proton Calendar
2+
3+
Connect Proton Calendar to Cal.com using Proton Calendar's ICS subscription URL.
4+
5+
This integration is read-only. Cal.com uses the feed to check availability and avoid conflicts.
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import process from "node:process";
2+
import type { NextApiRequest, NextApiResponse } from "next";
3+
4+
import { symmetricEncrypt } from "@calcom/lib/crypto";
5+
import logger from "@calcom/lib/logger";
6+
import prisma from "@calcom/prisma";
7+
8+
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
9+
import appConfig from "../config.json";
10+
import BuildCalendarService from "../lib/CalendarService";
11+
import { isValidProtonCalendarUrl, normalizeProtonCalendarUrl } from "../lib/validateProtonCalendarUrl";
12+
13+
const CALENDSO_ENCRYPTION_KEY = process.env.CALENDSO_ENCRYPTION_KEY || "";
14+
15+
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
16+
if (req.method === "POST") {
17+
const userId = req.session?.user?.id;
18+
if (!userId) {
19+
return res.status(401).json({ message: "Unauthorized" });
20+
}
21+
22+
const urls = Array.isArray(req.body.urls)
23+
? req.body.urls.map((url: unknown) => String(url).trim()).filter(Boolean)
24+
: [];
25+
26+
if (!urls.length || urls.some((url: string) => !isValidProtonCalendarUrl(url))) {
27+
return res.status(400).json({
28+
message: "Enter at least one valid Proton Calendar webcal or HTTPS ICS URL",
29+
});
30+
}
31+
32+
const normalizedUrls = urls.map((url: string) => normalizeProtonCalendarUrl(url));
33+
34+
const user = await prisma.user.findFirstOrThrow({
35+
where: {
36+
id: userId,
37+
},
38+
select: {
39+
id: true,
40+
email: true,
41+
},
42+
});
43+
44+
const data = {
45+
type: appConfig.type,
46+
key: symmetricEncrypt(JSON.stringify({ urls: normalizedUrls }), CALENDSO_ENCRYPTION_KEY),
47+
userId: user.id,
48+
teamId: null,
49+
appId: appConfig.slug,
50+
invalid: false,
51+
delegationCredentialId: null,
52+
};
53+
54+
try {
55+
const protonCalendar = BuildCalendarService({
56+
id: 0,
57+
...data,
58+
user: { email: user.email },
59+
encryptedKey: null,
60+
});
61+
const listedCalendars = await protonCalendar.listCalendars();
62+
63+
if (listedCalendars.length !== normalizedUrls.length) {
64+
throw new Error(`Listed calendars and URLs mismatch: ${listedCalendars.length} vs. ${normalizedUrls.length}`);
65+
}
66+
67+
await prisma.credential.create({
68+
data,
69+
});
70+
} catch (error) {
71+
logger.error("Could not add Proton Calendar feeds", error);
72+
return res.status(500).json({ message: "Could not add Proton Calendar feeds" });
73+
}
74+
75+
return res.status(200).json({
76+
url: getInstalledAppPath({ variant: "calendar", slug: appConfig.slug }),
77+
});
78+
}
79+
80+
if (req.method === "GET") {
81+
return res.status(200).json({ url: "/apps/proton-calendar/setup" });
82+
}
83+
84+
return res.status(405).json({ message: "Method not allowed" });
85+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default as add } from "./add";
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"name": "Proton Calendar",
3+
"title": "Proton Calendar",
4+
"slug": "proton-calendar",
5+
"dirName": "protoncalendar",
6+
"type": "protoncalendar_calendar",
7+
"logo": "icon.svg",
8+
"variant": "calendar",
9+
"categories": ["calendar"],
10+
"publisher": "Cal.com, Inc.",
11+
"email": "help@cal.com",
12+
"description": "Use a Proton Calendar ICS subscription URL for availability checks in Cal.com.",
13+
"isTemplate": false,
14+
"__createdUsingCli": true
15+
}

0 commit comments

Comments
 (0)