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; // For timed events date?: string; // For all-day events (YYYY-MM-DD format) timeZone?: string; }; end: { dateTime?: string; // For timed events date?: string; // For all-day events (YYYY-MM-DD format) 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 always uses dateTime for filtering, even for all-day events // All-day events have dateTime set to midnight (00:00:00) in the event's timezone // We filter by dateTime range which will include both timed and all-day events 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 works for both timed events and all-day events (which have dateTime at midnight) 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 || []; // Log all event subjects to help debug missing events like "test" and "retest" const allSubjects = events.map((e: MicrosoftEvent) => e.subject || '(sans titre)'); const testEvents = events.filter((e: MicrosoftEvent) => e.subject && (e.subject.toLowerCase().includes('test') || e.subject.toLowerCase().includes('retest')) ); logger.info('Microsoft Graph API response', { calendarId, eventCount: events.length, hasValue: !!response.data.value, status: response.status, dateRange: { start: startDate?.toISOString(), end: endDate?.toISOString(), }, // Log first few event IDs and subjects to verify they're being returned eventIds: events.slice(0, 10).map((e: MicrosoftEvent) => ({ id: e.id, subject: e.subject || '(sans titre)', start: e.start.dateTime || e.start.date, isAllDay: e.isAllDay, })), // Log all event subjects to help debug missing events allSubjects, // Specifically check for test/retest events testEventsFound: testEvents.length, testEventDetails: testEvents.map((e: MicrosoftEvent) => ({ id: e.id, subject: e.subject, start: e.start.dateTime || e.start.date, isAllDay: e.isAllDay, })), }); // If test/retest events are not found, log a warning with suggestions if (testEvents.length === 0 && events.length > 0) { logger.warn('"test"/"retest" events not found in Microsoft response', { calendarId, totalEvents: events.length, dateRange: { start: startDate?.toISOString(), end: endDate?.toISOString(), }, suggestion: 'Events might be in a different calendar. Check all calendars in Outlook.', allEventSubjects: allSubjects, }); } // 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; } // If calendar not found (404), try using the default calendar instead if (error.response?.status === 404 && error.response?.data?.error?.code === 'ErrorItemNotFound') { logger.warn('Calendar not found, trying default calendar', { userId, email, oldCalendarId: calendarId, }); // Try using the default calendar endpoint try { const accessToken = await getMicrosoftGraphClient(userId, email); const defaultUrl = 'https://graph.microsoft.com/v1.0/me/calendar/events'; const params: any = { $select: 'id,subject,body,start,end,location,isAllDay', $orderby: 'start/dateTime asc', $top: 1000, }; if (startDate && endDate) { const startDateTimeStr = startDate.toISOString(); const endDateTimeStr = endDate.toISOString(); params.$filter = `start/dateTime ge '${startDateTimeStr}' and start/dateTime le '${endDateTimeStr}'`; } logger.info('Fetching from default calendar', { url: defaultUrl, params: JSON.stringify(params), }); const response = await axios.get(defaultUrl, { headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, params, }); const events = response.data.value || []; logger.info('Successfully fetched from default calendar', { eventCount: events.length, }); return events; } catch (fallbackError: any) { logger.error('Failed to fetch from default calendar', { userId, email, error: fallbackError instanceof Error ? fallbackError.message : String(fallbackError), }); // Continue to throw original error } } 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 'date' field (YYYY-MM-DD format) or isAllDay=true // Timed events have 'dateTime' field (ISO format with time) const isAllDay = microsoftEvent.isAllDay || !!microsoftEvent.start.date; let startDate: Date; let endDate: Date; if (isAllDay) { // For all-day events, use 'date' field or parse dateTime if date not available const startDateStr = microsoftEvent.start.date || microsoftEvent.start.dateTime?.split('T')[0] || ''; const endDateStr = microsoftEvent.end.date || microsoftEvent.end.dateTime?.split('T')[0] || ''; startDate = new Date(startDateStr); endDate = new Date(endDateStr); // 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 if (!microsoftEvent.start.dateTime) { throw new Error(`Timed event missing dateTime: ${JSON.stringify(microsoftEvent.start)}`); } if (!microsoftEvent.end.dateTime) { throw new Error(`Timed event missing end dateTime: ${JSON.stringify(microsoftEvent.end)}`); } 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, }; } /** * Update a Microsoft calendar event via Graph API */ export async function updateMicrosoftEvent( userId: string, email: string, calendarId: string, eventId: string, eventData: { subject?: string; body?: string; start?: { dateTime: string; timeZone: string }; end?: { dateTime: string; timeZone: string }; location?: { displayName: string }; isAllDay?: boolean; } ): Promise { try { const accessToken = await getMicrosoftGraphClient(userId, email); // Build the update payload const payload: any = {}; if (eventData.subject !== undefined) payload.subject = eventData.subject; if (eventData.body !== undefined) { payload.body = { contentType: 'HTML', content: eventData.body, }; } if (eventData.start) payload.start = eventData.start; if (eventData.end) payload.end = eventData.end; if (eventData.location) payload.location = eventData.location; if (eventData.isAllDay !== undefined) payload.isAllDay = eventData.isAllDay; const url = `https://graph.microsoft.com/v1.0/me/calendars/${calendarId}/events/${eventId}`; logger.info('Updating Microsoft event', { userId, email, calendarId, eventId, url, payload: JSON.stringify(payload), }); await axios.patch(url, payload, { headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, }); logger.info('Successfully updated Microsoft event', { userId, email, eventId, }); } catch (error: any) { // Check if it's a permissions error (403) - likely missing Calendars.ReadWrite scope const isPermissionError = error.response?.status === 403; const errorCode = error.response?.data?.error?.code; logger.error('Error updating Microsoft event', { userId, email, calendarId, eventId, error: error instanceof Error ? error.message : String(error), responseStatus: error.response?.status, responseData: error.response?.data, isPermissionError, errorCode, suggestion: isPermissionError ? 'Token likely missing Calendars.ReadWrite scope. User needs to re-authenticate the Microsoft account in Courrier page to get new permissions.' : undefined, }); throw error; } } /** * Delete a Microsoft calendar event via Graph API */ export async function deleteMicrosoftEvent( userId: string, email: string, calendarId: string, eventId: string ): Promise { try { const accessToken = await getMicrosoftGraphClient(userId, email); const url = `https://graph.microsoft.com/v1.0/me/calendars/${calendarId}/events/${eventId}`; logger.info('Deleting Microsoft event', { userId, email, calendarId, eventId, url, }); await axios.delete(url, { headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, }); logger.info('Successfully deleted Microsoft event', { userId, email, eventId, }); } catch (error: any) { // Check if it's a permissions error (403) - likely missing Calendars.ReadWrite scope const isPermissionError = error.response?.status === 403; const errorCode = error.response?.data?.error?.code; logger.error('Error deleting Microsoft event', { userId, email, calendarId, eventId, error: error instanceof Error ? error.message : String(error), responseStatus: error.response?.status, responseData: error.response?.data, isPermissionError, errorCode, suggestion: isPermissionError ? 'Token likely missing Calendars.ReadWrite scope. User needs to re-authenticate the Microsoft account in Courrier page to get new permissions.' : undefined, }); throw error; } } /** * 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 1 year in the future to catch all events (including test events) const startDate = new Date(); startDate.setMonth(startDate.getMonth() - 1); startDate.setHours(0, 0, 0, 0); // Start of day const endDate = new Date(); endDate.setFullYear(endDate.getFullYear() + 1); // Extended to 1 year to catch all events 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() }, }); // Fetch events from Microsoft Graph API // If calendar ID is invalid (404), we'll try to discover and update it let microsoftEvents: MicrosoftEvent[]; let calendarIdToUse = syncConfig.externalCalendarId || ''; try { microsoftEvents = await fetchMicrosoftEvents( syncConfig.calendar.userId, creds.email, calendarIdToUse, startDate, endDate ); } catch (error: any) { // If calendar not found (404), try to discover available calendars and update if (error.response?.status === 404 && error.response?.data?.error?.code === 'ErrorItemNotFound') { logger.warn('Calendar ID not found, discovering available calendars', { calendarSyncId, oldCalendarId: calendarIdToUse, email: creds.email, }); // Discover available calendars const availableCalendars = await discoverMicrosoftCalendars( syncConfig.calendar.userId, creds.email ); if (availableCalendars.length > 0) { // Use the first calendar (usually the default "Calendar") const newCalendar = availableCalendars[0]; calendarIdToUse = newCalendar.id; logger.info('Updating calendar sync with new calendar ID', { calendarSyncId, oldCalendarId: syncConfig.externalCalendarId, newCalendarId: calendarIdToUse, newCalendarName: newCalendar.name, }); // Update the sync config with the new calendar ID await prisma.calendarSync.update({ where: { id: calendarSyncId }, data: { externalCalendarId: calendarIdToUse, lastSyncError: null, // Clear previous error }, }); // Retry fetching events with the new calendar ID microsoftEvents = await fetchMicrosoftEvents( syncConfig.calendar.userId, creds.email, calendarIdToUse, startDate, endDate ); } else { // No calendars found, try using default calendar endpoint logger.warn('No calendars discovered, using default calendar endpoint', { calendarSyncId, email: creds.email, }); // Use default calendar by fetching without a specific calendar ID const accessToken = await getMicrosoftGraphClient(syncConfig.calendar.userId, creds.email); const defaultUrl = 'https://graph.microsoft.com/v1.0/me/calendar/events'; const params: any = { $select: 'id,subject,body,start,end,location,isAllDay', $orderby: 'start/dateTime asc', $top: 1000, }; const startDateTimeStr = startDate.toISOString(); const endDateTimeStr = endDate.toISOString(); params.$filter = `start/dateTime ge '${startDateTimeStr}' and start/dateTime le '${endDateTimeStr}'`; const response = await axios.get(defaultUrl, { headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, params, }); microsoftEvents = response.data.value || []; // Try to get the default calendar ID for future use try { const calendarResponse = await axios.get('https://graph.microsoft.com/v1.0/me/calendar', { headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, }); const defaultCalendarId = calendarResponse.data.id; if (defaultCalendarId) { await prisma.calendarSync.update({ where: { id: calendarSyncId }, data: { externalCalendarId: defaultCalendarId, lastSyncError: null, }, }); logger.info('Updated sync config with default calendar ID', { calendarSyncId, defaultCalendarId, }); } } catch (calendarIdError) { logger.warn('Could not fetch default calendar ID', { error: calendarIdError instanceof Error ? calendarIdError.message : String(calendarIdError), }); } } } else { // Re-throw other errors throw error; } } // 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: MicrosoftEvent) => ({ 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" or "retest" events are in the list const testEvents = microsoftEvents.filter((e: MicrosoftEvent) => e.subject && (e.subject.toLowerCase().includes('test') || e.subject.toLowerCase().includes('retest')) ); if (testEvents.length > 0) { logger.info('Found "test"/"retest" events in Microsoft response', { calendarSyncId, count: testEvents.length, events: testEvents.map((e: MicrosoftEvent) => ({ id: e.id, subject: e.subject, start: e.start.dateTime || e.start.date, isAllDay: e.isAllDay, })) }); } else { logger.warn('"test"/"retest" events NOT found in Microsoft response', { calendarSyncId, totalEvents: microsoftEvents.length, dateRange: { start: startDate.toISOString(), end: endDate.toISOString() }, eventSubjects: microsoftEvents.map((e: MicrosoftEvent) => e.subject || '(sans titre)').slice(0, 10), // First 10 for debugging }); } // If no events found, try to discover which calendar actually has events if (microsoftEvents.length === 0) { logger.warn('No Microsoft events found in specified calendar, discovering available calendars', { calendarSyncId, email: creds.email, externalCalendarId: calendarIdToUse, dateRange: { start: startDate.toISOString(), end: endDate.toISOString() }, }); // Discover all available calendars const availableCalendars = await discoverMicrosoftCalendars( syncConfig.calendar.userId, creds.email ); logger.info('Discovered Microsoft calendars', { calendarSyncId, calendarsCount: availableCalendars.length, calendarNames: availableCalendars.map(cal => cal.name), }); // Try to find a calendar with events let calendarWithEvents = null; for (const cal of availableCalendars) { try { const testEvents = await fetchMicrosoftEvents( syncConfig.calendar.userId, creds.email, cal.id, startDate, endDate ); if (testEvents.length > 0) { calendarWithEvents = { calendar: cal, events: testEvents }; logger.info('Found calendar with events', { calendarSyncId, calendarId: cal.id, calendarName: cal.name, eventCount: testEvents.length, }); break; } } catch (error) { logger.debug('Error checking calendar for events', { calendarId: cal.id, calendarName: cal.name, error: error instanceof Error ? error.message : String(error), }); // Continue to next calendar } } // If we found a calendar with events, update the sync config if (calendarWithEvents) { const newCalendarId = calendarWithEvents.calendar.id; logger.info('Updating sync config to use calendar with events', { calendarSyncId, oldCalendarId: calendarIdToUse, newCalendarId: newCalendarId, newCalendarName: calendarWithEvents.calendar.name, eventCount: calendarWithEvents.events.length, }); await prisma.calendarSync.update({ where: { id: calendarSyncId }, data: { externalCalendarId: newCalendarId, lastSyncError: null, }, }); // Use the events from the calendar we found microsoftEvents = calendarWithEvents.events; } else { logger.warn('No Microsoft events found in any calendar', { calendarSyncId, email: creds.email, calendarsChecked: availableCalendars.length, 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: MicrosoftEvent) => { const eventStart = e.start.dateTime || e.start.date; if (!eventStart) return false; return new Date(eventStart) > now; }); logger.info('Microsoft events in the future', { calendarSyncId, futureEventCount: futureEvents.length, totalEvents: microsoftEvents.length, futureEvents: futureEvents.map((e: MicrosoftEvent) => ({ 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: MicrosoftEvent) => { const eventStart = e.start.dateTime || e.start.date; if (!eventStart) return false; return new Date(eventStart) <= now; }); if (pastEvents.length > 0) { logger.info('Microsoft events in the past', { calendarSyncId, pastEventCount: pastEvents.length, pastEvents: pastEvents.map((e: MicrosoftEvent) => ({ 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; if (existingEvent) { logger.debug('Matched event by externalEventId', { microsoftId, eventId: existingEvent.id, title: caldavEvent.summary, }); } // Priority 2: Fallback to checking description for [MS_ID:xxx] (backward compatibility) if (!existingEvent && microsoftId) { existingEvent = existingEvents.find((e: typeof existingEvents[0]) => { // 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; }); if (existingEvent) { logger.debug('Matched event by description [MS_ID]', { microsoftId, eventId: existingEvent.id, title: caldavEvent.summary, }); } } // Priority 3: Fallback to title + date matching for events without externalEventId // IMPORTANT: Only match if the event doesn't have an externalEventId (to avoid false matches) // This helps migrate old events that were created before externalEventId was added if (!existingEvent && microsoftId) { existingEvent = existingEvents.find( (e: typeof existingEvents[0]) => { // Access externalEventId safely (may not be in Prisma type if client not regenerated) const hasExternalId = !!(e as any).externalEventId; // Only match events that don't have externalEventId yet (to avoid false matches) if (hasExternalId) { return false; // Skip events that already have externalEventId } // Match by title and date (within 1 minute) if (e.title === caldavEvent.summary) { const timeDiff = Math.abs(new Date(e.start).getTime() - caldavEvent.start.getTime()); if (timeDiff < 60000) { // Within 1 minute logger.debug('Matched event by title + date (no externalEventId) - will update with externalEventId', { eventId: e.id, title: caldavEvent.summary, timeDiff, microsoftId, }); return true; } } return false; } ); } // Log if no match found (new event) if (!existingEvent) { logger.debug('No match found, will create new event', { microsoftId, title: caldavEvent.summary, start: caldavEvent.start.toISOString(), }); } // 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; } } } } // Delete events that are no longer in Microsoft calendar // Build a set of all Microsoft event IDs we just synced const syncedMicrosoftIds = new Set(); for (const caldavEvent of caldavEvents) { if (caldavEvent.uid) { syncedMicrosoftIds.add(caldavEvent.uid); } } // Re-fetch existing events to get any that were just created/updated const allExistingEvents = await prisma.event.findMany({ where: { calendarId: syncConfig.calendarId, }, }); // Find events in DB that have externalEventId but are not in the synced list // Only delete events that have an externalEventId (to avoid deleting manually created events) // This ensures events deleted in Microsoft are also deleted locally for (const existingEvent of allExistingEvents) { const externalId = (existingEvent as any).externalEventId; if (externalId && !syncedMicrosoftIds.has(externalId)) { // This event exists in DB but not in Microsoft - it was deleted in Microsoft logger.info('Deleting event that no longer exists in Microsoft', { eventId: existingEvent.id, title: existingEvent.title, externalEventId: externalId, calendarId: syncConfig.calendarId, }); try { await prisma.event.delete({ where: { id: existingEvent.id }, }); deleted++; logger.debug('Successfully deleted event', { eventId: existingEvent.id, title: existingEvent.title, }); } catch (deleteError) { logger.error('Error deleting event', { eventId: existingEvent.id, error: deleteError instanceof Error ? deleteError.message : String(deleteError), }); // Continue with other deletions } } } if (deleted > 0) { logger.info('Deleted events that no longer exist in Microsoft', { calendarSyncId, deletedCount: deleted, }); } // 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; } }