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:
85
apps/server/src/controllers/CaldavController.ts
Normal file
85
apps/server/src/controllers/CaldavController.ts
Normal 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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -3,3 +3,4 @@ export * from "./ChatController";
|
||||
export * from "./EventController";
|
||||
export * from "./AuthMiddleware";
|
||||
export * from "./LoggingMiddleware";
|
||||
export * from "./CaldavController";
|
||||
|
||||
Reference in New Issue
Block a user