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:
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user