367 lines
10 KiB
TypeScript
367 lines
10 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
|
|
if (startDate && endDate) {
|
|
// Microsoft Graph API expects ISO format for date filters
|
|
// For all-day events, we need to handle date-only format
|
|
const startFilter = startDate.toISOString();
|
|
const endFilter = endDate.toISOString();
|
|
params.$filter = `start/dateTime ge '${startFilter}' and start/dateTime le '${endFilter}'`;
|
|
}
|
|
|
|
// Get events from Microsoft Graph API
|
|
const response = await axios.get(
|
|
`https://graph.microsoft.com/v1.0/me/calendars/${calendarId}/events`,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
params,
|
|
}
|
|
);
|
|
|
|
return response.data.value || [];
|
|
} catch (error) {
|
|
logger.error('Error fetching Microsoft events', {
|
|
userId,
|
|
email,
|
|
calendarId,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert Microsoft event to CalDAV-like format
|
|
*/
|
|
export function convertMicrosoftEventToCalDAV(microsoftEvent: MicrosoftEvent): {
|
|
uid: string;
|
|
summary: string;
|
|
description?: string;
|
|
start: Date;
|
|
end: Date;
|
|
location?: string;
|
|
allDay: boolean;
|
|
} {
|
|
// Microsoft Graph API uses different formats for all-day vs timed events
|
|
// All-day events have dateTime in format "YYYY-MM-DD" without time
|
|
// Timed events have dateTime in ISO format with time
|
|
const isAllDay = microsoftEvent.isAllDay ||
|
|
(microsoftEvent.start.dateTime && !microsoftEvent.start.dateTime.includes('T'));
|
|
|
|
let startDate: Date;
|
|
let endDate: Date;
|
|
|
|
if (isAllDay) {
|
|
// For all-day events, parse date only (YYYY-MM-DD)
|
|
startDate = new Date(microsoftEvent.start.dateTime.split('T')[0]);
|
|
endDate = new Date(microsoftEvent.end.dateTime.split('T')[0]);
|
|
// Set to start of day
|
|
startDate.setHours(0, 0, 0, 0);
|
|
endDate.setHours(0, 0, 0, 0);
|
|
} else {
|
|
// For timed events, parse full ISO datetime
|
|
startDate = new Date(microsoftEvent.start.dateTime);
|
|
endDate = new Date(microsoftEvent.end.dateTime);
|
|
}
|
|
|
|
return {
|
|
uid: microsoftEvent.id,
|
|
summary: microsoftEvent.subject || 'Sans titre',
|
|
description: microsoftEvent.body?.content || undefined,
|
|
start: startDate,
|
|
end: endDate,
|
|
location: microsoftEvent.location?.displayName || undefined,
|
|
allDay: isAllDay,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Sync events from Microsoft calendar to local Prisma calendar
|
|
*/
|
|
export async function syncMicrosoftCalendar(
|
|
calendarSyncId: string,
|
|
forceSync: boolean = false
|
|
): Promise<{ synced: number; created: number; updated: number; deleted: number }> {
|
|
try {
|
|
const syncConfig = await prisma.calendarSync.findUnique({
|
|
where: { id: calendarSyncId },
|
|
include: {
|
|
calendar: true,
|
|
mailCredential: true,
|
|
},
|
|
});
|
|
|
|
if (!syncConfig || !syncConfig.syncEnabled) {
|
|
throw new Error('Calendar sync not enabled or not found');
|
|
}
|
|
|
|
if (!syncConfig.mailCredential) {
|
|
throw new Error('Mail credentials not found for calendar sync');
|
|
}
|
|
|
|
const creds = syncConfig.mailCredential;
|
|
|
|
// Check if sync is needed (based on syncFrequency)
|
|
if (!forceSync && syncConfig.lastSyncAt) {
|
|
const minutesSinceLastSync = (Date.now() - syncConfig.lastSyncAt.getTime()) / (1000 * 60);
|
|
if (minutesSinceLastSync < syncConfig.syncFrequency) {
|
|
logger.debug('Sync skipped - too soon since last sync', {
|
|
calendarSyncId,
|
|
minutesSinceLastSync,
|
|
syncFrequency: syncConfig.syncFrequency,
|
|
});
|
|
return { synced: 0, created: 0, updated: 0, deleted: 0 };
|
|
}
|
|
}
|
|
|
|
if (!creds.use_oauth || !creds.refresh_token) {
|
|
throw new Error('OAuth credentials required for Microsoft calendar sync');
|
|
}
|
|
|
|
// Fetch events from Microsoft Graph API
|
|
const startDate = new Date();
|
|
startDate.setMonth(startDate.getMonth() - 1); // Sync last month to next 3 months
|
|
const endDate = new Date();
|
|
endDate.setMonth(endDate.getMonth() + 3);
|
|
|
|
const microsoftEvents = await fetchMicrosoftEvents(
|
|
syncConfig.calendar.userId,
|
|
creds.email,
|
|
syncConfig.externalCalendarId || '',
|
|
startDate,
|
|
endDate
|
|
);
|
|
|
|
// Convert Microsoft events to CalDAV-like format
|
|
const caldavEvents = microsoftEvents.map(convertMicrosoftEventToCalDAV);
|
|
|
|
// Get existing events in local calendar
|
|
const existingEvents = await prisma.event.findMany({
|
|
where: {
|
|
calendarId: syncConfig.calendarId,
|
|
},
|
|
});
|
|
|
|
let created = 0;
|
|
let updated = 0;
|
|
let deleted = 0;
|
|
|
|
// Sync events: create or update
|
|
for (const caldavEvent of caldavEvents) {
|
|
// Try to find existing event by matching title and start date
|
|
const existingEvent = existingEvents.find(
|
|
(e) =>
|
|
e.title === caldavEvent.summary &&
|
|
Math.abs(new Date(e.start).getTime() - caldavEvent.start.getTime()) < 60000 // Within 1 minute
|
|
);
|
|
|
|
const eventData = {
|
|
title: caldavEvent.summary,
|
|
description: caldavEvent.description || null,
|
|
start: caldavEvent.start,
|
|
end: caldavEvent.end,
|
|
location: caldavEvent.location || null,
|
|
isAllDay: caldavEvent.allDay,
|
|
calendarId: syncConfig.calendarId,
|
|
userId: syncConfig.calendar.userId,
|
|
};
|
|
|
|
if (existingEvent) {
|
|
// Update existing event
|
|
await prisma.event.update({
|
|
where: { id: existingEvent.id },
|
|
data: eventData,
|
|
});
|
|
updated++;
|
|
} else {
|
|
// Create new event
|
|
await prisma.event.create({
|
|
data: eventData,
|
|
});
|
|
created++;
|
|
}
|
|
}
|
|
|
|
// Update sync timestamp
|
|
await prisma.calendarSync.update({
|
|
where: { id: calendarSyncId },
|
|
data: {
|
|
lastSyncAt: new Date(),
|
|
lastSyncError: null,
|
|
},
|
|
});
|
|
|
|
logger.info('Microsoft calendar sync completed', {
|
|
calendarSyncId,
|
|
calendarId: syncConfig.calendarId,
|
|
synced: caldavEvents.length,
|
|
created,
|
|
updated,
|
|
});
|
|
|
|
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;
|
|
}
|
|
}
|