Agenda Sync refactor
This commit is contained in:
parent
51e37af4c8
commit
50cdca1ac2
@ -90,7 +90,7 @@ export default async function CalendarPage() {
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-setup sync for Infomaniak accounts from courrier
|
||||
// Auto-setup sync for email accounts from courrier (Infomaniak and Microsoft)
|
||||
// Get all Infomaniak email accounts
|
||||
const infomaniakAccounts = await prisma.mailCredentials.findMany({
|
||||
where: {
|
||||
@ -110,6 +110,27 @@ export default async function CalendarPage() {
|
||||
}
|
||||
});
|
||||
|
||||
// Get all Microsoft email accounts (OAuth)
|
||||
const microsoftAccounts = await prisma.mailCredentials.findMany({
|
||||
where: {
|
||||
userId: session?.user?.id || '',
|
||||
host: {
|
||||
contains: 'outlook.office365.com'
|
||||
},
|
||||
use_oauth: true,
|
||||
refresh_token: {
|
||||
not: null
|
||||
}
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
display_name: true,
|
||||
refresh_token: true,
|
||||
use_oauth: true
|
||||
}
|
||||
});
|
||||
|
||||
// For each Infomaniak account, ensure there's a synced calendar
|
||||
for (const account of infomaniakAccounts) {
|
||||
// Check if a calendar sync already exists for this account
|
||||
@ -178,12 +199,88 @@ export default async function CalendarPage() {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error auto-setting up sync for account ${account.email}:`, error);
|
||||
console.error(`Error auto-setting up sync for Infomaniak account ${account.email}:`, error);
|
||||
// Continue with other accounts even if one fails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For each Microsoft account, ensure there's a synced calendar
|
||||
for (const account of microsoftAccounts) {
|
||||
// Check if a calendar sync already exists for this account
|
||||
const existingSync = await prisma.calendarSync.findFirst({
|
||||
where: {
|
||||
mailCredentialId: account.id,
|
||||
syncEnabled: true
|
||||
},
|
||||
include: {
|
||||
calendar: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!existingSync) {
|
||||
// Try to discover calendars for this account
|
||||
try {
|
||||
const { discoverMicrosoftCalendars } = await import('@/lib/services/microsoft-calendar-sync');
|
||||
const externalCalendars = await discoverMicrosoftCalendars(
|
||||
session?.user?.id || '',
|
||||
account.email
|
||||
);
|
||||
|
||||
if (externalCalendars.length > 0) {
|
||||
// Use the first calendar (usually the main calendar)
|
||||
const mainCalendar = externalCalendars[0];
|
||||
|
||||
// Create a private calendar for this account
|
||||
const calendar = await prisma.calendar.create({
|
||||
data: {
|
||||
name: "Privée",
|
||||
color: "#0078D4", // Microsoft blue
|
||||
description: `Calendrier synchronisé avec ${account.display_name || account.email}`,
|
||||
userId: session?.user?.id || '',
|
||||
}
|
||||
});
|
||||
|
||||
// Create sync configuration
|
||||
await prisma.calendarSync.create({
|
||||
data: {
|
||||
calendarId: calendar.id,
|
||||
mailCredentialId: account.id,
|
||||
provider: 'microsoft',
|
||||
externalCalendarId: mainCalendar.id,
|
||||
externalCalendarUrl: mainCalendar.webLink || mainCalendar.id,
|
||||
syncEnabled: true,
|
||||
syncFrequency: 15
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger initial sync
|
||||
try {
|
||||
const { syncMicrosoftCalendar } = await import('@/lib/services/microsoft-calendar-sync');
|
||||
const syncConfig = await prisma.calendarSync.findUnique({
|
||||
where: { calendarId: calendar.id },
|
||||
include: {
|
||||
calendar: true,
|
||||
mailCredential: true
|
||||
}
|
||||
});
|
||||
if (syncConfig) {
|
||||
await syncMicrosoftCalendar(syncConfig.id, true);
|
||||
}
|
||||
} catch (syncError) {
|
||||
console.error('Error during initial Microsoft sync:', syncError);
|
||||
// Don't fail if sync fails, calendar is still created
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error auto-setting up sync for Microsoft account ${account.email}:`, error);
|
||||
// Don't fail the page if Microsoft sync setup fails
|
||||
// The account might not have the calendar scope yet, or there might be a token issue
|
||||
// User can manually set up sync later if needed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh calendars after auto-setup
|
||||
// Exclude "Privée" and "Default" calendars that are not synced
|
||||
calendars = await prisma.calendar.findMany({
|
||||
|
||||
83
app/api/calendars/sync/discover-microsoft/route.ts
Normal file
83
app/api/calendars/sync/discover-microsoft/route.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/app/api/auth/options";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { discoverMicrosoftCalendars } from "@/lib/services/microsoft-calendar-sync";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
/**
|
||||
* Discover calendars for a Microsoft account
|
||||
* POST /api/calendars/sync/discover-microsoft
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { mailCredentialId } = await req.json();
|
||||
|
||||
if (!mailCredentialId) {
|
||||
return NextResponse.json(
|
||||
{ error: "mailCredentialId est requis" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get mail credentials
|
||||
const mailCreds = await prisma.mailCredentials.findFirst({
|
||||
where: {
|
||||
id: mailCredentialId,
|
||||
userId: session.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!mailCreds) {
|
||||
return NextResponse.json(
|
||||
{ error: "Credentials non trouvés" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if it's a Microsoft account
|
||||
if (!mailCreds.host.includes('outlook.office365.com') || !mailCreds.use_oauth) {
|
||||
return NextResponse.json(
|
||||
{ error: "Ce compte n'est pas un compte Microsoft OAuth" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!mailCreds.refresh_token) {
|
||||
return NextResponse.json(
|
||||
{ error: "Refresh token requis pour la synchronisation Microsoft" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Discover calendars
|
||||
const calendars = await discoverMicrosoftCalendars(
|
||||
session.user.id,
|
||||
mailCreds.email
|
||||
);
|
||||
|
||||
logger.info('Microsoft calendars discovered', {
|
||||
userId: session.user.id,
|
||||
email: mailCreds.email,
|
||||
calendarsCount: calendars.length,
|
||||
});
|
||||
|
||||
return NextResponse.json({ calendars });
|
||||
} catch (error) {
|
||||
logger.error('Error discovering Microsoft calendars', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Erreur lors de la découverte des calendriers",
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@ import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/app/api/auth/options";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { syncInfomaniakCalendar } from "@/lib/services/caldav-sync";
|
||||
import { syncMicrosoftCalendar } from "@/lib/services/microsoft-calendar-sync";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
/**
|
||||
@ -16,7 +17,7 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { calendarId, mailCredentialId, externalCalendarUrl, externalCalendarId, syncFrequency } = await req.json();
|
||||
const { calendarId, mailCredentialId, externalCalendarUrl, externalCalendarId, syncFrequency, provider } = await req.json();
|
||||
|
||||
if (!calendarId || !mailCredentialId || !externalCalendarUrl) {
|
||||
return NextResponse.json(
|
||||
@ -25,6 +26,24 @@ export async function POST(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Determine provider if not provided
|
||||
let detectedProvider = provider;
|
||||
if (!detectedProvider) {
|
||||
const mailCreds = await prisma.mailCredentials.findUnique({
|
||||
where: { id: mailCredentialId },
|
||||
});
|
||||
if (mailCreds?.host.includes('infomaniak')) {
|
||||
detectedProvider = 'infomaniak';
|
||||
} else if (mailCreds?.host.includes('outlook.office365.com')) {
|
||||
detectedProvider = 'microsoft';
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: "Provider non supporté ou non détecté" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify calendar belongs to user
|
||||
const calendar = await prisma.calendar.findFirst({
|
||||
where: {
|
||||
@ -61,7 +80,7 @@ export async function POST(req: NextRequest) {
|
||||
create: {
|
||||
calendarId,
|
||||
mailCredentialId,
|
||||
provider: 'infomaniak',
|
||||
provider: detectedProvider,
|
||||
externalCalendarId: externalCalendarId || null,
|
||||
externalCalendarUrl,
|
||||
syncEnabled: true,
|
||||
@ -69,6 +88,7 @@ export async function POST(req: NextRequest) {
|
||||
},
|
||||
update: {
|
||||
mailCredentialId,
|
||||
provider: detectedProvider,
|
||||
externalCalendarId: externalCalendarId || null,
|
||||
externalCalendarUrl,
|
||||
syncFrequency: syncFrequency || 15,
|
||||
@ -76,12 +96,19 @@ export async function POST(req: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
// Trigger initial sync
|
||||
// Trigger initial sync based on provider
|
||||
try {
|
||||
await syncInfomaniakCalendar(syncConfig.id, true);
|
||||
if (detectedProvider === 'infomaniak') {
|
||||
const { syncInfomaniakCalendar } = await import('@/lib/services/caldav-sync');
|
||||
await syncInfomaniakCalendar(syncConfig.id, true);
|
||||
} else if (detectedProvider === 'microsoft') {
|
||||
const { syncMicrosoftCalendar } = await import('@/lib/services/microsoft-calendar-sync');
|
||||
await syncMicrosoftCalendar(syncConfig.id, true);
|
||||
}
|
||||
} catch (syncError) {
|
||||
logger.error('Error during initial sync', {
|
||||
syncConfigId: syncConfig.id,
|
||||
provider: detectedProvider,
|
||||
error: syncError instanceof Error ? syncError.message : String(syncError),
|
||||
});
|
||||
// Don't fail the request if sync fails, just log it
|
||||
@ -137,8 +164,18 @@ export async function PUT(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Trigger sync
|
||||
const result = await syncInfomaniakCalendar(calendarSyncId, true);
|
||||
// Trigger sync based on provider
|
||||
let result;
|
||||
if (syncConfig.provider === 'infomaniak') {
|
||||
result = await syncInfomaniakCalendar(calendarSyncId, true);
|
||||
} else if (syncConfig.provider === 'microsoft') {
|
||||
result = await syncMicrosoftCalendar(calendarSyncId, true);
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: "Provider non supporté" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, result });
|
||||
} catch (error) {
|
||||
|
||||
@ -116,7 +116,7 @@ interface CalendarDialogProps {
|
||||
onClose: () => void;
|
||||
onSave: (calendarData: Partial<Calendar>) => Promise<void>;
|
||||
onDelete?: (calendarId: string) => Promise<void>;
|
||||
onSyncSetup?: (calendarId: string, mailCredentialId: string, externalCalendarUrl: string) => Promise<void>;
|
||||
onSyncSetup?: (calendarId: string, mailCredentialId: string, externalCalendarUrl: string, externalCalendarId?: string, provider?: string) => Promise<void>;
|
||||
initialData?: Partial<Calendar>;
|
||||
syncConfig?: {
|
||||
id: string;
|
||||
@ -175,14 +175,16 @@ function CalendarDialog({ open, onClose, onSave, onDelete, onSyncSetup, initialD
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success && data.accounts) {
|
||||
// Filter Infomaniak accounts only
|
||||
const infomaniakAccounts = data.accounts.filter((acc: any) =>
|
||||
acc.host && acc.host.includes('infomaniak')
|
||||
// Filter Infomaniak and Microsoft accounts
|
||||
const syncableAccounts = data.accounts.filter((acc: any) =>
|
||||
(acc.host && acc.host.includes('infomaniak')) ||
|
||||
(acc.host && acc.host.includes('outlook.office365.com') && acc.use_oauth)
|
||||
);
|
||||
setAvailableAccounts(infomaniakAccounts.map((acc: any) => ({
|
||||
setAvailableAccounts(syncableAccounts.map((acc: any) => ({
|
||||
id: acc.id,
|
||||
email: acc.email,
|
||||
display_name: acc.display_name
|
||||
display_name: acc.display_name,
|
||||
host: acc.host
|
||||
})));
|
||||
}
|
||||
}
|
||||
@ -196,7 +198,15 @@ function CalendarDialog({ open, onClose, onSave, onDelete, onSyncSetup, initialD
|
||||
|
||||
setIsDiscovering(true);
|
||||
try {
|
||||
const response = await fetch("/api/calendars/sync/discover", {
|
||||
// Determine which endpoint to use based on account type
|
||||
const selectedAccount = availableAccounts.find(acc => acc.id === selectedAccountId);
|
||||
const isMicrosoft = selectedAccount?.host?.includes('outlook.office365.com');
|
||||
|
||||
const endpoint = isMicrosoft
|
||||
? "/api/calendars/sync/discover-microsoft"
|
||||
: "/api/calendars/sync/discover";
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mailCredentialId: selectedAccountId }),
|
||||
@ -204,7 +214,13 @@ function CalendarDialog({ open, onClose, onSave, onDelete, onSyncSetup, initialD
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setAvailableCalendars(data.calendars || []);
|
||||
// Normalize calendar format (Infomaniak uses 'url', Microsoft uses 'id' and 'webLink')
|
||||
const normalizedCalendars = (data.calendars || []).map((cal: any) => ({
|
||||
id: cal.id,
|
||||
name: cal.name,
|
||||
url: cal.url || cal.webLink || cal.id, // Use url, webLink, or id as fallback
|
||||
}));
|
||||
setAvailableCalendars(normalizedCalendars);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || "Erreur lors de la découverte des calendriers");
|
||||
@ -222,7 +238,17 @@ function CalendarDialog({ open, onClose, onSave, onDelete, onSyncSetup, initialD
|
||||
|
||||
setIsSettingUpSync(true);
|
||||
try {
|
||||
await onSyncSetup(initialData.id, selectedAccountId, selectedCalendarUrl);
|
||||
// Determine provider based on selected account
|
||||
const selectedAccount = availableAccounts.find(acc => acc.id === selectedAccountId);
|
||||
const isMicrosoft = selectedAccount?.host?.includes('outlook.office365.com');
|
||||
const provider = isMicrosoft ? 'microsoft' : 'infomaniak';
|
||||
|
||||
// For Microsoft, use calendar ID instead of URL
|
||||
const externalCalendarId = isMicrosoft
|
||||
? availableCalendars.find(cal => cal.url === selectedCalendarUrl)?.id || selectedCalendarUrl
|
||||
: null;
|
||||
|
||||
await onSyncSetup(initialData.id, selectedAccountId, selectedCalendarUrl, externalCalendarId, provider);
|
||||
setShowSyncSection(false);
|
||||
alert("Synchronisation configurée avec succès !");
|
||||
} catch (error) {
|
||||
@ -1550,7 +1576,7 @@ export function CalendarClient({ initialCalendars, userId, userProfile }: Calend
|
||||
onClose={() => setIsCalendarModalOpen(false)}
|
||||
onSave={handleCalendarSave}
|
||||
onDelete={handleCalendarDelete}
|
||||
onSyncSetup={async (calendarId, mailCredentialId, externalCalendarUrl) => {
|
||||
onSyncSetup={async (calendarId, mailCredentialId, externalCalendarUrl, externalCalendarId, provider) => {
|
||||
try {
|
||||
const response = await fetch("/api/calendars/sync", {
|
||||
method: "POST",
|
||||
@ -1559,7 +1585,8 @@ export function CalendarClient({ initialCalendars, userId, userProfile }: Calend
|
||||
calendarId,
|
||||
mailCredentialId,
|
||||
externalCalendarUrl,
|
||||
provider: "infomaniak",
|
||||
externalCalendarId,
|
||||
provider: provider || "infomaniak",
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@ -51,8 +51,13 @@ export async function runCalendarSyncJob(): Promise<void> {
|
||||
|
||||
// Sync based on provider
|
||||
if (syncConfig.provider === 'infomaniak') {
|
||||
const { syncInfomaniakCalendar } = await import('./caldav-sync');
|
||||
await syncInfomaniakCalendar(syncConfig.id, false);
|
||||
results.successful++;
|
||||
} else if (syncConfig.provider === 'microsoft') {
|
||||
const { syncMicrosoftCalendar } = await import('./microsoft-calendar-sync');
|
||||
await syncMicrosoftCalendar(syncConfig.id, false);
|
||||
results.successful++;
|
||||
} else {
|
||||
logger.warn('Unknown sync provider', {
|
||||
calendarSyncId: syncConfig.id,
|
||||
|
||||
354
lib/services/microsoft-calendar-sync.ts
Normal file
354
lib/services/microsoft-calendar-sync.ts
Normal file
@ -0,0 +1,354 @@
|
||||
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
|
||||
const { accessToken, success } = await ensureFreshToken(userId, email);
|
||||
|
||||
if (!success || !accessToken) {
|
||||
throw new Error('Failed to obtain valid Microsoft access token');
|
||||
}
|
||||
|
||||
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
|
||||
if (error.response?.status === 403 || error.response?.status === 401) {
|
||||
logger.warn('Microsoft calendar access denied - may need to re-authenticate with calendar scope', {
|
||||
userId,
|
||||
email,
|
||||
error: error.response?.data?.error?.message || error.message,
|
||||
});
|
||||
// Return empty array instead of throwing - user can re-authenticate later
|
||||
return [];
|
||||
}
|
||||
|
||||
logger.error('Error discovering Microsoft calendars', {
|
||||
userId,
|
||||
email,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@ -14,11 +14,12 @@ const redirectUri = process.env.MICROSOFT_REDIRECT_URI;
|
||||
|
||||
// NOTE: In production we do not log Microsoft OAuth configuration to avoid noise and potential leakage.
|
||||
|
||||
// Required scopes for IMAP and SMTP access
|
||||
// Required scopes for IMAP, SMTP, and Calendar access
|
||||
const REQUIRED_SCOPES = [
|
||||
'offline_access',
|
||||
'https://outlook.office.com/IMAP.AccessAsUser.All',
|
||||
'https://outlook.office.com/SMTP.Send'
|
||||
'https://outlook.office.com/SMTP.Send',
|
||||
'https://graph.microsoft.com/Calendars.Read', // Microsoft Graph API scope for calendar read access
|
||||
].join(' ');
|
||||
|
||||
/**
|
||||
|
||||
Loading…
Reference in New Issue
Block a user