NeahStable/lib/services/caldav-sync.ts
2026-01-15 12:46:55 +01:00

591 lines
18 KiB
TypeScript

import { createClient, WebDAVClient } from 'webdav';
import { prisma } from '@/lib/prisma';
import { logger } from '@/lib/logger';
export interface CalDAVCalendar {
id: string;
name: string;
url: string;
color?: string;
}
export interface CalDAVEvent {
uid: string;
summary: string;
description?: string;
start: Date;
end: Date;
location?: string;
allDay: boolean;
}
/**
* Get CalDAV client for Infomaniak account
*/
export async function getInfomaniakCalDAVClient(
email: string,
password: string
): Promise<WebDAVClient> {
// Infomaniak CalDAV base URL (from Infomaniak sync assistant)
const baseUrl = 'https://sync.infomaniak.com';
const client = createClient(baseUrl, {
username: email,
password: password,
});
return client;
}
/**
* Discover calendars available for an Infomaniak account
*/
export async function discoverInfomaniakCalendars(
email: string,
password: string
): Promise<CalDAVCalendar[]> {
try {
const client = await getInfomaniakCalDAVClient(email, password);
// List all calendars using PROPFIND on root
const items = await client.getDirectoryContents('/');
const calendars: CalDAVCalendar[] = [];
for (const item of items) {
// Skip non-directories, root, and special directories like /principals
if (item.type !== 'directory' || item.filename === '/' || item.filename === '/principals') {
continue;
}
// Get calendar properties to verify it's actually a calendar
try {
const props = await client.customRequest(item.filename, {
method: 'PROPFIND',
headers: {
Depth: '0',
'Content-Type': 'application/xml',
},
data: `<?xml version="1.0" encoding="utf-8" ?>
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:displayname />
<c:calendar-color />
<d:resourcetype />
</d:prop>
</d:propfind>`,
});
// Check if this is actually a calendar (has <c:calendar> in resourcetype)
const isCalendar = props.data && props.data.includes('<c:calendar');
if (!isCalendar) {
logger.debug('Skipping non-calendar directory', {
filename: item.filename,
});
continue;
}
// Parse XML response to extract calendar name and color
const displayName = extractDisplayName(props.data);
const color = extractCalendarColor(props.data);
calendars.push({
id: item.filename.replace(/^\//, '').replace(/\/$/, ''),
name: displayName || item.basename || 'Calendrier',
url: item.filename,
color: color,
});
} catch (error) {
logger.error('Error fetching calendar properties', {
calendar: item.filename,
error: error instanceof Error ? error.message : String(error),
});
// Don't add calendars that fail property fetch - they might not be calendars
}
}
return calendars;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorDetails = error instanceof Error ? {
name: error.name,
message: error.message,
stack: error.stack?.substring(0, 200), // First 200 chars of stack
} : { raw: String(error) };
// Use logger.log instead of logger.error for non-critical errors
// This prevents console.error from showing up in the browser console
// The error is still logged server-side but won't appear as a red error in the browser
logger.log('info', 'Infomaniak calendar discovery failed (non-critical)', {
email,
error: errorMessage,
errorDetails,
});
// Ne pas faire échouer toute la page agenda si la découverte échoue
// On retourne simplement une liste vide -> pas de sync auto possible
return [];
}
}
/**
* Extract display name from PROPFIND XML response
*/
function extractDisplayName(xmlData: string): string | null {
try {
const match = xmlData.match(/<d:displayname[^>]*>([^<]+)<\/d:displayname>/i);
return match ? match[1] : null;
} catch {
return null;
}
}
/**
* Extract calendar color from PROPFIND XML response
*/
function extractCalendarColor(xmlData: string): string | undefined {
try {
const match = xmlData.match(/<c:calendar-color[^>]*>([^<]+)<\/c:calendar-color>/i);
return match ? match[1] : undefined;
} catch {
return undefined;
}
}
/**
* Fetch events from a CalDAV calendar
*/
export async function fetchCalDAVEvents(
email: string,
password: string,
calendarUrl: string,
startDate?: Date,
endDate?: Date
): Promise<CalDAVEvent[]> {
try {
// Validate calendar URL - must not be /principals or other non-calendar paths
if (!calendarUrl || calendarUrl === '/principals' || calendarUrl === '/') {
throw new Error(`Invalid calendar URL: ${calendarUrl}. This is not a calendar directory.`);
}
const client = await getInfomaniakCalDAVClient(email, password);
// Build calendar query URL
const queryUrl = calendarUrl.endsWith('/') ? calendarUrl : `${calendarUrl}/`;
// Build CALDAV query XML
const start = startDate ? startDate.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z' : '';
const end = endDate ? endDate.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z' : '';
const queryXml = `<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:getetag />
<C:calendar-data />
</D:prop>
${start && end ? `
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:time-range start="${start}" end="${end}" />
</C:comp-filter>
</C:comp-filter>
</C:filter>` : ''}
</C:calendar-query>`;
const response = await client.customRequest(queryUrl, {
method: 'REPORT',
headers: {
Depth: '1',
'Content-Type': 'application/xml',
},
data: queryXml,
});
// Validate response data exists
if (!response.data || typeof response.data !== 'string') {
throw new Error(`Invalid response from CalDAV server: expected string data, got ${typeof response.data}`);
}
// Parse iCalendar data from response
const events = parseICalendarEvents(response.data);
return events;
} catch (error) {
logger.error('Error fetching CalDAV events', {
email,
calendarUrl,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
/**
* Parse iCalendar format events from CalDAV response
*/
function parseICalendarEvents(icalData: string): CalDAVEvent[] {
const events: CalDAVEvent[] = [];
// Split by BEGIN:VEVENT
const eventBlocks = icalData.split(/BEGIN:VEVENT/gi);
for (const block of eventBlocks.slice(1)) { // Skip first empty block
try {
const event = parseICalendarEvent(block);
if (event) {
events.push(event);
}
} catch (error) {
logger.error('Error parsing iCalendar event', {
error: error instanceof Error ? error.message : String(error),
});
}
}
return events;
}
/**
* Parse a single iCalendar event block
*/
function parseICalendarEvent(block: string): CalDAVEvent | null {
const lines = block.split(/\r?\n/);
let uid: string | null = null;
let summary: string | null = null;
let description: string | undefined;
let dtstart: string | null = null;
let dtend: string | null = null;
let location: string | undefined;
let allDay = false;
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
// Handle line continuation (lines starting with space)
while (i + 1 < lines.length && lines[i + 1].startsWith(' ')) {
line += lines[i + 1].substring(1);
i++;
}
if (line.startsWith('UID:')) {
uid = line.substring(4).trim();
} else if (line.startsWith('SUMMARY:')) {
summary = line.substring(8).trim();
} else if (line.startsWith('DESCRIPTION:')) {
description = line.substring(12).trim();
} else if (line.startsWith('DTSTART')) {
const match = line.match(/DTSTART(?:;VALUE=DATE)?:([^;]+)/);
if (match) {
dtstart = match[1];
allDay = line.includes('VALUE=DATE');
}
} else if (line.startsWith('DTEND')) {
const match = line.match(/DTEND(?:;VALUE=DATE)?:([^;]+)/);
if (match) {
dtend = match[1];
}
} else if (line.startsWith('LOCATION:')) {
location = line.substring(9).trim();
}
}
if (!uid || !summary || !dtstart || !dtend) {
return null;
}
// Parse dates
const start = parseICalendarDate(dtstart, allDay);
const end = parseICalendarDate(dtend, allDay);
if (!start || !end) {
return null;
}
return {
uid,
summary,
description,
start,
end,
location,
allDay,
};
}
/**
* Parse iCalendar date format (YYYYMMDDTHHmmssZ or YYYYMMDD)
*/
function parseICalendarDate(dateStr: string, allDay: boolean): Date | null {
try {
if (allDay) {
// Format: YYYYMMDD
const year = parseInt(dateStr.substring(0, 4));
const month = parseInt(dateStr.substring(4, 6)) - 1; // Month is 0-indexed
const day = parseInt(dateStr.substring(6, 8));
return new Date(year, month, day);
} else {
// Format: YYYYMMDDTHHmmssZ or YYYYMMDDTHHmmss
const cleanDate = dateStr.replace(/[TZ]/g, '');
const year = parseInt(cleanDate.substring(0, 4));
const month = parseInt(cleanDate.substring(4, 6)) - 1;
const day = parseInt(cleanDate.substring(6, 8));
const hour = cleanDate.length > 8 ? parseInt(cleanDate.substring(9, 11)) : 0;
const minute = cleanDate.length > 10 ? parseInt(cleanDate.substring(11, 13)) : 0;
const second = cleanDate.length > 12 ? parseInt(cleanDate.substring(13, 15)) : 0;
const date = new Date(year, month, day, hour, minute, second);
// If timezone is UTC (Z), convert to local time
if (dateStr.includes('Z')) {
return new Date(date.getTime() - date.getTimezoneOffset() * 60000);
}
return date;
}
} catch (error) {
logger.error('Error parsing iCalendar date', {
dateStr,
error: error instanceof Error ? error.message : String(error),
});
return null;
}
}
/**
* Sync events from Infomaniak CalDAV calendar to local Prisma calendar
*/
export async function syncInfomaniakCalendar(
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.password) {
throw new Error('Password required for Infomaniak CalDAV sync');
}
// Fetch events from CalDAV
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 caldavEvents = await fetchCalDAVEvents(
creds.email,
creds.password,
syncConfig.externalCalendarUrl || '',
startDate,
endDate
);
// Get existing events in local calendar
const existingEvents = await prisma.event.findMany({
where: {
calendarId: syncConfig.calendarId,
},
});
// Create a map of existing events by externalEventId (UID) for fast lookup
// Use type assertion to handle case where Prisma client doesn't recognize externalEventId yet
type EventType = typeof existingEvents[number];
const existingEventsByExternalId = new Map<string, EventType>();
for (const event of existingEvents) {
// Access externalEventId safely (may not be in Prisma type if client not regenerated)
const externalId = (event as any).externalEventId;
if (externalId) {
existingEventsByExternalId.set(externalId, event);
}
}
let created = 0;
let updated = 0;
let deleted = 0;
// Sync events: create or update
for (const caldavEvent of caldavEvents) {
// Priority 1: Match by externalEventId (UID) - most reliable
let existingEvent: EventType | undefined = caldavEvent.uid
? existingEventsByExternalId.get(caldavEvent.uid)
: undefined;
// Priority 2: Fallback to title + date matching for events without externalEventId (backward compatibility)
if (!existingEvent) {
existingEvent = existingEvents.find(
(e: EventType) => {
// Access externalEventId safely (may not be in Prisma type if client not regenerated)
const hasExternalId = !!(e as any).externalEventId;
if (!hasExternalId && // Only match events that don't have externalEventId yet
e.title === caldavEvent.summary) {
const timeDiff = Math.abs(new Date(e.start).getTime() - caldavEvent.start.getTime());
return timeDiff < 60000; // Within 1 minute
}
return false;
}
);
}
// For updates, we cannot modify calendarId and userId (they are relations)
// For creates, we need them
// Build event data dynamically to handle case where externalEventId field doesn't exist yet
const baseEventData: any = {
title: caldavEvent.summary,
description: caldavEvent.description || null,
start: caldavEvent.start,
end: caldavEvent.end,
location: caldavEvent.location || null,
isAllDay: caldavEvent.allDay,
};
// Only add externalEventId if migration has been applied
// We'll try to add it, and if it fails, we'll retry without it
if (caldavEvent.uid) {
baseEventData.externalEventId = caldavEvent.uid;
}
if (existingEvent) {
// Update existing event (without calendarId and userId - they are relations)
try {
await prisma.event.update({
where: { id: existingEvent.id },
data: baseEventData,
});
updated++;
} catch (updateError: any) {
// If externalEventId field doesn't exist in Prisma client (even though it exists in DB),
// retry without it. This can happen if Prisma client wasn't regenerated after migration.
const errorMessage = updateError?.message || '';
const errorCode = updateError?.code || '';
if (errorMessage.includes('externalEventId') ||
errorMessage.includes('Unknown argument') ||
errorCode === 'P2009' ||
errorCode === 'P1012') {
logger.warn('externalEventId field not recognized by Prisma client, updating without it', {
eventId: existingEvent.id,
error: errorMessage.substring(0, 200),
});
const { externalEventId, ...dataWithoutExternalId } = baseEventData;
await prisma.event.update({
where: { id: existingEvent.id },
data: dataWithoutExternalId,
});
updated++;
} else {
throw updateError;
}
}
} else {
// Create new event (with calendarId and userId)
try {
await prisma.event.create({
data: {
...baseEventData,
calendarId: syncConfig.calendarId,
userId: syncConfig.calendar.userId,
},
});
created++;
} catch (createError: any) {
// If externalEventId field doesn't exist in Prisma client (even though it exists in DB),
// retry without it. This can happen if Prisma client wasn't regenerated after migration.
const errorMessage = createError?.message || '';
const errorCode = createError?.code || '';
if (errorMessage.includes('externalEventId') ||
errorMessage.includes('Unknown argument') ||
errorCode === 'P2009' ||
errorCode === 'P1012') {
logger.warn('externalEventId field not recognized by Prisma client, creating without it', {
error: errorMessage.substring(0, 200),
});
const { externalEventId, ...dataWithoutExternalId } = baseEventData;
await prisma.event.create({
data: {
...dataWithoutExternalId,
calendarId: syncConfig.calendarId,
userId: syncConfig.calendar.userId,
},
});
created++;
} else {
throw createError;
}
}
}
}
// Update sync timestamp
await prisma.calendarSync.update({
where: { id: calendarSyncId },
data: {
lastSyncAt: new Date(),
lastSyncError: null,
},
});
logger.info('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 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;
}
}