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 { getUpcomingEvents as getNotionEvents, refreshAccessToken as refreshNotionToken } from './notion-calendar'; import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); export interface EventReminder { method: 'popup' | 'email' | 'display'; // display = VALARM DISPLAY, popup = Google popup, email = email reminder minutes: number; // minutes before event } export interface EventAttendee { email: string; displayName?: string; responseStatus?: 'needsAction' | 'accepted' | 'declined' | 'tentative'; } export interface EventAttachment { url: string; title?: string; } export type BusyStatus = 'free' | 'tentative' | 'busy' | 'oof' | 'workingElsewhere'; export type EventVisibility = 'default' | 'public' | 'private' | 'confidential'; 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; recurrenceEndDate?: string; recurrenceCount?: number; recurrenceInterval?: number; recurrenceDays?: number[]; timezone?: string; recurringEventId?: string; isRecurring?: boolean; source: 'google' | 'apple' | 'outlook' | 'synology' | 'notion'; calendarId: string; calendarTitle: string; backgroundColor?: string; allDay?: boolean; reminders?: EventReminder[]; busyStatus?: BusyStatus; visibility?: EventVisibility; attendees?: EventAttendee[]; attachments?: EventAttachment[]; } /** * Convert friendly recurrence name to RRULE string */ function toRRule(recurrence?: string, recurrenceEndDate?: string, recurrenceCount?: number, recurrenceInterval?: number, recurrenceDays?: number[]): string | null { let freq: string; switch (recurrence) { case 'daily': freq = 'FREQ=DAILY'; break; case 'weekly': freq = 'FREQ=WEEKLY'; break; case 'biweekly': freq = 'FREQ=WEEKLY;INTERVAL=2'; break; case 'monthly': freq = 'FREQ=MONTHLY'; break; case 'yearly': freq = 'FREQ=YEARLY'; break; default: return null; } let rrule = `RRULE:${freq}`; if (recurrenceInterval && recurrenceInterval > 1 && recurrence !== 'biweekly') { rrule += `;INTERVAL=${recurrenceInterval}`; } if (recurrenceDays && recurrenceDays.length > 0 && recurrence === 'weekly') { const dayMap = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']; rrule += `;BYDAY=${recurrenceDays.map(d => dayMap[d]).join(',')}`; } if (recurrenceCount && recurrenceCount > 0) { rrule += `;COUNT=${recurrenceCount}`; } else if (recurrenceEndDate) { const d = new Date(recurrenceEndDate); d.setHours(23, 59, 59); rrule += `;UNTIL=${d.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')}`; } return rrule; } /** * Convert friendly recurrence name to Outlook Graph recurrence object */ function toOutlookRecurrence(recurrence?: string, startDate?: Date, recurrenceEndDate?: string, recurrenceCount?: number, recurrenceInterval?: number, recurrenceDays?: number[]): any { if (!recurrence || recurrence === 'none') return undefined; const start = startDate || new Date(); const interval = recurrenceInterval || (recurrence === 'biweekly' ? 2 : 1); const dayNames = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; let range: any = { type: 'noEnd', startDate: start.toISOString().split('T')[0], }; if (recurrenceCount && recurrenceCount > 0) { range = { type: 'numbered', startDate: start.toISOString().split('T')[0], numberOfOccurrences: recurrenceCount }; } else if (recurrenceEndDate) { range = { type: 'endDate', startDate: start.toISOString().split('T')[0], endDate: recurrenceEndDate }; } switch (recurrence) { case 'daily': return { pattern: { type: 'daily', interval }, range }; case 'weekly': case 'biweekly': { const days = recurrenceDays && recurrenceDays.length > 0 ? recurrenceDays.map(d => dayNames[d]) : [dayNames[start.getDay()]]; return { pattern: { type: 'weekly', interval, daysOfWeek: days }, range }; } case 'monthly': return { pattern: { type: 'absoluteMonthly', interval, dayOfMonth: start.getDate() }, range }; case 'yearly': return { pattern: { type: 'absoluteYearly', interval, 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; // Map Google reminders to our format const reminders: EventReminder[] | undefined = event.reminders?.overrides?.map((r: any) => ({ method: r.method === 'email' ? 'email' : 'popup', minutes: r.minutes, })) || undefined; // Map Google transparency to busyStatus const busyStatus: BusyStatus | undefined = event.transparency === 'transparent' ? 'free' : event.transparency === 'opaque' ? 'busy' : undefined; return { id: event.id, title: event.summary || '(No 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, reminders, busyStatus, visibility: event.visibility as EventVisibility || undefined, attendees: event.attendees?.map((a: any) => ({ email: a.email, displayName: a.displayName, responseStatus: a.responseStatus, })) as EventAttendee[] || undefined, }; })); } 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 freshCalendars = await getAppleCalendars(email, appPassword); // Use stored selection state if available, otherwise use all fresh calendars let calendars = freshCalendars; if (connection.calendars && Array.isArray(connection.calendars) && connection.calendars.length > 0) { const storedCalendars = connection.calendars as any[]; // Only fetch from calendars that are selected calendars = freshCalendars.filter(fc => { const stored = storedCalendars.find((sc: any) => sc.id === fc.id); return stored ? stored.selected !== false : true; }); } 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) => { 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', reminders: event.reminders as EventReminder[] || undefined, busyStatus: event.busyStatus as BusyStatus || undefined, visibility: event.visibility as EventVisibility || undefined, attendees: event.attendees as EventAttendee[] || undefined, attachments: event.attachments as EventAttachment[] || undefined, }; })); } } 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; } // Fetch live calendar list from Synology to detect deleted calendars let calendarIds: string[] = []; let calendars: any[] = []; let freshCalendars: any[] = []; try { freshCalendars = await getSynologyCalendars(serverUrl, username, password); } catch (err) { console.error('[CALENDAR] Synology: failed to fetch calendar list:', err); } const freshIds = new Set(freshCalendars.map(c => c.id)); if (connection.calendars && Array.isArray(connection.calendars) && connection.calendars.length > 0) { const storedCalendars = connection.calendars as any[]; // Remove calendars that no longer exist on the server calendars = storedCalendars.filter((c: any) => freshIds.has(c.id)); if (calendars.length < storedCalendars.length) { const removed = storedCalendars.length - calendars.length; console.log(`[CALENDAR] Synology: pruned ${removed} deleted calendar(s) from stored list`); // Update stored calendars in DB to remove stale entries try { await prisma.calendarConnection.update({ where: { id: connection.id }, data: { calendars: calendars }, }); } catch (dbErr) { console.error('[CALENDAR] Synology: failed to update stored calendars:', dbErr); } } 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 { // No stored calendars: use fresh list console.log('[CALENDAR] Synology: no stored calendars, using fresh list'); calendars = freshCalendars; calendarIds = freshCalendars.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); const freshCal = freshCalendars.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?.backgroundColor || calendarData?.color || freshCal?.color || '#1b85ff', reminders: event.reminders as EventReminder[] || undefined, busyStatus: event.busyStatus as BusyStatus || undefined, visibility: event.visibility as EventVisibility || undefined, attendees: event.attendees as EventAttendee[] || undefined, attachments: event.attachments as EventAttachment[] || undefined, }; })); } catch (calError: any) { const msg = calError?.message || ''; if (msg.includes('not found') || msg.includes('Not found') || calError?.status === 404) { console.warn(`[CALENDAR] Synology: calendar ${calendarId} not found, removing from stored list`); calendars = calendars.filter((c: any) => c.id !== calendarId); try { await prisma.calendarConnection.update({ where: { id: connection.id }, data: { calendars: calendars }, }); } catch (dbErr) { console.error('[CALENDAR] Synology: failed to prune calendar from DB:', dbErr); } } else { 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: calendarData?.backgroundColor || calendarData?.color || '#0078d4', reminders: event.reminders as EventReminder[] || undefined, busyStatus: event.busyStatus as BusyStatus || undefined, visibility: event.visibility as EventVisibility || undefined, attendees: event.attendees as EventAttendee[] || undefined, }))); } catch (calError) { console.error(`[CALENDAR] Error fetching Outlook events from ${calendarId}:`, calError); } } } } else if (connection.provider === 'notion') { console.log('[CALENDAR] Processing Notion connection:', connection.id); // Refresh token if available if (connection.refreshToken) { try { const refreshed = await refreshNotionToken( connection.refreshToken, process.env.NOTION_CLIENT_ID || '', process.env.NOTION_CLIENT_SECRET || '', ); accessToken = refreshed.accessToken; await prisma.calendarConnection.update({ where: { id: connection.id }, data: { accessToken: refreshed.accessToken, refreshToken: refreshed.refreshToken, updatedAt: new Date(), }, }); } catch (refreshErr) { console.error('[CALENDAR] Notion token refresh failed, using existing token:', refreshErr); } } // Get selected databases const databases = (connection.calendars as any[] || []).filter((c: any) => c.selected !== false); if (databases.length === 0) { console.log('[CALENDAR] Notion: no databases selected, skipping'); } for (const db of databases) { if (!db.dateProperty) { console.log(`[CALENDAR] Notion: database "${db.title}" has no date property, skipping`); continue; } try { const notionEvents = await getNotionEvents( accessToken, db.id, db.dateProperty, timeMin, timeMax, db.title, ); console.log(`[CALENDAR] Notion: fetched ${notionEvents.length} events from "${db.title}"`); events = events.concat(notionEvents.map((ne) => ({ id: ne.id, title: ne.title, description: ne.description, start: { dateTime: ne.allDay ? undefined : ne.start, date: ne.allDay ? ne.start : undefined, }, end: { dateTime: ne.allDay ? undefined : (ne.end || ne.start), date: ne.allDay ? (ne.end || ne.start) : undefined, }, location: undefined, url: ne.url, source: 'notion' as const, calendarId: db.id, calendarTitle: db.title, backgroundColor: '#000000', }))); } catch (dbError) { console.error(`[CALENDAR] Notion: error fetching from "${db.title}":`, dbError); } } } 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, event.recurrenceEndDate, event.recurrenceCount, event.recurrenceInterval, event.recurrenceDays); 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 } } : {}), ...(event.reminders?.length ? { reminders: { useDefault: false, overrides: event.reminders.map(r => ({ method: r.method === 'email' ? 'email' : 'popup', minutes: r.minutes })) } } : {}), ...(event.busyStatus ? { transparency: event.busyStatus === 'free' ? 'transparent' : 'opaque' } : {}), ...(event.visibility ? { visibility: event.visibility } : {}), ...(event.attendees?.length ? { attendees: event.attendees.map(a => ({ email: a.email, displayName: a.displayName })) } : {}), }; 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, event.recurrenceEndDate, event.recurrenceCount, event.recurrenceInterval, event.recurrenceDays), reminders: event.reminders, busyStatus: event.busyStatus, visibility: event.visibility, attendees: event.attendees, }); 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, recurrenceEndDate: event.recurrenceEndDate, recurrenceCount: event.recurrenceCount, recurrenceInterval: event.recurrenceInterval, recurrenceDays: event.recurrenceDays, timezone: event.timezone, start, end, reminders: event.reminders, attendees: event.attendees, attachments: event.attachments, busyStatus: event.busyStatus, visibility: event.visibility, }) ); 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, recurrenceEndDate: event.recurrenceEndDate, recurrenceCount: event.recurrenceCount, recurrenceInterval: event.recurrenceInterval, recurrenceDays: event.recurrenceDays, timezone: event.timezone, start: event.start!, end: event.end!, reminders: event.reminders, attendees: event.attendees, attachments: event.attachments, busyStatus: event.busyStatus, visibility: event.visibility, }) ); 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; } else if (connection.provider === 'notion') { if (!event.title) throw new Error('Event title is required'); if (!event.start) throw new Error('Event start time is required'); const databases = (connection.calendars as any[] || []); const db = databases.find((d: any) => d.id === calendarId); const dateProperty = db?.dateProperty; if (!dateProperty) throw new Error('No date property found for this Notion database'); const startDate = event.start.dateTime || event.start.date || ''; const endDate = event.end?.dateTime || event.end?.date || undefined; const { createEvent: createNotionEvent } = await import('./notion-calendar'); const pageId = await createNotionEvent( connection.accessToken, calendarId, dateProperty, event.title, startDate, endDate, ); return { id: pageId, title: event.title, start: event.start, end: event.end || event.start, source: 'notion', calendarId, calendarTitle: db?.title || '', } 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, event.recurrenceEndDate, event.recurrenceCount, event.recurrenceInterval, event.recurrenceDays); 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 }; if (event.reminders?.length) { googleEvent.reminders = { useDefault: false, overrides: event.reminders.map(r => ({ method: r.method === 'email' ? 'email' : 'popup', minutes: r.minutes })) }; } if (event.busyStatus) googleEvent.transparency = event.busyStatus === 'free' ? 'transparent' : 'opaque'; if (event.visibility) googleEvent.visibility = event.visibility; if (event.attendees) googleEvent.attendees = event.attendees.map(a => ({ email: a.email, displayName: a.displayName })); // 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, event.recurrenceEndDate, event.recurrenceCount, event.recurrenceInterval, event.recurrenceDays), reminders: event.reminders, busyStatus: event.busyStatus, visibility: event.visibility, attendees: event.attendees, }); 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, reminders: event.reminders, attendees: event.attendees, attachments: event.attachments, busyStatus: event.busyStatus, visibility: event.visibility, }) ); 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, reminders: event.reminders, attendees: event.attendees, attachments: event.attachments, busyStatus: event.busyStatus, visibility: event.visibility, }) ); 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; } else if (connection.provider === 'notion') { const databases = (connection.calendars as any[] || []); const db = databases.find((d: any) => d.id === calendarId); const dateProperty = db?.dateProperty; if (!dateProperty) throw new Error('No date property found for this Notion database'); const startDate = event.start?.dateTime || event.start?.date || undefined; const endDate = event.end?.dateTime || event.end?.date || undefined; const { updateEvent: updateNotionEvent } = await import('./notion-calendar'); await updateNotionEvent( connection.accessToken, eventId, dateProperty, event.title, startDate, endDate, ); return { id: eventId, title: event.title || '', start: event.start || { dateTime: '' }, end: event.end || event.start || { dateTime: '' }, source: 'notion', calendarId, calendarTitle: db?.title || '', } 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; } else if (connection.provider === 'notion') { const { deleteEvent: deleteNotionEvent } = await import('./notion-calendar'); await deleteNotionEvent(connection.accessToken, eventId); return; } throw new Error(`Provider ${connection.provider} does not support deleting events yet.`); };