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:
@@ -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"],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,10 @@ const EventSchema = new Schema<
|
||||
recurrenceRule: {
|
||||
type: String,
|
||||
},
|
||||
exceptionDates: {
|
||||
type: [String],
|
||||
default: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
|
||||
@@ -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.";
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user