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
|
// Get all Infomaniak email accounts
|
||||||
const infomaniakAccounts = await prisma.mailCredentials.findMany({
|
const infomaniakAccounts = await prisma.mailCredentials.findMany({
|
||||||
where: {
|
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 each Infomaniak account, ensure there's a synced calendar
|
||||||
for (const account of infomaniakAccounts) {
|
for (const account of infomaniakAccounts) {
|
||||||
// Check if a calendar sync already exists for this account
|
// Check if a calendar sync already exists for this account
|
||||||
@ -178,12 +199,88 @@ export default async function CalendarPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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
|
// 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
|
// Refresh calendars after auto-setup
|
||||||
// Exclude "Privée" and "Default" calendars that are not synced
|
// Exclude "Privée" and "Default" calendars that are not synced
|
||||||
calendars = await prisma.calendar.findMany({
|
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 { authOptions } from "@/app/api/auth/options";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { syncInfomaniakCalendar } from "@/lib/services/caldav-sync";
|
import { syncInfomaniakCalendar } from "@/lib/services/caldav-sync";
|
||||||
|
import { syncMicrosoftCalendar } from "@/lib/services/microsoft-calendar-sync";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -16,7 +17,7 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
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) {
|
if (!calendarId || !mailCredentialId || !externalCalendarUrl) {
|
||||||
return NextResponse.json(
|
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
|
// Verify calendar belongs to user
|
||||||
const calendar = await prisma.calendar.findFirst({
|
const calendar = await prisma.calendar.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@ -61,7 +80,7 @@ export async function POST(req: NextRequest) {
|
|||||||
create: {
|
create: {
|
||||||
calendarId,
|
calendarId,
|
||||||
mailCredentialId,
|
mailCredentialId,
|
||||||
provider: 'infomaniak',
|
provider: detectedProvider,
|
||||||
externalCalendarId: externalCalendarId || null,
|
externalCalendarId: externalCalendarId || null,
|
||||||
externalCalendarUrl,
|
externalCalendarUrl,
|
||||||
syncEnabled: true,
|
syncEnabled: true,
|
||||||
@ -69,6 +88,7 @@ export async function POST(req: NextRequest) {
|
|||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
mailCredentialId,
|
mailCredentialId,
|
||||||
|
provider: detectedProvider,
|
||||||
externalCalendarId: externalCalendarId || null,
|
externalCalendarId: externalCalendarId || null,
|
||||||
externalCalendarUrl,
|
externalCalendarUrl,
|
||||||
syncFrequency: syncFrequency || 15,
|
syncFrequency: syncFrequency || 15,
|
||||||
@ -76,12 +96,19 @@ export async function POST(req: NextRequest) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Trigger initial sync
|
// Trigger initial sync based on provider
|
||||||
try {
|
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) {
|
} catch (syncError) {
|
||||||
logger.error('Error during initial sync', {
|
logger.error('Error during initial sync', {
|
||||||
syncConfigId: syncConfig.id,
|
syncConfigId: syncConfig.id,
|
||||||
|
provider: detectedProvider,
|
||||||
error: syncError instanceof Error ? syncError.message : String(syncError),
|
error: syncError instanceof Error ? syncError.message : String(syncError),
|
||||||
});
|
});
|
||||||
// Don't fail the request if sync fails, just log it
|
// Don't fail the request if sync fails, just log it
|
||||||
@ -137,8 +164,18 @@ export async function PUT(req: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger sync
|
// Trigger sync based on provider
|
||||||
const result = await syncInfomaniakCalendar(calendarSyncId, true);
|
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 });
|
return NextResponse.json({ success: true, result });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -116,7 +116,7 @@ interface CalendarDialogProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: (calendarData: Partial<Calendar>) => Promise<void>;
|
onSave: (calendarData: Partial<Calendar>) => Promise<void>;
|
||||||
onDelete?: (calendarId: string) => 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>;
|
initialData?: Partial<Calendar>;
|
||||||
syncConfig?: {
|
syncConfig?: {
|
||||||
id: string;
|
id: string;
|
||||||
@ -175,14 +175,16 @@ function CalendarDialog({ open, onClose, onSave, onDelete, onSyncSetup, initialD
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.success && data.accounts) {
|
if (data.success && data.accounts) {
|
||||||
// Filter Infomaniak accounts only
|
// Filter Infomaniak and Microsoft accounts
|
||||||
const infomaniakAccounts = data.accounts.filter((acc: any) =>
|
const syncableAccounts = data.accounts.filter((acc: any) =>
|
||||||
acc.host && acc.host.includes('infomaniak')
|
(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,
|
id: acc.id,
|
||||||
email: acc.email,
|
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);
|
setIsDiscovering(true);
|
||||||
try {
|
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",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ mailCredentialId: selectedAccountId }),
|
body: JSON.stringify({ mailCredentialId: selectedAccountId }),
|
||||||
@ -204,7 +214,13 @@ function CalendarDialog({ open, onClose, onSave, onDelete, onSyncSetup, initialD
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
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 {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
alert(error.error || "Erreur lors de la découverte des calendriers");
|
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);
|
setIsSettingUpSync(true);
|
||||||
try {
|
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);
|
setShowSyncSection(false);
|
||||||
alert("Synchronisation configurée avec succès !");
|
alert("Synchronisation configurée avec succès !");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -1550,7 +1576,7 @@ export function CalendarClient({ initialCalendars, userId, userProfile }: Calend
|
|||||||
onClose={() => setIsCalendarModalOpen(false)}
|
onClose={() => setIsCalendarModalOpen(false)}
|
||||||
onSave={handleCalendarSave}
|
onSave={handleCalendarSave}
|
||||||
onDelete={handleCalendarDelete}
|
onDelete={handleCalendarDelete}
|
||||||
onSyncSetup={async (calendarId, mailCredentialId, externalCalendarUrl) => {
|
onSyncSetup={async (calendarId, mailCredentialId, externalCalendarUrl, externalCalendarId, provider) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/calendars/sync", {
|
const response = await fetch("/api/calendars/sync", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@ -1559,7 +1585,8 @@ export function CalendarClient({ initialCalendars, userId, userProfile }: Calend
|
|||||||
calendarId,
|
calendarId,
|
||||||
mailCredentialId,
|
mailCredentialId,
|
||||||
externalCalendarUrl,
|
externalCalendarUrl,
|
||||||
provider: "infomaniak",
|
externalCalendarId,
|
||||||
|
provider: provider || "infomaniak",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -51,8 +51,13 @@ export async function runCalendarSyncJob(): Promise<void> {
|
|||||||
|
|
||||||
// Sync based on provider
|
// Sync based on provider
|
||||||
if (syncConfig.provider === 'infomaniak') {
|
if (syncConfig.provider === 'infomaniak') {
|
||||||
|
const { syncInfomaniakCalendar } = await import('./caldav-sync');
|
||||||
await syncInfomaniakCalendar(syncConfig.id, false);
|
await syncInfomaniakCalendar(syncConfig.id, false);
|
||||||
results.successful++;
|
results.successful++;
|
||||||
|
} else if (syncConfig.provider === 'microsoft') {
|
||||||
|
const { syncMicrosoftCalendar } = await import('./microsoft-calendar-sync');
|
||||||
|
await syncMicrosoftCalendar(syncConfig.id, false);
|
||||||
|
results.successful++;
|
||||||
} else {
|
} else {
|
||||||
logger.warn('Unknown sync provider', {
|
logger.warn('Unknown sync provider', {
|
||||||
calendarSyncId: syncConfig.id,
|
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.
|
// 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 = [
|
const REQUIRED_SCOPES = [
|
||||||
'offline_access',
|
'offline_access',
|
||||||
'https://outlook.office.com/IMAP.AccessAsUser.All',
|
'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(' ');
|
].join(' ');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user