- Use TZID-based local time (instead of UTC 'Z') for DTSTART/DTEND when creating recurring CalDAV events, preventing DST-related time shifts (e.g. 13:40 CET showing as 14:40 CEST after clock change) - Send browser timezone from CalendarEventModal to server - Guard against NaN event duration (missing/invalid endTime) which caused event blocks to stretch to end of day v1.61.1 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1335 lines
50 KiB
TypeScript
1335 lines
50 KiB
TypeScript
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<string, string> = {
|
|
'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<string | null> {
|
|
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<string | null> {
|
|
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<CalendarEvent[]> => {
|
|
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<CalendarEvent>
|
|
): Promise<CalendarEvent> => {
|
|
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<CalendarEvent>, 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<CalendarEvent>
|
|
): Promise<CalendarEvent> => {
|
|
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<void> => {
|
|
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.`);
|
|
}; |