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:
2026-01-25 15:19:31 +01:00
parent a42e2a7c1c
commit 2b999d9b0f
35 changed files with 787 additions and 200 deletions

View File

@@ -10,11 +10,16 @@ import {
UpdateEventDTO,
EventAction,
CreateMessageDTO,
RecurringDeleteMode,
} from "@calchat/shared";
import { ChatRepository, EventRepository, AIProvider } from "./interfaces";
import { EventService } from "./EventService";
import { getWeeksOverview, getMonthOverview } from "../utils/eventFormatters";
type TestResponse = { content: string; proposedChanges?: ProposedEventChange[] };
type TestResponse = {
content: string;
proposedChanges?: ProposedEventChange[];
};
// Test response index (cycles through responses)
let responseIndex = 0;
@@ -25,8 +30,7 @@ const staticResponses: TestResponse[] = [
// === MULTI-EVENT TEST RESPONSES ===
// Response 0: 3 Meetings an verschiedenen Tagen
{
content:
"Alles klar! Ich erstelle dir 3 Team-Meetings für diese Woche:",
content: "Alles klar! Ich erstelle dir 3 Team-Meetings für diese Woche:",
proposedChanges: [
{
id: "multi-1-a",
@@ -62,8 +66,7 @@ const staticResponses: TestResponse[] = [
},
// Response 1: 5 Termine für einen Projekttag
{
content:
"Ich habe deinen kompletten Projekttag am Dienstag geplant:",
content: "Ich habe deinen kompletten Projekttag am Dienstag geplant:",
proposedChanges: [
{
id: "multi-2-a",
@@ -119,8 +122,7 @@ const staticResponses: TestResponse[] = [
},
// Response 2: 2 wiederkehrende Termine
{
content:
"Ich erstelle dir zwei wiederkehrende Fitness-Termine:",
content: "Ich erstelle dir zwei wiederkehrende Fitness-Termine:",
proposedChanges: [
{
id: "multi-3-a",
@@ -209,7 +211,8 @@ const staticResponses: TestResponse[] = [
title: "Arzttermin Dr. Müller",
startTime: getDay("Wednesday", 1, 9, 30),
endTime: getDay("Wednesday", 1, 10, 30),
description: "Routineuntersuchung - Versichertenkarte nicht vergessen",
description:
"Routineuntersuchung - Versichertenkarte nicht vergessen",
},
},
],
@@ -403,6 +406,7 @@ export class ChatService {
constructor(
private chatRepo: ChatRepository,
private eventRepo: EventRepository,
private eventService: EventService,
private aiProvider: AIProvider,
) {}
@@ -462,9 +466,15 @@ export class ChatService {
event?: CreateEventDTO,
eventId?: string,
updates?: UpdateEventDTO,
deleteMode?: RecurringDeleteMode,
occurrenceDate?: string,
): Promise<ChatResponse> {
// Update specific proposal with respondedAction
await this.chatRepo.updateProposalResponse(messageId, proposalId, "confirm");
await this.chatRepo.updateProposalResponse(
messageId,
proposalId,
"confirm",
);
// Perform the actual event operation
let content: string;
@@ -478,10 +488,25 @@ export class ChatService {
? `Der Termin "${updatedEvent.title}" wurde aktualisiert.`
: "Termin nicht gefunden.";
} else if (action === "delete" && eventId) {
await this.eventRepo.delete(eventId);
// Use deleteRecurring for proper handling of recurring events
const mode = deleteMode || "all";
await this.eventService.deleteRecurring(
eventId,
userId,
mode,
occurrenceDate,
);
// Build appropriate response message
let deleteDescription = "";
if (deleteMode === "single") {
deleteDescription = " (dieses Vorkommen)";
} else if (deleteMode === "future") {
deleteDescription = " (dieses und zukünftige Vorkommen)";
}
content = event?.title
? `Der Termin "${event.title}" wurde gelöscht.`
: "Der Termin wurde gelöscht.";
? `Der Termin "${event.title}"${deleteDescription} wurde gelöscht.`
: `Der Termin${deleteDescription} wurde gelöscht.`;
} else {
content = "Ungültige Aktion.";
}

View File

@@ -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}`;
}
}

View File

@@ -11,4 +11,5 @@ export interface EventRepository {
create(userId: string, data: CreateEventDTO): Promise<CalendarEvent>;
update(id: string, data: UpdateEventDTO): Promise<CalendarEvent | null>;
delete(id: string): Promise<boolean>;
addExceptionDate(id: string, date: string): Promise<CalendarEvent | null>;
}