From 2b999d9b0f4c6baa4740e3d8b4c3411564e66f8f Mon Sep 17 00:00:00 2001 From: Linus Waldowsky Date: Sun, 25 Jan 2026 15:19:31 +0100 Subject: [PATCH] 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 --- CLAUDE.md | 30 ++-- apps/client/src/Themes.tsx | 2 +- apps/client/src/app/(tabs)/_layout.tsx | 66 +++---- apps/client/src/app/(tabs)/calendar.tsx | 164 +++++++++++------- apps/client/src/app/(tabs)/chat.tsx | 45 +++-- apps/client/src/app/event/[id].tsx | 38 +++- apps/client/src/app/login.tsx | 5 +- apps/client/src/app/note/[id].tsx | 23 ++- apps/client/src/app/register.tsx | 5 +- apps/client/src/components/AuthButton.tsx | 4 +- apps/client/src/components/BaseButton.tsx | 6 +- apps/client/src/components/ChatBubble.tsx | 12 +- .../src/components/DeleteEventModal.tsx | 162 +++++++++++++++++ apps/client/src/components/EventCard.tsx | 6 +- apps/client/src/components/EventCardBase.tsx | 21 ++- .../src/components/EventConfirmDialog.tsx | 4 +- .../src/components/ProposedEventCard.tsx | 31 +++- apps/client/src/services/ApiClient.ts | 8 +- apps/client/src/services/ChatService.ts | 7 + apps/client/src/services/EventService.ts | 16 +- apps/client/src/stores/ThemeStore.ts | 4 +- apps/server/src/ai/utils/toolDefinitions.ts | 13 +- apps/server/src/ai/utils/toolExecutor.ts | 25 ++- apps/server/src/app.ts | 12 +- apps/server/src/controllers/ChatController.ts | 40 ++++- .../server/src/controllers/EventController.ts | 38 +++- apps/server/src/logging/Logged.ts | 5 +- .../mongo/MongoEventRepository.ts | 13 ++ .../repositories/mongo/models/EventModel.ts | 4 + apps/server/src/services/ChatService.ts | 49 ++++-- apps/server/src/services/EventService.ts | 94 ++++++++++ .../services/interfaces/EventRepository.ts | 1 + apps/server/src/utils/recurrenceExpander.ts | 17 ++ packages/shared/src/models/CalendarEvent.ts | 9 + packages/shared/src/models/ChatMessage.ts | 8 +- 35 files changed, 787 insertions(+), 200 deletions(-) create mode 100644 apps/client/src/components/DeleteEventModal.tsx diff --git a/CLAUDE.md b/CLAUDE.md index fe175c0..b05c16f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,7 +91,8 @@ src/ │ ├── EventCardBase.tsx # Shared event card layout with icons (used by EventCard & ProposedEventCard) │ ├── EventCard.tsx # Calendar event card (uses EventCardBase + edit/delete buttons) │ ├── EventConfirmDialog.tsx # AI-proposed event confirmation modal (skeleton) -│ └── ProposedEventCard.tsx # Chat event proposal (uses EventCardBase + confirm/reject buttons) +│ ├── ProposedEventCard.tsx # Chat event proposal (uses EventCardBase + confirm/reject buttons) +│ └── DeleteEventModal.tsx # Unified delete confirmation modal (recurring: 3 options, non-recurring: simple confirm) ├── Themes.tsx # Theme definitions: THEMES object with defaultLight/defaultDark, Theme type ├── logging/ │ ├── index.ts # Re-exports @@ -100,8 +101,8 @@ src/ │ ├── index.ts # Re-exports all services │ ├── ApiClient.ts # HTTP client with X-User-Id header injection, request logging, handles empty responses (204) │ ├── AuthService.ts # login(), register(), logout() - calls API and updates AuthStore -│ ├── EventService.ts # getAll(), getById(), getByDateRange(), create(), update(), delete() -│ └── ChatService.ts # sendMessage(), confirmEvent(), rejectEvent(), getConversations(), getConversation() +│ ├── EventService.ts # getAll(), getById(), getByDateRange(), create(), update(), delete(mode, occurrenceDate) +│ └── ChatService.ts # sendMessage(), confirmEvent(deleteMode, occurrenceDate), rejectEvent(), getConversations(), getConversation() └── stores/ # Zustand state management ├── index.ts # Re-exports all stores ├── AuthStore.ts # user, isAuthenticated, isLoading, login(), logout(), loadStoredUser() @@ -224,7 +225,7 @@ src/ - `GET /api/events/:id` - Get single event (protected) - `POST /api/events` - Create event (protected) - `PUT /api/events/:id` - Update event (protected) -- `DELETE /api/events/:id` - Delete event (protected) +- `DELETE /api/events/:id` - Delete event (protected, query params: mode, occurrenceDate for recurring) - `POST /api/chat/message` - Send message to AI (protected) - `POST /api/chat/confirm/:conversationId/:messageId` - Confirm proposed event (protected) - `POST /api/chat/reject/:conversationId/:messageId` - Reject proposed event (protected) @@ -254,12 +255,15 @@ src/ **Key Types:** - `User`: id, email, userName, passwordHash?, createdAt?, updatedAt? -- `CalendarEvent`: id, userId, title, description?, startTime, endTime, note?, isRecurring?, recurrenceRule? +- `CalendarEvent`: id, userId, title, description?, startTime, endTime, note?, isRecurring?, recurrenceRule?, exceptionDates? - `ExpandedEvent`: Extends CalendarEvent with occurrenceStart, occurrenceEnd (for recurring event instances) - `ChatMessage`: id, conversationId, sender ('user' | 'assistant'), content, proposedChanges? -- `ProposedEventChange`: id, action ('create' | 'update' | 'delete'), eventId?, event?, updates?, respondedAction? +- `ProposedEventChange`: id, action ('create' | 'update' | 'delete'), eventId?, event?, updates?, respondedAction?, deleteMode?, occurrenceDate? - Each proposal has unique `id` (e.g., "proposal-0") for individual confirm/reject - `respondedAction` tracks user response per proposal (not per message) + - `deleteMode` ('single' | 'future' | 'all') and `occurrenceDate` for recurring event deletion +- `RecurringDeleteMode`: 'single' | 'future' | 'all' - delete modes for recurring events +- `DeleteRecurringEventDTO`: mode, occurrenceDate? - DTO for recurring event deletion - `Conversation`: id, userId, createdAt?, updatedAt? (messages loaded separately via lazy loading) - `CreateUserDTO`: email, userName, password (for registration) - `LoginDTO`: identifier (email OR userName), password @@ -393,9 +397,9 @@ NODE_ENV=development # development = pretty logs, production = JSON - `dotenv` integration for environment variables - `ChatController`: sendMessage(), confirmEvent(), rejectEvent() - `ChatService`: processMessage() with test responses (create, update, delete actions), confirmEvent() handles all CRUD actions - - `MongoEventRepository`: Full CRUD implemented (findById, findByUserId, findByDateRange, create, update, delete) + - `MongoEventRepository`: Full CRUD implemented (findById, findByUserId, findByDateRange, create, update, delete, addExceptionDate) - `EventController`: Full CRUD (create, getById, getAll, getByDateRange, update, delete) - - `EventService`: Full CRUD with recurring event expansion via recurrenceExpander + - `EventService`: Full CRUD with recurring event expansion via recurrenceExpander, deleteRecurring() with three modes (single/future/all) - `utils/eventFormatters`: getWeeksOverview(), getMonthOverview() with German localization - `utils/recurrenceExpander`: expandRecurringEvents() using rrule library for RRULE parsing - `ChatController`: getConversations(), getConversation() with cursor-based pagination support @@ -405,7 +409,7 @@ NODE_ENV=development # development = pretty logs, production = JSON - `GPTAdapter`: Full implementation with OpenAI GPT (gpt-4o-mini model), function calling for calendar operations, collects multiple proposals per response - `ai/utils/`: Provider-agnostic shared utilities (systemPrompt, toolDefinitions, toolExecutor, eventFormatter) - `ai/utils/systemPrompt`: Includes RRULE documentation - AI knows to create separate events when times differ by day - - `utils/recurrenceExpander`: Handles RRULE parsing, strips `RRULE:` prefix if present (AI may include it) + - `utils/recurrenceExpander`: Handles RRULE parsing, strips `RRULE:` prefix if present (AI may include it), filters out exceptionDates - `logging/`: Structured logging with pino, pino-http middleware, @Logged decorator - All repositories and GPTAdapter decorated with @Logged for automatic method logging - CORS configured to allow X-User-Id header @@ -443,6 +447,7 @@ NODE_ENV=development # development = pretty logs, production = JSON - Tap-to-open modal overlay showing EventCards for selected day - Supports events from adjacent months visible in grid - Uses `useFocusEffect` for automatic reload on tab focus + - DeleteEventModal integration for recurring event deletion with three modes - Chat screen fully functional with FlashList, message sending, and event confirm/reject - **Multiple event proposals**: AI can propose multiple events in one response - Arrow navigation between proposals with "Event X von Y" counter @@ -454,11 +459,12 @@ NODE_ENV=development # development = pretty logs, production = JSON - KeyboardAvoidingView for proper keyboard handling (iOS padding, Android height) - Auto-scroll to end on new messages and keyboard show - keyboardDismissMode="interactive" and keyboardShouldPersistTaps="handled" -- `EventService`: getAll(), getById(), getByDateRange(), create(), update(), delete() - fully implemented -- `ChatService`: sendMessage(), confirmEvent(), rejectEvent(), getConversations(), getConversation() - fully implemented with cursor pagination +- `EventService`: getAll(), getById(), getByDateRange(), create(), update(), delete(mode, occurrenceDate) - fully implemented with recurring delete modes +- `ChatService`: sendMessage(), confirmEvent(deleteMode, occurrenceDate), rejectEvent(), getConversations(), getConversation() - fully implemented with cursor pagination and recurring delete support - `EventCardBase`: Shared base component with event layout (header, date/time/recurring icons, description) - used by both EventCard and ProposedEventCard - `EventCard`: Uses EventCardBase + edit/delete buttons for calendar display -- `ProposedEventCard`: Uses EventCardBase + confirm/reject buttons for chat proposals (supports create/update/delete actions) +- `ProposedEventCard`: Uses EventCardBase + confirm/reject buttons for chat proposals (supports create/update/delete actions with deleteMode display) +- `DeleteEventModal`: Unified delete confirmation modal - shows three options for recurring events (single/future/all), simple confirm for non-recurring - `Themes.tsx`: Theme definitions with THEMES object (defaultLight, defaultDark) including all color tokens (textPrimary, borderPrimary, eventIndicator, secondaryBg, shadowColor, etc.) - `EventsStore`: Zustand store with setEvents(), addEvent(), updateEvent(), deleteEvent() - stores ExpandedEvent[] - `ChatStore`: Zustand store with addMessage(), addMessages(), updateMessage(), clearMessages(), isWaitingForResponse/setWaitingForResponse() for typing indicator - loads from server on mount and persists across tab switches diff --git a/apps/client/src/Themes.tsx b/apps/client/src/Themes.tsx index 78a5493..dfb2a59 100644 --- a/apps/client/src/Themes.tsx +++ b/apps/client/src/Themes.tsx @@ -56,5 +56,5 @@ export const THEMES = { eventIndicator: "#DE6C20", borderPrimary: "#FFFFFF", shadowColor: "#FFFFFF", - } + }, } as const satisfies Record; diff --git a/apps/client/src/app/(tabs)/_layout.tsx b/apps/client/src/app/(tabs)/_layout.tsx index 7e3aecb..256e722 100644 --- a/apps/client/src/app/(tabs)/_layout.tsx +++ b/apps/client/src/app/(tabs)/_layout.tsx @@ -8,40 +8,40 @@ export default function TabLayout() { return ( - ( - - ), + screenOptions={{ + headerShown: false, + tabBarActiveTintColor: theme.chatBot, + tabBarInactiveTintColor: theme.primeFg, + tabBarStyle: { backgroundColor: theme.primeBg }, }} - /> - ( - - ), - }} - /> - ( - - ), - }} - /> + > + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> ); diff --git a/apps/client/src/app/(tabs)/calendar.tsx b/apps/client/src/app/(tabs)/calendar.tsx index 0a63270..c50583a 100644 --- a/apps/client/src/app/(tabs)/calendar.tsx +++ b/apps/client/src/app/(tabs)/calendar.tsx @@ -5,11 +5,17 @@ import { Text, View, ScrollView, - Alert, } from "react-native"; -import { DAYS, MONTHS, Month, ExpandedEvent } from "@calchat/shared"; +import { + DAYS, + MONTHS, + Month, + ExpandedEvent, + RecurringDeleteMode, +} from "@calchat/shared"; import Header from "../../components/Header"; import { EventCard } from "../../components/EventCard"; +import { DeleteEventModal } from "../../components/DeleteEventModal"; import React, { useCallback, useEffect, @@ -75,40 +81,49 @@ const Calendar = () => { const [currentYear, setCurrentYear] = useState(new Date().getFullYear()); const [selectedDate, setSelectedDate] = useState(null); + // State for delete modal + const [deleteModalVisible, setDeleteModalVisible] = useState(false); + const [eventToDelete, setEventToDelete] = useState( + null, + ); + const { events, setEvents, deleteEvent } = useEventsStore(); + // Function to load events for current view + const loadEvents = useCallback(async () => { + try { + // Calculate first visible day (up to 6 days before month start) + const firstOfMonth = new Date(currentYear, monthIndex, 1); + const dayOfWeek = firstOfMonth.getDay(); + const daysFromPrevMonth = dayOfWeek === 0 ? 6 : dayOfWeek - 1; + const startDate = new Date( + currentYear, + monthIndex, + 1 - daysFromPrevMonth, + ); + + // Calculate last visible day (6 weeks * 7 days = 42 days total) + const endDate = new Date(startDate); + endDate.setDate(startDate.getDate() + 41); + endDate.setHours(23, 59, 59); + + const loadedEvents = await EventService.getByDateRange( + startDate, + endDate, + ); + setEvents(loadedEvents); + } catch (error) { + console.error("Failed to load events:", error); + } + }, [monthIndex, currentYear, setEvents]); + // Load events when tab gains focus or month/year changes - // Include days from prev/next month that are visible in the grid + // NOTE: Wrapper needed because loadEvents is async (returns Promise) + // and useFocusEffect expects a sync function (optionally returning cleanup) useFocusEffect( useCallback(() => { - const loadEvents = async () => { - try { - // Calculate first visible day (up to 6 days before month start) - const firstOfMonth = new Date(currentYear, monthIndex, 1); - const dayOfWeek = firstOfMonth.getDay(); - const daysFromPrevMonth = dayOfWeek === 0 ? 6 : dayOfWeek - 1; - const startDate = new Date( - currentYear, - monthIndex, - 1 - daysFromPrevMonth, - ); - - // Calculate last visible day (6 weeks * 7 days = 42 days total) - const endDate = new Date(startDate); - endDate.setDate(startDate.getDate() + 41); - endDate.setHours(23, 59, 59); - - const loadedEvents = await EventService.getByDateRange( - startDate, - endDate, - ); - setEvents(loadedEvents); - } catch (error) { - console.error("Failed to load events:", error); - } - }; loadEvents(); - }, [monthIndex, currentYear, setEvents]), + }, [loadEvents]), ); // Group events by date (YYYY-MM-DD format) @@ -153,31 +168,40 @@ const Calendar = () => { // TODO: Navigate to event edit screen }; - const handleDeleteEvent = async (event: ExpandedEvent) => { - Alert.alert("Event löschen", `"${event.title}" wirklich löschen?`, [ - { text: "Abbrechen", style: "cancel" }, - { - text: "Löschen", - style: "destructive", - onPress: async () => { - try { - await EventService.delete(event.id); - deleteEvent(event.id); - // Close overlay if no more events for this date - if (selectedDate) { - const dateKey = getDateKey(selectedDate); - const remainingEvents = eventsByDate.get(dateKey) || []; - if (remainingEvents.length <= 1) { - setSelectedDate(null); - } - } - } catch (error) { - console.error("Failed to delete event:", error); - Alert.alert("Fehler", "Event konnte nicht gelöscht werden"); - } - }, - }, - ]); + const handleDeleteEvent = (event: ExpandedEvent) => { + // Show delete modal for both recurring and non-recurring events + setEventToDelete(event); + setDeleteModalVisible(true); + }; + + const handleDeleteConfirm = async (mode: RecurringDeleteMode) => { + if (!eventToDelete) return; + + setDeleteModalVisible(false); + const event = eventToDelete; + const occurrenceDate = getDateKey(new Date(event.occurrenceStart)); + + try { + if (event.isRecurring) { + // Recurring event: use mode and occurrenceDate + await EventService.delete(event.id, mode, occurrenceDate); + // Reload events to reflect changes + await loadEvents(); + } else { + // Non-recurring event: simple delete + await EventService.delete(event.id); + deleteEvent(event.id); + } + } catch (error) { + console.error("Failed to delete event:", error); + } finally { + setEventToDelete(null); + } + }; + + const handleDeleteCancel = () => { + setDeleteModalVisible(false); + setEventToDelete(null); }; // Get events for selected date @@ -203,8 +227,6 @@ const Calendar = () => { eventsByDate={eventsByDate} onDayPress={handleDayPress} /> - - {/* Event Overlay Modal */} { onEditEvent={handleEditEvent} onDeleteEvent={handleDeleteEvent} /> + ); }; @@ -274,7 +303,12 @@ const EventOverlay = ({ borderBottomColor: theme.borderPrimary, }} > - {dateString} + + {dateString} + {events.length} {events.length === 1 ? "Termin" : "Termine"} @@ -406,9 +440,7 @@ const MonthSelector = ({ className="w-full flex justify-center items-center py-2" style={{ backgroundColor: - item.monthIndex % 2 === 0 - ? theme.primeBg - : theme.secondaryBg, + item.monthIndex % 2 === 0 ? theme.primeBg : theme.secondaryBg, }} > @@ -511,11 +543,7 @@ const CalendarHeader = (props: CalendarHeaderProps) => { }} onPress={measureAndOpen} > - + { {/* TODO: px and gap need fine tuning to perfectly align with the grid */} {DAYS.map((day, i) => ( - {day.substring(0, 2).toUpperCase()} + + {day.substring(0, 2).toUpperCase()} + ))} ); diff --git a/apps/client/src/app/(tabs)/chat.tsx b/apps/client/src/app/(tabs)/chat.tsx index 2224958..f1b1c03 100644 --- a/apps/client/src/app/(tabs)/chat.tsx +++ b/apps/client/src/app/(tabs)/chat.tsx @@ -20,7 +20,7 @@ import { chatMessageToMessageData, MessageData, } from "../../stores"; -import { ProposedEventChange } from "@calchat/shared"; +import { ProposedEventChange, RespondedAction } from "@calchat/shared"; import { ProposedEventCard } from "../../components/ProposedEventCard"; import { Ionicons } from "@expo/vector-icons"; import TypingIndicator from "../../components/TypingIndicator"; @@ -104,7 +104,7 @@ const Chat = () => { }; const handleEventResponse = async ( - action: "confirm" | "reject", + action: RespondedAction, messageId: string, conversationId: string, proposalId: string, @@ -114,7 +114,9 @@ const Chat = () => { const message = messages.find((m) => m.id === messageId); if (message?.proposedChanges) { const updatedProposals = message.proposedChanges.map((p) => - p.id === proposalId ? { ...p, respondedAction: action as "confirm" | "reject" } : p, + p.id === proposalId + ? { ...p, respondedAction: action } + : p, ); updateMessage(messageId, { proposedChanges: updatedProposals }); } @@ -130,8 +132,14 @@ const Chat = () => { proposedChange.event, proposedChange.eventId, proposedChange.updates, + proposedChange.deleteMode, + proposedChange.occurrenceDate, ) - : await ChatService.rejectEvent(conversationId, messageId, proposalId); + : await ChatService.rejectEvent( + conversationId, + messageId, + proposalId, + ); const botMessage: MessageData = { id: response.message.id, @@ -225,14 +233,21 @@ const Chat = () => { ) } onReject={(proposalId) => - handleEventResponse("reject", item.id, item.conversationId!, proposalId) + handleEventResponse( + "reject", + item.id, + item.conversationId!, + proposalId, + ) } /> )} keyExtractor={(item) => item.id} keyboardDismissMode="interactive" keyboardShouldPersistTaps="handled" - ListFooterComponent={isWaitingForResponse ? : null} + ListFooterComponent={ + isWaitingForResponse ? : null + } /> @@ -251,7 +266,9 @@ const ChatHeader = () => { borderColor: theme.primeFg, }} > - CalChat + + CalChat + setCurrentIndex((i) => Math.max(0, i - 1)); const goToNext = () => - setCurrentIndex((i) => - Math.min((proposedChanges?.length || 1) - 1, i + 1), - ); + setCurrentIndex((i) => Math.min((proposedChanges?.length || 1) - 1, i + 1)); const canGoPrev = currentIndex > 0; const canGoNext = currentIndex < (proposedChanges?.length || 1) - 1; @@ -344,7 +359,9 @@ const ChatMessage = ({ minWidth: hasProposals ? "75%" : undefined, }} > - {content} + + {content} + {hasProposals && currentProposal && onConfirm && onReject && ( @@ -358,11 +375,7 @@ const ChatMessage = ({ className="p-1" style={{ opacity: canGoPrev ? 1 : 0.3 }} > - + )} diff --git a/apps/client/src/app/event/[id].tsx b/apps/client/src/app/event/[id].tsx index f903a2c..53b96a1 100644 --- a/apps/client/src/app/event/[id].tsx +++ b/apps/client/src/app/event/[id].tsx @@ -19,27 +19,49 @@ const EventDetailScreen = () => { return ( - Event Detail - ID: {id} + + Event Detail + + + ID: {id} + - - Save + + + Save + - - Delete + + + Delete + diff --git a/apps/client/src/app/login.tsx b/apps/client/src/app/login.tsx index fd8bb18..51f7efd 100644 --- a/apps/client/src/app/login.tsx +++ b/apps/client/src/app/login.tsx @@ -43,7 +43,10 @@ const LoginScreen = () => { {error && ( - + {error} )} diff --git a/apps/client/src/app/note/[id].tsx b/apps/client/src/app/note/[id].tsx index 7a940ff..06b3ce1 100644 --- a/apps/client/src/app/note/[id].tsx +++ b/apps/client/src/app/note/[id].tsx @@ -17,18 +17,31 @@ const NoteScreen = () => { return ( - Note - Event ID: {id} + + Note + + + Event ID: {id} + - - Save Note + + + Save Note + diff --git a/apps/client/src/app/register.tsx b/apps/client/src/app/register.tsx index d8098b7..e2a9ba8 100644 --- a/apps/client/src/app/register.tsx +++ b/apps/client/src/app/register.tsx @@ -51,7 +51,10 @@ const RegisterScreen = () => { {error && ( - + {error} )} diff --git a/apps/client/src/components/AuthButton.tsx b/apps/client/src/components/AuthButton.tsx index bb29a26..ee5d846 100644 --- a/apps/client/src/components/AuthButton.tsx +++ b/apps/client/src/components/AuthButton.tsx @@ -15,9 +15,7 @@ const AuthButton = ({ title, onPress, isLoading = false }: AuthButtonProps) => { disabled={isLoading} className="w-full rounded-lg p-4 mb-4 border-4" style={{ - backgroundColor: isLoading - ? theme.disabledButton - : theme.chatBot, + backgroundColor: isLoading ? theme.disabledButton : theme.chatBot, shadowColor: theme.shadowColor, shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.25, diff --git a/apps/client/src/components/BaseButton.tsx b/apps/client/src/components/BaseButton.tsx index 8a82151..d9ecfb6 100644 --- a/apps/client/src/components/BaseButton.tsx +++ b/apps/client/src/components/BaseButton.tsx @@ -8,7 +8,7 @@ type BaseButtonProps = { solid?: boolean; }; -const BaseButton = ({children, onPress, solid = false}: BaseButtonProps) => { +const BaseButton = ({ children, onPress, solid = false }: BaseButtonProps) => { const { theme } = useThemeStore(); return ( { onPress={onPress} style={{ borderColor: theme.borderPrimary, - backgroundColor: solid - ? theme.chatBot - : theme.primeBg, + backgroundColor: solid ? theme.chatBot : theme.primeBg, shadowColor: theme.shadowColor, shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.25, diff --git a/apps/client/src/components/ChatBubble.tsx b/apps/client/src/components/ChatBubble.tsx index 12fd9c2..61a2156 100644 --- a/apps/client/src/components/ChatBubble.tsx +++ b/apps/client/src/components/ChatBubble.tsx @@ -10,7 +10,12 @@ type ChatBubbleProps = { style?: ViewStyle; }; -export function ChatBubble({ side, children, className = "", style }: ChatBubbleProps) { +export function ChatBubble({ + side, + children, + className = "", + style, +}: ChatBubbleProps) { const { theme } = useThemeStore(); const borderColor = side === "left" ? theme.chatBot : theme.primeFg; const sideClass = @@ -21,7 +26,10 @@ export function ChatBubble({ side, children, className = "", style }: ChatBubble return ( {children} diff --git a/apps/client/src/components/DeleteEventModal.tsx b/apps/client/src/components/DeleteEventModal.tsx new file mode 100644 index 0000000..d808c3b --- /dev/null +++ b/apps/client/src/components/DeleteEventModal.tsx @@ -0,0 +1,162 @@ +import { Modal, Pressable, Text, View } from "react-native"; +import { RecurringDeleteMode } from "@calchat/shared"; +import { useThemeStore } from "../stores/ThemeStore"; + +type DeleteEventModalProps = { + visible: boolean; + eventTitle: string; + isRecurring: boolean; + onConfirm: (mode: RecurringDeleteMode) => void; + onCancel: () => void; +}; + +type DeleteOption = { + mode: RecurringDeleteMode; + label: string; + description: string; +}; + +const RECURRING_DELETE_OPTIONS: DeleteOption[] = [ + { + mode: "single", + label: "Nur dieses Vorkommen", + description: "Nur der ausgewaehlte Termin wird entfernt", + }, + { + mode: "future", + label: "Dieses und zukuenftige", + description: "Dieser und alle folgenden Termine werden entfernt", + }, + { + mode: "all", + label: "Alle Vorkommen", + description: "Die gesamte Terminserie wird geloescht", + }, +]; + +export const DeleteEventModal = ({ + visible, + eventTitle, + isRecurring, + onConfirm, + onCancel, +}: DeleteEventModalProps) => { + const { theme } = useThemeStore(); + + return ( + + + e.stopPropagation()} + > + {/* Header */} + + + {isRecurring + ? "Wiederkehrenden Termin loeschen" + : "Termin loeschen"} + + + {eventTitle} + + + + {/* Content */} + + {isRecurring ? ( + // Recurring event: show three options + RECURRING_DELETE_OPTIONS.map((option) => ( + onConfirm(option.mode)} + className="py-3 px-4 rounded-lg mb-2" + style={{ + backgroundColor: theme.secondaryBg, + borderWidth: 1, + borderColor: theme.borderPrimary, + }} + > + + {option.label} + + + {option.description} + + + )) + ) : ( + // Non-recurring event: simple confirmation + + + Möchtest du diesen Termin wirklich löschen? + + onConfirm("all")} + className="py-3 px-4 rounded-lg" + style={{ + backgroundColor: theme.rejectButton, + }} + > + + Loeschen + + + + )} + + + {/* Cancel button */} + + + Abbrechen + + + + + + ); +}; diff --git a/apps/client/src/components/EventCard.tsx b/apps/client/src/components/EventCard.tsx index 8e9c83a..f92df45 100644 --- a/apps/client/src/components/EventCard.tsx +++ b/apps/client/src/components/EventCard.tsx @@ -41,11 +41,7 @@ export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => { borderColor: theme.borderPrimary, }} > - + diff --git a/apps/client/src/components/EventCardBase.tsx b/apps/client/src/components/EventCardBase.tsx index afca945..c88d69b 100644 --- a/apps/client/src/components/EventCardBase.tsx +++ b/apps/client/src/components/EventCardBase.tsx @@ -75,11 +75,19 @@ export const EventCardBase = ({ borderBottomColor: theme.borderPrimary, }} > - {title} + + {title} + {/* Content */} - + {/* Date */} - - Wiederkehrend - + Wiederkehrend )} {/* Description */} {description && ( - + {description} )} diff --git a/apps/client/src/components/EventConfirmDialog.tsx b/apps/client/src/components/EventConfirmDialog.tsx index c6d5f59..08e6e1e 100644 --- a/apps/client/src/components/EventConfirmDialog.tsx +++ b/apps/client/src/components/EventConfirmDialog.tsx @@ -29,7 +29,9 @@ const EventConfirmDialog = ({ - EventConfirmDialog - Not Implemented + + EventConfirmDialog - Not Implemented + diff --git a/apps/client/src/components/ProposedEventCard.tsx b/apps/client/src/components/ProposedEventCard.tsx index d831dee..c0fb736 100644 --- a/apps/client/src/components/ProposedEventCard.tsx +++ b/apps/client/src/components/ProposedEventCard.tsx @@ -1,14 +1,34 @@ import { View, Text, Pressable } from "react-native"; -import { ProposedEventChange } from "@calchat/shared"; +import { ProposedEventChange, RecurringDeleteMode } from "@calchat/shared"; import { useThemeStore } from "../stores/ThemeStore"; import { EventCardBase } from "./EventCardBase"; +const DELETE_MODE_LABELS: Record = { + single: "Nur dieses Vorkommen", + future: "Dieses & zukuenftige", + all: "Alle Vorkommen", +}; + type ProposedEventCardProps = { proposedChange: ProposedEventChange; onConfirm: () => void; onReject: () => void; }; +const DeleteModeBadge = ({ mode }: { mode: RecurringDeleteMode }) => { + const { theme } = useThemeStore(); + return ( + + + {DELETE_MODE_LABELS[mode]} + + + ); +}; + const ConfirmRejectButtons = ({ isDisabled, respondedAction, @@ -68,6 +88,12 @@ export const ProposedEventCard = ({ // respondedAction is now part of the proposedChange const isDisabled = !!proposedChange.respondedAction; + // Show delete mode badge for delete actions on recurring events + const showDeleteModeBadge = + proposedChange.action === "delete" && + event?.isRecurring && + proposedChange.deleteMode; + if (!event) { return null; } @@ -82,6 +108,9 @@ export const ProposedEventCard = ({ description={event.description} isRecurring={event.isRecurring} > + {showDeleteModeBadge && ( + + )} ( const duration = Math.round(performance.now() - start); if (!response.ok) { - apiLogger.error(`${method} ${endpoint} - ${response.status} (${duration}ms)`); + apiLogger.error( + `${method} ${endpoint} - ${response.status} (${duration}ms)`, + ); throw new Error(`HTTP ${response.status}`); } - apiLogger.debug(`${method} ${endpoint} - ${response.status} (${duration}ms)`); + apiLogger.debug( + `${method} ${endpoint} - ${response.status} (${duration}ms)`, + ); const text = await response.text(); if (!text) { diff --git a/apps/client/src/services/ChatService.ts b/apps/client/src/services/ChatService.ts index 49a49e4..3b2016c 100644 --- a/apps/client/src/services/ChatService.ts +++ b/apps/client/src/services/ChatService.ts @@ -7,6 +7,7 @@ import { CreateEventDTO, UpdateEventDTO, EventAction, + RecurringDeleteMode, } from "@calchat/shared"; import { ApiClient } from "./ApiClient"; @@ -16,6 +17,8 @@ interface ConfirmEventRequest { event?: CreateEventDTO; eventId?: string; updates?: UpdateEventDTO; + deleteMode?: RecurringDeleteMode; + occurrenceDate?: string; } interface RejectEventRequest { @@ -35,6 +38,8 @@ export const ChatService = { event?: CreateEventDTO, eventId?: string, updates?: UpdateEventDTO, + deleteMode?: RecurringDeleteMode, + occurrenceDate?: string, ): Promise => { const body: ConfirmEventRequest = { proposalId, @@ -42,6 +47,8 @@ export const ChatService = { event, eventId, updates, + deleteMode, + occurrenceDate, }; return ApiClient.post( `/chat/confirm/${conversationId}/${messageId}`, diff --git a/apps/client/src/services/EventService.ts b/apps/client/src/services/EventService.ts index 4722c5b..34a8088 100644 --- a/apps/client/src/services/EventService.ts +++ b/apps/client/src/services/EventService.ts @@ -3,6 +3,7 @@ import { CreateEventDTO, UpdateEventDTO, ExpandedEvent, + RecurringDeleteMode, } from "@calchat/shared"; import { ApiClient } from "./ApiClient"; @@ -29,7 +30,18 @@ export const EventService = { return ApiClient.put(`/events/${id}`, data); }, - delete: async (id: string): Promise => { - return ApiClient.delete(`/events/${id}`); + delete: async ( + id: string, + mode?: RecurringDeleteMode, + occurrenceDate?: string, + ): Promise => { + const params = new URLSearchParams(); + if (mode) params.append("mode", mode); + if (occurrenceDate) params.append("occurrenceDate", occurrenceDate); + + const queryString = params.toString(); + const url = `/events/${id}${queryString ? `?${queryString}` : ""}`; + + return ApiClient.delete(url); }, }; diff --git a/apps/client/src/stores/ThemeStore.ts b/apps/client/src/stores/ThemeStore.ts index b3317be..9b7f5bf 100644 --- a/apps/client/src/stores/ThemeStore.ts +++ b/apps/client/src/stores/ThemeStore.ts @@ -8,5 +8,5 @@ interface ThemeState { export const useThemeStore = create((set) => ({ theme: THEMES.defaultLight, - setTheme: (themeName) => set({theme: THEMES[themeName]}) -})) + setTheme: (themeName) => set({ theme: THEMES[themeName] }), +})); diff --git a/apps/server/src/ai/utils/toolDefinitions.ts b/apps/server/src/ai/utils/toolDefinitions.ts index f38c49a..27e18d0 100644 --- a/apps/server/src/ai/utils/toolDefinitions.ts +++ b/apps/server/src/ai/utils/toolDefinitions.ts @@ -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"], }, diff --git a/apps/server/src/ai/utils/toolExecutor.ts b/apps/server/src/ai/utils/toolExecutor.ts index daa8e85..64a350b 100644 --- a/apps/server/src/ai/utils/toolExecutor.ts +++ b/apps/server/src/ai/utils/toolExecutor.ts @@ -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, }, }; } diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 1c4f8b7..d3c66c8 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -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); diff --git a/apps/server/src/controllers/ChatController.ts b/apps/server/src/controllers/ChatController.ts index 01d5f27..baa96a3 100644 --- a/apps/server/src/controllers/ChatController.ts +++ b/apps/server/src/controllers/ChatController.ts @@ -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" }); } } diff --git a/apps/server/src/controllers/EventController.ts b/apps/server/src/controllers/EventController.ts index 8bce569..da5378a 100644 --- a/apps/server/src/controllers/EventController.ts +++ b/apps/server/src/controllers/EventController.ts @@ -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 { 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, diff --git a/apps/server/src/logging/Logged.ts b/apps/server/src/logging/Logged.ts index 2d7974e..2f09bdc 100644 --- a/apps/server/src/logging/Logged.ts +++ b/apps/server/src/logging/Logged.ts @@ -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); diff --git a/apps/server/src/repositories/mongo/MongoEventRepository.ts b/apps/server/src/repositories/mongo/MongoEventRepository.ts index a7c255e..a3efc2d 100644 --- a/apps/server/src/repositories/mongo/MongoEventRepository.ts +++ b/apps/server/src/repositories/mongo/MongoEventRepository.ts @@ -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 { + const event = await EventModel.findByIdAndUpdate( + id, + { $addToSet: { exceptionDates: date } }, + { new: true }, + ); + if (!event) return null; + return event.toJSON() as unknown as CalendarEvent; + } } diff --git a/apps/server/src/repositories/mongo/models/EventModel.ts b/apps/server/src/repositories/mongo/models/EventModel.ts index 67a09af..99db0bd 100644 --- a/apps/server/src/repositories/mongo/models/EventModel.ts +++ b/apps/server/src/repositories/mongo/models/EventModel.ts @@ -46,6 +46,10 @@ const EventSchema = new Schema< recurrenceRule: { type: String, }, + exceptionDates: { + type: [String], + default: [], + }, }, { timestamps: true, diff --git a/apps/server/src/services/ChatService.ts b/apps/server/src/services/ChatService.ts index 56d8378..56c806f 100644 --- a/apps/server/src/services/ChatService.ts +++ b/apps/server/src/services/ChatService.ts @@ -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 { // 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."; } diff --git a/apps/server/src/services/EventService.ts b/apps/server/src/services/EventService.ts index 8017b76..90c60ae 100644 --- a/apps/server/src/services/EventService.ts +++ b/apps/server/src/services/EventService.ts @@ -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 { + 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}`; + } } diff --git a/apps/server/src/services/interfaces/EventRepository.ts b/apps/server/src/services/interfaces/EventRepository.ts index f66985d..160130d 100644 --- a/apps/server/src/services/interfaces/EventRepository.ts +++ b/apps/server/src/services/interfaces/EventRepository.ts @@ -11,4 +11,5 @@ export interface EventRepository { create(userId: string, data: CreateEventDTO): Promise; update(id: string, data: UpdateEventDTO): Promise; delete(id: string): Promise; + addExceptionDate(id: string, date: string): Promise; } diff --git a/apps/server/src/utils/recurrenceExpander.ts b/apps/server/src/utils/recurrenceExpander.ts index ed70408..36645fa 100644 --- a/apps/server/src/utils/recurrenceExpander.ts +++ b/apps/server/src/utils/recurrenceExpander.ts @@ -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}`; +} diff --git a/packages/shared/src/models/CalendarEvent.ts b/packages/shared/src/models/CalendarEvent.ts index 269e5a1..00c0d29 100644 --- a/packages/shared/src/models/CalendarEvent.ts +++ b/packages/shared/src/models/CalendarEvent.ts @@ -8,10 +8,18 @@ export interface CalendarEvent { note?: string; isRecurring?: boolean; recurrenceRule?: string; + exceptionDates?: string[]; // ISO date strings (YYYY-MM-DD) for excluded occurrences createdAt?: Date; updatedAt?: Date; } +export type RecurringDeleteMode = "single" | "future" | "all"; + +export interface DeleteRecurringEventDTO { + mode: RecurringDeleteMode; + occurrenceDate?: string; // ISO date string of the occurrence to delete +} + export interface CreateEventDTO { title: string; description?: string; @@ -30,6 +38,7 @@ export interface UpdateEventDTO { note?: string; isRecurring?: boolean; recurrenceRule?: string; + exceptionDates?: string[]; } export interface ExpandedEvent extends CalendarEvent { diff --git a/packages/shared/src/models/ChatMessage.ts b/packages/shared/src/models/ChatMessage.ts index 83ebf1f..d1cd08f 100644 --- a/packages/shared/src/models/ChatMessage.ts +++ b/packages/shared/src/models/ChatMessage.ts @@ -1,4 +1,8 @@ -import { CreateEventDTO, UpdateEventDTO } from "./CalendarEvent"; +import { + CreateEventDTO, + UpdateEventDTO, + RecurringDeleteMode, +} from "./CalendarEvent"; export type MessageSender = "user" | "assistant"; @@ -13,6 +17,8 @@ export interface ProposedEventChange { event?: CreateEventDTO; // Required for create updates?: UpdateEventDTO; // Required for update respondedAction?: RespondedAction; // User's response to this specific proposal + deleteMode?: RecurringDeleteMode; // For recurring event deletion + occurrenceDate?: string; // ISO date string of specific occurrence for single/future delete } export interface ChatMessage {