Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
5e278ac
feat: check for cached availability on outlook CalendarService.getAva…
ouwargui May 1, 2025
d943688
feat: implement microsoft graph webhook for outlook events
ouwargui May 2, 2025
fedbc89
chore: update .env.example to include outlook envs
ouwargui May 2, 2025
098bf9c
fix: webhook validation not working
ouwargui May 2, 2025
ca57c22
feat: support office365calendar on calendar-cache cronjob
ouwargui May 2, 2025
2838393
migration: add outlookSubscriptionId and outlookSubscriptionExpiratio…
ouwargui May 2, 2025
cce73d5
fix: zod schema
ouwargui May 2, 2025
644928e
fix: expirationDateTime not accepted by microsoft graph
ouwargui May 2, 2025
9f7f7d1
chore: fix review issues
ouwargui May 2, 2025
b091251
fix: log text
ouwargui May 2, 2025
9cff138
fix: prisma cron queries
ouwargui May 2, 2025
eb5bc3b
fix: test missing properties
ouwargui May 2, 2025
9429428
feat: filter duplicate subscriptionIds on webhook payload
ouwargui May 2, 2025
706ca02
test(unit): calendar cache
ouwargui May 2, 2025
5d72ca7
test(unit): set cache through fetchAvailabilityAndSetCache and use it…
ouwargui May 2, 2025
fbe8ff2
test(unit): watching and unwatching calendar + delegation credential
ouwargui May 3, 2025
983fe5b
test(unit): more watching and unwatching calendar
ouwargui May 3, 2025
a8a095a
test(unit): get availability
ouwargui May 3, 2025
fcb9e61
fix: token not defined on tests and caching wrong response
ouwargui May 3, 2025
955f95b
fix: subscribe only to specific calendar
ouwargui May 5, 2025
27876cb
fix: tests broken because of refactor on features repository
ouwargui May 6, 2025
42a91ce
Merge branch 'main' into feat/outlook-cache
ouwargui Jun 12, 2025
01cbb88
Merge branch 'main' into feat/outlook-cache
ouwargui Jun 18, 2025
8af0a3a
Merge branch 'main' into feat/outlook-cache
ouwargui Jul 24, 2025
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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,11 @@ GOOGLE_WEBHOOK_TOKEN=
# Optional URL to override for tunelling webhooks. Defaults to NEXT_PUBLIC_WEBAPP_URL.
GOOGLE_WEBHOOK_URL=

# Token to verify incoming webhooks from Microsoft Outlook
OUTLOOK_WEBHOOK_TOKEN=
Copy link
Contributor

Choose a reason for hiding this comment

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

Missing guidance on token generation requirements

# Optional URL to override for tunelling webhooks. Defaults to NEXT_PUBLIC_WEBAPP_URL.
OUTLOOK_WEBHOOK_URL=

# Inbox to send user feedback
SEND_FEEDBACK_EMAIL=

Expand Down
4 changes: 4 additions & 0 deletions apps/api/v1/test/lib/selected-calendars/_post.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ describe("POST /api/selected-calendars", () => {
googleChannelResourceUri: null,
googleChannelExpiration: null,
error: null,
outlookSubscriptionExpiration: null,
outlookSubscriptionId: null,
lastErrorAt: null,
watchAttempts: 0,
maxAttempts: 3,
Expand Down Expand Up @@ -134,6 +136,8 @@ describe("POST /api/selected-calendars", () => {
domainWideDelegationCredentialId: null,
eventTypeId: null,
error: null,
outlookSubscriptionExpiration: null,
outlookSubscriptionId: null,
lastErrorAt: null,
watchAttempts: 0,
maxAttempts: 3,
Expand Down
1 change: 1 addition & 0 deletions packages/app-store/office365calendar/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";
106 changes: 106 additions & 0 deletions packages/app-store/office365calendar/api/webhook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { NextApiRequest, NextApiResponse } from "next";

import { uniqueBy } from "@calcom/lib/array";
import { getCredentialForCalendarCache } from "@calcom/lib/delegationCredential/server";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";

import { getCalendar } from "../../_utils/getCalendar";
import { graphValidationTokenChallengeSchema, changeNotificationWebhookPayloadSchema } from "../zod";

const log = logger.getSubLogger({ prefix: ["Office365CalendarWebhook"] });

interface WebhookResponse {
[key: string]: {
processed: boolean;
message?: string;
};
}

function isApiKeyValid(clientState?: string) {
return clientState === process.env.OUTLOOK_WEBHOOK_TOKEN;
}

function getValidationToken(req: NextApiRequest) {
const graphValidationTokenChallengeParseRes = graphValidationTokenChallengeSchema.safeParse(req.query);
if (!graphValidationTokenChallengeParseRes.success) {
return null;
}

return graphValidationTokenChallengeParseRes.data.validationToken;
}

async function postHandler(req: NextApiRequest, res: NextApiResponse) {
const validationToken = getValidationToken(req);
if (validationToken) {
res.setHeader("Content-Type", "text/plain");
return res.status(200).send(validationToken);
}
Comment on lines +35 to +39
Copy link
Contributor Author

Choose a reason for hiding this comment

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

When you create a subscription, microsoft send a validation request to the webhook url with a validationToken on the search params, we have to return it in plain text.

More info here: https://learn.microsoft.com/en-us/graph/change-notifications-delivery-webhooks?tabs=http#notificationurl-validation


const webhookBodyParseRes = changeNotificationWebhookPayloadSchema.safeParse(req.body);

if (!webhookBodyParseRes.success) {
log.error("postHandler", safeStringify(webhookBodyParseRes.error));
Copy link
Contributor

Choose a reason for hiding this comment

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

Rule violated: Avoid Logging Sensitive Information

  Logging validation errors may expose sensitive information

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I can remove this or change to something else if wanted

return res.status(400).json({
message: "Invalid request body",
});
}

const events = webhookBodyParseRes.data.value;
const body: WebhookResponse = {};

const uniqueEvents = uniqueBy(events, ["subscriptionId"]);

const promises = uniqueEvents.map(async (event) => {
Comment on lines +50 to +55
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Microsoft send a value array on the payload which may contain one or more events for different subscriptionId.

When many changes occur, Microsoft Graph may send multiple notifications that correspond to different subscriptions in the same POST request.

I'm removing duplicate subscriptionIds to avoid fetching availability multiple times for the same calendar.

I'm not sure if the way I'm handling each event and adding the result to a body object is the best, but let me know if you have a different idea.

if (!isApiKeyValid(event.clientState)) {
body[event.subscriptionId] = {
processed: false,
message: "Invalid API key",
};
return;
}

const selectedCalendar = await SelectedCalendarRepository.findByOutlookSubscriptionId(
event.subscriptionId
);
if (!selectedCalendar) {
body[event.subscriptionId] = {
processed: false,
message: `No selected calendar found for outlookSubscriptionId: ${event.subscriptionId}`,
};
return;
}

const { credential } = selectedCalendar;
if (!credential) {
body[event.subscriptionId] = {
processed: false,
message: `No credential found for selected calendar for outlookSubscriptionId: ${event.subscriptionId}`,
};
return;
}

const { selectedCalendars } = credential;
const credentialForCalendarCache = await getCredentialForCalendarCache({ credentialId: credential.id });
const calendarServiceForCalendarCache = await getCalendar(credentialForCalendarCache);

await calendarServiceForCalendarCache?.fetchAvailabilityAndSetCache?.(selectedCalendars);
body[event.subscriptionId] = {
processed: true,
message: "ok",
};
return;
});

await Promise.all(promises);

return res.status(200).json(body);
}

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") return postHandler(req, res);

res.setHeader("Allow", "POST");
return res.status(405).json({});
}
Comment on lines +101 to +106
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I couldn't use the defaultHandler and defaultResponder here because I need to be able to return text/plain when Microsoft sends a validation challenge

Loading
Loading