From 7b84ffb3002d4082c7b1510b15d341ac0f5b6074 Mon Sep 17 00:00:00 2001 From: Eduardo Lelis Date: Thu, 9 Oct 2025 18:30:45 -0300 Subject: [PATCH 1/7] Use outlook endpoint --- .../calendars/CalendarConnectionCard.tsx | 25 +- .../calendars/CalendarConnections.tsx | 2 +- .../calendars/ConnectCalendar.tsx | 101 ++++++++ .../calendars/ConnectCalendarButton.tsx | 50 ---- .../(app)/[emailAccountId]/calendars/page.tsx | 6 +- .../api/outlook/calendar/auth-url/route.ts | 37 +++ .../api/outlook/calendar/callback/route.ts | 227 ++++++++++++++++++ .../images/product/outlook-calendar.svg | 1 + apps/web/utils/outlook/calendar-client.ts | 186 ++++++++++++++ apps/web/utils/outlook/scopes.ts | 10 + 10 files changed, 588 insertions(+), 57 deletions(-) create mode 100644 apps/web/app/(app)/[emailAccountId]/calendars/ConnectCalendar.tsx delete mode 100644 apps/web/app/(app)/[emailAccountId]/calendars/ConnectCalendarButton.tsx create mode 100644 apps/web/app/api/outlook/calendar/auth-url/route.ts create mode 100644 apps/web/app/api/outlook/calendar/callback/route.ts create mode 100644 apps/web/public/images/product/outlook-calendar.svg create mode 100644 apps/web/utils/outlook/calendar-client.ts diff --git a/apps/web/app/(app)/[emailAccountId]/calendars/CalendarConnectionCard.tsx b/apps/web/app/(app)/[emailAccountId]/calendars/CalendarConnectionCard.tsx index 2dcb7f30b..c4e11a8b3 100644 --- a/apps/web/app/(app)/[emailAccountId]/calendars/CalendarConnectionCard.tsx +++ b/apps/web/app/(app)/[emailAccountId]/calendars/CalendarConnectionCard.tsx @@ -28,6 +28,23 @@ interface CalendarConnectionCardProps { connection: CalendarConnection; } +const getProviderInfo = (provider: string) => { + const providers = { + microsoft: { + name: "Microsoft Calendar", + icon: "/images/product/outlook-calendar.svg", + alt: "Microsoft Calendar", + }, + google: { + name: "Google Calendar", + icon: "/images/product/google-calendar.svg", + alt: "Google Calendar", + }, + }; + + return providers[provider as keyof typeof providers] || providers.google; +}; + export function CalendarConnectionCard({ connection, }: CalendarConnectionCardProps) { @@ -37,6 +54,8 @@ export function CalendarConnectionCard({ Record >({}); + const providerInfo = getProviderInfo(connection.provider); + const { execute: executeDisconnect, isExecuting: isDisconnecting } = useAction(disconnectCalendarAction.bind(null, emailAccountId)); const { execute: executeToggle } = useAction( @@ -103,14 +122,14 @@ export function CalendarConnectionCard({
Google Calendar
- Google Calendar + {providerInfo.name} {connection.email} {!connection.isConnected && ( diff --git a/apps/web/app/(app)/[emailAccountId]/calendars/CalendarConnections.tsx b/apps/web/app/(app)/[emailAccountId]/calendars/CalendarConnections.tsx index 921f5cf37..56a1603b5 100644 --- a/apps/web/app/(app)/[emailAccountId]/calendars/CalendarConnections.tsx +++ b/apps/web/app/(app)/[emailAccountId]/calendars/CalendarConnections.tsx @@ -14,7 +14,7 @@ export function CalendarConnections() { {connections.length === 0 ? (

No calendar connections found.

-

Connect your Google Calendar to get started.

+

Connect your Google or Microsoft Calendar to get started.

) : (
diff --git a/apps/web/app/(app)/[emailAccountId]/calendars/ConnectCalendar.tsx b/apps/web/app/(app)/[emailAccountId]/calendars/ConnectCalendar.tsx new file mode 100644 index 000000000..ac424d09e --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/calendars/ConnectCalendar.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { useAccount } from "@/providers/EmailAccountProvider"; +import { toastError } from "@/components/Toast"; +import type { GetCalendarAuthUrlResponse } from "@/app/api/google/calendar/auth-url/route"; +import { fetchWithAccount } from "@/utils/fetch"; +import Image from "next/image"; + +export function ConnectCalendar() { + const { emailAccountId } = useAccount(); + const [isConnectingGoogle, setIsConnectingGoogle] = useState(false); + const [isConnectingMicrosoft, setIsConnectingMicrosoft] = useState(false); + + const handleConnectGoogle = async () => { + setIsConnectingGoogle(true); + try { + const response = await fetchWithAccount({ + url: "/api/google/calendar/auth-url", + emailAccountId, + init: { headers: { "Content-Type": "application/json" } }, + }); + + if (!response.ok) { + throw new Error("Failed to initiate Google calendar connection"); + } + + const data: GetCalendarAuthUrlResponse = await response.json(); + window.location.href = data.url; + } catch (error) { + console.error("Error initiating Google calendar connection:", error); + toastError({ + title: "Error initiating Google calendar connection", + description: "Please try again or contact support", + }); + setIsConnectingGoogle(false); + } + }; + + const handleConnectMicrosoft = async () => { + setIsConnectingMicrosoft(true); + try { + const response = await fetchWithAccount({ + url: "/api/outlook/calendar/auth-url", + emailAccountId, + init: { headers: { "Content-Type": "application/json" } }, + }); + + if (!response.ok) { + throw new Error("Failed to initiate Microsoft calendar connection"); + } + + const data: GetCalendarAuthUrlResponse = await response.json(); + window.location.href = data.url; + } catch (error) { + console.error("Error initiating Microsoft calendar connection:", error); + toastError({ + title: "Error initiating Microsoft calendar connection", + description: "Please try again or contact support", + }); + setIsConnectingMicrosoft(false); + } + }; + + return ( +
+ + + +
+ ); +} diff --git a/apps/web/app/(app)/[emailAccountId]/calendars/ConnectCalendarButton.tsx b/apps/web/app/(app)/[emailAccountId]/calendars/ConnectCalendarButton.tsx deleted file mode 100644 index 47114c75d..000000000 --- a/apps/web/app/(app)/[emailAccountId]/calendars/ConnectCalendarButton.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Calendar } from "lucide-react"; -import { useAccount } from "@/providers/EmailAccountProvider"; -import { toastError } from "@/components/Toast"; -import type { GetCalendarAuthUrlResponse } from "@/app/api/google/calendar/auth-url/route"; -import { fetchWithAccount } from "@/utils/fetch"; - -export function ConnectCalendarButton() { - const { emailAccountId } = useAccount(); - const [isConnecting, setIsConnecting] = useState(false); - - const handleConnect = async () => { - setIsConnecting(true); - try { - const response = await fetchWithAccount({ - url: "/api/google/calendar/auth-url", - emailAccountId, - init: { headers: { "Content-Type": "application/json" } }, - }); - - if (!response.ok) { - throw new Error("Failed to initiate calendar connection"); - } - - const data: GetCalendarAuthUrlResponse = await response.json(); - window.location.href = data.url; - } catch (error) { - console.error("Error initiating calendar connection:", error); - toastError({ - title: "Error initiating calendar connection", - description: "Please try again or contact support", - }); - setIsConnecting(false); - } - }; - - return ( - - ); -} diff --git a/apps/web/app/(app)/[emailAccountId]/calendars/page.tsx b/apps/web/app/(app)/[emailAccountId]/calendars/page.tsx index 40f2c17a8..e2688b834 100644 --- a/apps/web/app/(app)/[emailAccountId]/calendars/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/calendars/page.tsx @@ -1,17 +1,17 @@ import { PageWrapper } from "@/components/PageWrapper"; import { PageHeader } from "@/components/PageHeader"; import { CalendarConnections } from "./CalendarConnections"; -import { ConnectCalendarButton } from "@/app/(app)/[emailAccountId]/calendars/ConnectCalendarButton"; +import { ConnectCalendar } from "@/app/(app)/[emailAccountId]/calendars/ConnectCalendar"; export default function CalendarsPage() { return ( -
+
- +
diff --git a/apps/web/app/api/outlook/calendar/auth-url/route.ts b/apps/web/app/api/outlook/calendar/auth-url/route.ts new file mode 100644 index 000000000..fda94ae62 --- /dev/null +++ b/apps/web/app/api/outlook/calendar/auth-url/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; +import { withEmailAccount } from "@/utils/middleware"; +import { getCalendarOAuth2Url } from "@/utils/outlook/calendar-client"; +import { CALENDAR_STATE_COOKIE_NAME } from "@/utils/calendar/constants"; +import { + generateOAuthState, + oauthStateCookieOptions, +} from "@/utils/oauth/state"; + +export type GetCalendarAuthUrlResponse = { url: string }; + +const getAuthUrl = ({ emailAccountId }: { emailAccountId: string }) => { + const state = generateOAuthState({ + emailAccountId, + type: "calendar", + }); + + const url = getCalendarOAuth2Url(state); + + return { url, state }; +}; + +export const GET = withEmailAccount(async (request) => { + const { emailAccountId } = request.auth; + const { url, state } = getAuthUrl({ emailAccountId }); + + const res: GetCalendarAuthUrlResponse = { url }; + const response = NextResponse.json(res); + + response.cookies.set( + CALENDAR_STATE_COOKIE_NAME, + state, + oauthStateCookieOptions, + ); + + return response; +}); diff --git a/apps/web/app/api/outlook/calendar/callback/route.ts b/apps/web/app/api/outlook/calendar/callback/route.ts new file mode 100644 index 000000000..da5354c8a --- /dev/null +++ b/apps/web/app/api/outlook/calendar/callback/route.ts @@ -0,0 +1,227 @@ +import { NextResponse } from "next/server"; +import { env } from "@/env"; +import prisma from "@/utils/prisma"; +import { createScopedLogger } from "@/utils/logger"; +import { + fetchMicrosoftCalendars, + getCalendarClientWithRefresh, +} from "@/utils/outlook/calendar-client"; +import { withError } from "@/utils/middleware"; +import { CALENDAR_STATE_COOKIE_NAME } from "@/utils/calendar/constants"; +import { parseOAuthState } from "@/utils/oauth/state"; +import { auth } from "@/utils/auth"; + +const logger = createScopedLogger("outlook/calendar/callback"); + +export const GET = withError(async (request) => { + if (!env.MICROSOFT_CLIENT_ID || !env.MICROSOFT_CLIENT_SECRET) { + throw new Error("Microsoft login not enabled"); + } + + const searchParams = request.nextUrl.searchParams; + const code = searchParams.get("code"); + const receivedState = searchParams.get("state"); + const storedState = request.cookies.get(CALENDAR_STATE_COOKIE_NAME)?.value; + + // We'll set the proper redirect URL after we decode the state and get emailAccountId + const redirectUrl = new URL("/calendars", request.nextUrl.origin); + const response = NextResponse.redirect(redirectUrl); + + response.cookies.delete(CALENDAR_STATE_COOKIE_NAME); + + if (!code) { + logger.warn("Missing code in Microsoft Calendar callback"); + redirectUrl.searchParams.set("error", "missing_code"); + return NextResponse.redirect(redirectUrl, { headers: response.headers }); + } + + if (!storedState || !receivedState || storedState !== receivedState) { + logger.warn("Invalid state during Microsoft Calendar callback", { + receivedState, + hasStoredState: !!storedState, + }); + redirectUrl.searchParams.set("error", "invalid_state"); + return NextResponse.redirect(redirectUrl, { headers: response.headers }); + } + + let decodedState: { emailAccountId: string; type: string; nonce: string }; + try { + decodedState = parseOAuthState(storedState); + } catch (error) { + logger.error("Failed to decode state", { error }); + redirectUrl.searchParams.set("error", "invalid_state_format"); + return NextResponse.redirect(redirectUrl, { headers: response.headers }); + } + + const { emailAccountId } = decodedState; + + // Verify the user has access to this emailAccountId + const session = await auth(); + const emailAccount = await prisma.emailAccount.findFirst({ + where: { + id: emailAccountId, + user: { + id: session?.user?.id, + }, + }, + }); + + if (!emailAccount) { + logger.warn("User does not have access to email account", { + emailAccountId, + }); + redirectUrl.searchParams.set("error", "unauthorized"); + return NextResponse.redirect(redirectUrl, { headers: response.headers }); + } + + try { + // Exchange code for tokens + const tokenResponse = await fetch( + "https://login.microsoftonline.com/common/oauth2/v2.0/token", + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: env.MICROSOFT_CLIENT_ID, + client_secret: env.MICROSOFT_CLIENT_SECRET, + code, + grant_type: "authorization_code", + redirect_uri: `${env.NEXT_PUBLIC_BASE_URL}/api/outlook/calendar/callback`, + }), + }, + ); + + const tokens = await tokenResponse.json(); + + if (!tokenResponse.ok) { + throw new Error( + tokens.error_description || "Failed to exchange code for tokens", + ); + } + + // Get user profile using the access token + const profileResponse = await fetch("https://graph.microsoft.com/v1.0/me", { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + }, + }); + + if (!profileResponse.ok) { + throw new Error("Failed to fetch user profile"); + } + + const profile = await profileResponse.json(); + const microsoftEmail = profile.mail || profile.userPrincipalName; + + if (!microsoftEmail) { + throw new Error("Profile missing required email"); + } + + const existingConnection = await prisma.calendarConnection.findFirst({ + where: { + emailAccountId, + provider: "microsoft", + email: microsoftEmail, + }, + }); + + if (existingConnection) { + logger.info("Calendar connection already exists", { + emailAccountId, + microsoftEmail, + }); + redirectUrl.searchParams.set("message", "calendar_already_connected"); + return NextResponse.redirect(redirectUrl, { headers: response.headers }); + } + + const connection = await prisma.calendarConnection.create({ + data: { + provider: "microsoft", + email: microsoftEmail, + emailAccountId, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresAt: tokens.expires_in + ? new Date(Date.now() + tokens.expires_in * 1000) + : null, + isConnected: true, + }, + }); + + await syncMicrosoftCalendars( + connection.id, + tokens.access_token, + tokens.refresh_token, + emailAccountId, + ); + + logger.info("Calendar connected successfully", { + emailAccountId, + microsoftEmail, + connectionId: connection.id, + }); + + redirectUrl.searchParams.set("message", "calendar_connected"); + return NextResponse.redirect(redirectUrl, { headers: response.headers }); + } catch (error) { + logger.error("Error in Microsoft Calendar callback", { + error, + emailAccountId, + }); + redirectUrl.searchParams.set("error", "calendar_connection_failed"); + return NextResponse.redirect(redirectUrl, { headers: response.headers }); + } +}); + +async function syncMicrosoftCalendars( + connectionId: string, + accessToken: string, + refreshToken: string, + emailAccountId: string, +) { + try { + const calendarClient = await getCalendarClientWithRefresh({ + accessToken, + refreshToken, + expiresAt: null, + emailAccountId, + }); + + const microsoftCalendars = await fetchMicrosoftCalendars(calendarClient); + + for (const microsoftCalendar of microsoftCalendars) { + if (!microsoftCalendar.id) continue; + + await prisma.calendar.upsert({ + where: { + connectionId_calendarId: { + connectionId, + calendarId: microsoftCalendar.id, + }, + }, + update: { + name: microsoftCalendar.name || "Untitled Calendar", + description: microsoftCalendar.description, + timezone: microsoftCalendar.timeZone, + }, + create: { + connectionId, + calendarId: microsoftCalendar.id, + name: microsoftCalendar.name || "Untitled Calendar", + description: microsoftCalendar.description, + timezone: microsoftCalendar.timeZone, + isEnabled: true, + }, + }); + } + } catch (error) { + logger.error("Error syncing calendars", { error, connectionId }); + await prisma.calendarConnection.update({ + where: { id: connectionId }, + data: { isConnected: false }, + }); + throw error; + } +} diff --git a/apps/web/public/images/product/outlook-calendar.svg b/apps/web/public/images/product/outlook-calendar.svg new file mode 100644 index 000000000..6d48361a1 --- /dev/null +++ b/apps/web/public/images/product/outlook-calendar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/utils/outlook/calendar-client.ts b/apps/web/utils/outlook/calendar-client.ts new file mode 100644 index 000000000..5ee02e7e7 --- /dev/null +++ b/apps/web/utils/outlook/calendar-client.ts @@ -0,0 +1,186 @@ +import { env } from "@/env"; +import { createScopedLogger } from "@/utils/logger"; +import { CALENDAR_SCOPES } from "@/utils/outlook/scopes"; +import { SafeError } from "@/utils/error"; +import prisma from "@/utils/prisma"; +import { + Client, + type AuthenticationProvider, +} from "@microsoft/microsoft-graph-client"; + +const logger = createScopedLogger("outlook/calendar-client"); + +class CalendarAuthProvider implements AuthenticationProvider { + private readonly accessToken: string; + + constructor(accessToken: string) { + this.accessToken = accessToken; + } + + async getAccessToken(): Promise { + return this.accessToken; + } +} + +export function getCalendarOAuth2Url(state: string): string { + if (!env.MICROSOFT_CLIENT_ID) { + throw new Error("Microsoft login not enabled - missing client ID"); + } + + const baseUrl = + "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"; + const params = new URLSearchParams({ + client_id: env.MICROSOFT_CLIENT_ID, + response_type: "code", + redirect_uri: `${env.NEXT_PUBLIC_BASE_URL}/api/outlook/calendar/callback`, + scope: CALENDAR_SCOPES.join(" "), + state, + prompt: "consent", + }); + + return `${baseUrl}?${params.toString()}`; +} + +export const getCalendarClientWithRefresh = async ({ + accessToken, + refreshToken, + expiresAt, + emailAccountId, +}: { + accessToken?: string | null; + refreshToken: string | null; + expiresAt: number | null; + emailAccountId: string; +}): Promise => { + if (!refreshToken) throw new SafeError("No refresh token"); + + // Check if token is still valid + if (expiresAt && expiresAt > Date.now()) { + const authProvider = new CalendarAuthProvider(accessToken || ""); + return Client.initWithMiddleware({ authProvider }); + } + + // Token is expired or missing, need to refresh + try { + if (!env.MICROSOFT_CLIENT_ID || !env.MICROSOFT_CLIENT_SECRET) { + throw new Error("Microsoft login not enabled - missing credentials"); + } + + const response = await fetch( + "https://login.microsoftonline.com/common/oauth2/v2.0/token", + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: env.MICROSOFT_CLIENT_ID, + client_secret: env.MICROSOFT_CLIENT_SECRET, + refresh_token: refreshToken, + grant_type: "refresh_token", + scope: CALENDAR_SCOPES.join(" "), + }), + }, + ); + + const tokens = await response.json(); + + if (!response.ok) { + throw new Error(tokens.error_description || "Failed to refresh token"); + } + + // Find the calendar connection to update + const calendarConnection = await prisma.calendarConnection.findFirst({ + where: { + emailAccountId, + provider: "microsoft", + }, + select: { id: true }, + }); + + if (calendarConnection) { + await saveCalendarTokens({ + tokens: { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + expires_at: Math.floor(Date.now() / 1000 + tokens.expires_in), + }, + connectionId: calendarConnection.id, + }); + } else { + logger.warn("No calendar connection found to update tokens", { + emailAccountId, + }); + } + + const authProvider = new CalendarAuthProvider(tokens.access_token); + return Client.initWithMiddleware({ authProvider }); + } catch (error) { + const isInvalidGrantError = + error instanceof Error && error.message.includes("invalid_grant"); + + if (isInvalidGrantError) { + logger.warn("Error refreshing Calendar access token", { + emailAccountId, + error: error.message, + }); + } + + throw error; + } +}; + +export async function fetchMicrosoftCalendars(calendarClient: Client): Promise< + Array<{ + id?: string; + name?: string; + description?: string; + timeZone?: string; + }> +> { + try { + const response = await calendarClient.api("/me/calendars").get(); + + return response.value || []; + } catch (error) { + logger.error("Error fetching Microsoft calendars", { error }); + throw new SafeError("Failed to fetch calendars"); + } +} + +async function saveCalendarTokens({ + tokens, + connectionId, +}: { + tokens: { + access_token?: string; + refresh_token?: string; + expires_at?: number; // seconds + }; + connectionId: string; +}) { + if (!tokens.access_token) { + logger.warn("No access token to save for calendar connection", { + connectionId, + }); + return; + } + + try { + await prisma.calendarConnection.update({ + where: { id: connectionId }, + data: { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresAt: tokens.expires_at + ? new Date(tokens.expires_at * 1000) + : null, + }, + }); + + logger.info("Calendar tokens saved successfully", { connectionId }); + } catch (error) { + logger.error("Failed to save calendar tokens", { error, connectionId }); + throw error; + } +} diff --git a/apps/web/utils/outlook/scopes.ts b/apps/web/utils/outlook/scopes.ts index 1b9c9cef3..190f22b5f 100644 --- a/apps/web/utils/outlook/scopes.ts +++ b/apps/web/utils/outlook/scopes.ts @@ -16,3 +16,13 @@ export const SCOPES = [ "MailboxSettings.ReadWrite", // Read and write mailbox settings ...(env.NEXT_PUBLIC_CONTACTS_ENABLED ? ["Contacts.ReadWrite"] : []), ] as const; + +export const CALENDAR_SCOPES = [ + "openid", + "profile", + "email", + "User.Read", + "offline_access", // Required for refresh tokens + "Calendars.Read", // Read user calendars + "Calendars.ReadWrite", // Read and write user calendars +] as const; From ec6ab2f6511f14127c5ed1154a90cd120c0a12f7 Mon Sep 17 00:00:00 2001 From: Eduardo Lelis Date: Fri, 10 Oct 2025 01:58:38 -0300 Subject: [PATCH 2/7] PR feedback --- .../[emailAccountId]/calendars/ConnectCalendar.tsx | 14 ++++++++++++-- apps/web/utils/outlook/calendar-client.ts | 4 ++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/web/app/(app)/[emailAccountId]/calendars/ConnectCalendar.tsx b/apps/web/app/(app)/[emailAccountId]/calendars/ConnectCalendar.tsx index ac424d09e..e129926b5 100644 --- a/apps/web/app/(app)/[emailAccountId]/calendars/ConnectCalendar.tsx +++ b/apps/web/app/(app)/[emailAccountId]/calendars/ConnectCalendar.tsx @@ -6,12 +6,14 @@ import { useAccount } from "@/providers/EmailAccountProvider"; import { toastError } from "@/components/Toast"; import type { GetCalendarAuthUrlResponse } from "@/app/api/google/calendar/auth-url/route"; import { fetchWithAccount } from "@/utils/fetch"; +import { createScopedLogger } from "@/utils/logger"; import Image from "next/image"; export function ConnectCalendar() { const { emailAccountId } = useAccount(); const [isConnectingGoogle, setIsConnectingGoogle] = useState(false); const [isConnectingMicrosoft, setIsConnectingMicrosoft] = useState(false); + const logger = createScopedLogger("calendar-connection"); const handleConnectGoogle = async () => { setIsConnectingGoogle(true); @@ -29,7 +31,11 @@ export function ConnectCalendar() { const data: GetCalendarAuthUrlResponse = await response.json(); window.location.href = data.url; } catch (error) { - console.error("Error initiating Google calendar connection:", error); + logger.error("Error initiating Google calendar connection", { + error, + emailAccountId, + provider: "google", + }); toastError({ title: "Error initiating Google calendar connection", description: "Please try again or contact support", @@ -54,7 +60,11 @@ export function ConnectCalendar() { const data: GetCalendarAuthUrlResponse = await response.json(); window.location.href = data.url; } catch (error) { - console.error("Error initiating Microsoft calendar connection:", error); + logger.error("Error initiating Microsoft calendar connection", { + error, + emailAccountId, + provider: "microsoft", + }); toastError({ title: "Error initiating Microsoft calendar connection", description: "Please try again or contact support", diff --git a/apps/web/utils/outlook/calendar-client.ts b/apps/web/utils/outlook/calendar-client.ts index 5ee02e7e7..5813b566b 100644 --- a/apps/web/utils/outlook/calendar-client.ts +++ b/apps/web/utils/outlook/calendar-client.ts @@ -55,8 +55,8 @@ export const getCalendarClientWithRefresh = async ({ if (!refreshToken) throw new SafeError("No refresh token"); // Check if token is still valid - if (expiresAt && expiresAt > Date.now()) { - const authProvider = new CalendarAuthProvider(accessToken || ""); + if (expiresAt && expiresAt > Date.now() && accessToken) { + const authProvider = new CalendarAuthProvider(accessToken); return Client.initWithMiddleware({ authProvider }); } From d2665f9ca584d8d40b937b67e1fa9bb310ade7a8 Mon Sep 17 00:00:00 2001 From: Eduardo Lelis Date: Thu, 16 Oct 2025 16:16:56 -0300 Subject: [PATCH 3/7] PR feedback --- apps/web/utils/outlook/calendar-client.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/utils/outlook/calendar-client.ts b/apps/web/utils/outlook/calendar-client.ts index 5813b566b..d315e0b2b 100644 --- a/apps/web/utils/outlook/calendar-client.ts +++ b/apps/web/utils/outlook/calendar-client.ts @@ -103,7 +103,9 @@ export const getCalendarClientWithRefresh = async ({ tokens: { access_token: tokens.access_token, refresh_token: tokens.refresh_token, - expires_at: Math.floor(Date.now() / 1000 + tokens.expires_in), + expires_at: Math.floor( + Date.now() / 1000 + Number(tokens.expires_in ?? 0), + ), }, connectionId: calendarConnection.id, }); From 9769179bb61961617be8aa6399ce3a0f3ae953db Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 23 Oct 2025 00:41:01 +0300 Subject: [PATCH 4/7] add microsoft calendar set up notes --- README.md | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b1cd8516d..717dda31a 100644 --- a/README.md +++ b/README.md @@ -401,8 +401,6 @@ For more detailed Docker build instructions and security considerations, see [do ### Calendar integrations -*Note:* The calendar integration feature is a work in progress. - #### Google Calendar 1. Visit: https://console.cloud.google.com/apis/library @@ -413,6 +411,25 @@ For more detailed Docker build instructions and security considerations, see [do 2. In `Authorized redirect URIs` add: - `http://localhost:3000/api/google/calendar/callback` +#### Microsoft Calendar + +1. Go to your existing Microsoft Azure app registration (created earlier in the Microsoft OAuth setup) +2. Add the calendar redirect URI: + 1. In the "Manage" menu click "Authentication (Preview)" + 2. Add the Redirect URI: `http://localhost:3000/api/outlook/calendar/callback` +3. Add calendar permissions: + 1. In the "Manage" menu click "API permissions" + 2. Click "Add a permission" + 3. Select "Microsoft Graph" + 4. Select "Delegated permissions" + 5. Add the following calendar permissions: + - Calendars.Read + - Calendars.ReadWrite + 6. Click "Add permissions" + 7. Click "Grant admin consent" if you're an admin + +Note: The calendar integration uses a separate OAuth flow from the main email OAuth, so users can connect their calendar independently. + ## Contributing to the project You can view open tasks in our [GitHub Issues](https://github.com/elie222/inbox-zero/issues). From 016ff9aa3371a02da80a64566098c5027d008147 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 23 Oct 2025 00:50:45 +0300 Subject: [PATCH 5/7] fix(calendar): fix OAuth callback redirects and standardize error handling Fix calendar OAuth callbacks to redirect to /{emailAccountId}/calendars and standardize error handling between Google and Microsoft providers. --- .../app/api/google/calendar/callback/route.ts | 2 - .../api/outlook/calendar/callback/route.ts | 42 +++++++++++++------ 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/apps/web/app/api/google/calendar/callback/route.ts b/apps/web/app/api/google/calendar/callback/route.ts index b3ce59a7d..9733141c8 100644 --- a/apps/web/app/api/google/calendar/callback/route.ts +++ b/apps/web/app/api/google/calendar/callback/route.ts @@ -92,8 +92,6 @@ export const GET = withError(async (request) => { return NextResponse.redirect(redirectUrl, { headers: response.headers }); } - redirectUrl.pathname = `/${emailAccountId}/calendars`; - const googleAuth = getCalendarOAuth2Client(); try { diff --git a/apps/web/app/api/outlook/calendar/callback/route.ts b/apps/web/app/api/outlook/calendar/callback/route.ts index da5354c8a..83b3b6c8f 100644 --- a/apps/web/app/api/outlook/calendar/callback/route.ts +++ b/apps/web/app/api/outlook/calendar/callback/route.ts @@ -10,6 +10,7 @@ import { withError } from "@/utils/middleware"; import { CALENDAR_STATE_COOKIE_NAME } from "@/utils/calendar/constants"; import { parseOAuthState } from "@/utils/oauth/state"; import { auth } from "@/utils/auth"; +import { prefixPath } from "@/utils/path"; const logger = createScopedLogger("outlook/calendar/callback"); @@ -24,7 +25,7 @@ export const GET = withError(async (request) => { const storedState = request.cookies.get(CALENDAR_STATE_COOKIE_NAME)?.value; // We'll set the proper redirect URL after we decode the state and get emailAccountId - const redirectUrl = new URL("/calendars", request.nextUrl.origin); + let redirectUrl = new URL("/calendars", request.nextUrl.origin); const response = NextResponse.redirect(redirectUrl); response.cookies.delete(CALENDAR_STATE_COOKIE_NAME); @@ -53,24 +54,44 @@ export const GET = withError(async (request) => { return NextResponse.redirect(redirectUrl, { headers: response.headers }); } + if (decodedState.type !== "calendar") { + logger.error("Invalid state type for calendar callback", { + type: decodedState.type, + }); + redirectUrl.searchParams.set("error", "invalid_state_type"); + return NextResponse.redirect(redirectUrl, { headers: response.headers }); + } + const { emailAccountId } = decodedState; - // Verify the user has access to this emailAccountId + // Update redirect URL to include emailAccountId + redirectUrl = new URL( + prefixPath(emailAccountId, "/calendars"), + request.nextUrl.origin, + ); + + // Verify user owns this email account const session = await auth(); + if (!session?.user?.id) { + logger.warn("Unauthorized calendar callback - no session"); + redirectUrl.searchParams.set("error", "unauthorized"); + return NextResponse.redirect(redirectUrl, { headers: response.headers }); + } + const emailAccount = await prisma.emailAccount.findFirst({ where: { id: emailAccountId, - user: { - id: session?.user?.id, - }, + userId: session.user.id, }, + select: { id: true }, }); if (!emailAccount) { - logger.warn("User does not have access to email account", { + logger.warn("Unauthorized calendar callback - invalid email account", { emailAccountId, + userId: session.user.id, }); - redirectUrl.searchParams.set("error", "unauthorized"); + redirectUrl.searchParams.set("error", "forbidden"); return NextResponse.redirect(redirectUrl, { headers: response.headers }); } @@ -166,11 +187,8 @@ export const GET = withError(async (request) => { redirectUrl.searchParams.set("message", "calendar_connected"); return NextResponse.redirect(redirectUrl, { headers: response.headers }); } catch (error) { - logger.error("Error in Microsoft Calendar callback", { - error, - emailAccountId, - }); - redirectUrl.searchParams.set("error", "calendar_connection_failed"); + logger.error("Error in calendar callback", { error, emailAccountId }); + redirectUrl.searchParams.set("error", "connection_failed"); return NextResponse.redirect(redirectUrl, { headers: response.headers }); } }); From f6dbad489b46c7329bf6bfbda96575ece05c415e Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 23 Oct 2025 01:29:11 +0300 Subject: [PATCH 6/7] unify google and microsoft calendar connection --- .../app/api/google/calendar/callback/route.ts | 219 +--------------- .../api/outlook/calendar/callback/route.ts | 241 +----------------- .../calendar/handle-calendar-callback.ts | 135 ++++++++++ .../utils/calendar/oauth-callback-helpers.ts | 201 +++++++++++++++ apps/web/utils/calendar/oauth-types.ts | 39 +++ apps/web/utils/calendar/providers/google.ts | 98 +++++++ .../web/utils/calendar/providers/microsoft.ts | 124 +++++++++ version.txt | 2 +- 8 files changed, 604 insertions(+), 455 deletions(-) create mode 100644 apps/web/utils/calendar/handle-calendar-callback.ts create mode 100644 apps/web/utils/calendar/oauth-callback-helpers.ts create mode 100644 apps/web/utils/calendar/oauth-types.ts create mode 100644 apps/web/utils/calendar/providers/google.ts create mode 100644 apps/web/utils/calendar/providers/microsoft.ts diff --git a/apps/web/app/api/google/calendar/callback/route.ts b/apps/web/app/api/google/calendar/callback/route.ts index 9733141c8..693e9822b 100644 --- a/apps/web/app/api/google/calendar/callback/route.ts +++ b/apps/web/app/api/google/calendar/callback/route.ts @@ -1,223 +1,10 @@ -import { NextResponse } from "next/server"; -import { env } from "@/env"; -import prisma from "@/utils/prisma"; import { createScopedLogger } from "@/utils/logger"; -import { - getCalendarOAuth2Client, - fetchGoogleCalendars, - getCalendarClientWithRefresh, -} from "@/utils/calendar/client"; import { withError } from "@/utils/middleware"; -import { CALENDAR_STATE_COOKIE_NAME } from "@/utils/calendar/constants"; -import { parseOAuthState } from "@/utils/oauth/state"; -import { auth } from "@/utils/auth"; -import { prefixPath } from "@/utils/path"; +import { handleCalendarCallback } from "@/utils/calendar/handle-calendar-callback"; +import { googleCalendarProvider } from "@/utils/calendar/providers/google"; const logger = createScopedLogger("google/calendar/callback"); export const GET = withError(async (request) => { - const searchParams = request.nextUrl.searchParams; - const code = searchParams.get("code"); - const receivedState = searchParams.get("state"); - const storedState = request.cookies.get(CALENDAR_STATE_COOKIE_NAME)?.value; - - // We'll set the proper redirect URL after we decode the state and get emailAccountId - let redirectUrl = new URL("/calendars", request.nextUrl.origin); - const response = NextResponse.redirect(redirectUrl); - - response.cookies.delete(CALENDAR_STATE_COOKIE_NAME); - - if (!code) { - logger.warn("Missing code in Google Calendar callback"); - redirectUrl.searchParams.set("error", "missing_code"); - return NextResponse.redirect(redirectUrl, { headers: response.headers }); - } - - if (!storedState || !receivedState || storedState !== receivedState) { - logger.warn("Invalid state during Google Calendar callback", { - receivedState, - hasStoredState: !!storedState, - }); - redirectUrl.searchParams.set("error", "invalid_state"); - return NextResponse.redirect(redirectUrl, { headers: response.headers }); - } - - let decodedState: { emailAccountId: string; type: string; nonce: string }; - try { - decodedState = parseOAuthState(storedState); - } catch (error) { - logger.error("Failed to decode state", { error }); - redirectUrl.searchParams.set("error", "invalid_state_format"); - return NextResponse.redirect(redirectUrl, { headers: response.headers }); - } - - if (decodedState.type !== "calendar") { - logger.error("Invalid state type for calendar callback", { - type: decodedState.type, - }); - redirectUrl.searchParams.set("error", "invalid_state_type"); - return NextResponse.redirect(redirectUrl, { headers: response.headers }); - } - - const { emailAccountId } = decodedState; - - // Update redirect URL to include emailAccountId - redirectUrl = new URL( - prefixPath(emailAccountId, "/calendars"), - request.nextUrl.origin, - ); - - // Verify user owns this email account - const session = await auth(); - if (!session?.user?.id) { - logger.warn("Unauthorized calendar callback - no session"); - redirectUrl.searchParams.set("error", "unauthorized"); - return NextResponse.redirect(redirectUrl, { headers: response.headers }); - } - - const emailAccount = await prisma.emailAccount.findFirst({ - where: { - id: emailAccountId, - userId: session.user.id, - }, - select: { id: true }, - }); - - if (!emailAccount) { - logger.warn("Unauthorized calendar callback - invalid email account", { - emailAccountId, - userId: session.user.id, - }); - redirectUrl.searchParams.set("error", "forbidden"); - return NextResponse.redirect(redirectUrl, { headers: response.headers }); - } - - const googleAuth = getCalendarOAuth2Client(); - - try { - const { tokens } = await googleAuth.getToken(code); - const { id_token, access_token, refresh_token, expiry_date } = tokens; - - if (!id_token) { - throw new Error("Missing id_token from Google response"); - } - - if (!access_token || !refresh_token) { - logger.warn("No refresh_token returned from Google", { emailAccountId }); - redirectUrl.searchParams.set("error", "missing_refresh_token"); - return NextResponse.redirect(redirectUrl, { headers: response.headers }); - } - - const ticket = await googleAuth.verifyIdToken({ - idToken: id_token, - audience: env.GOOGLE_CLIENT_ID, - }); - const payload = ticket.getPayload(); - - if (!payload?.email) { - throw new Error("Could not get email from ID token"); - } - - const googleEmail = payload.email; - - const existingConnection = await prisma.calendarConnection.findFirst({ - where: { - emailAccountId, - provider: "google", - email: googleEmail, - }, - }); - - if (existingConnection) { - logger.info("Calendar connection already exists", { - emailAccountId, - googleEmail, - }); - redirectUrl.searchParams.set("message", "calendar_already_connected"); - return NextResponse.redirect(redirectUrl, { headers: response.headers }); - } - - const connection = await prisma.calendarConnection.create({ - data: { - provider: "google", - email: googleEmail, - emailAccountId, - accessToken: access_token, - refreshToken: refresh_token, - expiresAt: expiry_date ? new Date(expiry_date) : null, - isConnected: true, - }, - }); - - await syncGoogleCalendars( - connection.id, - access_token, - refresh_token, - emailAccountId, - ); - - logger.info("Calendar connected successfully", { - emailAccountId, - googleEmail, - connectionId: connection.id, - }); - - redirectUrl.searchParams.set("message", "calendar_connected"); - return NextResponse.redirect(redirectUrl, { headers: response.headers }); - } catch (error) { - logger.error("Error in calendar callback", { error, emailAccountId }); - redirectUrl.searchParams.set("error", "connection_failed"); - return NextResponse.redirect(redirectUrl, { headers: response.headers }); - } + return handleCalendarCallback(request, googleCalendarProvider, logger); }); - -async function syncGoogleCalendars( - connectionId: string, - accessToken: string, - refreshToken: string, - emailAccountId: string, -) { - try { - const calendarClient = await getCalendarClientWithRefresh({ - accessToken, - refreshToken, - expiresAt: null, - emailAccountId, - }); - - const googleCalendars = await fetchGoogleCalendars(calendarClient); - - for (const googleCalendar of googleCalendars) { - if (!googleCalendar.id) continue; - - await prisma.calendar.upsert({ - where: { - connectionId_calendarId: { - connectionId, - calendarId: googleCalendar.id, - }, - }, - update: { - name: googleCalendar.summary || "Untitled Calendar", - description: googleCalendar.description, - timezone: googleCalendar.timeZone, - }, - create: { - connectionId, - calendarId: googleCalendar.id, - name: googleCalendar.summary || "Untitled Calendar", - description: googleCalendar.description, - timezone: googleCalendar.timeZone, - isEnabled: true, - }, - }); - } - } catch (error) { - logger.error("Error syncing calendars", { error, connectionId }); - await prisma.calendarConnection.update({ - where: { id: connectionId }, - data: { isConnected: false }, - }); - throw error; - } -} diff --git a/apps/web/app/api/outlook/calendar/callback/route.ts b/apps/web/app/api/outlook/calendar/callback/route.ts index 83b3b6c8f..488a97780 100644 --- a/apps/web/app/api/outlook/calendar/callback/route.ts +++ b/apps/web/app/api/outlook/calendar/callback/route.ts @@ -1,245 +1,10 @@ -import { NextResponse } from "next/server"; -import { env } from "@/env"; -import prisma from "@/utils/prisma"; import { createScopedLogger } from "@/utils/logger"; -import { - fetchMicrosoftCalendars, - getCalendarClientWithRefresh, -} from "@/utils/outlook/calendar-client"; import { withError } from "@/utils/middleware"; -import { CALENDAR_STATE_COOKIE_NAME } from "@/utils/calendar/constants"; -import { parseOAuthState } from "@/utils/oauth/state"; -import { auth } from "@/utils/auth"; -import { prefixPath } from "@/utils/path"; +import { handleCalendarCallback } from "@/utils/calendar/handle-calendar-callback"; +import { microsoftCalendarProvider } from "@/utils/calendar/providers/microsoft"; const logger = createScopedLogger("outlook/calendar/callback"); export const GET = withError(async (request) => { - if (!env.MICROSOFT_CLIENT_ID || !env.MICROSOFT_CLIENT_SECRET) { - throw new Error("Microsoft login not enabled"); - } - - const searchParams = request.nextUrl.searchParams; - const code = searchParams.get("code"); - const receivedState = searchParams.get("state"); - const storedState = request.cookies.get(CALENDAR_STATE_COOKIE_NAME)?.value; - - // We'll set the proper redirect URL after we decode the state and get emailAccountId - let redirectUrl = new URL("/calendars", request.nextUrl.origin); - const response = NextResponse.redirect(redirectUrl); - - response.cookies.delete(CALENDAR_STATE_COOKIE_NAME); - - if (!code) { - logger.warn("Missing code in Microsoft Calendar callback"); - redirectUrl.searchParams.set("error", "missing_code"); - return NextResponse.redirect(redirectUrl, { headers: response.headers }); - } - - if (!storedState || !receivedState || storedState !== receivedState) { - logger.warn("Invalid state during Microsoft Calendar callback", { - receivedState, - hasStoredState: !!storedState, - }); - redirectUrl.searchParams.set("error", "invalid_state"); - return NextResponse.redirect(redirectUrl, { headers: response.headers }); - } - - let decodedState: { emailAccountId: string; type: string; nonce: string }; - try { - decodedState = parseOAuthState(storedState); - } catch (error) { - logger.error("Failed to decode state", { error }); - redirectUrl.searchParams.set("error", "invalid_state_format"); - return NextResponse.redirect(redirectUrl, { headers: response.headers }); - } - - if (decodedState.type !== "calendar") { - logger.error("Invalid state type for calendar callback", { - type: decodedState.type, - }); - redirectUrl.searchParams.set("error", "invalid_state_type"); - return NextResponse.redirect(redirectUrl, { headers: response.headers }); - } - - const { emailAccountId } = decodedState; - - // Update redirect URL to include emailAccountId - redirectUrl = new URL( - prefixPath(emailAccountId, "/calendars"), - request.nextUrl.origin, - ); - - // Verify user owns this email account - const session = await auth(); - if (!session?.user?.id) { - logger.warn("Unauthorized calendar callback - no session"); - redirectUrl.searchParams.set("error", "unauthorized"); - return NextResponse.redirect(redirectUrl, { headers: response.headers }); - } - - const emailAccount = await prisma.emailAccount.findFirst({ - where: { - id: emailAccountId, - userId: session.user.id, - }, - select: { id: true }, - }); - - if (!emailAccount) { - logger.warn("Unauthorized calendar callback - invalid email account", { - emailAccountId, - userId: session.user.id, - }); - redirectUrl.searchParams.set("error", "forbidden"); - return NextResponse.redirect(redirectUrl, { headers: response.headers }); - } - - try { - // Exchange code for tokens - const tokenResponse = await fetch( - "https://login.microsoftonline.com/common/oauth2/v2.0/token", - { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - client_id: env.MICROSOFT_CLIENT_ID, - client_secret: env.MICROSOFT_CLIENT_SECRET, - code, - grant_type: "authorization_code", - redirect_uri: `${env.NEXT_PUBLIC_BASE_URL}/api/outlook/calendar/callback`, - }), - }, - ); - - const tokens = await tokenResponse.json(); - - if (!tokenResponse.ok) { - throw new Error( - tokens.error_description || "Failed to exchange code for tokens", - ); - } - - // Get user profile using the access token - const profileResponse = await fetch("https://graph.microsoft.com/v1.0/me", { - headers: { - Authorization: `Bearer ${tokens.access_token}`, - }, - }); - - if (!profileResponse.ok) { - throw new Error("Failed to fetch user profile"); - } - - const profile = await profileResponse.json(); - const microsoftEmail = profile.mail || profile.userPrincipalName; - - if (!microsoftEmail) { - throw new Error("Profile missing required email"); - } - - const existingConnection = await prisma.calendarConnection.findFirst({ - where: { - emailAccountId, - provider: "microsoft", - email: microsoftEmail, - }, - }); - - if (existingConnection) { - logger.info("Calendar connection already exists", { - emailAccountId, - microsoftEmail, - }); - redirectUrl.searchParams.set("message", "calendar_already_connected"); - return NextResponse.redirect(redirectUrl, { headers: response.headers }); - } - - const connection = await prisma.calendarConnection.create({ - data: { - provider: "microsoft", - email: microsoftEmail, - emailAccountId, - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresAt: tokens.expires_in - ? new Date(Date.now() + tokens.expires_in * 1000) - : null, - isConnected: true, - }, - }); - - await syncMicrosoftCalendars( - connection.id, - tokens.access_token, - tokens.refresh_token, - emailAccountId, - ); - - logger.info("Calendar connected successfully", { - emailAccountId, - microsoftEmail, - connectionId: connection.id, - }); - - redirectUrl.searchParams.set("message", "calendar_connected"); - return NextResponse.redirect(redirectUrl, { headers: response.headers }); - } catch (error) { - logger.error("Error in calendar callback", { error, emailAccountId }); - redirectUrl.searchParams.set("error", "connection_failed"); - return NextResponse.redirect(redirectUrl, { headers: response.headers }); - } + return handleCalendarCallback(request, microsoftCalendarProvider, logger); }); - -async function syncMicrosoftCalendars( - connectionId: string, - accessToken: string, - refreshToken: string, - emailAccountId: string, -) { - try { - const calendarClient = await getCalendarClientWithRefresh({ - accessToken, - refreshToken, - expiresAt: null, - emailAccountId, - }); - - const microsoftCalendars = await fetchMicrosoftCalendars(calendarClient); - - for (const microsoftCalendar of microsoftCalendars) { - if (!microsoftCalendar.id) continue; - - await prisma.calendar.upsert({ - where: { - connectionId_calendarId: { - connectionId, - calendarId: microsoftCalendar.id, - }, - }, - update: { - name: microsoftCalendar.name || "Untitled Calendar", - description: microsoftCalendar.description, - timezone: microsoftCalendar.timeZone, - }, - create: { - connectionId, - calendarId: microsoftCalendar.id, - name: microsoftCalendar.name || "Untitled Calendar", - description: microsoftCalendar.description, - timezone: microsoftCalendar.timeZone, - isEnabled: true, - }, - }); - } - } catch (error) { - logger.error("Error syncing calendars", { error, connectionId }); - await prisma.calendarConnection.update({ - where: { id: connectionId }, - data: { isConnected: false }, - }); - throw error; - } -} diff --git a/apps/web/utils/calendar/handle-calendar-callback.ts b/apps/web/utils/calendar/handle-calendar-callback.ts new file mode 100644 index 000000000..6a526673c --- /dev/null +++ b/apps/web/utils/calendar/handle-calendar-callback.ts @@ -0,0 +1,135 @@ +import type { NextRequest, NextResponse } from "next/server"; +import type { Logger } from "@/utils/logger"; +import type { CalendarOAuthProvider } from "./oauth-types"; +import { + validateOAuthCallback, + parseAndValidateCalendarState, + buildCalendarRedirectUrl, + verifyEmailAccountAccess, + checkExistingConnection, + createCalendarConnection, + redirectWithMessage, + redirectWithError, + RedirectError, +} from "./oauth-callback-helpers"; + +/** + * Unified handler for calendar OAuth callbacks + */ +export async function handleCalendarCallback( + request: NextRequest, + provider: CalendarOAuthProvider, + logger: Logger, +): Promise { + try { + // Step 1: Validate OAuth callback parameters + const { code, redirectUrl, response } = await validateOAuthCallback( + request, + logger, + ); + + const storedState = request.cookies.get("calendar_state")?.value; + if (!storedState) { + throw new Error("Missing stored state"); + } + + // Step 2: Parse and validate the OAuth state + const decodedState = parseAndValidateCalendarState( + storedState, + logger, + redirectUrl, + response.headers, + ); + + const { emailAccountId } = decodedState; + + // Step 3: Update redirect URL to include emailAccountId + const finalRedirectUrl = buildCalendarRedirectUrl( + emailAccountId, + request.nextUrl.origin, + ); + + // Step 4: Verify user owns this email account + await verifyEmailAccountAccess( + emailAccountId, + logger, + finalRedirectUrl, + response.headers, + ); + + // Step 5: Exchange code for tokens and get email + const { accessToken, refreshToken, expiresAt, email } = + await provider.exchangeCodeForTokens(code); + + // Step 6: Check if connection already exists + const existingConnection = await checkExistingConnection( + emailAccountId, + provider.name, + email, + ); + + if (existingConnection) { + logger.info("Calendar connection already exists", { + emailAccountId, + email, + provider: provider.name, + }); + return redirectWithMessage( + finalRedirectUrl, + "calendar_already_connected", + response.headers, + ); + } + + // Step 7: Create calendar connection + const connection = await createCalendarConnection({ + provider: provider.name, + email, + emailAccountId, + accessToken, + refreshToken, + expiresAt, + }); + + // Step 8: Sync calendars + await provider.syncCalendars( + connection.id, + accessToken, + refreshToken, + emailAccountId, + ); + + logger.info("Calendar connected successfully", { + emailAccountId, + email, + provider: provider.name, + connectionId: connection.id, + }); + + return redirectWithMessage( + finalRedirectUrl, + "calendar_connected", + response.headers, + ); + } catch (error) { + // Handle redirect errors + if (error instanceof RedirectError) { + return redirectWithError( + error.redirectUrl, + "connection_failed", + error.responseHeaders, + ); + } + + // Handle all other errors + logger.error("Error in calendar callback", { error }); + + // Try to build a redirect URL, fallback to /calendars + const errorRedirectUrl = new URL("/calendars", request.nextUrl.origin); + return redirectWithError( + errorRedirectUrl, + "connection_failed", + new Headers(), + ); + } +} diff --git a/apps/web/utils/calendar/oauth-callback-helpers.ts b/apps/web/utils/calendar/oauth-callback-helpers.ts new file mode 100644 index 000000000..3d7c9b034 --- /dev/null +++ b/apps/web/utils/calendar/oauth-callback-helpers.ts @@ -0,0 +1,201 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import prisma from "@/utils/prisma"; +import { CALENDAR_STATE_COOKIE_NAME } from "@/utils/calendar/constants"; +import { parseOAuthState } from "@/utils/oauth/state"; +import { auth } from "@/utils/auth"; +import { prefixPath } from "@/utils/path"; +import type { Logger } from "@/utils/logger"; +import type { + OAuthCallbackValidation, + CalendarOAuthState, +} from "./oauth-types"; + +/** + * Validate OAuth callback parameters and setup redirect + */ +export async function validateOAuthCallback( + request: NextRequest, + logger: Logger, +): Promise { + const searchParams = request.nextUrl.searchParams; + const code = searchParams.get("code"); + const receivedState = searchParams.get("state"); + const storedState = request.cookies.get(CALENDAR_STATE_COOKIE_NAME)?.value; + + const redirectUrl = new URL("/calendars", request.nextUrl.origin); + const response = NextResponse.redirect(redirectUrl); + + response.cookies.delete(CALENDAR_STATE_COOKIE_NAME); + + if (!code) { + logger.warn("Missing code in calendar callback"); + redirectUrl.searchParams.set("error", "missing_code"); + throw new RedirectError(redirectUrl, response.headers); + } + + if (!storedState || !receivedState || storedState !== receivedState) { + logger.warn("Invalid state during calendar callback", { + receivedState, + hasStoredState: !!storedState, + }); + redirectUrl.searchParams.set("error", "invalid_state"); + throw new RedirectError(redirectUrl, response.headers); + } + + return { code, redirectUrl, response }; +} + +/** + * Parse and validate the OAuth state + */ +export function parseAndValidateCalendarState( + storedState: string, + logger: Logger, + redirectUrl: URL, + responseHeaders: Headers, +): CalendarOAuthState { + let decodedState: CalendarOAuthState; + try { + decodedState = + parseOAuthState>(storedState); + } catch (error) { + logger.error("Failed to decode state", { error }); + redirectUrl.searchParams.set("error", "invalid_state_format"); + throw new RedirectError(redirectUrl, responseHeaders); + } + + if (decodedState.type !== "calendar") { + logger.error("Invalid state type for calendar callback", { + type: decodedState.type, + }); + redirectUrl.searchParams.set("error", "invalid_state_type"); + throw new RedirectError(redirectUrl, responseHeaders); + } + + return decodedState; +} + +/** + * Build redirect URL with emailAccountId + */ +export function buildCalendarRedirectUrl( + emailAccountId: string, + origin: string, +): URL { + return new URL(prefixPath(emailAccountId, "/calendars"), origin); +} + +/** + * Verify user owns the email account + */ +export async function verifyEmailAccountAccess( + emailAccountId: string, + logger: Logger, + redirectUrl: URL, + responseHeaders: Headers, +): Promise { + const session = await auth(); + if (!session?.user?.id) { + logger.warn("Unauthorized calendar callback - no session"); + redirectUrl.searchParams.set("error", "unauthorized"); + throw new RedirectError(redirectUrl, responseHeaders); + } + + const emailAccount = await prisma.emailAccount.findFirst({ + where: { + id: emailAccountId, + userId: session.user.id, + }, + select: { id: true }, + }); + + if (!emailAccount) { + logger.warn("Unauthorized calendar callback - invalid email account", { + emailAccountId, + userId: session.user.id, + }); + redirectUrl.searchParams.set("error", "forbidden"); + throw new RedirectError(redirectUrl, responseHeaders); + } +} + +/** + * Check if calendar connection already exists + */ +export async function checkExistingConnection( + emailAccountId: string, + provider: "google" | "microsoft", + email: string, +) { + return await prisma.calendarConnection.findFirst({ + where: { + emailAccountId, + provider, + email, + }, + }); +} + +/** + * Create a calendar connection record + */ +export async function createCalendarConnection(params: { + provider: "google" | "microsoft"; + email: string; + emailAccountId: string; + accessToken: string; + refreshToken: string; + expiresAt: Date | null; +}) { + return await prisma.calendarConnection.create({ + data: { + provider: params.provider, + email: params.email, + emailAccountId: params.emailAccountId, + accessToken: params.accessToken, + refreshToken: params.refreshToken, + expiresAt: params.expiresAt, + isConnected: true, + }, + }); +} + +/** + * Redirect with success message + */ +export function redirectWithMessage( + redirectUrl: URL, + message: string, + responseHeaders: Headers, +): NextResponse { + redirectUrl.searchParams.set("message", message); + return NextResponse.redirect(redirectUrl, { headers: responseHeaders }); +} + +/** + * Redirect with error message + */ +export function redirectWithError( + redirectUrl: URL, + error: string, + responseHeaders: Headers, +): NextResponse { + redirectUrl.searchParams.set("error", error); + return NextResponse.redirect(redirectUrl, { headers: responseHeaders }); +} + +/** + * Custom error class for redirect responses + */ +export class RedirectError extends Error { + redirectUrl: URL; + responseHeaders: Headers; + + constructor(redirectUrl: URL, responseHeaders: Headers) { + super("Redirect required"); + this.name = "RedirectError"; + this.redirectUrl = redirectUrl; + this.responseHeaders = responseHeaders; + } +} diff --git a/apps/web/utils/calendar/oauth-types.ts b/apps/web/utils/calendar/oauth-types.ts new file mode 100644 index 000000000..2987a384b --- /dev/null +++ b/apps/web/utils/calendar/oauth-types.ts @@ -0,0 +1,39 @@ +import type { NextResponse } from "next/server"; + +export interface CalendarTokens { + accessToken: string; + refreshToken: string; + expiresAt: Date | null; + email: string; +} + +export interface CalendarOAuthProvider { + name: "google" | "microsoft"; + + /** + * Exchange OAuth code for tokens and get user email + */ + exchangeCodeForTokens(code: string): Promise; + + /** + * Sync calendars for this provider + */ + syncCalendars( + connectionId: string, + accessToken: string, + refreshToken: string, + emailAccountId: string, + ): Promise; +} + +export interface OAuthCallbackValidation { + code: string; + redirectUrl: URL; + response: NextResponse; +} + +export interface CalendarOAuthState { + emailAccountId: string; + type: string; + nonce: string; +} diff --git a/apps/web/utils/calendar/providers/google.ts b/apps/web/utils/calendar/providers/google.ts new file mode 100644 index 000000000..710bb4c6d --- /dev/null +++ b/apps/web/utils/calendar/providers/google.ts @@ -0,0 +1,98 @@ +import { env } from "@/env"; +import prisma from "@/utils/prisma"; +import { createScopedLogger } from "@/utils/logger"; +import { + getCalendarOAuth2Client, + fetchGoogleCalendars, + getCalendarClientWithRefresh, +} from "@/utils/calendar/client"; +import type { CalendarOAuthProvider, CalendarTokens } from "../oauth-types"; + +const logger = createScopedLogger("google/calendar/provider"); + +export const googleCalendarProvider: CalendarOAuthProvider = { + name: "google", + + async exchangeCodeForTokens(code: string): Promise { + const googleAuth = getCalendarOAuth2Client(); + + const { tokens } = await googleAuth.getToken(code); + const { id_token, access_token, refresh_token, expiry_date } = tokens; + + if (!id_token) { + throw new Error("Missing id_token from Google response"); + } + + if (!access_token || !refresh_token) { + throw new Error("No refresh_token returned from Google"); + } + + const ticket = await googleAuth.verifyIdToken({ + idToken: id_token, + audience: env.GOOGLE_CLIENT_ID, + }); + const payload = ticket.getPayload(); + + if (!payload?.email) { + throw new Error("Could not get email from ID token"); + } + + return { + accessToken: access_token, + refreshToken: refresh_token, + expiresAt: expiry_date ? new Date(expiry_date) : null, + email: payload.email, + }; + }, + + async syncCalendars( + connectionId: string, + accessToken: string, + refreshToken: string, + emailAccountId: string, + ): Promise { + try { + const calendarClient = await getCalendarClientWithRefresh({ + accessToken, + refreshToken, + expiresAt: null, + emailAccountId, + }); + + const googleCalendars = await fetchGoogleCalendars(calendarClient); + + for (const googleCalendar of googleCalendars) { + if (!googleCalendar.id) continue; + + await prisma.calendar.upsert({ + where: { + connectionId_calendarId: { + connectionId, + calendarId: googleCalendar.id, + }, + }, + update: { + name: googleCalendar.summary || "Untitled Calendar", + description: googleCalendar.description, + timezone: googleCalendar.timeZone, + }, + create: { + connectionId, + calendarId: googleCalendar.id, + name: googleCalendar.summary || "Untitled Calendar", + description: googleCalendar.description, + timezone: googleCalendar.timeZone, + isEnabled: true, + }, + }); + } + } catch (error) { + logger.error("Error syncing calendars", { error, connectionId }); + await prisma.calendarConnection.update({ + where: { id: connectionId }, + data: { isConnected: false }, + }); + throw error; + } + }, +}; diff --git a/apps/web/utils/calendar/providers/microsoft.ts b/apps/web/utils/calendar/providers/microsoft.ts new file mode 100644 index 000000000..975e7e91e --- /dev/null +++ b/apps/web/utils/calendar/providers/microsoft.ts @@ -0,0 +1,124 @@ +import { env } from "@/env"; +import prisma from "@/utils/prisma"; +import { createScopedLogger } from "@/utils/logger"; +import { + fetchMicrosoftCalendars, + getCalendarClientWithRefresh, +} from "@/utils/outlook/calendar-client"; +import type { CalendarOAuthProvider, CalendarTokens } from "../oauth-types"; + +const logger = createScopedLogger("microsoft/calendar/provider"); + +export const microsoftCalendarProvider: CalendarOAuthProvider = { + name: "microsoft", + + async exchangeCodeForTokens(code: string): Promise { + if (!env.MICROSOFT_CLIENT_ID || !env.MICROSOFT_CLIENT_SECRET) { + throw new Error("Microsoft credentials not configured"); + } + + // Exchange code for tokens + const tokenResponse = await fetch( + "https://login.microsoftonline.com/common/oauth2/v2.0/token", + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: env.MICROSOFT_CLIENT_ID, + client_secret: env.MICROSOFT_CLIENT_SECRET, + code, + grant_type: "authorization_code", + redirect_uri: `${env.NEXT_PUBLIC_BASE_URL}/api/outlook/calendar/callback`, + }), + }, + ); + + const tokens = await tokenResponse.json(); + + if (!tokenResponse.ok) { + throw new Error( + tokens.error_description || "Failed to exchange code for tokens", + ); + } + + // Get user profile using the access token + const profileResponse = await fetch("https://graph.microsoft.com/v1.0/me", { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + }, + }); + + if (!profileResponse.ok) { + throw new Error("Failed to fetch user profile"); + } + + const profile = await profileResponse.json(); + const microsoftEmail = profile.mail || profile.userPrincipalName; + + if (!microsoftEmail) { + throw new Error("Profile missing required email"); + } + + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresAt: tokens.expires_in + ? new Date(Date.now() + tokens.expires_in * 1000) + : null, + email: microsoftEmail, + }; + }, + + async syncCalendars( + connectionId: string, + accessToken: string, + refreshToken: string, + emailAccountId: string, + ): Promise { + try { + const calendarClient = await getCalendarClientWithRefresh({ + accessToken, + refreshToken, + expiresAt: null, + emailAccountId, + }); + + const microsoftCalendars = await fetchMicrosoftCalendars(calendarClient); + + for (const microsoftCalendar of microsoftCalendars) { + if (!microsoftCalendar.id) continue; + + await prisma.calendar.upsert({ + where: { + connectionId_calendarId: { + connectionId, + calendarId: microsoftCalendar.id, + }, + }, + update: { + name: microsoftCalendar.name || "Untitled Calendar", + description: microsoftCalendar.description, + timezone: microsoftCalendar.timeZone, + }, + create: { + connectionId, + calendarId: microsoftCalendar.id, + name: microsoftCalendar.name || "Untitled Calendar", + description: microsoftCalendar.description, + timezone: microsoftCalendar.timeZone, + isEnabled: true, + }, + }); + } + } catch (error) { + logger.error("Error syncing calendars", { error, connectionId }); + await prisma.calendarConnection.update({ + where: { id: connectionId }, + data: { isConnected: false }, + }); + throw error; + } + }, +}; diff --git a/version.txt b/version.txt index 59b343bf4..ff12bbb1e 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v2.17.7 +v2.18.0 From 6545499c2f4b7317d0ed7b8433bb3439ae8d5d1a Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 23 Oct 2025 01:34:30 +0300 Subject: [PATCH 7/7] get availabilty for ai drafting from microsoft calendars --- apps/web/utils/ai/calendar/availability.ts | 56 +++---- apps/web/utils/calendar/availability-types.ts | 21 +++ apps/web/utils/calendar/availability.ts | 109 -------------- .../calendar/providers/google-availability.ts | 89 ++++++++++++ .../providers/microsoft-availability.ts | 94 ++++++++++++ .../utils/calendar/unified-availability.ts | 137 ++++++++++++++++++ 6 files changed, 361 insertions(+), 145 deletions(-) create mode 100644 apps/web/utils/calendar/availability-types.ts delete mode 100644 apps/web/utils/calendar/availability.ts create mode 100644 apps/web/utils/calendar/providers/google-availability.ts create mode 100644 apps/web/utils/calendar/providers/microsoft-availability.ts create mode 100644 apps/web/utils/calendar/unified-availability.ts diff --git a/apps/web/utils/ai/calendar/availability.ts b/apps/web/utils/ai/calendar/availability.ts index 5005ebabd..52cc71fd5 100644 --- a/apps/web/utils/ai/calendar/availability.ts +++ b/apps/web/utils/ai/calendar/availability.ts @@ -3,7 +3,7 @@ import { tool } from "ai"; import { createScopedLogger } from "@/utils/logger"; import { createGenerateText } from "@/utils/llms"; import { getModel } from "@/utils/llms/model"; -import { getCalendarAvailability } from "@/utils/calendar/availability"; +import { getUnifiedCalendarAvailability } from "@/utils/calendar/unified-availability"; import type { EmailAccountWithAI } from "@/utils/llms/types"; import type { EmailForLLM } from "@/utils/types"; import prisma from "@/utils/prisma"; @@ -109,7 +109,8 @@ ${threadContent} ) || result.steps.length > 5, tools: { checkCalendarAvailability: tool({ - description: "Check Google Calendar availability for meeting requests", + description: + "Check calendar availability across all connected calendars (Google and Microsoft) for meeting requests", inputSchema: z.object({ timeMin: z .string() @@ -122,40 +123,23 @@ ${threadContent} const startDate = new Date(timeMin); const endDate = new Date(timeMax); - const promises = calendarConnections.map( - async (calendarConnection) => { - const calendarIds = calendarConnections.flatMap((conn) => - conn.calendars.map((cal) => cal.calendarId), - ); - - if (!calendarIds.length) return; - - try { - const availabilityData = await getCalendarAvailability({ - accessToken: calendarConnection.accessToken, - refreshToken: calendarConnection.refreshToken, - expiresAt: calendarConnection.expiresAt?.getTime() || null, - emailAccountId: emailAccount.id, - calendarIds, - startDate, - endDate, - timezone: userTimezone, - }); - - logger.trace("Calendar availability data", { - availabilityData, - }); - - return availabilityData; - } catch (error) { - logger.error("Error checking calendar availability", { error }); - } - }, - ); - - const busyPeriods = await Promise.all(promises); - - return { busyPeriods: busyPeriods.flat() }; + try { + const busyPeriods = await getUnifiedCalendarAvailability({ + emailAccountId: emailAccount.id, + startDate, + endDate, + timezone: userTimezone, + }); + + logger.trace("Unified calendar availability data", { + busyPeriods, + }); + + return { busyPeriods }; + } catch (error) { + logger.error("Error checking calendar availability", { error }); + return { busyPeriods: [] }; + } }, }), returnSuggestedTimes: tool({ diff --git a/apps/web/utils/calendar/availability-types.ts b/apps/web/utils/calendar/availability-types.ts new file mode 100644 index 000000000..767bc501f --- /dev/null +++ b/apps/web/utils/calendar/availability-types.ts @@ -0,0 +1,21 @@ +export type BusyPeriod = { + start: string; + end: string; +}; + +export interface CalendarAvailabilityProvider { + name: "google" | "microsoft"; + + /** + * Fetch busy periods for the given calendars + */ + fetchBusyPeriods(params: { + accessToken?: string | null; + refreshToken: string | null; + expiresAt: number | null; + emailAccountId: string; + calendarIds: string[]; + timeMin: string; + timeMax: string; + }): Promise; +} diff --git a/apps/web/utils/calendar/availability.ts b/apps/web/utils/calendar/availability.ts deleted file mode 100644 index 61f1af495..000000000 --- a/apps/web/utils/calendar/availability.ts +++ /dev/null @@ -1,109 +0,0 @@ -import type { calendar_v3 } from "@googleapis/calendar"; -import { TZDate } from "@date-fns/tz"; -import { getCalendarClientWithRefresh } from "./client"; -import { createScopedLogger } from "@/utils/logger"; -import { startOfDay, endOfDay } from "date-fns"; - -const logger = createScopedLogger("calendar/availability"); - -export type BusyPeriod = { - start: string; - end: string; -}; - -async function fetchCalendarBusyPeriods({ - calendarClient, - calendarIds, - timeMin, - timeMax, -}: { - calendarClient: calendar_v3.Calendar; - calendarIds: string[]; - timeMin: string; - timeMax: string; -}): Promise { - try { - const response = await calendarClient.freebusy.query({ - requestBody: { - timeMin, - timeMax, - items: calendarIds.map((id) => ({ id })), - }, - }); - - const busyPeriods: BusyPeriod[] = []; - - if (response.data.calendars) { - for (const [_calendarId, calendar] of Object.entries( - response.data.calendars, - )) { - if (calendar.busy) { - for (const period of calendar.busy) { - if (period.start && period.end) { - busyPeriods.push({ - start: period.start, - end: period.end, - }); - } - } - } - } - } - - logger.trace("Calendar busy periods", { busyPeriods, timeMin, timeMax }); - - return busyPeriods; - } catch (error) { - logger.error("Error fetching calendar busy periods", { error }); - throw error; - } -} - -export async function getCalendarAvailability({ - accessToken, - refreshToken, - expiresAt, - emailAccountId, - calendarIds, - startDate, - endDate, - timezone = "UTC", -}: { - accessToken?: string | null; - refreshToken: string | null; - expiresAt: number | null; - emailAccountId: string; - calendarIds: string[]; - startDate: Date; - endDate: Date; - timezone?: string; -}): Promise { - const calendarClient = await getCalendarClientWithRefresh({ - accessToken, - refreshToken, - expiresAt, - emailAccountId, - }); - - // Compute day boundaries directly in the user's timezone using TZDate - const startDateInTZ = new TZDate(startDate, timezone); - const endDateInTZ = new TZDate(endDate, timezone); - - const timeMin = startOfDay(startDateInTZ).toISOString(); - const timeMax = endOfDay(endDateInTZ).toISOString(); - - logger.trace("Calendar availability request with timezone", { - timezone, - startDate: startDate.toISOString(), - endDate: endDate.toISOString(), - timeMin, - timeMax, - }); - - return await fetchCalendarBusyPeriods({ - calendarClient, - calendarIds, - timeMin, - timeMax, - }); -} diff --git a/apps/web/utils/calendar/providers/google-availability.ts b/apps/web/utils/calendar/providers/google-availability.ts new file mode 100644 index 000000000..0d2f6bbfa --- /dev/null +++ b/apps/web/utils/calendar/providers/google-availability.ts @@ -0,0 +1,89 @@ +import type { calendar_v3 } from "@googleapis/calendar"; +import { createScopedLogger } from "@/utils/logger"; +import { getCalendarClientWithRefresh } from "../client"; +import type { + CalendarAvailabilityProvider, + BusyPeriod, +} from "../availability-types"; + +const logger = createScopedLogger("calendar/google-availability"); + +async function fetchGoogleCalendarBusyPeriods({ + calendarClient, + calendarIds, + timeMin, + timeMax, +}: { + calendarClient: calendar_v3.Calendar; + calendarIds: string[]; + timeMin: string; + timeMax: string; +}): Promise { + try { + const response = await calendarClient.freebusy.query({ + requestBody: { + timeMin, + timeMax, + items: calendarIds.map((id) => ({ id })), + }, + }); + + const busyPeriods: BusyPeriod[] = []; + + if (response.data.calendars) { + for (const [_calendarId, calendar] of Object.entries( + response.data.calendars, + )) { + if (calendar.busy) { + for (const period of calendar.busy) { + if (period.start && period.end) { + busyPeriods.push({ + start: period.start, + end: period.end, + }); + } + } + } + } + } + + logger.trace("Google Calendar busy periods", { + busyPeriods, + timeMin, + timeMax, + }); + + return busyPeriods; + } catch (error) { + logger.error("Error fetching Google Calendar busy periods", { error }); + throw error; + } +} + +export const googleAvailabilityProvider: CalendarAvailabilityProvider = { + name: "google", + + async fetchBusyPeriods({ + accessToken, + refreshToken, + expiresAt, + emailAccountId, + calendarIds, + timeMin, + timeMax, + }) { + const calendarClient = await getCalendarClientWithRefresh({ + accessToken, + refreshToken, + expiresAt, + emailAccountId, + }); + + return await fetchGoogleCalendarBusyPeriods({ + calendarClient, + calendarIds, + timeMin, + timeMax, + }); + }, +}; diff --git a/apps/web/utils/calendar/providers/microsoft-availability.ts b/apps/web/utils/calendar/providers/microsoft-availability.ts new file mode 100644 index 000000000..9db7b8bc9 --- /dev/null +++ b/apps/web/utils/calendar/providers/microsoft-availability.ts @@ -0,0 +1,94 @@ +import type { Client } from "@microsoft/microsoft-graph-client"; +import { createScopedLogger } from "@/utils/logger"; +import { getCalendarClientWithRefresh } from "@/utils/outlook/calendar-client"; +import type { + CalendarAvailabilityProvider, + BusyPeriod, +} from "../availability-types"; + +const logger = createScopedLogger("calendar/microsoft-availability"); + +async function fetchMicrosoftCalendarBusyPeriods({ + calendarClient, + calendarIds, + timeMin, + timeMax, +}: { + calendarClient: Client; + calendarIds: string[]; + timeMin: string; + timeMax: string; +}): Promise { + try { + // Microsoft Graph API getSchedule endpoint + const response = await calendarClient.api("/me/calendar/getSchedule").post({ + schedules: calendarIds, + startTime: { + dateTime: timeMin, + timeZone: "UTC", + }, + endTime: { + dateTime: timeMax, + timeZone: "UTC", + }, + }); + + const busyPeriods: BusyPeriod[] = []; + + if (response.value) { + for (const schedule of response.value) { + if (schedule.scheduleItems) { + for (const item of schedule.scheduleItems) { + // Microsoft returns various statuses: busy, tentative, oof, workingElsewhere + // We consider all non-free items as busy + if (item.status !== "free" && item.start && item.end) { + busyPeriods.push({ + start: item.start.dateTime, + end: item.end.dateTime, + }); + } + } + } + } + } + + logger.trace("Microsoft Calendar busy periods", { + busyPeriods, + timeMin, + timeMax, + }); + + return busyPeriods; + } catch (error) { + logger.error("Error fetching Microsoft Calendar busy periods", { error }); + throw error; + } +} + +export const microsoftAvailabilityProvider: CalendarAvailabilityProvider = { + name: "microsoft", + + async fetchBusyPeriods({ + accessToken, + refreshToken, + expiresAt, + emailAccountId, + calendarIds, + timeMin, + timeMax, + }) { + const calendarClient = await getCalendarClientWithRefresh({ + accessToken, + refreshToken, + expiresAt, + emailAccountId, + }); + + return await fetchMicrosoftCalendarBusyPeriods({ + calendarClient, + calendarIds, + timeMin, + timeMax, + }); + }, +}; diff --git a/apps/web/utils/calendar/unified-availability.ts b/apps/web/utils/calendar/unified-availability.ts new file mode 100644 index 000000000..dbf7ba9c0 --- /dev/null +++ b/apps/web/utils/calendar/unified-availability.ts @@ -0,0 +1,137 @@ +import { TZDate } from "@date-fns/tz"; +import { startOfDay, endOfDay } from "date-fns"; +import { createScopedLogger } from "@/utils/logger"; +import prisma from "@/utils/prisma"; +import type { BusyPeriod } from "./availability-types"; +import { googleAvailabilityProvider } from "./providers/google-availability"; +import { microsoftAvailabilityProvider } from "./providers/microsoft-availability"; + +const logger = createScopedLogger("calendar/unified-availability"); + +/** + * Fetch calendar availability across all connected calendars (Google and Microsoft) + */ +export async function getUnifiedCalendarAvailability({ + emailAccountId, + startDate, + endDate, + timezone = "UTC", +}: { + emailAccountId: string; + startDate: Date; + endDate: Date; + timezone?: string; +}): Promise { + // Compute day boundaries in the user's timezone + const startDateInTZ = new TZDate(startDate, timezone); + const endDateInTZ = new TZDate(endDate, timezone); + + const timeMin = startOfDay(startDateInTZ).toISOString(); + const timeMax = endOfDay(endDateInTZ).toISOString(); + + logger.trace("Unified calendar availability request", { + timezone, + emailAccountId, + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + timeMin, + timeMax, + }); + + // Fetch all calendar connections with their calendars + const calendarConnections = await prisma.calendarConnection.findMany({ + where: { + emailAccountId, + isConnected: true, + }, + include: { + calendars: { + where: { isEnabled: true }, + select: { + calendarId: true, + }, + }, + }, + }); + + if (!calendarConnections.length) { + logger.info("No calendar connections found", { emailAccountId }); + return []; + } + + // Group calendars by provider + const googleConnections = calendarConnections.filter( + (conn) => conn.provider === "google", + ); + const microsoftConnections = calendarConnections.filter( + (conn) => conn.provider === "microsoft", + ); + + const promises: Promise[] = []; + + // Fetch Google calendar availability + for (const connection of googleConnections) { + const calendarIds = connection.calendars.map((cal) => cal.calendarId); + if (!calendarIds.length) continue; + + promises.push( + googleAvailabilityProvider + .fetchBusyPeriods({ + accessToken: connection.accessToken, + refreshToken: connection.refreshToken, + expiresAt: connection.expiresAt?.getTime() || null, + emailAccountId, + calendarIds, + timeMin, + timeMax, + }) + .catch((error) => { + logger.error("Error fetching Google calendar availability", { + error, + connectionId: connection.id, + }); + return []; // Return empty array on error + }), + ); + } + + // Fetch Microsoft calendar availability + for (const connection of microsoftConnections) { + const calendarIds = connection.calendars.map((cal) => cal.calendarId); + if (!calendarIds.length) continue; + + promises.push( + microsoftAvailabilityProvider + .fetchBusyPeriods({ + accessToken: connection.accessToken, + refreshToken: connection.refreshToken, + expiresAt: connection.expiresAt?.getTime() || null, + emailAccountId, + calendarIds, + timeMin, + timeMax, + }) + .catch((error) => { + logger.error("Error fetching Microsoft calendar availability", { + error, + connectionId: connection.id, + }); + return []; // Return empty array on error + }), + ); + } + + // Wait for all providers to return results + const results = await Promise.all(promises); + + // Flatten and merge all busy periods + const allBusyPeriods = results.flat(); + + logger.trace("Unified calendar availability results", { + totalBusyPeriods: allBusyPeriods.length, + googleConnectionsCount: googleConnections.length, + microsoftConnectionsCount: microsoftConnections.length, + }); + + return allBusyPeriods; +}