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

@@ -140,7 +140,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
{
name: "proposeDeleteEvent",
description:
"Propose deleting an event. The user must confirm. Use this when the user wants to remove an appointment.",
"Propose deleting an event. The user must confirm. Use this when the user wants to remove an appointment. For recurring events, specify deleteMode to control which occurrences to delete.",
parameters: {
type: "object",
properties: {
@@ -148,6 +148,17 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
type: "string",
description: "ID of the event to delete",
},
deleteMode: {
type: "string",
enum: ["single", "future", "all"],
description:
"For recurring events: 'single' = only this occurrence, 'future' = this and all future, 'all' = entire recurring event. Defaults to 'all' for non-recurring events.",
},
occurrenceDate: {
type: "string",
description:
"ISO date string (YYYY-MM-DD) of the specific occurrence to delete. Required for 'single' and 'future' modes.",
},
},
required: ["eventId"],
},

View File

@@ -3,6 +3,7 @@ import {
getDay,
Day,
DAY_TO_GERMAN,
RecurringDeleteMode,
} from "@calchat/shared";
import { AIContext } from "../../services/interfaces";
import { formatDate, formatTime, formatDateTime } from "./eventFormatter";
@@ -111,6 +112,8 @@ export function executeToolCall(
case "proposeDeleteEvent": {
const eventId = args.eventId as string;
const deleteMode = (args.deleteMode as RecurringDeleteMode) || "all";
const occurrenceDate = args.occurrenceDate as string | undefined;
const existingEvent = context.existingEvents.find(
(e) => e.id === eventId,
);
@@ -119,8 +122,24 @@ export function executeToolCall(
return { content: `Event mit ID ${eventId} nicht gefunden.` };
}
// Build descriptive content based on delete mode
let modeDescription = "";
if (existingEvent.isRecurring) {
switch (deleteMode) {
case "single":
modeDescription = " (nur dieses Vorkommen)";
break;
case "future":
modeDescription = " (dieses und alle zukünftigen Vorkommen)";
break;
case "all":
modeDescription = " (alle Vorkommen)";
break;
}
}
return {
content: `Lösch-Vorschlag für "${existingEvent.title}" erstellt.`,
content: `Lösch-Vorschlag für "${existingEvent.title}"${modeDescription} erstellt.`,
proposedChange: {
action: "delete",
eventId,
@@ -131,6 +150,10 @@ export function executeToolCall(
description: existingEvent.description,
isRecurring: existingEvent.isRecurring,
},
deleteMode: existingEvent.isRecurring ? deleteMode : undefined,
occurrenceDate: existingEvent.isRecurring
? occurrenceDate
: undefined,
},
};
}

View File

@@ -35,7 +35,10 @@ if (process.env.NODE_ENV !== "production") {
"Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, OPTIONS",
);
res.header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-User-Id");
res.header(
"Access-Control-Allow-Headers",
"Content-Type, Authorization, X-User-Id",
);
if (req.method === "OPTIONS") {
res.sendStatus(200);
return;
@@ -54,8 +57,13 @@ const aiProvider = new GPTAdapter();
// Initialize services
const authService = new AuthService(userRepo);
const chatService = new ChatService(chatRepo, eventRepo, aiProvider);
const eventService = new EventService(eventRepo);
const chatService = new ChatService(
chatRepo,
eventRepo,
eventService,
aiProvider,
);
// Initialize controllers
const authController = new AuthController(authService);

View File

@@ -5,6 +5,7 @@ import {
UpdateEventDTO,
EventAction,
GetMessagesOptions,
RecurringDeleteMode,
} from "@calchat/shared";
import { ChatService } from "../services";
import { createLogger } from "../logging";
@@ -22,7 +23,10 @@ export class ChatController {
const response = await this.chatService.processMessage(userId, data);
res.json(response);
} catch (error) {
log.error({ error, userId: req.user?.userId }, "Error processing message");
log.error(
{ error, userId: req.user?.userId },
"Error processing message",
);
res.status(500).json({ error: "Failed to process message" });
}
}
@@ -31,12 +35,22 @@ export class ChatController {
try {
const userId = req.user!.userId;
const { conversationId, messageId } = req.params;
const { proposalId, action, event, eventId, updates } = req.body as {
const {
proposalId,
action,
event,
eventId,
updates,
deleteMode,
occurrenceDate,
} = req.body as {
proposalId: string;
action: EventAction;
event?: CreateEventDTO;
eventId?: string;
updates?: UpdateEventDTO;
deleteMode?: RecurringDeleteMode;
occurrenceDate?: string;
};
const response = await this.chatService.confirmEvent(
userId,
@@ -47,10 +61,15 @@ export class ChatController {
event,
eventId,
updates,
deleteMode,
occurrenceDate,
);
res.json(response);
} catch (error) {
log.error({ error, conversationId: req.params.conversationId }, "Error confirming event");
log.error(
{ error, conversationId: req.params.conversationId },
"Error confirming event",
);
res.status(500).json({ error: "Failed to confirm event" });
}
}
@@ -68,7 +87,10 @@ export class ChatController {
);
res.json(response);
} catch (error) {
log.error({ error, conversationId: req.params.conversationId }, "Error rejecting event");
log.error(
{ error, conversationId: req.params.conversationId },
"Error rejecting event",
);
res.status(500).json({ error: "Failed to reject event" });
}
}
@@ -82,7 +104,10 @@ export class ChatController {
const conversations = await this.chatService.getConversations(userId);
res.json(conversations);
} catch (error) {
log.error({ error, userId: req.user?.userId }, "Error getting conversations");
log.error(
{ error, userId: req.user?.userId },
"Error getting conversations",
);
res.status(500).json({ error: "Failed to get conversations" });
}
}
@@ -113,7 +138,10 @@ export class ChatController {
if ((error as Error).message === "Conversation not found") {
res.status(404).json({ error: "Conversation not found" });
} else {
log.error({ error, conversationId: req.params.id }, "Error getting conversation");
log.error(
{ error, conversationId: req.params.id },
"Error getting conversation",
);
res.status(500).json({ error: "Failed to get conversation" });
}
}

View File

@@ -1,4 +1,5 @@
import { Response } from "express";
import { RecurringDeleteMode } from "@calchat/shared";
import { EventService } from "../services";
import { createLogger } from "../logging";
import { AuthenticatedRequest } from "./AuthMiddleware";
@@ -72,7 +73,10 @@ export class EventController {
);
res.json(events);
} catch (error) {
log.error({ error, start: req.query.start, end: req.query.end }, "Error getting events by range");
log.error(
{ error, start: req.query.start, end: req.query.end },
"Error getting events by range",
);
res.status(500).json({ error: "Failed to get events" });
}
}
@@ -97,6 +101,38 @@ export class EventController {
async delete(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { mode, occurrenceDate } = req.query as {
mode?: RecurringDeleteMode;
occurrenceDate?: string;
};
// If mode is specified, use deleteRecurring
if (mode) {
const result = await this.eventService.deleteRecurring(
req.params.id,
req.user!.userId,
mode,
occurrenceDate,
);
// For 'all' mode or when event was completely deleted, return 204
if (result === null && mode === "all") {
res.status(204).send();
return;
}
// For 'single' or 'future' modes, return updated event
if (result) {
res.json(result);
return;
}
// result is null but mode wasn't 'all' - event not found or was deleted
res.status(204).send();
return;
}
// Default behavior: delete completely
const deleted = await this.eventService.delete(
req.params.id,
req.user!.userId,

View File

@@ -80,7 +80,10 @@ export function Logged(name: string) {
const method = String(propKey);
// Summarize args to avoid huge log entries
log.debug({ method, args: summarizeArgs(methodArgs) }, `${method} started`);
log.debug(
{ method, args: summarizeArgs(methodArgs) },
`${method} started`,
);
const logCompletion = (err?: unknown) => {
const duration = Math.round(performance.now() - start);

View File

@@ -47,4 +47,17 @@ export class MongoEventRepository implements EventRepository {
const result = await EventModel.findByIdAndDelete(id);
return result !== null;
}
async addExceptionDate(
id: string,
date: string,
): Promise<CalendarEvent | null> {
const event = await EventModel.findByIdAndUpdate(
id,
{ $addToSet: { exceptionDates: date } },
{ new: true },
);
if (!event) return null;
return event.toJSON() as unknown as CalendarEvent;
}
}

View File

@@ -46,6 +46,10 @@ const EventSchema = new Schema<
recurrenceRule: {
type: String,
},
exceptionDates: {
type: [String],
default: [],
},
},
{
timestamps: true,

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

View File

@@ -71,10 +71,19 @@ export function expandRecurringEvents(
true, // inclusive
);
// Build set of exception dates for fast lookup
const exceptionSet = new Set(event.exceptionDates || []);
for (const occurrence of occurrences) {
const occurrenceStart = fromRRuleDate(occurrence);
const occurrenceEnd = new Date(occurrenceStart.getTime() + duration);
// Skip if this occurrence is in the exception dates
const dateKey = formatDateKey(occurrenceStart);
if (exceptionSet.has(dateKey)) {
continue;
}
expanded.push({
...event,
occurrenceStart,
@@ -113,3 +122,11 @@ function formatRRuleDateString(date: Date): string {
const seconds = String(date.getSeconds()).padStart(2, "0");
return `${year}${month}${day}T${hours}${minutes}${seconds}`;
}
// Format date as YYYY-MM-DD for exception date comparison
function 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}`;
}