620 lines
20 KiB
TypeScript
620 lines
20 KiB
TypeScript
// @ts-ignore - webdav package types may not be available
|
|
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 {
|
|
logger.debug('[CALDAV] Starting calendar discovery', { email: email.substring(0, 5) + '***' });
|
|
const client = await getInfomaniakCalDAVClient(email, password);
|
|
|
|
// List all calendars using PROPFIND on root
|
|
logger.debug('[CALDAV] Fetching directory contents from root');
|
|
const itemsResult = await client.getDirectoryContents('/');
|
|
|
|
// Handle both FileStat[] and ResponseDataDetailed<FileStat[]> return types
|
|
const items = Array.isArray(itemsResult) ? itemsResult : itemsResult.data;
|
|
|
|
logger.debug('[CALDAV] Found items in root directory', {
|
|
count: items.length,
|
|
items: items.map((item: any) => ({ filename: item.filename, type: item.type, basename: item.basename }))
|
|
});
|
|
|
|
const calendars: CalDAVCalendar[] = [];
|
|
|
|
for (const item of items as any[]) {
|
|
// Skip non-directories, root, and special directories like /principals
|
|
if (item.type !== 'directory' || item.filename === '/' || item.filename === '/principals') {
|
|
logger.debug('[CALDAV] Skipping item', { filename: item.filename, type: item.type });
|
|
continue;
|
|
}
|
|
|
|
// Get calendar properties to verify it's actually a calendar
|
|
try {
|
|
logger.debug('[CALDAV] Checking if item is a calendar', { filename: item.filename });
|
|
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)
|
|
// Try multiple patterns to be more flexible with XML namespaces
|
|
// Handle both Response and ResponseDataDetailed types
|
|
const propsData = (props as any).data || '';
|
|
const dataStr = typeof propsData === 'string' ? propsData : '';
|
|
const isCalendar = dataStr.includes('<c:calendar') ||
|
|
dataStr.includes('calendar') ||
|
|
dataStr.includes('urn:ietf:params:xml:ns:caldav');
|
|
|
|
logger.debug('[CALDAV] Calendar check result', {
|
|
filename: item.filename,
|
|
isCalendar,
|
|
hasData: !!propsData,
|
|
dataLength: dataStr.length,
|
|
});
|
|
|
|
if (!isCalendar) {
|
|
logger.debug('[CALDAV] Skipping - not a calendar', { filename: item.filename });
|
|
continue;
|
|
}
|
|
|
|
// Parse XML response to extract calendar name and color
|
|
const displayName = extractDisplayName(dataStr);
|
|
const color = extractCalendarColor(dataStr);
|
|
|
|
const calendar = {
|
|
id: item.filename.replace(/^\//, '').replace(/\/$/, ''),
|
|
name: displayName || item.basename || 'Calendrier',
|
|
url: item.filename,
|
|
color: color,
|
|
};
|
|
|
|
logger.debug('[CALDAV] Found valid calendar', { calendar });
|
|
calendars.push(calendar);
|
|
} catch (error) {
|
|
logger.error('[CALDAV] Error fetching calendar properties', {
|
|
filename: item.filename,
|
|
error: error instanceof Error ? error.message : String(error)
|
|
});
|
|
// Don't add calendars that fail property fetch - they might not be calendars
|
|
}
|
|
}
|
|
|
|
logger.debug('[CALDAV] Discovery completed', {
|
|
email: email.substring(0, 5) + '***',
|
|
count: calendars.length,
|
|
calendars: calendars.map(cal => ({ id: cal.id, name: cal.name, url: cal.url }))
|
|
});
|
|
|
|
return calendars;
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
const errorStack = error instanceof Error ? error.stack?.substring(0, 500) : undefined;
|
|
|
|
logger.error('[CALDAV] Calendar discovery failed', {
|
|
email: email.substring(0, 5) + '***',
|
|
error: errorMessage,
|
|
stack: errorStack,
|
|
});
|
|
|
|
// 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
|
|
// Handle both Response and ResponseDataDetailed types
|
|
const responseData = (response as any).data || '';
|
|
if (!responseData || typeof responseData !== 'string') {
|
|
throw new Error(`Invalid response from CalDAV server: expected string data, got ${typeof responseData}`);
|
|
}
|
|
|
|
// Parse iCalendar data from response
|
|
const events = parseICalendarEvents(responseData);
|
|
|
|
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;
|
|
}
|
|
}
|