feat: add CalDAV synchronization with automatic sync

- Add CaldavService with tsdav/ical.js for CalDAV server communication
- Add CaldavController, CaldavRepository, and caldav routes
- Add client-side CaldavConfigService with sync(), config CRUD
- Add CalDAV settings UI with config load/save in settings screen
- Sync on login, auto-login (AuthGuard), periodic timer (calendar), and sync button
- Push single events to CalDAV on server-side create/update/delete
- Push all events to CalDAV after chat event confirmation
- Refactor ChatService to use EventService instead of direct EventRepository
- Rename CalDav/calDav to Caldav/caldav for consistent naming
- Add Radicale Docker setup for local CalDAV testing
- Update PlantUML diagrams and CLAUDE.md with CalDAV architecture
This commit is contained in:
2026-02-08 19:24:59 +01:00
parent 81221d8b70
commit 325246826a
44 changed files with 7074 additions and 126 deletions

View File

@@ -0,0 +1,11 @@
// import { createLogger } from "../logging";
// import { CaldavService } from "./CaldavService";
//
// const logger = createLogger("CaldavService-Test");
//
// const cdService = new CaldavService();
//
// test("print events", async () => {
// const client = await cdService.login();
// await cdService.pullEvents(client);
// });

View File

@@ -0,0 +1,260 @@
import crypto from "crypto";
import { DAVClient } from "tsdav";
import ICAL from "ical.js";
import { createLogger } from "../logging/logger";
import { CaldavRepository } from "./interfaces/CaldavRepository";
import {
CalendarEvent,
CreateEventDTO,
} from "@calchat/shared/src/models/CalendarEvent";
import { EventService } from "./EventService";
import { CaldavConfig, formatDateKey } from "@calchat/shared";
const logger = createLogger("CaldavService");
export class CaldavService {
constructor(
private caldavRepo: CaldavRepository,
private eventService: EventService,
) {}
/**
* Login to CalDAV server and return client + first calendar.
*/
async connect(userId: string) {
const config = await this.caldavRepo.findByUserId(userId);
if (config === null) {
throw new Error(`Coudn't find config by user id ${userId}`);
}
const client = new DAVClient({
serverUrl: config.serverUrl,
credentials: {
username: config.username,
password: config.password,
},
authMethod: "Basic",
defaultAccountType: "caldav",
});
try {
await client.login();
} catch (error) {
throw new Error("Caldav login failed");
}
const calendars = await client.fetchCalendars();
if (calendars.length === 0) {
throw new Error("No calendars found on CalDAV server");
}
return { client, calendar: calendars[0] };
}
/**
* Pull events from CalDAV server and sync with local database.
* - Compares etags to skip unchanged events
* - Creates new or updates existing events in the database
* - Deletes local events that were removed on the CalDAV server
*
* @returns List of newly created or updated events
*/
async pullEvents(userId: string): Promise<CalendarEvent[]> {
const { client, calendar } = await this.connect(userId);
const calendarEvents: CalendarEvent[] = [];
const caldavEventUUIDs = new Set<string>();
const events = await client.fetchCalendarObjects({ calendar });
for (const event of events) {
const etag = event.etag;
const jcal = ICAL.parse(event.data);
const comp = new ICAL.Component(jcal);
// A CalendarObject (.ics file) can contain multiple VEVENTs (e.g.
// recurring events with RECURRENCE-ID exceptions), but the etag belongs
// to the whole file, not individual VEVENTs. We only need the first
// VEVENT since we handle recurrence via RRULE/exceptionDates, not as
// separate events.
const vevent = comp.getFirstSubcomponent("vevent");
if (!vevent) continue;
const icalEvent = new ICAL.Event(vevent);
caldavEventUUIDs.add(icalEvent.uid);
const exceptionDates = vevent
.getAllProperties("exdate")
.flatMap((prop) => prop.getValues())
.map((time: ICAL.Time) => formatDateKey(time.toJSDate()));
const existingEvent = await this.eventService.findByCaldavUUID(
userId,
icalEvent.uid,
);
const didChange = existingEvent?.etag !== etag;
if (existingEvent && !didChange) {
continue;
}
const eventObject: CreateEventDTO = {
caldavUUID: icalEvent.uid,
etag,
title: icalEvent.summary,
description: icalEvent.description,
startTime: icalEvent.startDate.toJSDate(),
endTime: icalEvent.endDate.toJSDate(),
recurrenceRule: vevent.getFirstPropertyValue("rrule")?.toString(),
exceptionDates,
caldavSyncStatus: "synced",
};
const calendarEvent = existingEvent
? await this.eventService.update(existingEvent.id, userId, eventObject)
: await this.eventService.create(userId, eventObject);
if (calendarEvent) {
calendarEvents.push(calendarEvent);
}
}
// delete all events, that got deleted remotely
const localEvents = await this.eventService.getAll(userId);
for (const localEvent of localEvents) {
if (
localEvent.caldavUUID &&
!caldavEventUUIDs.has(localEvent.caldavUUID)
) {
await this.eventService.delete(localEvent.id, userId);
}
}
return calendarEvents;
}
/**
* Push a single event to the CalDAV server.
* Creates a new event if no caldavUUID exists, updates otherwise.
*/
async pushEvent(userId: string, event: CalendarEvent): Promise<void> {
const { client, calendar } = await this.connect(userId);
try {
if (event.caldavUUID) {
await client.updateCalendarObject({
calendarObject: {
url: `${calendar.url}${event.caldavUUID}.ics`,
data: this.toICalString(event.caldavUUID, event),
etag: event.etag || "",
},
});
} else {
const uid = crypto.randomUUID();
await client.createCalendarObject({
calendar,
filename: `${uid}.ics`,
iCalString: this.toICalString(uid, event),
});
await this.eventService.update(event.id, userId, { caldavUUID: uid });
}
// Fetch updated etag from server
const objects = await client.fetchCalendarObjects({ calendar });
const caldavUUID =
event.caldavUUID ||
(await this.eventService.getById(event.id, userId))?.caldavUUID;
const pushed = objects.find((o) => o.data?.includes(caldavUUID!));
await this.eventService.update(event.id, userId, {
etag: pushed?.etag || undefined,
caldavSyncStatus: "synced",
});
} catch (error) {
await this.eventService.update(event.id, userId, {
caldavSyncStatus: "error",
});
throw error;
}
}
/**
* Build an iCalendar string from a CalendarEvent using ical.js.
*/
private toICalString(uid: string, event: CalendarEvent): string {
const vcalendar = new ICAL.Component("vcalendar");
vcalendar.addPropertyWithValue("version", "2.0");
vcalendar.addPropertyWithValue("prodid", "-//CalChat//EN");
const vevent = new ICAL.Component("vevent");
vevent.addPropertyWithValue("uid", uid);
vevent.addPropertyWithValue("summary", event.title);
vevent.addPropertyWithValue(
"dtstart",
ICAL.Time.fromJSDate(new Date(event.startTime)),
);
vevent.addPropertyWithValue(
"dtend",
ICAL.Time.fromJSDate(new Date(event.endTime)),
);
if (event.description) {
vevent.addPropertyWithValue("description", event.description);
}
if (event.recurrenceRule) {
// Strip RRULE: prefix if present — fromString expects only the value part,
// and addPropertyWithValue("rrule", ...) adds the RRULE: prefix automatically.
const rule = event.recurrenceRule.replace(/^RRULE:/i, "");
vevent.addPropertyWithValue("rrule", ICAL.Recur.fromString(rule));
}
if (event.exceptionDates?.length) {
for (const exdate of event.exceptionDates) {
vevent.addPropertyWithValue("exdate", ICAL.Time.fromDateString(exdate));
}
}
vcalendar.addSubcomponent(vevent);
return vcalendar.toString();
}
async pushAll(userId: string): Promise<void> {
const allEvents = await this.eventService.getAll(userId);
for (const event of allEvents) {
if (event.caldavSyncStatus !== "synced") {
await this.pushEvent(userId, event);
}
}
}
async deleteEvent(userId: string, caldavUUID: string) {
const { client, calendar } = await this.connect(userId);
await client.deleteCalendarObject({
calendarObject: {
url: `${calendar.url}${caldavUUID}.ics`,
},
});
}
async findEventByCaldavUUID(userId: string, caldavUUID: string) {
return this.eventService.findByCaldavUUID(userId, caldavUUID);
}
async getConfig(userId: string): Promise<CaldavConfig | null> {
return this.caldavRepo.findByUserId(userId);
}
async saveConfig(config: CaldavConfig): Promise<CaldavConfig> {
const savedConfig = await this.caldavRepo.createOrUpdate(config);
try {
await this.connect(savedConfig.userId);
} catch (error) {
await this.caldavRepo.deleteByUserId(savedConfig.userId);
throw new Error("failed to connect");
}
return savedConfig;
}
async deleteConfig(userId: string) {
return await this.caldavRepo.deleteByUserId(userId);
}
}

View File

@@ -12,7 +12,7 @@ import {
RecurringDeleteMode,
ConflictingEvent,
} from "@calchat/shared";
import { ChatRepository, EventRepository, AIProvider } from "./interfaces";
import { ChatRepository, AIProvider } from "./interfaces";
import { EventService } from "./EventService";
import { getWeeksOverview, getMonthOverview } from "../utils/eventFormatters";
@@ -333,7 +333,7 @@ const staticResponses: TestResponse[] = [
async function getTestResponse(
index: number,
eventRepo: EventRepository,
eventService: EventService,
userId: string,
): Promise<TestResponse> {
const responseIdx = index % staticResponses.length;
@@ -341,7 +341,7 @@ async function getTestResponse(
// === SPORT TEST SCENARIO (Dynamic responses) ===
// Response 1: Add exception to "Sport" (2 weeks later)
if (responseIdx === 1) {
const events = await eventRepo.findByUserId(userId);
const events = await eventService.getAll(userId);
const sportEvent = events.find((e) => e.title === "Sport");
if (sportEvent) {
// Calculate date 2 weeks from the first occurrence
@@ -380,7 +380,7 @@ async function getTestResponse(
// Response 2: Add UNTIL to "Sport" (after 6 weeks total)
if (responseIdx === 2) {
const events = await eventRepo.findByUserId(userId);
const events = await eventService.getAll(userId);
const sportEvent = events.find((e) => e.title === "Sport");
if (sportEvent) {
// Calculate UNTIL date: 6 weeks from start
@@ -418,7 +418,7 @@ async function getTestResponse(
// Response 3: Add another exception to "Sport" (2 weeks after the first exception)
if (responseIdx === 3) {
const events = await eventRepo.findByUserId(userId);
const events = await eventService.getAll(userId);
const sportEvent = events.find((e) => e.title === "Sport");
if (sportEvent) {
// Calculate date 4 weeks from the first occurrence (2 weeks after the first exception)
@@ -458,12 +458,12 @@ async function getTestResponse(
// Dynamic responses: fetch events from DB and format
// (Note: indices shifted by +3 due to new sport responses)
if (responseIdx === 6) {
return { content: await getWeeksOverview(eventRepo, userId, 2) };
return { content: await getWeeksOverview(eventService, userId, 2) };
}
if (responseIdx === 7) {
// Delete "Meeting mit Jens"
const events = await eventRepo.findByUserId(userId);
const events = await eventService.getAll(userId);
const jensEvent = events.find((e) => e.title === "Meeting mit Jens");
if (jensEvent) {
return {
@@ -487,12 +487,12 @@ async function getTestResponse(
}
if (responseIdx === 11) {
return { content: await getWeeksOverview(eventRepo, userId, 1) };
return { content: await getWeeksOverview(eventService, userId, 1) };
}
if (responseIdx === 13) {
// Update "Telefonat mit Mama" +3 days and change time to 13:00
const events = await eventRepo.findByUserId(userId);
const events = await eventService.getAll(userId);
const mamaEvent = events.find((e) => e.title === "Telefonat mit Mama");
if (mamaEvent) {
const newStart = new Date(mamaEvent.startTime);
@@ -527,7 +527,7 @@ async function getTestResponse(
const now = new Date();
return {
content: await getMonthOverview(
eventRepo,
eventService,
userId,
now.getFullYear(),
now.getMonth(),
@@ -541,7 +541,6 @@ async function getTestResponse(
export class ChatService {
constructor(
private chatRepo: ChatRepository,
private eventRepo: EventRepository,
private eventService: EventService,
private aiProvider: AIProvider,
) {}
@@ -566,7 +565,7 @@ export class ChatService {
if (process.env.USE_TEST_RESPONSES === "true") {
// Test mode: use static responses
response = await getTestResponse(responseIndex, this.eventRepo, userId);
response = await getTestResponse(responseIndex, this.eventService, userId);
responseIndex++;
} else {
// Production mode: use real AI
@@ -582,7 +581,7 @@ export class ChatService {
return this.eventService.getByDateRange(userId, start, end);
},
searchEvents: async (query) => {
return this.eventRepo.searchByTitle(userId, query);
return this.eventService.searchByTitle(userId, query);
},
fetchEventById: async (eventId) => {
return this.eventService.getById(eventId, userId);
@@ -623,10 +622,10 @@ export class ChatService {
let content: string;
if (action === "create" && event) {
const createdEvent = await this.eventRepo.create(userId, event);
const createdEvent = await this.eventService.create(userId, event);
content = `Der Termin "${createdEvent.title}" wurde erstellt.`;
} else if (action === "update" && eventId && updates) {
const updatedEvent = await this.eventRepo.update(eventId, updates);
const updatedEvent = await this.eventService.update(eventId, userId, updates);
content = updatedEvent
? `Der Termin "${updatedEvent.title}" wurde aktualisiert.`
: "Termin nicht gefunden.";

View File

@@ -24,10 +24,18 @@ export class EventService {
return event;
}
async findByCaldavUUID(userId: string, caldavUUID: string): Promise<CalendarEvent | null> {
return this.eventRepo.findByCaldavUUID(userId, caldavUUID);
}
async getAll(userId: string): Promise<CalendarEvent[]> {
return this.eventRepo.findByUserId(userId);
}
async searchByTitle(userId: string, query: string): Promise<CalendarEvent[]> {
return this.eventRepo.searchByTitle(userId, query);
}
async getByDateRange(
userId: string,
startDate: Date,

View File

@@ -0,0 +1,7 @@
import { CaldavConfig } from "@calchat/shared/src/models/CaldavConfig";
export interface CaldavRepository {
findByUserId(userId: string): Promise<CaldavConfig | null>;
createOrUpdate(config: CaldavConfig): Promise<CaldavConfig>;
deleteByUserId(userId: string): Promise<boolean>;
}

View File

@@ -8,6 +8,7 @@ export interface EventRepository {
startDate: Date,
endDate: Date,
): Promise<CalendarEvent[]>;
findByCaldavUUID(userId: string, caldavUUID: string): Promise<CalendarEvent | null>;
searchByTitle(userId: string, query: string): Promise<CalendarEvent[]>;
create(userId: string, data: CreateEventDTO): Promise<CalendarEvent>;
update(id: string, data: UpdateEventDTO): Promise<CalendarEvent | null>;