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 // Note: Microsoft Graph API filter syntax // For timed events: start/dateTime // For all-day events: start/date // We need to handle both cases if (startDate && endDate) { // Format dates for Microsoft Graph API // For dateTime: ISO 8601 format (e.g., 2026-01-14T21:00:00Z) // For date: YYYY-MM-DD format const startDateStr = startDate.toISOString().split('T')[0]; const endDateStr = endDate.toISOString().split('T')[0]; const startDateTimeStr = startDate.toISOString(); const endDateTimeStr = endDate.toISOString(); // Microsoft Graph API filter: match events where start is within range // This handles both timed events (dateTime) and all-day events (date) // The filter checks if either dateTime OR date is within range params.$filter = `(start/dateTime ge '${startDateTimeStr}' or start/date ge '${startDateStr}') and (start/dateTime le '${endDateTimeStr}' or start/date le '${endDateStr}')`; logger.debug('Microsoft Graph API filter', { filter: params.$filter, startDate: startDateStr, endDate: endDateStr, startDateTime: startDateTimeStr, endDateTime: endDateTimeStr, }); } else { // If no date filter, get all events (might be too many, but useful for debugging) logger.warn('No date filter provided, fetching all events (this might be slow)'); } logger.debug('Fetching Microsoft events with params', { email, calendarId, startDate: startDate?.toISOString(), endDate: endDate?.toISOString(), filter: params.$filter, }); // Get events from Microsoft Graph API const url = `https://graph.microsoft.com/v1.0/me/calendars/${calendarId}/events`; logger.debug('Fetching Microsoft events', { url, params: JSON.stringify(params), }); const response = await axios.get(url, { headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, params, }); const events = response.data.value || []; logger.info('Microsoft Graph API response', { calendarId, eventCount: events.length, hasValue: !!response.data.value, status: response.status, // Log first few event IDs to verify they're being returned eventIds: events.slice(0, 5).map(e => e.id), }); // Log if we got fewer events than expected if (events.length === 0 && startDate && endDate) { logger.warn('No events returned from Microsoft Graph API', { calendarId, dateRange: { start: startDate.toISOString(), end: endDate.toISOString(), }, filter: params.$filter, }); } return events; } 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 // Sync from 1 month ago to 6 months in the future to catch all events const startDate = new Date(); startDate.setMonth(startDate.getMonth() - 1); startDate.setHours(0, 0, 0, 0); // Start of day const endDate = new Date(); endDate.setMonth(endDate.getMonth() + 6); endDate.setHours(23, 59, 59, 999); // End of day logger.info('Starting Microsoft calendar sync', { calendarSyncId, calendarId: syncConfig.calendarId, email: creds.email, externalCalendarId: syncConfig.externalCalendarId, dateRange: { start: startDate.toISOString(), end: endDate.toISOString() }, }); const microsoftEvents = await fetchMicrosoftEvents( syncConfig.calendar.userId, creds.email, syncConfig.externalCalendarId || '', startDate, endDate ); // Log all events, not just first 10 logger.info('Fetched Microsoft events', { calendarSyncId, eventCount: microsoftEvents.length, dateRange: { start: startDate.toISOString(), end: endDate.toISOString() }, allEvents: microsoftEvents.map(e => ({ id: e.id, subject: e.subject, start: e.start.dateTime || e.start.date, isAllDay: e.isAllDay, end: e.end.dateTime || e.end.date })), }); if (microsoftEvents.length === 0) { logger.warn('No Microsoft events found in date range', { calendarSyncId, email: creds.email, externalCalendarId: syncConfig.externalCalendarId, dateRange: { start: startDate.toISOString(), end: endDate.toISOString() }, }); } else { // Log events in the future to help debug const now = new Date(); const futureEvents = microsoftEvents.filter(e => { const eventStart = e.start.dateTime || e.start.date; return new Date(eventStart) > now; }); logger.info('Microsoft events in the future', { calendarSyncId, futureEventCount: futureEvents.length, futureEvents: futureEvents.slice(0, 5).map(e => ({ id: e.id, subject: e.subject, start: e.start.dateTime || e.start.date, })), }); } // 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; logger.info('Syncing events to database', { calendarSyncId, existingEventsCount: existingEvents.length, newEventsCount: caldavEvents.length, }); // Sync events: create or update for (const caldavEvent of caldavEvents) { // Store Microsoft ID in description with a special prefix for matching const microsoftId = caldavEvent.uid; const descriptionWithId = caldavEvent.description ? `[MS_ID:${microsoftId}]\n${caldavEvent.description}` : `[MS_ID:${microsoftId}]`; // Try to find existing event by Microsoft ID first (most reliable) let existingEvent = existingEvents.find((e) => { if (e.description && e.description.includes(`[MS_ID:${microsoftId}]`)) { return true; } return false; }); // Fallback: try to find by matching title and start date (for events created before this fix) if (!existingEvent) { 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: descriptionWithId, 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++; logger.debug('Updated event', { eventId: existingEvent.id, title: caldavEvent.summary, microsoftId, }); } else { // Create new event const newEvent = await prisma.event.create({ data: eventData, }); created++; logger.debug('Created new event', { eventId: newEvent.id, title: caldavEvent.summary, microsoftId, start: caldavEvent.start.toISOString(), }); } } // Update sync timestamp await prisma.calendarSync.update({ where: { id: calendarSyncId }, data: { lastSyncAt: new Date(), lastSyncError: null, }, }); // Invalidate cache for this user's calendars so new events appear immediately try { const { invalidateCalendarCache } = await import('@/lib/redis'); await invalidateCalendarCache(syncConfig.calendar.userId); logger.info('Invalidated calendar cache after sync', { userId: syncConfig.calendar.userId, calendarId: syncConfig.calendarId, }); } catch (cacheError) { // Don't fail sync if cache invalidation fails logger.warn('Failed to invalidate calendar cache', { userId: syncConfig.calendar.userId, error: cacheError instanceof Error ? cacheError.message : String(cacheError), }); } // Verify events were actually saved to DB const eventsInDb = await prisma.event.count({ where: { calendarId: syncConfig.calendarId } }); logger.info('Microsoft calendar sync completed', { calendarSyncId, calendarId: syncConfig.calendarId, email: creds.email, synced: caldavEvents.length, created, updated, deleted, eventsInDb, }); // Log summary of created/updated events if (created > 0 || updated > 0) { logger.info('Microsoft calendar sync summary', { calendarSyncId, newEventsCreated: created, eventsUpdated: updated, totalEventsInCalendar: caldavEvents.length, eventsInDbAfterSync: eventsInDb, }); } // Log warning if events count doesn't match if (caldavEvents.length > 0 && eventsInDb === 0) { logger.error('WARNING: Events were fetched but none were saved to DB', { calendarSyncId, fetchedCount: caldavEvents.length, dbCount: eventsInDb, }); } 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; } }