import { GoogleCalendarEvent, getUserCalendars as getGoogleCalendars, getUpcomingEvents as getGoogleEvents, initializeOAuth as initializeGoogleOAuth } from './google-calendar'; import { AppleCalendarEvent, getUserCalendars as getAppleCalendars, getUpcomingEvents as getAppleEvents } from './apple-calendar'; import { getUpcomingEvents as getOutlookEvents, refreshAccessToken as refreshOutlookTokenAPI, createEvent as createOutlookEvent, updateEvent as updateOutlookEvent, deleteEvent as deleteOutlookEvent } from './outlook-calendar'; import { getUserCalendars as getSynologyCalendars, getUpcomingEvents as getSynologyEvents } from './synology-calendar'; import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); export interface CalendarEvent { id: string; title: string; description?: string; start: { dateTime?: string; date?: string; }; end: { dateTime?: string; date?: string; }; location?: string; url?: string; recurrence?: string; recurringEventId?: string; isRecurring?: boolean; source: 'google' | 'apple' | 'outlook' | 'synology'; calendarId: string; calendarTitle: string; backgroundColor?: string; allDay?: boolean; } /** * Convert friendly recurrence name to RRULE string */ function toRRule(recurrence?: string): string | null { switch (recurrence) { case 'daily': return 'RRULE:FREQ=DAILY'; case 'weekly': return 'RRULE:FREQ=WEEKLY'; case 'biweekly': return 'RRULE:FREQ=WEEKLY;INTERVAL=2'; case 'monthly': return 'RRULE:FREQ=MONTHLY'; case 'yearly': return 'RRULE:FREQ=YEARLY'; default: return null; } } /** * Convert friendly recurrence name to Outlook Graph recurrence object */ function toOutlookRecurrence(recurrence?: string, startDate?: Date): any { if (!recurrence || recurrence === 'none') return undefined; const start = startDate || new Date(); const range = { type: 'noEnd', startDate: start.toISOString().split('T')[0], }; switch (recurrence) { case 'daily': return { pattern: { type: 'daily', interval: 1 }, range }; case 'weekly': return { pattern: { type: 'weekly', interval: 1, daysOfWeek: [['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'][start.getDay()]] }, range }; case 'biweekly': return { pattern: { type: 'weekly', interval: 2, daysOfWeek: [['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'][start.getDay()]] }, range }; case 'monthly': return { pattern: { type: 'absoluteMonthly', interval: 1, dayOfMonth: start.getDate() }, range }; case 'yearly': return { pattern: { type: 'absoluteYearly', interval: 1, dayOfMonth: start.getDate(), month: start.getMonth() + 1 }, range }; default: return undefined; } } // Google Calendar event color mapping (colorId -> hex color) const GOOGLE_EVENT_COLORS: Record = { '1': '#7986cb', // Lavender '2': '#33b679', // Sage '3': '#8e24aa', // Grape '4': '#e67c73', // Flamingo '5': '#f6bf26', // Banana '6': '#f4511e', // Tangerine '7': '#039be5', // Peacock '8': '#616161', // Graphite '9': '#3f51b5', // Blueberry '10': '#0b8043', // Basil '11': '#d50000', // Tomato }; function getGoogleEventColor(colorId: string): string { return GOOGLE_EVENT_COLORS[colorId] || '#039be5'; // Default to Peacock } export interface CalendarConnection { id: string; provider: 'google' | 'apple' | 'outlook' | 'synology'; accessToken: string; refreshToken?: string; expiresAt?: Date; calendars?: Array<{ id: string; title: string; isPrimary?: boolean; selected?: boolean; backgroundColor?: string; }> | any; // Type it loosely for JSON compatibility } /** * Refresh the Google access token using the refresh token */ async function refreshGoogleToken(connection: CalendarConnection): Promise { if (!connection.refreshToken) { console.log('[CALENDAR] No refresh token available for connection:', connection.id); return null; } try { const oauth2Client = initializeGoogleOAuth( process.env.GOOGLE_CLIENT_ID || '', process.env.GOOGLE_CLIENT_SECRET || '', process.env.GOOGLE_REDIRECT_URI || '' ); oauth2Client.setCredentials({ refresh_token: connection.refreshToken }); console.log('[CALENDAR] Refreshing Google access token...'); const { credentials } = await oauth2Client.refreshAccessToken(); if (credentials.access_token) { // Update the token in the database await prisma.calendarConnection.update({ where: { id: connection.id }, data: { accessToken: credentials.access_token, expiresAt: credentials.expiry_date ? new Date(credentials.expiry_date) : null } }); console.log('[CALENDAR] Token refreshed successfully'); return credentials.access_token; } } catch (error) { console.error('[CALENDAR] Failed to refresh token:', error); } return null; } /** * Refresh the Outlook access token using the refresh token */ async function refreshOutlookToken(connection: CalendarConnection): Promise { if (!connection.refreshToken) { console.log('[CALENDAR] No refresh token available for connection:', connection.id); return null; } try { console.log('[CALENDAR] Refreshing Outlook access token...'); const data = await refreshOutlookTokenAPI(connection.refreshToken); if (data.access_token) { // Update the token in the database const expiresAt = new Date(); expiresAt.setSeconds(expiresAt.getSeconds() + data.expires_in); await prisma.calendarConnection.update({ where: { id: connection.id }, data: { accessToken: data.access_token, refreshToken: data.refresh_token || connection.refreshToken, // Update refresh token if provided expiresAt } }); console.log('[CALENDAR] Outlook token refreshed successfully'); return data.access_token; } } catch (error) { console.error('[CALENDAR] Failed to refresh Outlook token:', error); } return null; } /** * Check if the token is expired or about to expire */ function isTokenExpired(expiresAt?: Date): boolean { if (!expiresAt) return true; // Assume expired if no expiry info // Consider token expired if it expires within the next 5 minutes const bufferMs = 5 * 60 * 1000; return new Date().getTime() > (new Date(expiresAt).getTime() - bufferMs); } /** * Fetch calendar events from connected accounts */ export const getCalendarEvents = async ( connections: CalendarConnection[], timeMin: string, timeMax: string ): Promise => { const allEvents: CalendarEvent[] = []; console.log('[CALENDAR] Fetching events from', connections.length, 'connections'); console.log('[CALENDAR] Date range:', timeMin, 'to', timeMax); for (const connection of connections) { try { let events: any[] = []; let accessToken = connection.accessToken; if (connection.provider === 'google') { console.log('[CALENDAR] Processing Google connection:', connection.id); // Check if token is expired and refresh if needed if (isTokenExpired(connection.expiresAt)) { console.log('[CALENDAR] Token expired, attempting refresh...'); const newToken = await refreshGoogleToken(connection); if (newToken) { accessToken = newToken; } else { console.error('[CALENDAR] Failed to refresh token, skipping connection'); continue; } } // Initialize OAuth client const oauth2Client = initializeGoogleOAuth( process.env.GOOGLE_CLIENT_ID || '', process.env.GOOGLE_CLIENT_SECRET || '', process.env.GOOGLE_REDIRECT_URI || '' ); // Identify which calendars to fetch events from let calendarIds: string[] = []; let calendars: any[] = []; // Use stored calendars if available and filtered by selection if (connection.calendars && Array.isArray(connection.calendars)) { // We have stored preferences - but check if they have backgroundColor const storedCalendars = connection.calendars as any[]; const hasMissingColors = storedCalendars.some((c: any) => !c.backgroundColor); if (hasMissingColors) { // Refresh from API to get colors console.log('[CALENDAR] Stored calendars missing backgroundColor, refreshing from API...'); const freshCalendars = await getGoogleCalendars(oauth2Client, accessToken); // Merge fresh data with stored selection preferences calendars = storedCalendars.map((stored: any) => { const fresh = freshCalendars.find(f => f.id === stored.id); return { ...stored, backgroundColor: fresh?.backgroundColor || stored.backgroundColor, summary: fresh?.summary || stored.title // Ensure summary is available }; }); console.log('[CALENDAR] Refreshed calendars with colors:', calendars.map(c => ({ id: c.id, bg: c.backgroundColor }))); } else { calendars = storedCalendars; } calendarIds = calendars .filter((c: any) => c.selected !== false) // Include unless explicitly false .map((c: any) => c.id); console.log('[CALENDAR] Using calendars, selected:', calendarIds.length, 'of', calendars.length); } else { // Fallback: fetch all if no stored list (legacy behavior) console.log('[CALENDAR] No stored calendars, fetching from API...'); calendars = await getGoogleCalendars(oauth2Client, accessToken); calendarIds = calendars.map(c => c.id); console.log('[CALENDAR] Fetched', calendars.length, 'calendars from API'); } if (calendarIds.length === 0) { console.log('[CALENDAR] No calendars selected, skipping'); continue; } // Fetch events for each calendar console.log('[CALENDAR] Fetching events from', calendarIds.length, 'calendars...'); for (const calendarId of calendarIds) { try { console.log('[CALENDAR] Fetching events from calendar:', calendarId); const calendarEvents = await getGoogleEvents( oauth2Client, accessToken, calendarId, timeMin, timeMax ); console.log('[CALENDAR] Found', calendarEvents.length, 'events in calendar:', calendarId); // Get calendar data for color fallback const calendarData = calendars.find(c => c.id === calendarId); events = events.concat(calendarEvents.map((event: any) => { const eventColor = event.colorId ? getGoogleEventColor(event.colorId) : calendarData?.backgroundColor; return { id: event.id, title: event.summary || '(No Title)', // Map summary to title description: event.description, start: event.start, end: event.end, location: event.location, source: 'google' as const, calendarId, calendarTitle: calendarData?.summary || calendarData?.title || 'Google Calendar', backgroundColor: eventColor }; })); } catch (calError) { console.error(`[CALENDAR] Error fetching events from calendar ${calendarId}:`, calError); // Continue with other calendars } } } else if (connection.provider === 'apple') { const [email, appPassword] = connection.accessToken.split(':'); if (!email || !appPassword) { console.error('[CALENDAR] Invalid Apple credentials format'); continue; } // Get user calendars to identify which ones to fetch events from // We can pass null/dummy client if getUserCalendars just calls validateCredentials which creates its own client // Actually getUserCalendars in apple-calendar.ts (wrapper around validateCredentials) takes (email, password) // But here it was imported as getAppleCalendars(appleClient, accessToken) which matched the old signature? // Let's check apple-calendar.ts signature for getUserCalendars. // It is: export const getUserCalendars = validateCredentials; // validateCredentials: (email: string, appSpecificPassword: string) const calendars = await getAppleCalendars(email, appPassword); const calendarIds = calendars.map(c => c.id); // Fetch events for each calendar for (const calendarId of calendarIds) { const calendarEvents = await getAppleEvents( email, appPassword, calendarId, timeMin, timeMax ); console.log(`[CALENDAR] Fetched ${calendarEvents.length} events from calendar ${calendarId}`); events = events.concat(calendarEvents.map((event: any) => { // Date-only strings (YYYY-MM-DD) indicate all-day events const isDateOnly = (s: string) => s && !s.includes('T'); const startIsAllDay = isDateOnly(event.startDate); return { id: event.id, title: event.title, description: event.description, start: { dateTime: startIsAllDay ? undefined : event.startDate, date: startIsAllDay ? event.startDate : undefined, }, end: { dateTime: startIsAllDay ? undefined : event.endDate, date: startIsAllDay ? event.endDate : undefined, }, location: event.location, url: event.url, recurringEventId: event.recurringEventId, isRecurring: event.isRecurring, source: 'apple' as const, calendarId, calendarTitle: calendars.find(c => c.id === calendarId)?.title || 'Apple Calendar', backgroundColor: calendars.find(c => c.id === calendarId)?.color || '#FF3B30' }; })); } } else if (connection.provider === 'synology') { const [username, password] = connection.accessToken.split(':'); const serverUrl = connection.refreshToken; if (!username || !password || !serverUrl) { console.error('[CALENDAR] Invalid Synology credentials format'); continue; } // Use stored calendar selection (same pattern as Outlook/Google) let calendarIds: string[] = []; let calendars: any[] = []; if (connection.calendars && Array.isArray(connection.calendars) && connection.calendars.length > 0) { calendars = connection.calendars as any[]; calendarIds = calendars .filter((c: any) => c.selected !== false) .map((c: any) => c.id); console.log('[CALENDAR] Synology: using stored calendars, selected:', calendarIds.length, 'of', calendars.length); } else { // Fallback: fetch fresh from server if no stored calendars console.log('[CALENDAR] Synology: no stored calendars, fetching from server...'); const fresh = await getSynologyCalendars(serverUrl, username, password); calendars = fresh; calendarIds = fresh.map(c => c.id); } if (calendarIds.length === 0) { console.log('[CALENDAR] Synology: no calendars selected, skipping'); continue; } for (const calendarId of calendarIds) { try { const calendarEvents = await getSynologyEvents( serverUrl, username, password, calendarId, timeMin, timeMax ); console.log(`[CALENDAR] Synology: fetched ${calendarEvents.length} events from calendar ${calendarId}`); const calendarData = calendars.find(c => c.id === calendarId); events = events.concat(calendarEvents.map((event: any) => { const isDateOnly = (s: string) => s && !s.includes('T'); const startIsAllDay = isDateOnly(event.startDate); return { id: event.id, title: event.title, description: event.description, start: { dateTime: startIsAllDay ? undefined : event.startDate, date: startIsAllDay ? event.startDate : undefined, }, end: { dateTime: startIsAllDay ? undefined : event.endDate, date: startIsAllDay ? event.endDate : undefined, }, location: event.location, url: event.url, recurringEventId: event.recurringEventId, isRecurring: event.isRecurring, source: 'synology' as const, calendarId, calendarTitle: calendarData?.title || 'Synology Calendar', backgroundColor: calendarData?.color || '#1b85ff' }; })); } catch (calError) { console.error(`[CALENDAR] Synology: error fetching events from calendar ${calendarId}:`, calError); } } } else if (connection.provider === 'outlook') { console.log('[CALENDAR] Processing Outlook connection:', connection.id); if (isTokenExpired(connection.expiresAt)) { console.log('[CALENDAR] Outlook token expired, attempting refresh...'); const newToken = await refreshOutlookToken(connection); if (newToken) { accessToken = newToken; } else { console.error('[CALENDAR] Failed to refresh Outlook token, skipping connection'); continue; } } // Get calendars to fetch let calendarIds: string[] = []; let calendars: any[] = []; if (connection.calendars && Array.isArray(connection.calendars)) { calendars = connection.calendars as any[]; calendarIds = calendars .filter((c: any) => c.selected !== false) .map((c: any) => c.id); } if (calendarIds.length > 0) { console.log('[CALENDAR] Fetching Outlook events from', calendarIds.length, 'calendars'); for (const calendarId of calendarIds) { try { const outlookEvents = await getOutlookEvents( accessToken, calendarId, timeMin, timeMax ); const calendarData = calendars.find(c => c.id === calendarId); events = events.concat(outlookEvents.map((event: any) => ({ id: event.id, title: event.summary || '(No Title)', description: event.description, start: event.start, end: event.end, location: event.location || '', source: 'outlook' as const, calendarId, calendarTitle: calendarData?.title || 'Outlook Calendar', backgroundColor: '#0078d4' // Outlook Blue }))); } catch (calError) { console.error(`[CALENDAR] Error fetching Outlook events from ${calendarId}:`, calError); } } } } console.log('[CALENDAR] Total events for connection:', events.length); allEvents.push(...events); } catch (error) { console.error(`[CALENDAR] Error fetching events from ${connection.provider} calendar:`, error); // Continue with other connections even if one fails } } console.log('[CALENDAR] Total events from all connections:', allEvents.length); return allEvents; }; /** * Format event for display in the weekly view */ export const formatEventForDisplay = (event: CalendarEvent): CalendarEvent => { // Ensure consistent structure for display return { id: event.id, title: event.title, description: event.description, start: { dateTime: event.start.dateTime, date: event.start.date, }, end: { dateTime: event.end.dateTime, date: event.end.date, }, location: event.location, source: event.source, calendarId: event.calendarId, calendarTitle: event.calendarTitle, }; }; /** * Merge calendar events with existing tasks to avoid duplicates */ export const mergeWithTasks = ( events: CalendarEvent[], tasks: Array<{ id: string; title: string }> ): CalendarEvent[] => { // Simple deduplication logic - if event title matches task title, consider it a duplicate const taskTitles = new Set(tasks.map(task => task.title.toLowerCase())); return events.filter(event => { // Only show events where the title doesn't match an existing task title return !taskTitles.has(event.title.toLowerCase()); }); }; /** * Filter events by date range (week view) */ export const filterEventsByDateRange = ( events: CalendarEvent[], startOfWeek: Date, endOfWeek: Date ): CalendarEvent[] => { const startTimestamp = startOfWeek.getTime(); const endTimestamp = endOfWeek.getTime(); return events.filter(event => { const eventStart = event.start.dateTime ? new Date(event.start.dateTime).getTime() : event.start.date ? new Date(event.start.date).getTime() : 0; return eventStart >= startTimestamp && eventStart <= endTimestamp; }); }; /** * Determine event visibility based on user settings */ export const determineEventVisibility = ( event: CalendarEvent, userSettings: { showGoogleEvents?: boolean; showAppleEvents?: boolean; showPrivateEvents?: boolean; } ): boolean => { // Check if the user wants to see events from this provider if ((event.source === 'google' && !userSettings.showGoogleEvents) || (event.source === 'apple' && !userSettings.showAppleEvents)) { return false; } // Check if private events should be shown (this is a simplified check) // In a real implementation, this would involve checking privacy settings if (!userSettings.showPrivateEvents) { // Simplified - in practice, private events would be filtered based on actual privacy flags return true; } return true; }; /** * Create a new calendar event */ export const createCalendarEvent = async ( connection: CalendarConnection, calendarId: string, event: Partial ): Promise => { if (connection.provider === 'google') { // Check key fields if (!event.title) throw new Error('Event title is required'); if (!event.start || !event.end) throw new Error('Event start and end times are required'); // Refresh token if needed let accessToken = connection.accessToken; if (isTokenExpired(connection.expiresAt)) { const newToken = await refreshGoogleToken(connection); if (newToken) accessToken = newToken; else throw new Error('Failed to refresh token'); } const oauth2Client = initializeGoogleOAuth( process.env.GOOGLE_CLIENT_ID || '', process.env.GOOGLE_CLIENT_SECRET || '', process.env.GOOGLE_REDIRECT_URI || '' ); // Map to Google format const rrule = toRRule(event.recurrence); const googleEvent: any = { summary: event.title, description: event.description, start: event.start, end: event.end, location: event.location, ...(rrule ? { recurrence: [rrule] } : {}), ...(event.allDay ? { allDay: true } : {}), ...(event.url ? { source: { url: event.url, title: event.url } } : {}), }; const createdEvent = await import('./google-calendar').then(m => m.createEvent(oauth2Client, accessToken, calendarId, googleEvent) ); return { id: createdEvent.id, title: createdEvent.summary, description: createdEvent.description, start: createdEvent.start, end: createdEvent.end, location: createdEvent.location, source: 'google', calendarId, calendarTitle: '', // We don't have this here, simpler to leave empty or fetch } as CalendarEvent; } else if (connection.provider === 'outlook') { if (!event.title) throw new Error('Event title is required'); if (!event.start || !event.end) throw new Error('Event start and end times are required'); let accessToken = connection.accessToken; if (isTokenExpired(connection.expiresAt)) { const newToken = await refreshOutlookToken(connection); if (newToken) accessToken = newToken; else throw new Error('Failed to refresh token'); } const startDate = event.start?.dateTime ? new Date(event.start.dateTime) : new Date(); const createdEvent = await createOutlookEvent(accessToken, calendarId, { summary: event.title, description: event.description, start: event.start, end: event.end, location: event.location, allDay: event.allDay, recurrence: toOutlookRecurrence(event.recurrence, startDate), }); return { id: createdEvent.id, title: createdEvent.summary, description: createdEvent.description, start: createdEvent.start, end: createdEvent.end, location: createdEvent.location, allDay: createdEvent.allDay, source: 'outlook', calendarId, calendarTitle: '', } as CalendarEvent; } else if (connection.provider === 'apple') { const [email, appPassword] = connection.accessToken.split(':'); // We need to map our event format to what createEvent expects // createEvent expects: { title, description?, location?, start: {dateTime?, date?}, end: ... } // Our 'event' arg is Partial, which matches well. // However, event.start and event.end might be undefined in Partial, so we need checks. // Handle all-day event normalization for Apple (requires 'date' property, not 'dateTime') // AND exclusive end date (add 1 day if needed) const start = { ...event.start! }; const end = { ...event.end! }; if (event.allDay) { if (start.dateTime && !start.date) { start.date = start.dateTime.split('T')[0]; delete start.dateTime; } if (end.dateTime && !end.date) { end.date = end.dateTime.split('T')[0]; delete end.dateTime; } // Apple/iCloud requires DTEND to be the day AFTER the last day of the event if (start.date && end.date && start.date === end.date) { const d = new Date(end.date); d.setDate(d.getDate() + 1); end.date = d.toISOString().split('T')[0]; } } const createdEvent = await import('./apple-calendar').then(m => m.createEvent(email, appPassword, calendarId, { title: event.title!, description: event.description, location: event.location, url: event.url, recurrence: event.recurrence, start, end }) ); return { id: createdEvent.id, title: createdEvent.title, description: createdEvent.description, start: { dateTime: createdEvent.startDate }, end: { dateTime: createdEvent.endDate }, location: createdEvent.location, source: 'apple', calendarId, calendarTitle: '', // Fetch if needed } as CalendarEvent; } else if (connection.provider === 'synology') { const [username, password] = connection.accessToken.split(':'); const serverUrl = connection.refreshToken; if (!username || !password || !serverUrl) throw new Error('Invalid Synology credentials'); if (!event.title) throw new Error('Event title is required'); if (!event.start || !event.end) throw new Error('Event start and end times are required'); const createdEvent = await import('./synology-calendar').then(m => m.createEvent(serverUrl, username, password, calendarId, { title: event.title!, description: event.description, location: event.location, url: event.url, recurrence: event.recurrence, start: event.start!, end: event.end! }) ); return { id: createdEvent.id, title: createdEvent.title, description: createdEvent.description, start: { dateTime: createdEvent.startDate }, end: { dateTime: createdEvent.endDate }, location: createdEvent.location, source: 'synology', calendarId, calendarTitle: '', } as CalendarEvent; } throw new Error(`Provider ${connection.provider} does not support creating events yet.`); }; /** * Update an existing calendar event */ export const updateCalendarEvent = async ( connection: CalendarConnection, calendarId: string, eventId: string, event: Partial ): Promise => { if (connection.provider === 'google') { // Refresh token if needed let accessToken = connection.accessToken; if (isTokenExpired(connection.expiresAt)) { const newToken = await refreshGoogleToken(connection); if (newToken) accessToken = newToken; else throw new Error('Failed to refresh token'); } const oauth2Client = initializeGoogleOAuth( process.env.GOOGLE_CLIENT_ID || '', process.env.GOOGLE_CLIENT_SECRET || '', process.env.GOOGLE_REDIRECT_URI || '' ); // Map to Google format const rrule = toRRule(event.recurrence); const googleEvent: any = {}; if (event.title !== undefined) googleEvent.summary = event.title; if (event.description !== undefined) googleEvent.description = event.description; if (event.start !== undefined) googleEvent.start = event.start; if (event.end !== undefined) googleEvent.end = event.end; if (event.location !== undefined) googleEvent.location = event.location; if (rrule) googleEvent.recurrence = [rrule]; if (event.url) googleEvent.source = { url: event.url, title: event.url }; // Google adds _date suffix for instances. Editing base series only. const baseEventId = eventId.split('_')[0]; const updatedEvent = await import('./google-calendar').then(m => m.updateEvent(oauth2Client, accessToken, calendarId, baseEventId, googleEvent) ); return { id: updatedEvent.id, title: updatedEvent.summary, description: updatedEvent.description, start: updatedEvent.start, end: updatedEvent.end, location: updatedEvent.location, source: 'google', calendarId, calendarTitle: '', } as CalendarEvent; } else if (connection.provider === 'outlook') { let accessToken = connection.accessToken; if (isTokenExpired(connection.expiresAt)) { const newToken = await refreshOutlookToken(connection); if (newToken) accessToken = newToken; else throw new Error('Failed to refresh token'); } // Extract base series ID for Outlook const baseEventId = eventId.includes('::') ? eventId.split('::')[0] : eventId; const startDate = event.start?.dateTime ? new Date(event.start.dateTime) : new Date(); const updatedEvent = await updateOutlookEvent(accessToken, calendarId, baseEventId, { summary: event.title, description: event.description, start: event.start, end: event.end, location: event.location, allDay: event.allDay, recurrence: toOutlookRecurrence(event.recurrence, startDate), }); return { id: updatedEvent.id, title: updatedEvent.summary, description: updatedEvent.description, start: updatedEvent.start, end: updatedEvent.end, location: updatedEvent.location, allDay: updatedEvent.allDay, source: 'outlook', calendarId, calendarTitle: '', } as CalendarEvent; } else if (connection.provider === 'apple') { const [email, appPassword] = connection.accessToken.split(':'); // Normalize for Apple updates too if (event.allDay) { if (event.start && event.start.dateTime && !event.start.date) { event.start.date = event.start.dateTime.split('T')[0]; delete event.start.dateTime; } if (event.end && event.end.dateTime && !event.end.date) { event.end.date = event.end.dateTime.split('T')[0]; delete event.end.dateTime; } if (event.start && event.end && event.start.date && event.end.date && event.start.date === event.end.date) { const d = new Date(event.end.date); d.setDate(d.getDate() + 1); event.end.date = d.toISOString().split('T')[0]; } } const updatedEvent = await import('./apple-calendar').then(m => m.updateEvent(email, appPassword, calendarId, eventId, { title: event.title, description: event.description, location: event.location, url: event.url, start: event.start, end: event.end }) ); return { id: updatedEvent.id, title: updatedEvent.title, description: updatedEvent.description, start: { dateTime: updatedEvent.startDate }, end: { dateTime: updatedEvent.endDate }, location: updatedEvent.location, url: updatedEvent.url, source: 'apple', calendarId, calendarTitle: '', } as CalendarEvent; } else if (connection.provider === 'synology') { const [username, password] = connection.accessToken.split(':'); const serverUrl = connection.refreshToken; if (!username || !password || !serverUrl) throw new Error('Invalid Synology credentials'); const updatedEvent = await import('./synology-calendar').then(m => m.updateEvent(serverUrl, username, password, calendarId, eventId, { title: event.title, description: event.description, location: event.location, url: event.url, start: event.start, end: event.end }) ); return { id: updatedEvent.id, title: updatedEvent.title, description: updatedEvent.description, start: { dateTime: updatedEvent.startDate }, end: { dateTime: updatedEvent.endDate }, location: updatedEvent.location, url: updatedEvent.url, source: 'synology', calendarId, calendarTitle: '', } as CalendarEvent; } throw new Error(`Provider ${connection.provider} does not support updating events yet.`); }; /** * Delete a calendar event */ export const deleteCalendarEvent = async ( connection: CalendarConnection, calendarId: string, eventId: string ): Promise => { if (connection.provider === 'google') { // Refresh token if needed let accessToken = connection.accessToken; if (isTokenExpired(connection.expiresAt)) { const newToken = await refreshGoogleToken(connection); if (newToken) accessToken = newToken; else throw new Error('Failed to refresh token'); } const oauth2Client = initializeGoogleOAuth( process.env.GOOGLE_CLIENT_ID || '', process.env.GOOGLE_CLIENT_SECRET || '', process.env.GOOGLE_REDIRECT_URI || '' ); // Default to deleting the whole series if repeating const baseEventId = eventId.split('_')[0]; await import('./google-calendar').then(m => m.deleteEvent(oauth2Client, accessToken, calendarId, baseEventId) ); return; } else if (connection.provider === 'outlook') { let accessToken = connection.accessToken; if (isTokenExpired(connection.expiresAt)) { const newToken = await refreshOutlookToken(connection); if (newToken) accessToken = newToken; else throw new Error('Failed to refresh token'); } // Extract base series ID for Outlook const baseEventId = eventId.includes('::') ? eventId.split('::')[0] : eventId; await deleteOutlookEvent(accessToken, calendarId, baseEventId); return; } else if (connection.provider === 'apple') { const [email, appPassword] = connection.accessToken.split(':'); await import('./apple-calendar').then(m => m.deleteEvent(email, appPassword, calendarId, eventId) ); return; } else if (connection.provider === 'synology') { const [username, password] = connection.accessToken.split(':'); const serverUrl = connection.refreshToken; if (!username || !password || !serverUrl) throw new Error('Invalid Synology credentials'); await import('./synology-calendar').then(m => m.deleteEvent(serverUrl, username, password, calendarId, eventId) ); return; } throw new Error(`Provider ${connection.provider} does not support deleting events yet.`); };