// Google Calendar OAuth wrapper import { GoogleCalendarEvent, getUserCalendars as getGoogleCalendars, getUpcomingEvents as getGoogleEvents, initializeOAuth as initializeGoogleOAuth } from './google-calendar'; import { AppleCalendarEvent, getUserCalendars as getAppleCalendars, getUpcomingEvents as getAppleEvents, initializeOAuth as initializeAppleOAuth } from './apple-calendar'; import { getUpcomingEvents as getOutlookEvents, refreshAccessToken as refreshOutlookTokenAPI, createEvent as createOutlookEvent, updateEvent as updateOutlookEvent, deleteEvent as deleteOutlookEvent } from './outlook-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; source: 'google' | 'apple' | 'outlook'; calendarId: string; calendarTitle: string; backgroundColor?: string; } // 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'; 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') { // ... Apple logic ... // Initialize Apple OAuth client const appleClient = initializeAppleOAuth( process.env.APPLE_CLIENT_ID || '', process.env.APPLE_CLIENT_SECRET || '', process.env.APPLE_REDIRECT_URI || '' ); // Get user calendars to identify which ones to fetch events from const calendars = await getAppleCalendars(appleClient, accessToken); const calendarIds = calendars.map(c => c.id); // Fetch events for each calendar for (const calendarId of calendarIds) { const calendarEvents = await getAppleEvents( appleClient, accessToken, calendarId, timeMin, timeMax ); events = events.concat(calendarEvents.map((event: any) => ({ ...event, source: 'apple' as const, calendarId, calendarTitle: calendars.find(c => c.id === calendarId)?.title || 'Apple Calendar' }))); } } 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.id === 'google' ? event.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 googleEvent: any = { summary: event.title, description: event.description, start: event.start, end: event.end, location: event.location, }; 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 createdEvent = await createOutlookEvent(accessToken, calendarId, { summary: event.title, description: event.description, start: event.start, end: event.end, location: event.location }); return { id: createdEvent.id, title: createdEvent.summary, description: createdEvent.description, start: createdEvent.start, end: createdEvent.end, location: createdEvent.location, source: 'outlook', 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 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; const updatedEvent = await import('./google-calendar').then(m => m.updateEvent(oauth2Client, accessToken, calendarId, eventId, 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'); } const updatedEvent = await updateOutlookEvent(accessToken, calendarId, eventId, { summary: event.title, description: event.description, start: event.start, end: event.end, location: event.location }); return { id: updatedEvent.id, title: updatedEvent.summary, description: updatedEvent.description, start: updatedEvent.start, end: updatedEvent.end, location: updatedEvent.location, source: 'outlook', 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 || '' ); await import('./google-calendar').then(m => m.deleteEvent(oauth2Client, accessToken, calendarId, eventId) ); 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'); } await deleteOutlookEvent(accessToken, calendarId, eventId); return; } throw new Error(`Provider ${connection.provider} does not support deleting events yet.`); };