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 { return this.eventRepo.create(userId, data); } async getById(id: string, userId: string): Promise { const event = await this.eventRepo.findById(id); if (!event || event.userId !== userId) { return null; } return event; } async findByCaldavUUID(userId: string, caldavUUID: string): Promise { return this.eventRepo.findByCaldavUUID(userId, caldavUUID); } async getAll(userId: string): Promise { return this.eventRepo.findByUserId(userId); } async searchByTitle(userId: string, query: string): Promise { return this.eventRepo.searchByTitle(userId, query); } async getByDateRange( userId: string, startDate: Date, endDate: Date, ): Promise { // 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 { 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 { 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 { 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}`; } }