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 // Note: The token might not have calendar scope if the account was authenticated before calendar scope was added // In that case, the user will need to re-authenticate const { accessToken, success } = await ensureFreshToken(userId, email); if (!success || !accessToken) { throw new Error('Failed to obtain valid Microsoft access token. The account may need to be re-authenticated with calendar permissions.'); } 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 or invalid audience if (error.response?.status === 403 || error.response?.status === 401) { const errorMessage = error.response?.data?.error?.message || error.message || ''; const needsReauth = errorMessage.includes('Invalid audience') || errorMessage.includes('insufficient_privileges') || errorMessage.includes('invalid_token'); if (needsReauth) { logger.warn('Microsoft calendar access denied - account needs re-authentication with calendar scope', { userId, email, error: errorMessage, }); // Return empty array - user needs to re-authenticate their Microsoft account // The account was authenticated before calendar scope was added return []; } } logger.error('Error discovering Microsoft calendars', { userId, email, error: error instanceof Error ? error.message : String(error), responseStatus: error.response?.status, responseData: error.response?.data, }); // 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; } }