Files
calchat/apps/server/src/services/EventService.ts
Linus Waldowsky 325246826a 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
2026-02-08 19:24:59 +01:00

173 lines
5.3 KiB
TypeScript

import {
CalendarEvent,
CreateEventDTO,
UpdateEventDTO,
ExpandedEvent,
RecurringDeleteMode,
} from "@calchat/shared";
import { RRule, rrulestr } from "rrule";
import { EventRepository } from "./interfaces";
import { expandRecurringEvents } from "../utils/recurrenceExpander";
export class EventService {
constructor(private eventRepo: EventRepository) {}
async create(userId: string, data: CreateEventDTO): Promise<CalendarEvent> {
return this.eventRepo.create(userId, data);
}
async getById(id: string, userId: string): Promise<CalendarEvent | null> {
const event = await this.eventRepo.findById(id);
if (!event || event.userId !== userId) {
return null;
}
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,
endDate: Date,
): Promise<ExpandedEvent[]> {
// Get all events for the user
const allEvents = await this.eventRepo.findByUserId(userId);
// Separate recurring and non-recurring events
const recurringEvents = allEvents.filter((e) => e.recurrenceRule);
const nonRecurringEvents = allEvents.filter((e) => !e.recurrenceRule);
// Expand all events (recurring get multiple instances, non-recurring stay as-is)
const expanded = expandRecurringEvents(
[...nonRecurringEvents, ...recurringEvents],
startDate,
endDate,
);
return expanded;
}
async update(
id: string,
userId: string,
data: UpdateEventDTO,
): Promise<CalendarEvent | null> {
const event = await this.eventRepo.findById(id);
if (!event || event.userId !== userId) {
return null;
}
return this.eventRepo.update(id, data);
}
async delete(id: string, userId: string): Promise<boolean> {
const event = await this.eventRepo.findById(id);
if (!event || event.userId !== userId) {
return false;
}
return this.eventRepo.delete(id);
}
/**
* Delete a recurring event with different modes:
* - 'all': Delete the entire event (all occurrences)
* - 'single': Add the occurrence date to exception list (EXDATE)
* - 'future': Set UNTIL in RRULE to stop future occurrences
*
* @returns Updated event for 'single'/'future' modes, null for 'all' mode or if not found
*/
async deleteRecurring(
id: string,
userId: string,
mode: RecurringDeleteMode,
occurrenceDate?: string,
): Promise<CalendarEvent | null> {
const event = await this.eventRepo.findById(id);
if (!event || event.userId !== userId) {
return null;
}
// For non-recurring events, always delete completely
if (!event.recurrenceRule) {
await this.eventRepo.delete(id);
return null;
}
switch (mode) {
case "all":
await this.eventRepo.delete(id);
return null;
case "single":
if (!occurrenceDate) {
throw new Error("occurrenceDate required for single delete mode");
}
// Add to exception dates
return this.eventRepo.addExceptionDate(id, occurrenceDate);
case "future":
if (!occurrenceDate) {
throw new Error("occurrenceDate required for future delete mode");
}
// Check if this is the first occurrence
const startDateKey = this.formatDateKey(new Date(event.startTime));
if (occurrenceDate <= startDateKey) {
// Deleting from first occurrence = delete all
await this.eventRepo.delete(id);
return null;
}
// Set UNTIL to the day before the occurrence
const updatedRule = this.addUntilToRRule(
event.recurrenceRule,
occurrenceDate,
);
return this.eventRepo.update(id, { recurrenceRule: updatedRule });
default:
throw new Error(`Unknown delete mode: ${mode}`);
}
}
/**
* Add or replace UNTIL clause in an RRULE string.
* The UNTIL is set to 23:59:59 of the day before the occurrence date.
*/
private addUntilToRRule(ruleString: string, occurrenceDate: string): string {
// Normalize: ensure we have RRULE: prefix for parsing
const normalizedRule = ruleString.replace(/^RRULE:/i, "");
const parsedRule = rrulestr(`RRULE:${normalizedRule}`);
// Calculate the day before the occurrence at 23:59:59
const untilDate = new Date(occurrenceDate);
untilDate.setDate(untilDate.getDate() - 1);
untilDate.setHours(23, 59, 59, 0);
// Create new rule with UNTIL, removing COUNT (they're mutually exclusive)
const newRule = new RRule({
...parsedRule.options,
count: undefined,
until: untilDate,
});
// toString() returns "RRULE:...", we store without prefix
return newRule.toString().replace(/^RRULE:/, "");
}
private formatDateKey(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
}