-
Notifications
You must be signed in to change notification settings - Fork 8.8k
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
Changes from 82 commits
2112733
a10db1d
c2b1b20
e729c49
64fa70f
a650f90
2e1f50c
a68041a
f39dd5c
c3a1460
cf73a42
e90d7aa
9ee3e6c
0ee722c
b95981e
7282557
5c14a23
b6ea1e8
c35cdf6
f8707b6
3b90870
7d33bfa
235e68b
e0284ba
65a79f6
bc2b8a5
3b4e28a
670cb8c
3928638
25f2ea2
9705c6c
d50d999
e34ca43
8d80312
1e4f962
c064643
8ba932c
d77b02f
0bad86c
ecb3149
454b0dd
2ed30db
43e97f0
0f1d528
54701b5
8e6eb7f
889aeaa
2866e14
1cb0431
eaf6781
646a9c3
f2a490e
d9e4d9d
e4e5876
5f50ac1
24ddac3
5b90cf9
092da04
4b99164
782409d
ebc62c0
591b6d7
4d6bc5c
85471e4
27df337
0083a24
9df1598
c546085
4160467
ee86f42
10d5e71
fc44f8c
f99e6ef
615bff3
2d20193
a73d175
7a7773e
4c2792a
cfd926e
b2933e8
323b0f0
1ace701
9d3fc4b
d67a534
1cf5313
8de6dea
aa64264
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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; | ||
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>; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; |
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}`, | ||
|
@@ -20,7 +23,7 @@ try { | |
"*/5 * * * * *", | ||
async function () { | ||
await Promise.allSettled([ | ||
fetchCron("/tasks/cron"), | ||
fetchCron("/calendar-cache/cron"), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For testing locally |
||
// fetchCron("/cron/calVideoNoShowWebhookTriggers"), | ||
// | ||
// fetchCron("/tasks/cleanup"), | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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({ | ||
|
@@ -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 }); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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({ | ||
zomars marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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({ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -8,6 +8,10 @@ | |
"path": "/api/tasks/cron", | ||
"schedule": "* * * * *" | ||
}, | ||
{ | ||
"path": "/api/calendar-cache/cron", | ||
"schedule": "* * * * *" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Running each minute |
||
}, | ||
{ | ||
"path": "/api/tasks/cleanup", | ||
"schedule": "0 0 * * *" | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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({ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
|
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"; |
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 | ||
zomars marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if (req.headers["x-goog-channel-token"] !== process.env.CRON_API_KEY) { | ||
zomars marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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" }); | ||
zomars marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const { credential } = selectedCalendar; | ||
if (!credential) throw new HttpError({ statusCode: 404, message: "No credential found" }); | ||
zomars marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const { selectedCalendars } = credential; | ||
zomars marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const calendar = await getCalendar(credential); | ||
await calendar?.fetchAvailabilityAndSetCache?.(selectedCalendars); | ||
keithwillcode marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return { message: "ok" }; | ||
} | ||
|
||
export default defaultHandler({ | ||
POST: Promise.resolve({ default: defaultResponder(postHandler) }), | ||
}); |
There was a problem hiding this comment.
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