import dayjs from "dayjs";
import ICAL from "ical.js";
import { createDAVClient, type DAVCalendar, type DAVObject } from "tsdav";
import type { LocalCache } from "../types/cache.types.js";
import type {
Attendee,
CalDavConnection,
CalendarEvent,
CalendarSearchOptions,
FreeBusyInfo,
} from "../types/calendar.types.js";
import {
CircuitBreaker,
type CircuitBreakerConfig,
type CircuitBreakerMetrics,
} from "./CircuitBreaker.js";
import { createLogger } from "./Logger.js";
export class CalendarService {
private connection: CalDavConnection;
private cache: LocalCache;
private client: Awaited<ReturnType<typeof createDAVClient>> | null = null;
private circuitBreaker: CircuitBreaker;
private logger = createLogger("CalendarService");
constructor(
connection: CalDavConnection,
cache: LocalCache,
circuitBreakerConfig?: CircuitBreakerConfig,
) {
this.connection = connection;
this.cache = cache;
// Initialize circuit breaker with defaults or provided config
const cbConfig = circuitBreakerConfig || {
failureThreshold: 3,
recoveryTimeout: 5000, // 5 seconds
monitoringInterval: 2000, // 2 seconds
};
this.circuitBreaker = new CircuitBreaker(cbConfig);
}
protected async getClient(): Promise<
Awaited<ReturnType<typeof createDAVClient>>
> {
if (!this.client) {
this.client = await createDAVClient({
serverUrl: this.connection.baseUrl,
credentials: {
username: this.connection.username,
password: this.connection.password,
},
authMethod: "Basic",
defaultAccountType: "caldav",
});
}
return this.client;
}
async getCalendarEvents(
options: CalendarSearchOptions,
): Promise<CalendarEvent[]> {
const cacheKey = `calendar_events:${JSON.stringify(options)}`;
const cached = this.cache.get<CalendarEvent[]>(cacheKey);
if (cached) {
return cached;
}
// Try to get fresh data, fallback to stale cache on connection failure
try {
const events = await this.fetchCalendarEvents(options);
this.cache.set(cacheKey, events, 900000); // 15 minutes TTL
return events;
} catch (error) {
// Fallback: Return stale cache if available
if (this.isConnectionError(error)) {
const staleEvents = this.tryGetStaleCache<CalendarEvent[]>(cacheKey);
if (staleEvents) {
this.logger.warning(
"Returning stale calendar events due to connection failure",
{
operation: "getCalendarEvents",
service: "CalendarService",
},
);
return staleEvents;
}
}
throw error;
}
}
async searchCalendar(
options: CalendarSearchOptions,
): Promise<CalendarEvent[]> {
const cacheKey = `calendar_search:${JSON.stringify(options)}`;
const cached = this.cache.get<CalendarEvent[]>(cacheKey);
if (cached) {
return cached;
}
// Try to get fresh data, fallback to stale cache on connection failure
try {
const events = await this.fetchCalendarEvents(options);
const filteredEvents = this.filterEventsByQuery(events, options.query);
this.cache.set(cacheKey, filteredEvents, 900000); // 15 minutes TTL
return filteredEvents;
} catch (error) {
// Fallback: Return stale cache if available
if (this.isConnectionError(error)) {
const staleEvents = this.tryGetStaleCache<CalendarEvent[]>(cacheKey);
if (staleEvents) {
this.logger.warning(
"Returning stale calendar search results due to connection failure",
{
operation: "searchCalendar",
service: "CalendarService",
},
);
return staleEvents;
}
}
throw error;
}
}
async getFreeBusy(
start: Date,
end: Date,
calendar?: string,
): Promise<FreeBusyInfo> {
const cacheKey = `freebusy:${start.toISOString()}:${end.toISOString()}:${calendar || "all"}`;
const cached = this.cache.get<FreeBusyInfo>(cacheKey);
if (cached) {
return cached;
}
// Try to get fresh data, fallback to stale cache on connection failure
try {
const events = await this.fetchCalendarEvents({
start,
end,
calendar,
});
const freeBusy = this.calculateFreeBusy(events, start, end);
this.cache.set(cacheKey, freeBusy, 300000); // 5 minutes TTL
return freeBusy;
} catch (error) {
// Fallback: Return stale cache if available
if (this.isConnectionError(error)) {
const staleFreeBusy = this.tryGetStaleCache<FreeBusyInfo>(cacheKey);
if (staleFreeBusy) {
this.logger.warning(
"Returning stale free/busy info due to connection failure",
{
operation: "getFreeBusy",
service: "CalendarService",
},
);
return staleFreeBusy;
}
}
throw error;
}
}
private async fetchCalendarEvents(
options: CalendarSearchOptions,
): Promise<CalendarEvent[]> {
try {
const calendars =
this.connection.calendars || (await this.discoverCalendars());
const allEvents: CalendarEvent[] = [];
for (const calendar of calendars) {
if (options.calendar && calendar !== options.calendar) {
continue;
}
const calendarEvents = await this.fetchEventsFromCalendar(
calendar,
options,
);
allEvents.push(...calendarEvents);
}
return this.sortAndLimitEvents(allEvents, options);
} catch (error) {
this.logger.error(
"Error fetching calendar events",
{
operation: "getEvents",
service: "CalendarService",
},
{
options,
error: error instanceof Error ? error.message : String(error),
},
);
return [];
}
}
protected async discoverCalendars(): Promise<string[]> {
try {
const calendars = await this.circuitBreaker.execute(async () => {
const client = await this.getClient();
return await client.fetchCalendars();
});
return calendars.map(
(cal: DAVCalendar) => (cal.displayName as string) || cal.url,
);
} catch (error) {
this.logger.error(
"Calendar discovery failed",
{
operation: "getCalendarList",
service: "CalendarService",
},
{ error: error instanceof Error ? error.message : String(error) },
);
return ["personal"];
}
}
private async fetchEventsFromCalendar(
calendar: string,
options: CalendarSearchOptions,
): Promise<CalendarEvent[]> {
try {
const { calendars, calendarObjects } = await this.circuitBreaker.execute(
async () => {
const client = await this.getClient();
const calendars = await client.fetchCalendars();
const targetCalendar = calendars.find(
(cal: DAVCalendar) =>
cal.displayName === calendar || cal.url.includes(calendar),
);
if (!targetCalendar) {
throw new Error(`Calendar ${calendar} not found`);
}
const calendarObjects = await client.fetchCalendarObjects({
calendar: targetCalendar,
timeRange:
options.start && options.end
? {
start: options.start.toISOString(),
end: options.end.toISOString(),
}
: undefined,
});
return { calendars, calendarObjects };
},
);
return await this.parseCalendarObjects(calendarObjects, calendar);
} catch (error) {
this.logger.error(
`Error fetching events from calendar ${calendar}`,
{
operation: "fetchCalendarEvents",
service: "CalendarService",
},
{
calendar,
options,
error: error instanceof Error ? error.message : String(error),
},
);
return [];
}
}
private parseCalendarObjects(
calendarObjects: DAVObject[],
calendar: string,
): CalendarEvent[] {
const events: CalendarEvent[] = [];
try {
for (const obj of calendarObjects) {
if (obj.data) {
const parsedEvents = this.parseICalData(obj.data, calendar);
events.push(...parsedEvents);
}
}
} catch (error) {
this.logger.error(
"Error parsing calendar objects",
{
operation: "parseCalendarObjects",
service: "CalendarService",
},
{ error: error instanceof Error ? error.message : String(error) },
);
}
return events;
}
private parseICalData(icalData: string, calendar: string): CalendarEvent[] {
const events: CalendarEvent[] = [];
try {
const jcalData = ICAL.parse(icalData);
const comp = new ICAL.Component(jcalData);
const vevents = comp.getAllSubcomponents("vevent");
for (const vevent of vevents) {
const event = this.parseVEvent(vevent, calendar);
if (event) {
events.push(event);
}
}
} catch (error) {
this.logger.error(
"Error parsing iCal data",
{
operation: "parseICalData",
service: "CalendarService",
},
{ error: error instanceof Error ? error.message : String(error) },
);
}
return events;
}
private parseVEvent(vevent: unknown, calendar: string): CalendarEvent | null {
try {
const event = new ICAL.Event(vevent as ICAL.Component);
const basicInfo = this.extractBasicEventInfo(event);
if (!this.hasValidDates(event)) {
return null;
}
const timeInfo = this.extractTimeInfo(event);
const participantInfo = this.extractParticipants(event);
const metadataInfo = this.extractMetadata(event, calendar);
return {
...basicInfo,
...timeInfo,
...participantInfo,
...metadataInfo,
};
} catch (error) {
this.logger.error(
"Error parsing VEVENT",
{
operation: "parseVEvent",
service: "CalendarService",
},
{ error: error instanceof Error ? error.message : String(error) },
);
return null;
}
}
private extractBasicEventInfo(
event: ICAL.Event,
): Pick<
CalendarEvent,
"id" | "uid" | "summary" | "description" | "location"
> {
const uid = event.uid || `${Date.now()}-${Math.random()}`;
return {
id: uid,
uid,
summary: event.summary || "",
description: event.description,
location: event.location,
};
}
private hasValidDates(event: ICAL.Event): boolean {
return !!(event.startDate && event.endDate);
}
private extractTimeInfo(
event: ICAL.Event,
): Pick<
CalendarEvent,
"start" | "end" | "allDay" | "recurring" | "recurrenceRule"
> {
return {
start: event.startDate.toJSDate(),
end: event.endDate.toJSDate(),
allDay: event.startDate.isDate,
recurring: event.isRecurring(),
recurrenceRule: event.component
.getFirstPropertyValue("rrule")
?.toString(),
};
}
private extractParticipants(
event: ICAL.Event,
): Pick<CalendarEvent, "attendees" | "organizer"> {
const attendees = this.parseAttendees(event.attendees);
const organizer = this.parseOrganizer(event.organizer);
return {
attendees: attendees.length > 0 ? attendees : undefined,
organizer,
};
}
private parseAttendees(attendees: unknown[] | null | undefined): Attendee[] {
// Früher Return mit leerem Array wenn attendees falsy ist
if (!attendees || !Array.isArray(attendees)) {
return [];
}
return attendees.map((attendee: unknown) => {
const email = this.extractEmail(attendee);
return {
email: email.replace("mailto:", ""),
name: this.extractName(attendee, email),
status: this.extractStatus(attendee),
};
});
}
private parseOrganizer(
organizer: unknown,
): { email: string; name: string } | undefined {
if (!organizer) {
return undefined;
}
const email = this.extractEmail(organizer).replace("mailto:", "");
return {
email,
name: this.extractName(organizer, email),
};
}
private extractEmail(participant: unknown): string {
const typedParticipant = participant as { getFirstValue?: () => string };
return typedParticipant.getFirstValue
? typedParticipant.getFirstValue()
: String(participant);
}
private extractName(participant: unknown, email: string): string {
const typedParticipant = participant as {
getParameter?: (name: string) => string;
};
return typedParticipant.getParameter
? typedParticipant.getParameter("cn")
: email.replace("mailto:", "").split("@")[0];
}
private extractStatus(
attendee: unknown,
): "needs-action" | "accepted" | "declined" | "tentative" {
// Handle null/undefined or non-object attendees
if (!attendee || typeof attendee !== "object") {
return "needs-action";
}
const typedAttendee = attendee as {
getParameter?: (name: string) => string;
};
const status = typedAttendee.getParameter
? typedAttendee.getParameter("partstat") || "needs-action"
: "needs-action";
// Map iCal status values to our expected types
switch (status.toLowerCase()) {
case "accepted":
return "accepted";
case "declined":
return "declined";
case "tentative":
return "tentative";
default:
return "needs-action";
}
}
private extractMetadata(
event: ICAL.Event,
calendar: string,
): Pick<CalendarEvent, "calendar" | "categories" | "created" | "modified"> {
const categories = this.parseCategories(
event.component.getFirstPropertyValue("categories"),
);
return {
calendar,
categories,
created:
this.parseICalDate(event.component.getFirstPropertyValue("created")) ||
new Date(),
modified:
this.parseICalDate(
event.component.getFirstPropertyValue("last-modified"),
) || new Date(),
};
}
private parseCategories(categories: unknown): string[] {
return categories && typeof categories === "string"
? categories.split(",").map((cat: string) => cat.trim())
: [];
}
private parseICalDate(value: unknown): Date | null {
if (!value) return null;
const typedValue = value as { toJSDate?: () => Date };
if (typedValue.toJSDate && typeof typedValue.toJSDate === "function") {
return typedValue.toJSDate();
}
if (typeof value === "string") {
return dayjs(value).toDate();
}
return null;
}
private filterEventsByQuery(
events: CalendarEvent[],
query?: string,
): CalendarEvent[] {
if (!query) return events;
const searchTerm = query.toLowerCase();
return events.filter(
event =>
event.summary.toLowerCase().includes(searchTerm) ||
event.description?.toLowerCase().includes(searchTerm) ||
event.location?.toLowerCase().includes(searchTerm),
);
}
private sortAndLimitEvents(
events: CalendarEvent[],
options: CalendarSearchOptions,
): CalendarEvent[] {
const sorted = events.sort((a, b) => a.start.getTime() - b.start.getTime());
if (options.offset || options.limit) {
const start = options.offset || 0;
const end = options.limit ? start + options.limit : undefined;
return sorted.slice(start, end);
}
return sorted;
}
private calculateFreeBusy(
events: CalendarEvent[],
start: Date,
end: Date,
): FreeBusyInfo {
const busy: Array<{ start: Date; end: Date; summary?: string }> = [];
for (const event of events) {
if (event.start < end && event.end > start) {
busy.push({
start: new Date(Math.max(event.start.getTime(), start.getTime())),
end: new Date(Math.min(event.end.getTime(), end.getTime())),
summary: event.summary,
});
}
}
// Calculate free time slots
const free: Array<{ start: Date; end: Date }> = [];
busy.sort((a, b) => a.start.getTime() - b.start.getTime());
let currentTime = start;
for (const busySlot of busy) {
if (currentTime < busySlot.start) {
free.push({
start: new Date(currentTime),
end: new Date(busySlot.start),
});
}
currentTime = new Date(
Math.max(currentTime.getTime(), busySlot.end.getTime()),
);
}
if (currentTime < end) {
free.push({
start: new Date(currentTime),
end: new Date(end),
});
}
return { start, end, busy, free };
}
private isConnectionError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
const message = error.message.toLowerCase();
return (
message.includes("connection") ||
message.includes("timeout") ||
message.includes("econnreset") ||
message.includes("enotfound") ||
message.includes("econnrefused") ||
message.includes("circuit breaker is open")
);
}
private tryGetStaleCache<T>(key: string): T | null {
// Try to get data from cache even if expired
return this.cache.getStale<T>(key);
}
// Get circuit breaker metrics
getCircuitBreakerMetrics(): CircuitBreakerMetrics {
return this.circuitBreaker.getMetrics();
}
// Reset circuit breaker (for administrative purposes)
resetCircuitBreaker(): void {
this.circuitBreaker.reset();
}
// Clean up resources
async disconnect(): Promise<void> {
this.client = null;
this.circuitBreaker.reset();
}
}