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 { // 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 { 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: ` `, }); // Check if this is actually a calendar (has in resourcetype) const isCalendar = props.data && props.data.includes(' 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>/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>/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 { 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 = ` ${start && end ? ` ` : ''} `; 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(); 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; } }