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 is limited // We can't easily filter both dateTime and date in one query // So we'll filter by dateTime and handle all-day events separately if needed if (startDate && endDate) { // Format dates for Microsoft Graph API // Use ISO 8601 format for dateTime filter const startDateTimeStr = startDate.toISOString(); const endDateTimeStr = endDate.toISOString(); // Microsoft Graph API filter: filter by start/dateTime // This will match timed events. All-day events might need separate handling // but Microsoft Graph usually returns all-day events with dateTime set to start of day params.$filter = `start/dateTime ge '${startDateTimeStr}' and start/dateTime le '${endDateTimeStr}'`; logger.debug('Microsoft Graph API filter', { filter: params.$filter, 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: any) { // Log detailed error information for debugging const errorDetails: any = { userId, email, calendarId, error: error instanceof Error ? error.message : String(error), }; if (error.response) { errorDetails.status = error.response.status; errorDetails.statusText = error.response.statusText; errorDetails.data = error.response.data; errorDetails.url = error.config?.url; errorDetails.params = error.config?.params; } logger.error('Error fetching Microsoft events', errorDetails); 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 with full details 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 || '(sans titre)', start: e.start.dateTime || e.start.date, isAllDay: e.isAllDay, end: e.end.dateTime || e.end.date, // Log full start/end objects to debug startObj: e.start, endObj: e.end })), }); // Check if "Test" event is in the list const testEvent = microsoftEvents.find(e => e.subject && e.subject.toLowerCase().includes('test') ); if (testEvent) { logger.info('Found "Test" event in Microsoft response', { calendarSyncId, testEvent: { id: testEvent.id, subject: testEvent.subject, start: testEvent.start.dateTime || testEvent.start.date, isAllDay: testEvent.isAllDay, } }); } else { logger.warn('"Test" event NOT found in Microsoft response', { calendarSyncId, totalEvents: microsoftEvents.length, eventSubjects: microsoftEvents.map(e => e.subject || '(sans titre)'), }); } 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, totalEvents: microsoftEvents.length, futureEvents: futureEvents.map(e => ({ id: e.id, subject: e.subject || '(sans titre)', start: e.start.dateTime || e.start.date, isAllDay: e.isAllDay, })), }); // Also log events in the past to see all events const pastEvents = microsoftEvents.filter(e => { const eventStart = e.start.dateTime || e.start.date; return new Date(eventStart) <= now; }); if (pastEvents.length > 0) { logger.info('Microsoft events in the past', { calendarSyncId, pastEventCount: pastEvents.length, pastEvents: pastEvents.map(e => ({ id: e.id, subject: e.subject || '(sans titre)', 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, }, }); // Create a map of existing events by externalEventId (Microsoft ID) for fast lookup // Use type assertion to handle case where Prisma client doesn't recognize externalEventId yet const existingEventsByExternalId = new Map(); for (const event of existingEvents) { // Access externalEventId safely (may not be in Prisma type if client not regenerated) const externalId = (event as any).externalEventId; if (externalId) { existingEventsByExternalId.set(externalId, event); } } let created = 0; let updated = 0; let deleted = 0; logger.info('Syncing events to database', { calendarSyncId, existingEventsCount: existingEvents.length, newEventsCount: caldavEvents.length, }); // Helper function to clean description (remove [MS_ID:xxx] prefix if present) const cleanDescription = (description: string | null | undefined): string | null => { if (!description) return null; // Remove [MS_ID:xxx] prefix if present const cleaned = description.replace(/^\[MS_ID:[^\]]+\]\n?/, ''); return cleaned.trim() || null; }; // Sync events: create or update for (const caldavEvent of caldavEvents) { const microsoftId = caldavEvent.uid; // Priority 1: Match by externalEventId (Microsoft ID) - most reliable let existingEvent = microsoftId ? existingEventsByExternalId.get(microsoftId) : undefined; // Priority 2: Fallback to checking description for [MS_ID:xxx] (backward compatibility) if (!existingEvent && microsoftId) { existingEvent = existingEvents.find((e) => { // Access externalEventId safely (may not be in Prisma type if client not regenerated) const hasExternalId = !!(e as any).externalEventId; if (!hasExternalId && e.description && e.description.includes(`[MS_ID:${microsoftId}]`)) { return true; } return false; }); } // Priority 3: Fallback to title + date matching for events without externalEventId if (!existingEvent) { existingEvent = existingEvents.find( (e) => { // Access externalEventId safely (may not be in Prisma type if client not regenerated) const hasExternalId = !!(e as any).externalEventId; if (!hasExternalId && // Only match events that don't have externalEventId yet e.title === caldavEvent.summary) { const timeDiff = Math.abs(new Date(e.start).getTime() - caldavEvent.start.getTime()); return timeDiff < 60000; // Within 1 minute } return false; } ); } // Clean description (remove [MS_ID:xxx] prefix if present from previous syncs) const cleanedDescription = cleanDescription(caldavEvent.description); // For updates, we cannot modify calendarId and userId (they are relations) // For creates, we need them // Build event data dynamically to handle case where externalEventId field doesn't exist yet const baseEventData: any = { title: caldavEvent.summary, description: cleanedDescription, // Clean description without [MS_ID:xxx] prefix start: caldavEvent.start, end: caldavEvent.end, location: caldavEvent.location || null, isAllDay: caldavEvent.allDay, }; // Only add externalEventId if migration has been applied // We'll try to add it, and if it fails, we'll retry without it if (microsoftId) { baseEventData.externalEventId = microsoftId; } if (existingEvent) { // Update existing event (without calendarId and userId - they are relations) try { await prisma.event.update({ where: { id: existingEvent.id }, data: baseEventData, }); updated++; logger.debug('Updated event', { eventId: existingEvent.id, title: caldavEvent.summary, microsoftId, }); } catch (updateError: any) { // If externalEventId field doesn't exist in Prisma client (even though it exists in DB), // retry without it. This can happen if Prisma client wasn't regenerated after migration. const errorMessage = updateError?.message || ''; const errorCode = updateError?.code || ''; if (errorMessage.includes('externalEventId') || errorMessage.includes('Unknown argument') || errorCode === 'P2009' || errorCode === 'P1012') { logger.warn('externalEventId field not recognized by Prisma client, updating without it', { eventId: existingEvent.id, error: errorMessage.substring(0, 200), }); const { externalEventId, ...dataWithoutExternalId } = baseEventData; await prisma.event.update({ where: { id: existingEvent.id }, data: dataWithoutExternalId, }); updated++; } else { throw updateError; } } } else { // Create new event (with calendarId and userId) try { const newEvent = await prisma.event.create({ data: { ...baseEventData, calendarId: syncConfig.calendarId, userId: syncConfig.calendar.userId, }, }); created++; logger.debug('Created new event', { eventId: newEvent.id, title: caldavEvent.summary, microsoftId, start: caldavEvent.start.toISOString(), }); } catch (createError: any) { // If externalEventId field doesn't exist in Prisma client (even though it exists in DB), // retry without it. This can happen if Prisma client wasn't regenerated after migration. const errorMessage = createError?.message || ''; const errorCode = createError?.code || ''; if (errorMessage.includes('externalEventId') || errorMessage.includes('Unknown argument') || errorCode === 'P2009' || errorCode === 'P1012') { logger.warn('externalEventId field not recognized by Prisma client, creating without it', { error: errorMessage.substring(0, 200), }); const { externalEventId, ...dataWithoutExternalId } = baseEventData; const newEvent = await prisma.event.create({ data: { ...dataWithoutExternalId, calendarId: syncConfig.calendarId, userId: syncConfig.calendar.userId, }, }); created++; } else { throw createError; } } } } // 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; } }