Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Populate gCal calendar cache via webhooks #11928

Merged
merged 87 commits into from
Nov 15, 2024
Merged
Show file tree
Hide file tree
Changes from 82 commits
Commits
Show all changes
87 commits
Select commit Hold shift + click to select a range
2112733
WIP
zomars Sep 25, 2023
a10db1d
WIP
zomars Sep 25, 2023
c2b1b20
WIP
zomars Sep 29, 2023
e729c49
WIP
zomars Sep 29, 2023
64fa70f
WIP
zomars Oct 17, 2023
a650f90
WIP
zomars Oct 17, 2023
2e1f50c
WIP
zomars Oct 17, 2023
a68041a
WIP
zomars Oct 17, 2023
f39dd5c
WIP
zomars Oct 19, 2023
c3a1460
Update CalendarService.ts
zomars Oct 19, 2023
cf73a42
Type fixes
zomars Oct 19, 2023
e90d7aa
WIP
zomars Oct 19, 2023
9ee3e6c
fix: improve cache hits
zomars Oct 20, 2023
0ee722c
Update CalendarService.ts
zomars Oct 20, 2023
b95981e
Update CalendarService.ts
zomars Oct 20, 2023
7282557
Update CalendarService.ts
zomars Oct 20, 2023
5c14a23
Update CalendarService.ts
zomars Oct 20, 2023
b6ea1e8
Update CalendarService.ts
zomars Oct 26, 2023
c35cdf6
Update CalendarService.ts
zomars Oct 26, 2023
f8707b6
Feedback
zomars Oct 27, 2023
3b90870
Merge branch 'main' into feat/calendar-cache-inbound
zomars Jan 29, 2024
7d33bfa
Update CalendarService.ts
zomars Jan 29, 2024
235e68b
Update CalendarService.ts
zomars Jan 29, 2024
e0284ba
Update _router.ts
zomars Jan 29, 2024
65a79f6
feedback
zomars Jan 29, 2024
bc2b8a5
WIP
zomars Jan 30, 2024
3b4e28a
Merge branch 'main' into feat/calendar-cache-inbound
zomars Feb 7, 2024
670cb8c
WIP
zomars Feb 7, 2024
3928638
Update schema.prisma
zomars Feb 7, 2024
25f2ea2
feedback
zomars Feb 7, 2024
9705c6c
Merge branch 'main' into feat/calendar-cache-inbound
zomars Feb 7, 2024
d50d999
typefixes
zomars Feb 7, 2024
e34ca43
Update Calendar.d.ts
zomars Feb 7, 2024
8d80312
Merge branch 'main' into feat/calendar-cache-inbound
zomars Feb 8, 2024
1e4f962
Merge branch 'main' into feat/calendar-cache-inbound
Udit-takkar Feb 12, 2024
c064643
Merge branch 'main' into feat/calendar-cache-inbound
zomars Feb 14, 2024
8ba932c
fix: watches when adding a calendar
zomars Feb 14, 2024
d77b02f
Merge branch 'main' into feat/calendar-cache-inbound
zomars Feb 14, 2024
0bad86c
Merge branch 'main' into feat/calendar-cache-inbound
zomars Oct 22, 2024
ecb3149
Merge branch 'main' into feat/calendar-cache-inbound
zomars Oct 24, 2024
454b0dd
Discard changes to packages/app-store/googlecalendar/api/add.ts
zomars Oct 24, 2024
2ed30db
Merge branch 'main' into feat/calendar-cache-inbound
zomars Nov 5, 2024
43e97f0
WIP
zomars Nov 7, 2024
0f1d528
WIP
zomars Nov 7, 2024
54701b5
WP
zomars Nov 7, 2024
8e6eb7f
Update calendar.ts
zomars Nov 7, 2024
889aeaa
Update calendar.ts
zomars Nov 7, 2024
2866e14
Update callback.ts
zomars Nov 7, 2024
1cb0431
Update callback.ts
zomars Nov 7, 2024
eaf6781
Merge branch 'main' into feat/calendar-cache-inbound
zomars Nov 7, 2024
646a9c3
Conflicts
zomars Nov 7, 2024
f2a490e
WIP
zomars Nov 7, 2024
d9e4d9d
WIP
zomars Nov 7, 2024
e4e5876
Update CalendarService.ts
zomars Nov 7, 2024
5f50ac1
Cleanup
zomars Nov 7, 2024
24ddac3
Discard changes to packages/features/settings/layouts/SettingsLayout.tsx
zomars Nov 7, 2024
5b90cf9
Update calendar-cache.repository.ts
zomars Nov 7, 2024
092da04
WIP
zomars Nov 8, 2024
4b99164
Update getSelectedCalendarsToWatch.sql
zomars Nov 8, 2024
782409d
WIP
zomars Nov 8, 2024
ebc62c0
Update CalendarService.ts
zomars Nov 8, 2024
591b6d7
Merge branch 'main' into feat/calendar-cache-inbound
zomars Nov 11, 2024
4d6bc5c
Cleanup
zomars Nov 11, 2024
85471e4
Merge branch 'main' into feat/calendar-cache-inbound
zomars Nov 11, 2024
27df337
Discard changes to packages/app-store/googlecalendar/lib/CalendarServ…
zomars Nov 12, 2024
0083a24
Create CalendarService.wip.ts
zomars Nov 12, 2024
9df1598
WIP
zomars Nov 12, 2024
c546085
Update CalendarService.ts
zomars Nov 12, 2024
4160467
Update getSelectedCalendarsToWatch.sql
zomars Nov 12, 2024
ee86f42
Delete CalendarService.wip.ts
zomars Nov 12, 2024
10d5e71
test updates
zomars Nov 12, 2024
fc44f8c
cleanup
zomars Nov 12, 2024
f99e6ef
Update CalendarService.test.ts
zomars Nov 12, 2024
615bff3
Update CalendarService.ts
zomars Nov 12, 2024
2d20193
type fixes
zomars Nov 12, 2024
a73d175
Update OAuthManager.ts
zomars Nov 12, 2024
7a7773e
Update CalendarService.ts
zomars Nov 12, 2024
4c2792a
Almost there
zomars Nov 12, 2024
cfd926e
Update CalendarService.test.ts
zomars Nov 12, 2024
b2933e8
Update calendar.ts
zomars Nov 13, 2024
323b0f0
Update callback.ts
zomars Nov 13, 2024
1ace701
Merge branch 'main' into feat/calendar-cache-inbound
zomars Nov 13, 2024
9d3fc4b
Update toggleFeatureFlag.handler.ts
zomars Nov 13, 2024
d67a534
Merge branch 'main' into feat/calendar-cache-inbound
zomars Nov 14, 2024
1cf5313
fix: feedback
zomars Nov 15, 2024
8de6dea
Update getSelectedCalendarsToWatch.sql
zomars Nov 15, 2024
aa64264
Fix unit tests
zomars Nov 15, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/web/app/api/calendar-cache/cron/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export { GET } from "@calcom/features/calendar-cache/api/cron";

/**
* This runs each minute and we need fresh data each time
* @see https://nextjs.org/docs/app/building-your-application/caching#opting-out-2
**/
export const revalidate = 0;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So cron is always fresh

11 changes: 11 additions & 0 deletions apps/web/app/settings/(admin-layout)/admin/calendar-cache/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { _generateMetadata } from "app/_utils";

export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("admin"),
() => ""
);

const Page = () => <h1>Calendar cache index</h1>;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a placeholder page where we might control cache entries or remove them if necessary.


export default Page;
7 changes: 5 additions & 2 deletions apps/web/cron-tester.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { CronJob } from "cron";
import dotEnv from "dotenv";

dotEnv.config({ path: "../../.env" });

async function fetchCron(endpoint: string) {
const apiKey = process.env.CRON_API_KEY;

const res = await fetch(`http://localhost:3000/api${endpoint}?${apiKey}`, {
const res = await fetch(`http://localhost:3000/api${endpoint}?apiKey=${apiKey}`, {
headers: {
"Content-Type": "application/json",
authorization: `Bearer ${process.env.CRON_SECRET}`,
Expand All @@ -20,7 +23,7 @@ try {
"*/5 * * * * *",
async function () {
await Promise.allSettled([
fetchCron("/tasks/cron"),
fetchCron("/calendar-cache/cron"),
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For testing locally

// fetchCron("/cron/calVideoNoShowWebhookTriggers"),
//
// fetchCron("/tasks/cleanup"),
Expand Down
132 changes: 73 additions & 59 deletions apps/web/pages/api/availability/calendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,26 @@ import { z } from "zod";

import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { CalendarCache } from "@calcom/features/calendar-cache/calendar-cache";
import { HttpError } from "@calcom/lib/http-error";
import notEmpty from "@calcom/lib/notEmpty";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
import prisma from "@calcom/prisma";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";

const selectedCalendarSelectSchema = z.object({
integration: z.string(),
externalId: z.string(),
credentialId: z.number().optional(),
credentialId: z.coerce.number(),
});

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession({ req, res });
/** Shared authentication middleware for GET, DELETE and POST requests */
async function authMiddleware(req: CustomNextApiRequest) {
const session = await getServerSession({ req });

if (!session?.user?.id) {
res.status(401).json({ message: "Not authenticated" });
return;
throw new HttpError({ statusCode: 401, message: "Not authenticated" });
}

const userWithCredentials = await prisma.user.findUnique({
Expand All @@ -35,66 +39,76 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
});
if (!userWithCredentials) {
res.status(401).json({ message: "Not authenticated" });
return;
throw new HttpError({ statusCode: 401, message: "Not authenticated" });
}
const { credentials, ...user } = userWithCredentials;
req.userWithCredentials = userWithCredentials;
return userWithCredentials;
}

if (req.method === "POST") {
const { integration, externalId, credentialId } = selectedCalendarSelectSchema.parse(req.body);
await prisma.selectedCalendar.upsert({
where: {
userId_integration_externalId: {
userId: user.id,
integration,
externalId,
},
},
create: {
userId: user.id,
integration,
externalId,
credentialId,
},
// already exists
update: {},
});
res.status(200).json({ message: "Calendar Selection Saved" });
}
type CustomNextApiRequest = NextApiRequest & {
userWithCredentials?: Awaited<ReturnType<typeof authMiddleware>>;
};

if (req.method === "DELETE") {
const { integration, externalId } = selectedCalendarSelectSchema.parse(req.query);
await prisma.selectedCalendar.delete({
where: {
userId_integration_externalId: {
userId: user.id,
externalId,
integration,
},
},
});
async function postHandler(req: CustomNextApiRequest) {
if (!req.userWithCredentials) throw new HttpError({ statusCode: 401, message: "Not authenticated" });
const user = req.userWithCredentials;
const { integration, externalId, credentialId } = selectedCalendarSelectSchema.parse(req.body);
await SelectedCalendarRepository.upsert({
userId: user.id,
integration,
externalId,
credentialId,
});

res.status(200).json({ message: "Calendar Selection Saved" });
}
return { message: "Calendar Selection Saved" };
}

if (req.method === "GET") {
const selectedCalendarIds = await prisma.selectedCalendar.findMany({
where: {
async function deleteHandler(req: CustomNextApiRequest) {
if (!req.userWithCredentials) throw new HttpError({ statusCode: 401, message: "Not authenticated" });
const user = req.userWithCredentials;
const { integration, externalId, credentialId } = selectedCalendarSelectSchema.parse(req.query);
const calendarCacheRepository = await CalendarCache.initFromCredentialId(credentialId);
await calendarCacheRepository.unwatchCalendar({ calendarId: externalId });
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even tho we already handle subscriptions in the cron, we make sure we purge cache when removing a selected calendar.

await prisma.selectedCalendar.delete({
where: {
userId_integration_externalId: {
userId: user.id,
externalId,
integration,
},
select: {
externalId: true,
},
});
},
});

// get user's credentials + their connected integrations
const calendarCredentials = getCalendarCredentials(credentials);
// get all the connected integrations' calendars (from third party)
const { connectedCalendars } = await getConnectedCalendars(calendarCredentials, user.selectedCalendars);
const calendars = connectedCalendars.flatMap((c) => c.calendars).filter(notEmpty);
const selectableCalendars = calendars.map((cal) => {
return { selected: selectedCalendarIds.findIndex((s) => s.externalId === cal.externalId) > -1, ...cal };
});
res.status(200).json(selectableCalendars);
}
return { message: "Calendar Selection Saved" };
}

async function getHandler(req: CustomNextApiRequest) {
if (!req.userWithCredentials) throw new HttpError({ statusCode: 401, message: "Not authenticated" });
const user = req.userWithCredentials;
const selectedCalendarIds = await prisma.selectedCalendar.findMany({
where: {
userId: user.id,
},
select: {
externalId: true,
},
});
// get user's credentials + their connected integrations
const calendarCredentials = getCalendarCredentials(user.credentials);
// get all the connected integrations' calendars (from third party)
const { connectedCalendars } = await getConnectedCalendars(calendarCredentials, user.selectedCalendars);
const calendars = connectedCalendars.flatMap((c) => c.calendars).filter(notEmpty);
const selectableCalendars = calendars.map((cal) => {
return { selected: selectedCalendarIds.findIndex((s) => s.externalId === cal.externalId) > -1, ...cal };
});
return selectableCalendars;
}

export default defaultResponder(async (req: NextApiRequest, res: NextApiResponse) => {
await authMiddleware(req);
return defaultHandler({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice changes in this file 🙏

GET: Promise.resolve({ default: defaultResponder(getHandler) }),
POST: Promise.resolve({ default: defaultResponder(postHandler) }),
DELETE: Promise.resolve({ default: defaultResponder(deleteHandler) }),
})(req, res);
});
6 changes: 6 additions & 0 deletions apps/web/pages/api/cron/calendar-cache-cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const apiKey = req.headers.authorization || req.query.apiKey;
if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) {
res.status(401).json({ message: "Not authenticated" });
return;
}

const deleted = await prisma.calendarCache.deleteMany({
where: {
// Delete all cache entries that expired before now
Expand Down
4 changes: 4 additions & 0 deletions apps/web/vercel.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
"path": "/api/tasks/cron",
"schedule": "* * * * *"
},
{
"path": "/api/calendar-cache/cron",
"schedule": "* * * * *"
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Running each minute

},
{
"path": "/api/tasks/cleanup",
"schedule": "0 0 * * *"
Expand Down
2 changes: 1 addition & 1 deletion packages/app-store/googlecalendar/api/callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
// Wrapping in a try/catch to reduce chance of race conditions-
// also this improves performance for most of the happy-paths.
try {
await GoogleRepository.createSelectedCalendar({
await GoogleRepository.upsertSelectedCalendar({
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switched to upset since watching a calendar already creates it. To prevent a race condition.

credentialId: gcalCredential.id,
externalId: selectedCalendarWhereUnique.externalId,
userId: selectedCalendarWhereUnique.userId,
Expand Down
1 change: 1 addition & 0 deletions packages/app-store/googlecalendar/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { default as add } from "./add";
export { default as callback } from "./callback";
export { default as webhook } from "./webhook";
47 changes: 47 additions & 0 deletions packages/app-store/googlecalendar/api/webhook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { NextApiRequest } from "next";

import { HttpError } from "@calcom/lib/http-error";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";

import { getCalendar } from "../../_utils/getCalendar";

async function postHandler(req: NextApiRequest) {
// 1. validate request
if (req.headers["x-goog-channel-token"] !== process.env.CRON_API_KEY) {
throw new HttpError({ statusCode: 403, message: "Invalid API key" });
}
if (typeof req.headers["x-goog-channel-id"] !== "string") {
throw new HttpError({ statusCode: 403, message: "Missing Channel ID" });
}

const selectedCalendar = await prisma.selectedCalendar.findUnique({
where: {
googleChannelId: req.headers["x-goog-channel-id"],
},
select: {
credential: {
select: {
...credentialForCalendarServiceSelect,
selectedCalendars: {
orderBy: {
externalId: "asc",
},
},
},
},
},
});
if (!selectedCalendar) throw new HttpError({ statusCode: 404, message: "No calendar found" });
const { credential } = selectedCalendar;
if (!credential) throw new HttpError({ statusCode: 404, message: "No credential found" });
const { selectedCalendars } = credential;
const calendar = await getCalendar(credential);
await calendar?.fetchAvailabilityAndSetCache?.(selectedCalendars);
return { message: "ok" };
}

export default defaultHandler({
POST: Promise.resolve({ default: defaultResponder(postHandler) }),
});
Loading
Loading