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

@@ -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,