1228 lines
42 KiB
TypeScript
1228 lines
42 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; // 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<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 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<void> {
|
|
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<void> {
|
|
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<string, typeof existingEvents[0]>();
|
|
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<string>();
|
|
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;
|
|
}
|
|
}
|