Agenda Sync refactor

This commit is contained in:
alma 2026-01-14 15:40:40 +01:00
parent 51e37af4c8
commit 50cdca1ac2
7 changed files with 625 additions and 21 deletions

View File

@ -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({

View 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 }
);
}
}

View File

@ -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) {

View File

@ -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",
}),
});

View File

@ -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,

View 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;
}
}

View File

@ -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(' ');
/**