- 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
173 lines
5.3 KiB
TypeScript
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}`;
|
|
}
|
|
}
|