From 50cdca1ac252d112bd73fb96a806af8f51c21c33 Mon Sep 17 00:00:00 2001 From: alma Date: Wed, 14 Jan 2026 15:40:40 +0100 Subject: [PATCH] Agenda Sync refactor --- app/agenda/page.tsx | 101 ++++- .../sync/discover-microsoft/route.ts | 83 ++++ app/api/calendars/sync/route.ts | 49 ++- components/calendar/calendar-client.tsx | 49 ++- lib/services/calendar-sync-job.ts | 5 + lib/services/microsoft-calendar-sync.ts | 354 ++++++++++++++++++ lib/services/microsoft-oauth.ts | 5 +- 7 files changed, 625 insertions(+), 21 deletions(-) create mode 100644 app/api/calendars/sync/discover-microsoft/route.ts create mode 100644 lib/services/microsoft-calendar-sync.ts diff --git a/app/agenda/page.tsx b/app/agenda/page.tsx index e27fa3b..483c7e4 100644 --- a/app/agenda/page.tsx +++ b/app/agenda/page.tsx @@ -90,7 +90,7 @@ export default async function CalendarPage() { } }); - // Auto-setup sync for Infomaniak accounts from courrier + // Auto-setup sync for email accounts from courrier (Infomaniak and Microsoft) // Get all Infomaniak email accounts const infomaniakAccounts = await prisma.mailCredentials.findMany({ where: { @@ -110,6 +110,27 @@ export default async function CalendarPage() { } }); + // Get all Microsoft email accounts (OAuth) + const microsoftAccounts = await prisma.mailCredentials.findMany({ + where: { + userId: session?.user?.id || '', + host: { + contains: 'outlook.office365.com' + }, + use_oauth: true, + refresh_token: { + not: null + } + }, + select: { + id: true, + email: true, + display_name: true, + refresh_token: true, + use_oauth: true + } + }); + // For each Infomaniak account, ensure there's a synced calendar for (const account of infomaniakAccounts) { // Check if a calendar sync already exists for this account @@ -178,12 +199,88 @@ export default async function CalendarPage() { } } } catch (error) { - console.error(`Error auto-setting up sync for account ${account.email}:`, error); + console.error(`Error auto-setting up sync for Infomaniak account ${account.email}:`, error); // Continue with other accounts even if one fails } } } + // For each Microsoft account, ensure there's a synced calendar + for (const account of microsoftAccounts) { + // Check if a calendar sync already exists for this account + const existingSync = await prisma.calendarSync.findFirst({ + where: { + mailCredentialId: account.id, + syncEnabled: true + }, + include: { + calendar: true + } + }); + + if (!existingSync) { + // Try to discover calendars for this account + try { + const { discoverMicrosoftCalendars } = await import('@/lib/services/microsoft-calendar-sync'); + const externalCalendars = await discoverMicrosoftCalendars( + session?.user?.id || '', + account.email + ); + + if (externalCalendars.length > 0) { + // Use the first calendar (usually the main calendar) + const mainCalendar = externalCalendars[0]; + + // Create a private calendar for this account + const calendar = await prisma.calendar.create({ + data: { + name: "Privée", + color: "#0078D4", // Microsoft blue + description: `Calendrier synchronisé avec ${account.display_name || account.email}`, + userId: session?.user?.id || '', + } + }); + + // Create sync configuration + await prisma.calendarSync.create({ + data: { + calendarId: calendar.id, + mailCredentialId: account.id, + provider: 'microsoft', + externalCalendarId: mainCalendar.id, + externalCalendarUrl: mainCalendar.webLink || mainCalendar.id, + syncEnabled: true, + syncFrequency: 15 + } + }); + + // Trigger initial sync + try { + const { syncMicrosoftCalendar } = await import('@/lib/services/microsoft-calendar-sync'); + const syncConfig = await prisma.calendarSync.findUnique({ + where: { calendarId: calendar.id }, + include: { + calendar: true, + mailCredential: true + } + }); + if (syncConfig) { + await syncMicrosoftCalendar(syncConfig.id, true); + } + } catch (syncError) { + console.error('Error during initial Microsoft sync:', syncError); + // Don't fail if sync fails, calendar is still created + } + } + } catch (error) { + console.error(`Error auto-setting up sync for Microsoft account ${account.email}:`, error); + // Don't fail the page if Microsoft sync setup fails + // The account might not have the calendar scope yet, or there might be a token issue + // User can manually set up sync later if needed + } + } + } + // Refresh calendars after auto-setup // Exclude "Privée" and "Default" calendars that are not synced calendars = await prisma.calendar.findMany({ diff --git a/app/api/calendars/sync/discover-microsoft/route.ts b/app/api/calendars/sync/discover-microsoft/route.ts new file mode 100644 index 0000000..2421289 --- /dev/null +++ b/app/api/calendars/sync/discover-microsoft/route.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/api/auth/options"; +import { prisma } from "@/lib/prisma"; +import { discoverMicrosoftCalendars } from "@/lib/services/microsoft-calendar-sync"; +import { logger } from "@/lib/logger"; + +/** + * Discover calendars for a Microsoft account + * POST /api/calendars/sync/discover-microsoft + */ +export async function POST(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } + + const { mailCredentialId } = await req.json(); + + if (!mailCredentialId) { + return NextResponse.json( + { error: "mailCredentialId est requis" }, + { status: 400 } + ); + } + + // Get mail credentials + const mailCreds = await prisma.mailCredentials.findFirst({ + where: { + id: mailCredentialId, + userId: session.user.id, + }, + }); + + if (!mailCreds) { + return NextResponse.json( + { error: "Credentials non trouvés" }, + { status: 404 } + ); + } + + // Check if it's a Microsoft account + if (!mailCreds.host.includes('outlook.office365.com') || !mailCreds.use_oauth) { + return NextResponse.json( + { error: "Ce compte n'est pas un compte Microsoft OAuth" }, + { status: 400 } + ); + } + + if (!mailCreds.refresh_token) { + return NextResponse.json( + { error: "Refresh token requis pour la synchronisation Microsoft" }, + { status: 400 } + ); + } + + // Discover calendars + const calendars = await discoverMicrosoftCalendars( + session.user.id, + mailCreds.email + ); + + logger.info('Microsoft calendars discovered', { + userId: session.user.id, + email: mailCreds.email, + calendarsCount: calendars.length, + }); + + return NextResponse.json({ calendars }); + } catch (error) { + logger.error('Error discovering Microsoft calendars', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { + error: "Erreur lors de la découverte des calendriers", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 } + ); + } +} diff --git a/app/api/calendars/sync/route.ts b/app/api/calendars/sync/route.ts index 25ed354..a0a617d 100644 --- a/app/api/calendars/sync/route.ts +++ b/app/api/calendars/sync/route.ts @@ -3,6 +3,7 @@ import { getServerSession } from "next-auth/next"; import { authOptions } from "@/app/api/auth/options"; import { prisma } from "@/lib/prisma"; import { syncInfomaniakCalendar } from "@/lib/services/caldav-sync"; +import { syncMicrosoftCalendar } from "@/lib/services/microsoft-calendar-sync"; import { logger } from "@/lib/logger"; /** @@ -16,7 +17,7 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); } - const { calendarId, mailCredentialId, externalCalendarUrl, externalCalendarId, syncFrequency } = await req.json(); + const { calendarId, mailCredentialId, externalCalendarUrl, externalCalendarId, syncFrequency, provider } = await req.json(); if (!calendarId || !mailCredentialId || !externalCalendarUrl) { return NextResponse.json( @@ -25,6 +26,24 @@ export async function POST(req: NextRequest) { ); } + // Determine provider if not provided + let detectedProvider = provider; + if (!detectedProvider) { + const mailCreds = await prisma.mailCredentials.findUnique({ + where: { id: mailCredentialId }, + }); + if (mailCreds?.host.includes('infomaniak')) { + detectedProvider = 'infomaniak'; + } else if (mailCreds?.host.includes('outlook.office365.com')) { + detectedProvider = 'microsoft'; + } else { + return NextResponse.json( + { error: "Provider non supporté ou non détecté" }, + { status: 400 } + ); + } + } + // Verify calendar belongs to user const calendar = await prisma.calendar.findFirst({ where: { @@ -61,7 +80,7 @@ export async function POST(req: NextRequest) { create: { calendarId, mailCredentialId, - provider: 'infomaniak', + provider: detectedProvider, externalCalendarId: externalCalendarId || null, externalCalendarUrl, syncEnabled: true, @@ -69,6 +88,7 @@ export async function POST(req: NextRequest) { }, update: { mailCredentialId, + provider: detectedProvider, externalCalendarId: externalCalendarId || null, externalCalendarUrl, syncFrequency: syncFrequency || 15, @@ -76,12 +96,19 @@ export async function POST(req: NextRequest) { }, }); - // Trigger initial sync + // Trigger initial sync based on provider try { - await syncInfomaniakCalendar(syncConfig.id, true); + if (detectedProvider === 'infomaniak') { + const { syncInfomaniakCalendar } = await import('@/lib/services/caldav-sync'); + await syncInfomaniakCalendar(syncConfig.id, true); + } else if (detectedProvider === 'microsoft') { + const { syncMicrosoftCalendar } = await import('@/lib/services/microsoft-calendar-sync'); + await syncMicrosoftCalendar(syncConfig.id, true); + } } catch (syncError) { logger.error('Error during initial sync', { syncConfigId: syncConfig.id, + provider: detectedProvider, error: syncError instanceof Error ? syncError.message : String(syncError), }); // Don't fail the request if sync fails, just log it @@ -137,8 +164,18 @@ export async function PUT(req: NextRequest) { ); } - // Trigger sync - const result = await syncInfomaniakCalendar(calendarSyncId, true); + // Trigger sync based on provider + let result; + if (syncConfig.provider === 'infomaniak') { + result = await syncInfomaniakCalendar(calendarSyncId, true); + } else if (syncConfig.provider === 'microsoft') { + result = await syncMicrosoftCalendar(calendarSyncId, true); + } else { + return NextResponse.json( + { error: "Provider non supporté" }, + { status: 400 } + ); + } return NextResponse.json({ success: true, result }); } catch (error) { diff --git a/components/calendar/calendar-client.tsx b/components/calendar/calendar-client.tsx index 2df5abe..dc85b81 100644 --- a/components/calendar/calendar-client.tsx +++ b/components/calendar/calendar-client.tsx @@ -116,7 +116,7 @@ interface CalendarDialogProps { onClose: () => void; onSave: (calendarData: Partial) => Promise; onDelete?: (calendarId: string) => Promise; - onSyncSetup?: (calendarId: string, mailCredentialId: string, externalCalendarUrl: string) => Promise; + onSyncSetup?: (calendarId: string, mailCredentialId: string, externalCalendarUrl: string, externalCalendarId?: string, provider?: string) => Promise; initialData?: Partial; syncConfig?: { id: string; @@ -175,14 +175,16 @@ function CalendarDialog({ open, onClose, onSave, onDelete, onSyncSetup, initialD if (response.ok) { const data = await response.json(); if (data.success && data.accounts) { - // Filter Infomaniak accounts only - const infomaniakAccounts = data.accounts.filter((acc: any) => - acc.host && acc.host.includes('infomaniak') + // Filter Infomaniak and Microsoft accounts + const syncableAccounts = data.accounts.filter((acc: any) => + (acc.host && acc.host.includes('infomaniak')) || + (acc.host && acc.host.includes('outlook.office365.com') && acc.use_oauth) ); - setAvailableAccounts(infomaniakAccounts.map((acc: any) => ({ + setAvailableAccounts(syncableAccounts.map((acc: any) => ({ id: acc.id, email: acc.email, - display_name: acc.display_name + display_name: acc.display_name, + host: acc.host }))); } } @@ -196,7 +198,15 @@ function CalendarDialog({ open, onClose, onSave, onDelete, onSyncSetup, initialD setIsDiscovering(true); try { - const response = await fetch("/api/calendars/sync/discover", { + // Determine which endpoint to use based on account type + const selectedAccount = availableAccounts.find(acc => acc.id === selectedAccountId); + const isMicrosoft = selectedAccount?.host?.includes('outlook.office365.com'); + + const endpoint = isMicrosoft + ? "/api/calendars/sync/discover-microsoft" + : "/api/calendars/sync/discover"; + + const response = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mailCredentialId: selectedAccountId }), @@ -204,7 +214,13 @@ function CalendarDialog({ open, onClose, onSave, onDelete, onSyncSetup, initialD if (response.ok) { const data = await response.json(); - setAvailableCalendars(data.calendars || []); + // Normalize calendar format (Infomaniak uses 'url', Microsoft uses 'id' and 'webLink') + const normalizedCalendars = (data.calendars || []).map((cal: any) => ({ + id: cal.id, + name: cal.name, + url: cal.url || cal.webLink || cal.id, // Use url, webLink, or id as fallback + })); + setAvailableCalendars(normalizedCalendars); } else { const error = await response.json(); alert(error.error || "Erreur lors de la découverte des calendriers"); @@ -222,7 +238,17 @@ function CalendarDialog({ open, onClose, onSave, onDelete, onSyncSetup, initialD setIsSettingUpSync(true); try { - await onSyncSetup(initialData.id, selectedAccountId, selectedCalendarUrl); + // Determine provider based on selected account + const selectedAccount = availableAccounts.find(acc => acc.id === selectedAccountId); + const isMicrosoft = selectedAccount?.host?.includes('outlook.office365.com'); + const provider = isMicrosoft ? 'microsoft' : 'infomaniak'; + + // For Microsoft, use calendar ID instead of URL + const externalCalendarId = isMicrosoft + ? availableCalendars.find(cal => cal.url === selectedCalendarUrl)?.id || selectedCalendarUrl + : null; + + await onSyncSetup(initialData.id, selectedAccountId, selectedCalendarUrl, externalCalendarId, provider); setShowSyncSection(false); alert("Synchronisation configurée avec succès !"); } catch (error) { @@ -1550,7 +1576,7 @@ export function CalendarClient({ initialCalendars, userId, userProfile }: Calend onClose={() => setIsCalendarModalOpen(false)} onSave={handleCalendarSave} onDelete={handleCalendarDelete} - onSyncSetup={async (calendarId, mailCredentialId, externalCalendarUrl) => { + onSyncSetup={async (calendarId, mailCredentialId, externalCalendarUrl, externalCalendarId, provider) => { try { const response = await fetch("/api/calendars/sync", { method: "POST", @@ -1559,7 +1585,8 @@ export function CalendarClient({ initialCalendars, userId, userProfile }: Calend calendarId, mailCredentialId, externalCalendarUrl, - provider: "infomaniak", + externalCalendarId, + provider: provider || "infomaniak", }), }); diff --git a/lib/services/calendar-sync-job.ts b/lib/services/calendar-sync-job.ts index dd61c5f..5ad4368 100644 --- a/lib/services/calendar-sync-job.ts +++ b/lib/services/calendar-sync-job.ts @@ -51,8 +51,13 @@ export async function runCalendarSyncJob(): Promise { // Sync based on provider if (syncConfig.provider === 'infomaniak') { + const { syncInfomaniakCalendar } = await import('./caldav-sync'); await syncInfomaniakCalendar(syncConfig.id, false); results.successful++; + } else if (syncConfig.provider === 'microsoft') { + const { syncMicrosoftCalendar } = await import('./microsoft-calendar-sync'); + await syncMicrosoftCalendar(syncConfig.id, false); + results.successful++; } else { logger.warn('Unknown sync provider', { calendarSyncId: syncConfig.id, diff --git a/lib/services/microsoft-calendar-sync.ts b/lib/services/microsoft-calendar-sync.ts new file mode 100644 index 0000000..42183b8 --- /dev/null +++ b/lib/services/microsoft-calendar-sync.ts @@ -0,0 +1,354 @@ +import axios from 'axios'; +import { prisma } from '@/lib/prisma'; +import { logger } from '@/lib/logger'; +import { ensureFreshToken } from './token-refresh'; + +export interface MicrosoftCalendar { + id: string; + name: string; + color?: string; + webLink?: string; +} + +export interface MicrosoftEvent { + id: string; + subject: string; + body?: { + content?: string; + contentType?: string; + }; + start: { + dateTime: string; + timeZone: string; + }; + end: { + dateTime: string; + timeZone: string; + }; + location?: { + displayName?: string; + }; + isAllDay?: boolean; +} + +/** + * Get Microsoft Graph API client with fresh access token + */ +async function getMicrosoftGraphClient( + userId: string, + email: string +): Promise { + // Ensure we have a fresh access token + const { accessToken, success } = await ensureFreshToken(userId, email); + + if (!success || !accessToken) { + throw new Error('Failed to obtain valid Microsoft access token'); + } + + return accessToken; +} + +/** + * Discover calendars available for a Microsoft account + */ +export async function discoverMicrosoftCalendars( + userId: string, + email: string +): Promise { + try { + const accessToken = await getMicrosoftGraphClient(userId, email); + + // Get calendars from Microsoft Graph API + const response = await axios.get( + 'https://graph.microsoft.com/v1.0/me/calendars', + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + } + ); + + const calendars: MicrosoftCalendar[] = (response.data.value || []).map((cal: any) => ({ + id: cal.id, + name: cal.name, + color: cal.color || undefined, + webLink: cal.webLink || undefined, + })); + + logger.info('Microsoft calendars discovered', { + userId, + email, + calendarsCount: calendars.length, + }); + + return calendars; + } catch (error: any) { + // Check if error is due to missing calendar scope + if (error.response?.status === 403 || error.response?.status === 401) { + logger.warn('Microsoft calendar access denied - may need to re-authenticate with calendar scope', { + userId, + email, + error: error.response?.data?.error?.message || error.message, + }); + // Return empty array instead of throwing - user can re-authenticate later + return []; + } + + logger.error('Error discovering Microsoft calendars', { + userId, + email, + error: error instanceof Error ? error.message : String(error), + }); + // Return empty array instead of throwing to avoid breaking the page + return []; + } +} + +/** + * Fetch events from a Microsoft calendar + */ +export async function fetchMicrosoftEvents( + userId: string, + email: string, + calendarId: string, + startDate?: Date, + endDate?: Date +): Promise { + try { + const accessToken = await getMicrosoftGraphClient(userId, email); + + // Build query parameters + const params: any = { + $select: 'id,subject,body,start,end,location,isAllDay', + $orderby: 'start/dateTime asc', + $top: 1000, // Limit to 1000 events + }; + + // Add date filter if provided + if (startDate && endDate) { + // Microsoft Graph API expects ISO format for date filters + // For all-day events, we need to handle date-only format + const startFilter = startDate.toISOString(); + const endFilter = endDate.toISOString(); + params.$filter = `start/dateTime ge '${startFilter}' and start/dateTime le '${endFilter}'`; + } + + // Get events from Microsoft Graph API + const response = await axios.get( + `https://graph.microsoft.com/v1.0/me/calendars/${calendarId}/events`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + params, + } + ); + + return response.data.value || []; + } catch (error) { + logger.error('Error fetching Microsoft events', { + userId, + email, + calendarId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } +} + +/** + * Convert Microsoft event to CalDAV-like format + */ +export function convertMicrosoftEventToCalDAV(microsoftEvent: MicrosoftEvent): { + uid: string; + summary: string; + description?: string; + start: Date; + end: Date; + location?: string; + allDay: boolean; +} { + // Microsoft Graph API uses different formats for all-day vs timed events + // All-day events have dateTime in format "YYYY-MM-DD" without time + // Timed events have dateTime in ISO format with time + const isAllDay = microsoftEvent.isAllDay || + (microsoftEvent.start.dateTime && !microsoftEvent.start.dateTime.includes('T')); + + let startDate: Date; + let endDate: Date; + + if (isAllDay) { + // For all-day events, parse date only (YYYY-MM-DD) + startDate = new Date(microsoftEvent.start.dateTime.split('T')[0]); + endDate = new Date(microsoftEvent.end.dateTime.split('T')[0]); + // Set to start of day + startDate.setHours(0, 0, 0, 0); + endDate.setHours(0, 0, 0, 0); + } else { + // For timed events, parse full ISO datetime + startDate = new Date(microsoftEvent.start.dateTime); + endDate = new Date(microsoftEvent.end.dateTime); + } + + return { + uid: microsoftEvent.id, + summary: microsoftEvent.subject || 'Sans titre', + description: microsoftEvent.body?.content || undefined, + start: startDate, + end: endDate, + location: microsoftEvent.location?.displayName || undefined, + allDay: isAllDay, + }; +} + +/** + * Sync events from Microsoft calendar to local Prisma calendar + */ +export async function syncMicrosoftCalendar( + calendarSyncId: string, + forceSync: boolean = false +): Promise<{ synced: number; created: number; updated: number; deleted: number }> { + try { + const syncConfig = await prisma.calendarSync.findUnique({ + where: { id: calendarSyncId }, + include: { + calendar: true, + mailCredential: true, + }, + }); + + if (!syncConfig || !syncConfig.syncEnabled) { + throw new Error('Calendar sync not enabled or not found'); + } + + if (!syncConfig.mailCredential) { + throw new Error('Mail credentials not found for calendar sync'); + } + + const creds = syncConfig.mailCredential; + + // Check if sync is needed (based on syncFrequency) + if (!forceSync && syncConfig.lastSyncAt) { + const minutesSinceLastSync = (Date.now() - syncConfig.lastSyncAt.getTime()) / (1000 * 60); + if (minutesSinceLastSync < syncConfig.syncFrequency) { + logger.debug('Sync skipped - too soon since last sync', { + calendarSyncId, + minutesSinceLastSync, + syncFrequency: syncConfig.syncFrequency, + }); + return { synced: 0, created: 0, updated: 0, deleted: 0 }; + } + } + + if (!creds.use_oauth || !creds.refresh_token) { + throw new Error('OAuth credentials required for Microsoft calendar sync'); + } + + // Fetch events from Microsoft Graph API + const startDate = new Date(); + startDate.setMonth(startDate.getMonth() - 1); // Sync last month to next 3 months + const endDate = new Date(); + endDate.setMonth(endDate.getMonth() + 3); + + const microsoftEvents = await fetchMicrosoftEvents( + syncConfig.calendar.userId, + creds.email, + syncConfig.externalCalendarId || '', + startDate, + endDate + ); + + // Convert Microsoft events to CalDAV-like format + const caldavEvents = microsoftEvents.map(convertMicrosoftEventToCalDAV); + + // Get existing events in local calendar + const existingEvents = await prisma.event.findMany({ + where: { + calendarId: syncConfig.calendarId, + }, + }); + + let created = 0; + let updated = 0; + let deleted = 0; + + // Sync events: create or update + for (const caldavEvent of caldavEvents) { + // Try to find existing event by matching title and start date + const existingEvent = existingEvents.find( + (e) => + e.title === caldavEvent.summary && + Math.abs(new Date(e.start).getTime() - caldavEvent.start.getTime()) < 60000 // Within 1 minute + ); + + const eventData = { + title: caldavEvent.summary, + description: caldavEvent.description || null, + start: caldavEvent.start, + end: caldavEvent.end, + location: caldavEvent.location || null, + isAllDay: caldavEvent.allDay, + calendarId: syncConfig.calendarId, + userId: syncConfig.calendar.userId, + }; + + if (existingEvent) { + // Update existing event + await prisma.event.update({ + where: { id: existingEvent.id }, + data: eventData, + }); + updated++; + } else { + // Create new event + await prisma.event.create({ + data: eventData, + }); + created++; + } + } + + // Update sync timestamp + await prisma.calendarSync.update({ + where: { id: calendarSyncId }, + data: { + lastSyncAt: new Date(), + lastSyncError: null, + }, + }); + + logger.info('Microsoft calendar sync completed', { + calendarSyncId, + calendarId: syncConfig.calendarId, + synced: caldavEvents.length, + created, + updated, + }); + + return { + synced: caldavEvents.length, + created, + updated, + deleted, + }; + } catch (error) { + logger.error('Error syncing Microsoft calendar', { + calendarSyncId, + error: error instanceof Error ? error.message : String(error), + }); + + // Update sync error + await prisma.calendarSync.update({ + where: { id: calendarSyncId }, + data: { + lastSyncError: error instanceof Error ? error.message : String(error), + }, + }).catch(() => { + // Ignore errors updating sync error + }); + + throw error; + } +} diff --git a/lib/services/microsoft-oauth.ts b/lib/services/microsoft-oauth.ts index 18a5f9b..877f716 100644 --- a/lib/services/microsoft-oauth.ts +++ b/lib/services/microsoft-oauth.ts @@ -14,11 +14,12 @@ const redirectUri = process.env.MICROSOFT_REDIRECT_URI; // NOTE: In production we do not log Microsoft OAuth configuration to avoid noise and potential leakage. -// Required scopes for IMAP and SMTP access +// Required scopes for IMAP, SMTP, and Calendar access const REQUIRED_SCOPES = [ 'offline_access', 'https://outlook.office.com/IMAP.AccessAsUser.All', - 'https://outlook.office.com/SMTP.Send' + 'https://outlook.office.com/SMTP.Send', + 'https://graph.microsoft.com/Calendars.Read', // Microsoft Graph API scope for calendar read access ].join(' '); /**