NeahStable/lib/services/microsoft-calendar-sync.ts
2026-01-15 12:11:26 +01:00

625 lines
20 KiB
TypeScript

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<string> {
// 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<MicrosoftCalendar[]> {
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<MicrosoftEvent[]> {
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
const existingEventsByExternalId = new Map<string, typeof existingEvents[0]>();
for (const event of existingEvents) {
if (event.externalEventId) {
existingEventsByExternalId.set(event.externalEventId, 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) => {
if (!e.externalEventId && 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) =>
!e.externalEventId && // Only match events that don't have externalEventId yet
e.title === caldavEvent.summary &&
Math.abs(new Date(e.start).getTime() - caldavEvent.start.getTime()) < 60000 // Within 1 minute
);
}
// Clean description (remove [MS_ID:xxx] prefix if present from previous syncs)
const cleanedDescription = cleanDescription(caldavEvent.description);
const eventData = {
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,
calendarId: syncConfig.calendarId,
userId: syncConfig.calendar.userId,
externalEventId: microsoftId, // Store Microsoft ID for reliable matching
};
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;
}
}