feat: add recurring event deletion with three modes
Implement three deletion modes for recurring events: - single: exclude specific occurrence via EXDATE mechanism - future: set RRULE UNTIL to stop future occurrences - all: delete entire event series Changes include: - Add exceptionDates field to CalendarEvent model - Add RecurringDeleteMode type and DeleteRecurringEventDTO - EventService.deleteRecurring() with mode-based logic using rrule library - EventController DELETE endpoint accepts mode/occurrenceDate query params - recurrenceExpander filters out exception dates during expansion - AI tools support deleteMode and occurrenceDate for proposed deletions - ChatService.confirmEvent() handles recurring delete modes - New DeleteEventModal component for unified delete confirmation UI - Calendar screen integrates modal for both recurring and non-recurring events
This commit is contained in:
@@ -3,7 +3,9 @@ import {
|
||||
CreateEventDTO,
|
||||
UpdateEventDTO,
|
||||
ExpandedEvent,
|
||||
RecurringDeleteMode,
|
||||
} from "@calchat/shared";
|
||||
import { RRule, rrulestr } from "rrule";
|
||||
import { EventRepository } from "./interfaces";
|
||||
import { expandRecurringEvents } from "../utils/recurrenceExpander";
|
||||
|
||||
@@ -67,4 +69,96 @@ export class EventService {
|
||||
}
|
||||
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.isRecurring || !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}`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user