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,85 @@
import { Response } from "express";
import { createLogger } from "../logging/logger";
import { AuthenticatedRequest } from "./AuthMiddleware";
import { CaldavConfig } from "@calchat/shared";
import { CaldavService } from "../services/CaldavService";
const log = createLogger("CaldavController");
export class CaldavController {
constructor(private caldavService: CaldavService) {}
async saveConfig(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const config: CaldavConfig = { userId: req.user!.userId, ...req.body };
const response = await this.caldavService.saveConfig(config);
res.json(response);
} catch (error) {
log.error({ error, userId: req.user?.userId }, "Error saving config");
res.status(500).json({ error: "Failed to save config" });
}
}
async loadConfig(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const config = await this.caldavService.getConfig(req.user!.userId);
if (!config) {
res.status(404).json({ error: "No CalDAV config found" });
return;
}
// Don't expose the password to the client
res.json(config);
} catch (error) {
log.error({ error, userId: req.user?.userId }, "Error loading config");
res.status(500).json({ error: "Failed to load config" });
}
}
async deleteConfig(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
await this.caldavService.deleteConfig(req.user!.userId);
res.status(204).send();
} catch (error) {
log.error({ error, userId: req.user?.userId }, "Error deleting config");
res.status(500).json({ error: "Failed to delete config" });
}
}
async pullEvents(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const events = await this.caldavService.pullEvents(req.user!.userId);
res.json(events);
} catch (error) {
log.error({ error, userId: req.user?.userId }, "Error pulling events");
res.status(500).json({ error: "Failed to pull events" });
}
}
async pushEvents(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
await this.caldavService.pushAll(req.user!.userId);
res.status(204).send();
} catch (error) {
log.error({ error, userId: req.user?.userId }, "Error pushing events");
res.status(500).json({ error: "Failed to push events" });
}
}
async pushEvent(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const event = await this.caldavService.findEventByCaldavUUID(
req.user!.userId,
req.params.caldavUUID,
);
if (!event) {
res.status(404).json({ error: "Event not found" });
return;
}
await this.caldavService.pushEvent(req.user!.userId, event);
res.status(204).send();
} catch (error) {
log.error({ error, userId: req.user?.userId }, "Error pushing event");
res.status(500).json({ error: "Failed to push event" });
}
}
}

View File

@@ -8,13 +8,17 @@ import {
RecurringDeleteMode,
} from "@calchat/shared";
import { ChatService } from "../services";
import { CaldavService } from "../services/CaldavService";
import { createLogger } from "../logging";
import { AuthenticatedRequest } from "./AuthMiddleware";
const log = createLogger("ChatController");
export class ChatController {
constructor(private chatService: ChatService) {}
constructor(
private chatService: ChatService,
private caldavService: CaldavService,
) {}
async sendMessage(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
@@ -68,6 +72,16 @@ export class ChatController {
deleteMode,
occurrenceDate,
);
// Sync confirmed event to CalDAV
try {
if (await this.caldavService.getConfig(userId)) {
await this.caldavService.pushAll(userId);
}
} catch (error) {
log.error({ error, userId }, "CalDAV push after confirm failed");
}
res.json(response);
} catch (error) {
log.error(

View File

@@ -1,17 +1,43 @@
import { Response } from "express";
import { RecurringDeleteMode } from "@calchat/shared";
import { CalendarEvent, RecurringDeleteMode } from "@calchat/shared";
import { EventService } from "../services";
import { createLogger } from "../logging";
import { AuthenticatedRequest } from "./AuthMiddleware";
import { CaldavService } from "../services/CaldavService";
const log = createLogger("EventController");
export class EventController {
constructor(private eventService: EventService) {}
constructor(
private eventService: EventService,
private caldavService: CaldavService,
) {}
private async pushToCaldav(userId: string, event: CalendarEvent) {
if (await this.caldavService.getConfig(userId)) {
try {
await this.caldavService.pushEvent(userId, event);
} catch (error) {
log.error({ error, userId }, "Error pushing event to CalDAV");
}
}
}
private async deleteFromCaldav(userId: string, event: CalendarEvent) {
if (event.caldavUUID && (await this.caldavService.getConfig(userId))) {
try {
await this.caldavService.deleteEvent(userId, event.caldavUUID);
} catch (error) {
log.error({ error, userId }, "Error deleting event from CalDAV");
}
}
}
async create(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const event = await this.eventService.create(req.user!.userId, req.body);
const userId = req.user!.userId;
const event = await this.eventService.create(userId, req.body);
await this.pushToCaldav(userId, event);
res.status(201).json(event);
} catch (error) {
log.error({ error, userId: req.user?.userId }, "Error creating event");
@@ -83,15 +109,19 @@ export class EventController {
async update(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user!.userId;
const event = await this.eventService.update(
req.params.id,
req.user!.userId,
userId,
req.body,
);
if (!event) {
res.status(404).json({ error: "Event not found" });
return;
}
await this.pushToCaldav(userId, event);
res.json(event);
} catch (error) {
log.error({ error, eventId: req.params.id }, "Error updating event");
@@ -101,46 +131,44 @@ export class EventController {
async delete(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user!.userId;
const { mode, occurrenceDate } = req.query as {
mode?: RecurringDeleteMode;
occurrenceDate?: string;
};
// Fetch event before deletion to get caldavUUID for sync
const event = await this.eventService.getById(req.params.id, userId);
if (!event) {
res.status(404).json({ error: "Event not found" });
return;
}
// If mode is specified, use deleteRecurring
if (mode) {
const result = await this.eventService.deleteRecurring(
req.params.id,
req.user!.userId,
userId,
mode,
occurrenceDate,
);
// For 'all' mode or when event was completely deleted, return 204
if (result === null && mode === "all") {
res.status(204).send();
return;
}
// For 'single' or 'future' modes, return updated event
// Event was updated (single/future mode) - push update to CalDAV
if (result) {
await this.pushToCaldav(userId, result);
res.json(result);
return;
}
// result is null but mode wasn't 'all' - event not found or was deleted
// Event was fully deleted (all mode, or future from first occurrence)
await this.deleteFromCaldav(userId, event);
res.status(204).send();
return;
}
// Default behavior: delete completely
const deleted = await this.eventService.delete(
req.params.id,
req.user!.userId,
);
if (!deleted) {
res.status(404).json({ error: "Event not found" });
return;
}
await this.eventService.delete(req.params.id, userId);
await this.deleteFromCaldav(userId, event);
res.status(204).send();
} catch (error) {
log.error({ error, eventId: req.params.id }, "Error deleting event");

View File

@@ -3,3 +3,4 @@ export * from "./ChatController";
export * from "./EventController";
export * from "./AuthMiddleware";
export * from "./LoggingMiddleware";
export * from "./CaldavController";