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:
30
CLAUDE.md
30
CLAUDE.md
@@ -91,7 +91,8 @@ src/
|
|||||||
│ ├── EventCardBase.tsx # Shared event card layout with icons (used by EventCard & ProposedEventCard)
|
│ ├── EventCardBase.tsx # Shared event card layout with icons (used by EventCard & ProposedEventCard)
|
||||||
│ ├── EventCard.tsx # Calendar event card (uses EventCardBase + edit/delete buttons)
|
│ ├── EventCard.tsx # Calendar event card (uses EventCardBase + edit/delete buttons)
|
||||||
│ ├── EventConfirmDialog.tsx # AI-proposed event confirmation modal (skeleton)
|
│ ├── 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
|
├── Themes.tsx # Theme definitions: THEMES object with defaultLight/defaultDark, Theme type
|
||||||
├── logging/
|
├── logging/
|
||||||
│ ├── index.ts # Re-exports
|
│ ├── index.ts # Re-exports
|
||||||
@@ -100,8 +101,8 @@ src/
|
|||||||
│ ├── index.ts # Re-exports all services
|
│ ├── index.ts # Re-exports all services
|
||||||
│ ├── ApiClient.ts # HTTP client with X-User-Id header injection, request logging, handles empty responses (204)
|
│ ├── 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
|
│ ├── AuthService.ts # login(), register(), logout() - calls API and updates AuthStore
|
||||||
│ ├── EventService.ts # getAll(), getById(), getByDateRange(), create(), update(), delete()
|
│ ├── EventService.ts # getAll(), getById(), getByDateRange(), create(), update(), delete(mode, occurrenceDate)
|
||||||
│ └── ChatService.ts # sendMessage(), confirmEvent(), rejectEvent(), getConversations(), getConversation()
|
│ └── ChatService.ts # sendMessage(), confirmEvent(deleteMode, occurrenceDate), rejectEvent(), getConversations(), getConversation()
|
||||||
└── stores/ # Zustand state management
|
└── stores/ # Zustand state management
|
||||||
├── index.ts # Re-exports all stores
|
├── index.ts # Re-exports all stores
|
||||||
├── AuthStore.ts # user, isAuthenticated, isLoading, login(), logout(), loadStoredUser()
|
├── AuthStore.ts # user, isAuthenticated, isLoading, login(), logout(), loadStoredUser()
|
||||||
@@ -224,7 +225,7 @@ src/
|
|||||||
- `GET /api/events/:id` - Get single event (protected)
|
- `GET /api/events/:id` - Get single event (protected)
|
||||||
- `POST /api/events` - Create event (protected)
|
- `POST /api/events` - Create event (protected)
|
||||||
- `PUT /api/events/:id` - Update 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/message` - Send message to AI (protected)
|
||||||
- `POST /api/chat/confirm/:conversationId/:messageId` - Confirm proposed event (protected)
|
- `POST /api/chat/confirm/:conversationId/:messageId` - Confirm proposed event (protected)
|
||||||
- `POST /api/chat/reject/:conversationId/:messageId` - Reject proposed event (protected)
|
- `POST /api/chat/reject/:conversationId/:messageId` - Reject proposed event (protected)
|
||||||
@@ -254,12 +255,15 @@ src/
|
|||||||
|
|
||||||
**Key Types:**
|
**Key Types:**
|
||||||
- `User`: id, email, userName, passwordHash?, createdAt?, updatedAt?
|
- `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)
|
- `ExpandedEvent`: Extends CalendarEvent with occurrenceStart, occurrenceEnd (for recurring event instances)
|
||||||
- `ChatMessage`: id, conversationId, sender ('user' | 'assistant'), content, proposedChanges?
|
- `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
|
- Each proposal has unique `id` (e.g., "proposal-0") for individual confirm/reject
|
||||||
- `respondedAction` tracks user response per proposal (not per message)
|
- `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)
|
- `Conversation`: id, userId, createdAt?, updatedAt? (messages loaded separately via lazy loading)
|
||||||
- `CreateUserDTO`: email, userName, password (for registration)
|
- `CreateUserDTO`: email, userName, password (for registration)
|
||||||
- `LoginDTO`: identifier (email OR userName), password
|
- `LoginDTO`: identifier (email OR userName), password
|
||||||
@@ -393,9 +397,9 @@ NODE_ENV=development # development = pretty logs, production = JSON
|
|||||||
- `dotenv` integration for environment variables
|
- `dotenv` integration for environment variables
|
||||||
- `ChatController`: sendMessage(), confirmEvent(), rejectEvent()
|
- `ChatController`: sendMessage(), confirmEvent(), rejectEvent()
|
||||||
- `ChatService`: processMessage() with test responses (create, update, delete actions), confirmEvent() handles all CRUD actions
|
- `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)
|
- `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/eventFormatters`: getWeeksOverview(), getMonthOverview() with German localization
|
||||||
- `utils/recurrenceExpander`: expandRecurringEvents() using rrule library for RRULE parsing
|
- `utils/recurrenceExpander`: expandRecurringEvents() using rrule library for RRULE parsing
|
||||||
- `ChatController`: getConversations(), getConversation() with cursor-based pagination support
|
- `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
|
- `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/`: 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
|
- `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
|
- `logging/`: Structured logging with pino, pino-http middleware, @Logged decorator
|
||||||
- All repositories and GPTAdapter decorated with @Logged for automatic method logging
|
- All repositories and GPTAdapter decorated with @Logged for automatic method logging
|
||||||
- CORS configured to allow X-User-Id header
|
- 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
|
- Tap-to-open modal overlay showing EventCards for selected day
|
||||||
- Supports events from adjacent months visible in grid
|
- Supports events from adjacent months visible in grid
|
||||||
- Uses `useFocusEffect` for automatic reload on tab focus
|
- 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
|
- Chat screen fully functional with FlashList, message sending, and event confirm/reject
|
||||||
- **Multiple event proposals**: AI can propose multiple events in one response
|
- **Multiple event proposals**: AI can propose multiple events in one response
|
||||||
- Arrow navigation between proposals with "Event X von Y" counter
|
- 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)
|
- KeyboardAvoidingView for proper keyboard handling (iOS padding, Android height)
|
||||||
- Auto-scroll to end on new messages and keyboard show
|
- Auto-scroll to end on new messages and keyboard show
|
||||||
- keyboardDismissMode="interactive" and keyboardShouldPersistTaps="handled"
|
- keyboardDismissMode="interactive" and keyboardShouldPersistTaps="handled"
|
||||||
- `EventService`: getAll(), getById(), getByDateRange(), create(), update(), delete() - fully implemented
|
- `EventService`: getAll(), getById(), getByDateRange(), create(), update(), delete(mode, occurrenceDate) - fully implemented with recurring delete modes
|
||||||
- `ChatService`: sendMessage(), confirmEvent(), rejectEvent(), getConversations(), getConversation() - fully implemented with cursor pagination
|
- `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
|
- `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
|
- `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.)
|
- `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[]
|
- `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
|
- `ChatStore`: Zustand store with addMessage(), addMessages(), updateMessage(), clearMessages(), isWaitingForResponse/setWaitingForResponse() for typing indicator - loads from server on mount and persists across tab switches
|
||||||
|
|||||||
@@ -56,5 +56,5 @@ export const THEMES = {
|
|||||||
eventIndicator: "#DE6C20",
|
eventIndicator: "#DE6C20",
|
||||||
borderPrimary: "#FFFFFF",
|
borderPrimary: "#FFFFFF",
|
||||||
shadowColor: "#FFFFFF",
|
shadowColor: "#FFFFFF",
|
||||||
}
|
},
|
||||||
} as const satisfies Record<string, Theme>;
|
} as const satisfies Record<string, Theme>;
|
||||||
|
|||||||
@@ -5,11 +5,17 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Alert,
|
|
||||||
} from "react-native";
|
} 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 Header from "../../components/Header";
|
||||||
import { EventCard } from "../../components/EventCard";
|
import { EventCard } from "../../components/EventCard";
|
||||||
|
import { DeleteEventModal } from "../../components/DeleteEventModal";
|
||||||
import React, {
|
import React, {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -75,13 +81,16 @@ const Calendar = () => {
|
|||||||
const [currentYear, setCurrentYear] = useState(new Date().getFullYear());
|
const [currentYear, setCurrentYear] = useState(new Date().getFullYear());
|
||||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||||
|
|
||||||
|
// State for delete modal
|
||||||
|
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||||
|
const [eventToDelete, setEventToDelete] = useState<ExpandedEvent | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
const { events, setEvents, deleteEvent } = useEventsStore();
|
const { events, setEvents, deleteEvent } = useEventsStore();
|
||||||
|
|
||||||
// Load events when tab gains focus or month/year changes
|
// Function to load events for current view
|
||||||
// Include days from prev/next month that are visible in the grid
|
const loadEvents = useCallback(async () => {
|
||||||
useFocusEffect(
|
|
||||||
useCallback(() => {
|
|
||||||
const loadEvents = async () => {
|
|
||||||
try {
|
try {
|
||||||
// Calculate first visible day (up to 6 days before month start)
|
// Calculate first visible day (up to 6 days before month start)
|
||||||
const firstOfMonth = new Date(currentYear, monthIndex, 1);
|
const firstOfMonth = new Date(currentYear, monthIndex, 1);
|
||||||
@@ -106,9 +115,15 @@ const Calendar = () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load events:", error);
|
console.error("Failed to load events:", error);
|
||||||
}
|
}
|
||||||
};
|
}, [monthIndex, currentYear, setEvents]);
|
||||||
|
|
||||||
|
// Load events when tab gains focus or month/year changes
|
||||||
|
// NOTE: Wrapper needed because loadEvents is async (returns Promise)
|
||||||
|
// and useFocusEffect expects a sync function (optionally returning cleanup)
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
loadEvents();
|
loadEvents();
|
||||||
}, [monthIndex, currentYear, setEvents]),
|
}, [loadEvents]),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Group events by date (YYYY-MM-DD format)
|
// Group events by date (YYYY-MM-DD format)
|
||||||
@@ -153,31 +168,40 @@ const Calendar = () => {
|
|||||||
// TODO: Navigate to event edit screen
|
// TODO: Navigate to event edit screen
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteEvent = async (event: ExpandedEvent) => {
|
const handleDeleteEvent = (event: ExpandedEvent) => {
|
||||||
Alert.alert("Event löschen", `"${event.title}" wirklich löschen?`, [
|
// Show delete modal for both recurring and non-recurring events
|
||||||
{ text: "Abbrechen", style: "cancel" },
|
setEventToDelete(event);
|
||||||
{
|
setDeleteModalVisible(true);
|
||||||
text: "Löschen",
|
};
|
||||||
style: "destructive",
|
|
||||||
onPress: async () => {
|
const handleDeleteConfirm = async (mode: RecurringDeleteMode) => {
|
||||||
|
if (!eventToDelete) return;
|
||||||
|
|
||||||
|
setDeleteModalVisible(false);
|
||||||
|
const event = eventToDelete;
|
||||||
|
const occurrenceDate = getDateKey(new Date(event.occurrenceStart));
|
||||||
|
|
||||||
try {
|
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);
|
await EventService.delete(event.id);
|
||||||
deleteEvent(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) {
|
} catch (error) {
|
||||||
console.error("Failed to delete event:", error);
|
console.error("Failed to delete event:", error);
|
||||||
Alert.alert("Fehler", "Event konnte nicht gelöscht werden");
|
} finally {
|
||||||
|
setEventToDelete(null);
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
},
|
|
||||||
]);
|
const handleDeleteCancel = () => {
|
||||||
|
setDeleteModalVisible(false);
|
||||||
|
setEventToDelete(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get events for selected date
|
// Get events for selected date
|
||||||
@@ -203,8 +227,6 @@ const Calendar = () => {
|
|||||||
eventsByDate={eventsByDate}
|
eventsByDate={eventsByDate}
|
||||||
onDayPress={handleDayPress}
|
onDayPress={handleDayPress}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Event Overlay Modal */}
|
|
||||||
<EventOverlay
|
<EventOverlay
|
||||||
visible={selectedDate !== null}
|
visible={selectedDate !== null}
|
||||||
date={selectedDate}
|
date={selectedDate}
|
||||||
@@ -213,6 +235,13 @@ const Calendar = () => {
|
|||||||
onEditEvent={handleEditEvent}
|
onEditEvent={handleEditEvent}
|
||||||
onDeleteEvent={handleDeleteEvent}
|
onDeleteEvent={handleDeleteEvent}
|
||||||
/>
|
/>
|
||||||
|
<DeleteEventModal
|
||||||
|
visible={deleteModalVisible}
|
||||||
|
eventTitle={eventToDelete?.title || ""}
|
||||||
|
isRecurring={eventToDelete?.isRecurring || false}
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
onCancel={handleDeleteCancel}
|
||||||
|
/>
|
||||||
</BaseBackground>
|
</BaseBackground>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -274,7 +303,12 @@ const EventOverlay = ({
|
|||||||
borderBottomColor: theme.borderPrimary,
|
borderBottomColor: theme.borderPrimary,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className="font-bold text-lg" style={{ color: theme.textPrimary }}>{dateString}</Text>
|
<Text
|
||||||
|
className="font-bold text-lg"
|
||||||
|
style={{ color: theme.textPrimary }}
|
||||||
|
>
|
||||||
|
{dateString}
|
||||||
|
</Text>
|
||||||
<Text style={{ color: theme.textPrimary }}>
|
<Text style={{ color: theme.textPrimary }}>
|
||||||
{events.length} {events.length === 1 ? "Termin" : "Termine"}
|
{events.length} {events.length === 1 ? "Termin" : "Termine"}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -406,9 +440,7 @@ const MonthSelector = ({
|
|||||||
className="w-full flex justify-center items-center py-2"
|
className="w-full flex justify-center items-center py-2"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
item.monthIndex % 2 === 0
|
item.monthIndex % 2 === 0 ? theme.primeBg : theme.secondaryBg,
|
||||||
? theme.primeBg
|
|
||||||
: theme.secondaryBg,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className="text-xl" style={{ color: theme.primeFg }}>
|
<Text className="text-xl" style={{ color: theme.primeFg }}>
|
||||||
@@ -511,11 +543,7 @@ const CalendarHeader = (props: CalendarHeaderProps) => {
|
|||||||
}}
|
}}
|
||||||
onPress={measureAndOpen}
|
onPress={measureAndOpen}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons name="chevron-down" size={28} color={theme.primeFg} />
|
||||||
name="chevron-down"
|
|
||||||
size={28}
|
|
||||||
color={theme.primeFg}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
<MonthSelector
|
<MonthSelector
|
||||||
@@ -576,7 +604,9 @@ const WeekDaysLine = () => {
|
|||||||
<View className="flex flex-row items-center justify-around px-2 gap-2">
|
<View className="flex flex-row items-center justify-around px-2 gap-2">
|
||||||
{/* TODO: px and gap need fine tuning to perfectly align with the grid */}
|
{/* TODO: px and gap need fine tuning to perfectly align with the grid */}
|
||||||
{DAYS.map((day, i) => (
|
{DAYS.map((day, i) => (
|
||||||
<Text key={i} style={{ color: theme.textPrimary }}>{day.substring(0, 2).toUpperCase()}</Text>
|
<Text key={i} style={{ color: theme.textPrimary }}>
|
||||||
|
{day.substring(0, 2).toUpperCase()}
|
||||||
|
</Text>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
chatMessageToMessageData,
|
chatMessageToMessageData,
|
||||||
MessageData,
|
MessageData,
|
||||||
} from "../../stores";
|
} from "../../stores";
|
||||||
import { ProposedEventChange } from "@calchat/shared";
|
import { ProposedEventChange, RespondedAction } from "@calchat/shared";
|
||||||
import { ProposedEventCard } from "../../components/ProposedEventCard";
|
import { ProposedEventCard } from "../../components/ProposedEventCard";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import TypingIndicator from "../../components/TypingIndicator";
|
import TypingIndicator from "../../components/TypingIndicator";
|
||||||
@@ -104,7 +104,7 @@ const Chat = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleEventResponse = async (
|
const handleEventResponse = async (
|
||||||
action: "confirm" | "reject",
|
action: RespondedAction,
|
||||||
messageId: string,
|
messageId: string,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
proposalId: string,
|
proposalId: string,
|
||||||
@@ -114,7 +114,9 @@ const Chat = () => {
|
|||||||
const message = messages.find((m) => m.id === messageId);
|
const message = messages.find((m) => m.id === messageId);
|
||||||
if (message?.proposedChanges) {
|
if (message?.proposedChanges) {
|
||||||
const updatedProposals = message.proposedChanges.map((p) =>
|
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 });
|
updateMessage(messageId, { proposedChanges: updatedProposals });
|
||||||
}
|
}
|
||||||
@@ -130,8 +132,14 @@ const Chat = () => {
|
|||||||
proposedChange.event,
|
proposedChange.event,
|
||||||
proposedChange.eventId,
|
proposedChange.eventId,
|
||||||
proposedChange.updates,
|
proposedChange.updates,
|
||||||
|
proposedChange.deleteMode,
|
||||||
|
proposedChange.occurrenceDate,
|
||||||
)
|
)
|
||||||
: await ChatService.rejectEvent(conversationId, messageId, proposalId);
|
: await ChatService.rejectEvent(
|
||||||
|
conversationId,
|
||||||
|
messageId,
|
||||||
|
proposalId,
|
||||||
|
);
|
||||||
|
|
||||||
const botMessage: MessageData = {
|
const botMessage: MessageData = {
|
||||||
id: response.message.id,
|
id: response.message.id,
|
||||||
@@ -225,14 +233,21 @@ const Chat = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
onReject={(proposalId) =>
|
onReject={(proposalId) =>
|
||||||
handleEventResponse("reject", item.id, item.conversationId!, proposalId)
|
handleEventResponse(
|
||||||
|
"reject",
|
||||||
|
item.id,
|
||||||
|
item.conversationId!,
|
||||||
|
proposalId,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
keyboardDismissMode="interactive"
|
keyboardDismissMode="interactive"
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
ListFooterComponent={isWaitingForResponse ? <TypingIndicator /> : null}
|
ListFooterComponent={
|
||||||
|
isWaitingForResponse ? <TypingIndicator /> : null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<ChatInput onSend={handleSend} />
|
<ChatInput onSend={handleSend} />
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
@@ -251,7 +266,9 @@ const ChatHeader = () => {
|
|||||||
borderColor: theme.primeFg,
|
borderColor: theme.primeFg,
|
||||||
}}
|
}}
|
||||||
></View>
|
></View>
|
||||||
<Text className="text-lg pl-3" style={{ color: theme.textPrimary }}>CalChat</Text>
|
<Text className="text-lg pl-3" style={{ color: theme.textPrimary }}>
|
||||||
|
CalChat
|
||||||
|
</Text>
|
||||||
<View
|
<View
|
||||||
className="h-2 bg-black"
|
className="h-2 bg-black"
|
||||||
style={{
|
style={{
|
||||||
@@ -329,9 +346,7 @@ const ChatMessage = ({
|
|||||||
|
|
||||||
const goToPrev = () => setCurrentIndex((i) => Math.max(0, i - 1));
|
const goToPrev = () => setCurrentIndex((i) => Math.max(0, i - 1));
|
||||||
const goToNext = () =>
|
const goToNext = () =>
|
||||||
setCurrentIndex((i) =>
|
setCurrentIndex((i) => Math.min((proposedChanges?.length || 1) - 1, i + 1));
|
||||||
Math.min((proposedChanges?.length || 1) - 1, i + 1),
|
|
||||||
);
|
|
||||||
|
|
||||||
const canGoPrev = currentIndex > 0;
|
const canGoPrev = currentIndex > 0;
|
||||||
const canGoNext = currentIndex < (proposedChanges?.length || 1) - 1;
|
const canGoNext = currentIndex < (proposedChanges?.length || 1) - 1;
|
||||||
@@ -344,7 +359,9 @@ const ChatMessage = ({
|
|||||||
minWidth: hasProposals ? "75%" : undefined,
|
minWidth: hasProposals ? "75%" : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className="p-2" style={{ color: theme.textPrimary }}>{content}</Text>
|
<Text className="p-2" style={{ color: theme.textPrimary }}>
|
||||||
|
{content}
|
||||||
|
</Text>
|
||||||
|
|
||||||
{hasProposals && currentProposal && onConfirm && onReject && (
|
{hasProposals && currentProposal && onConfirm && onReject && (
|
||||||
<View>
|
<View>
|
||||||
@@ -358,11 +375,7 @@ const ChatMessage = ({
|
|||||||
className="p-1"
|
className="p-1"
|
||||||
style={{ opacity: canGoPrev ? 1 : 0.3 }}
|
style={{ opacity: canGoPrev ? 1 : 0.3 }}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons name="chevron-back" size={24} color={theme.primeFg} />
|
||||||
name="chevron-back"
|
|
||||||
size={24}
|
|
||||||
color={theme.primeFg}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -19,27 +19,49 @@ const EventDetailScreen = () => {
|
|||||||
return (
|
return (
|
||||||
<BaseBackground>
|
<BaseBackground>
|
||||||
<View className="flex-1 p-4">
|
<View className="flex-1 p-4">
|
||||||
<Text className="text-2xl mb-4" style={{ color: theme.textPrimary }}>Event Detail</Text>
|
<Text className="text-2xl mb-4" style={{ color: theme.textPrimary }}>
|
||||||
<Text className="mb-4" style={{ color: theme.textSecondary }}>ID: {id}</Text>
|
Event Detail
|
||||||
|
</Text>
|
||||||
|
<Text className="mb-4" style={{ color: theme.textSecondary }}>
|
||||||
|
ID: {id}
|
||||||
|
</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="Title"
|
placeholder="Title"
|
||||||
placeholderTextColor={theme.textMuted}
|
placeholderTextColor={theme.textMuted}
|
||||||
className="w-full border rounded p-2 mb-4"
|
className="w-full border rounded p-2 mb-4"
|
||||||
style={{ color: theme.textPrimary, borderColor: theme.borderPrimary, backgroundColor: theme.secondaryBg }}
|
style={{
|
||||||
|
color: theme.textPrimary,
|
||||||
|
borderColor: theme.borderPrimary,
|
||||||
|
backgroundColor: theme.secondaryBg,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="Description"
|
placeholder="Description"
|
||||||
placeholderTextColor={theme.textMuted}
|
placeholderTextColor={theme.textMuted}
|
||||||
multiline
|
multiline
|
||||||
className="w-full border rounded p-2 mb-4 h-24"
|
className="w-full border rounded p-2 mb-4 h-24"
|
||||||
style={{ color: theme.textPrimary, borderColor: theme.borderPrimary, backgroundColor: theme.secondaryBg }}
|
style={{
|
||||||
|
color: theme.textPrimary,
|
||||||
|
borderColor: theme.borderPrimary,
|
||||||
|
backgroundColor: theme.secondaryBg,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<View className="flex-row gap-2">
|
<View className="flex-row gap-2">
|
||||||
<Pressable className="p-3 rounded flex-1" style={{ backgroundColor: theme.confirmButton }}>
|
<Pressable
|
||||||
<Text className="text-center" style={{ color: theme.buttonText }}>Save</Text>
|
className="p-3 rounded flex-1"
|
||||||
|
style={{ backgroundColor: theme.confirmButton }}
|
||||||
|
>
|
||||||
|
<Text className="text-center" style={{ color: theme.buttonText }}>
|
||||||
|
Save
|
||||||
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable className="p-3 rounded flex-1" style={{ backgroundColor: theme.rejectButton }}>
|
<Pressable
|
||||||
<Text className="text-center" style={{ color: theme.buttonText }}>Delete</Text>
|
className="p-3 rounded flex-1"
|
||||||
|
style={{ backgroundColor: theme.rejectButton }}
|
||||||
|
>
|
||||||
|
<Text className="text-center" style={{ color: theme.buttonText }}>
|
||||||
|
Delete
|
||||||
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -43,7 +43,10 @@ const LoginScreen = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Text className="mb-4 text-center" style={{ color: theme.rejectButton }}>
|
<Text
|
||||||
|
className="mb-4 text-center"
|
||||||
|
style={{ color: theme.rejectButton }}
|
||||||
|
>
|
||||||
{error}
|
{error}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -17,18 +17,31 @@ const NoteScreen = () => {
|
|||||||
return (
|
return (
|
||||||
<BaseBackground>
|
<BaseBackground>
|
||||||
<View className="flex-1 p-4">
|
<View className="flex-1 p-4">
|
||||||
<Text className="text-2xl mb-4" style={{ color: theme.textPrimary }}>Note</Text>
|
<Text className="text-2xl mb-4" style={{ color: theme.textPrimary }}>
|
||||||
<Text className="mb-4" style={{ color: theme.textSecondary }}>Event ID: {id}</Text>
|
Note
|
||||||
|
</Text>
|
||||||
|
<Text className="mb-4" style={{ color: theme.textSecondary }}>
|
||||||
|
Event ID: {id}
|
||||||
|
</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="Write your note here..."
|
placeholder="Write your note here..."
|
||||||
placeholderTextColor={theme.textMuted}
|
placeholderTextColor={theme.textMuted}
|
||||||
multiline
|
multiline
|
||||||
className="w-full border rounded p-2 flex-1 mb-4"
|
className="w-full border rounded p-2 flex-1 mb-4"
|
||||||
textAlignVertical="top"
|
textAlignVertical="top"
|
||||||
style={{ color: theme.textPrimary, borderColor: theme.borderPrimary, backgroundColor: theme.secondaryBg }}
|
style={{
|
||||||
|
color: theme.textPrimary,
|
||||||
|
borderColor: theme.borderPrimary,
|
||||||
|
backgroundColor: theme.secondaryBg,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Pressable className="p-3 rounded" style={{ backgroundColor: theme.confirmButton }}>
|
<Pressable
|
||||||
<Text className="text-center" style={{ color: theme.buttonText }}>Save Note</Text>
|
className="p-3 rounded"
|
||||||
|
style={{ backgroundColor: theme.confirmButton }}
|
||||||
|
>
|
||||||
|
<Text className="text-center" style={{ color: theme.buttonText }}>
|
||||||
|
Save Note
|
||||||
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</BaseBackground>
|
</BaseBackground>
|
||||||
|
|||||||
@@ -51,7 +51,10 @@ const RegisterScreen = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Text className="mb-4 text-center" style={{ color: theme.rejectButton }}>
|
<Text
|
||||||
|
className="mb-4 text-center"
|
||||||
|
style={{ color: theme.rejectButton }}
|
||||||
|
>
|
||||||
{error}
|
{error}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -15,9 +15,7 @@ const AuthButton = ({ title, onPress, isLoading = false }: AuthButtonProps) => {
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="w-full rounded-lg p-4 mb-4 border-4"
|
className="w-full rounded-lg p-4 mb-4 border-4"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isLoading
|
backgroundColor: isLoading ? theme.disabledButton : theme.chatBot,
|
||||||
? theme.disabledButton
|
|
||||||
: theme.chatBot,
|
|
||||||
shadowColor: theme.shadowColor,
|
shadowColor: theme.shadowColor,
|
||||||
shadowOffset: { width: 0, height: 2 },
|
shadowOffset: { width: 0, height: 2 },
|
||||||
shadowOpacity: 0.25,
|
shadowOpacity: 0.25,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ type BaseButtonProps = {
|
|||||||
solid?: boolean;
|
solid?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BaseButton = ({children, onPress, solid = false}: BaseButtonProps) => {
|
const BaseButton = ({ children, onPress, solid = false }: BaseButtonProps) => {
|
||||||
const { theme } = useThemeStore();
|
const { theme } = useThemeStore();
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -16,9 +16,7 @@ const BaseButton = ({children, onPress, solid = false}: BaseButtonProps) => {
|
|||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
style={{
|
style={{
|
||||||
borderColor: theme.borderPrimary,
|
borderColor: theme.borderPrimary,
|
||||||
backgroundColor: solid
|
backgroundColor: solid ? theme.chatBot : theme.primeBg,
|
||||||
? theme.chatBot
|
|
||||||
: theme.primeBg,
|
|
||||||
shadowColor: theme.shadowColor,
|
shadowColor: theme.shadowColor,
|
||||||
shadowOffset: { width: 0, height: 2 },
|
shadowOffset: { width: 0, height: 2 },
|
||||||
shadowOpacity: 0.25,
|
shadowOpacity: 0.25,
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ type ChatBubbleProps = {
|
|||||||
style?: ViewStyle;
|
style?: ViewStyle;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ChatBubble({ side, children, className = "", style }: ChatBubbleProps) {
|
export function ChatBubble({
|
||||||
|
side,
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
style,
|
||||||
|
}: ChatBubbleProps) {
|
||||||
const { theme } = useThemeStore();
|
const { theme } = useThemeStore();
|
||||||
const borderColor = side === "left" ? theme.chatBot : theme.primeFg;
|
const borderColor = side === "left" ? theme.chatBot : theme.primeFg;
|
||||||
const sideClass =
|
const sideClass =
|
||||||
@@ -21,7 +26,10 @@ export function ChatBubble({ side, children, className = "", style }: ChatBubble
|
|||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className={`border-2 border-solid rounded-xl my-2 ${sideClass} ${className}`}
|
className={`border-2 border-solid rounded-xl my-2 ${sideClass} ${className}`}
|
||||||
style={[{ borderColor, elevation: 8, backgroundColor: theme.secondaryBg }, style]}
|
style={[
|
||||||
|
{ borderColor, elevation: 8, backgroundColor: theme.secondaryBg },
|
||||||
|
style,
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
162
apps/client/src/components/DeleteEventModal.tsx
Normal file
162
apps/client/src/components/DeleteEventModal.tsx
Normal file
@@ -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 (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
transparent={true}
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={onCancel}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
className="flex-1 justify-center items-center"
|
||||||
|
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||||
|
onPress={onCancel}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
className="w-11/12 rounded-2xl overflow-hidden"
|
||||||
|
style={{
|
||||||
|
backgroundColor: theme.primeBg,
|
||||||
|
borderWidth: 4,
|
||||||
|
borderColor: theme.borderPrimary,
|
||||||
|
}}
|
||||||
|
onPress={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View
|
||||||
|
className="px-4 py-3"
|
||||||
|
style={{
|
||||||
|
backgroundColor: theme.chatBot,
|
||||||
|
borderBottomWidth: 3,
|
||||||
|
borderBottomColor: theme.borderPrimary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="font-bold text-lg"
|
||||||
|
style={{ color: theme.textPrimary }}
|
||||||
|
>
|
||||||
|
{isRecurring
|
||||||
|
? "Wiederkehrenden Termin loeschen"
|
||||||
|
: "Termin loeschen"}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: theme.textSecondary }} numberOfLines={1}>
|
||||||
|
{eventTitle}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<View className="p-4">
|
||||||
|
{isRecurring ? (
|
||||||
|
// Recurring event: show three options
|
||||||
|
RECURRING_DELETE_OPTIONS.map((option) => (
|
||||||
|
<Pressable
|
||||||
|
key={option.mode}
|
||||||
|
onPress={() => onConfirm(option.mode)}
|
||||||
|
className="py-3 px-4 rounded-lg mb-2"
|
||||||
|
style={{
|
||||||
|
backgroundColor: theme.secondaryBg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.borderPrimary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="font-medium text-base"
|
||||||
|
style={{ color: theme.textPrimary }}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
className="text-sm mt-1"
|
||||||
|
style={{ color: theme.textMuted }}
|
||||||
|
>
|
||||||
|
{option.description}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
// Non-recurring event: simple confirmation
|
||||||
|
<View>
|
||||||
|
<Text
|
||||||
|
className="text-base mb-4"
|
||||||
|
style={{ color: theme.textPrimary }}
|
||||||
|
>
|
||||||
|
Möchtest du diesen Termin wirklich löschen?
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => onConfirm("all")}
|
||||||
|
className="py-3 px-4 rounded-lg"
|
||||||
|
style={{
|
||||||
|
backgroundColor: theme.rejectButton,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="font-medium text-base text-center"
|
||||||
|
style={{ color: theme.buttonText }}
|
||||||
|
>
|
||||||
|
Loeschen
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Cancel button */}
|
||||||
|
<Pressable
|
||||||
|
onPress={onCancel}
|
||||||
|
className="py-3 items-center"
|
||||||
|
style={{
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: theme.placeholderBg,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: theme.primeFg }} className="font-bold">
|
||||||
|
Abbrechen
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -41,11 +41,7 @@ export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
|
|||||||
borderColor: theme.borderPrimary,
|
borderColor: theme.borderPrimary,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Feather
|
<Feather name="trash-2" size={18} color={theme.textPrimary} />
|
||||||
name="trash-2"
|
|
||||||
size={18}
|
|
||||||
color={theme.textPrimary}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</EventCardBase>
|
</EventCardBase>
|
||||||
|
|||||||
@@ -75,11 +75,19 @@ export const EventCardBase = ({
|
|||||||
borderBottomColor: theme.borderPrimary,
|
borderBottomColor: theme.borderPrimary,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className="font-bold text-base" style={{ color: theme.textPrimary }}>{title}</Text>
|
<Text
|
||||||
|
className="font-bold text-base"
|
||||||
|
style={{ color: theme.textPrimary }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<View className="px-3 py-2" style={{ backgroundColor: theme.secondaryBg }}>
|
<View
|
||||||
|
className="px-3 py-2"
|
||||||
|
style={{ backgroundColor: theme.secondaryBg }}
|
||||||
|
>
|
||||||
{/* Date */}
|
{/* Date */}
|
||||||
<View className="flex-row items-center mb-1">
|
<View className="flex-row items-center mb-1">
|
||||||
<Feather
|
<Feather
|
||||||
@@ -116,18 +124,13 @@ export const EventCardBase = ({
|
|||||||
color={theme.textPrimary}
|
color={theme.textPrimary}
|
||||||
style={{ marginRight: 8 }}
|
style={{ marginRight: 8 }}
|
||||||
/>
|
/>
|
||||||
<Text style={{ color: theme.textPrimary }}>
|
<Text style={{ color: theme.textPrimary }}>Wiederkehrend</Text>
|
||||||
Wiederkehrend
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
{description && (
|
{description && (
|
||||||
<Text
|
<Text style={{ color: theme.textPrimary }} className="text-sm mt-1">
|
||||||
style={{ color: theme.textPrimary }}
|
|
||||||
className="text-sm mt-1"
|
|
||||||
>
|
|
||||||
{description}
|
{description}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ const EventConfirmDialog = ({
|
|||||||
<Modal visible={false} transparent animationType="fade">
|
<Modal visible={false} transparent animationType="fade">
|
||||||
<View>
|
<View>
|
||||||
<Pressable>
|
<Pressable>
|
||||||
<Text style={{ color: theme.textPrimary }}>EventConfirmDialog - Not Implemented</Text>
|
<Text style={{ color: theme.textPrimary }}>
|
||||||
|
EventConfirmDialog - Not Implemented
|
||||||
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,14 +1,34 @@
|
|||||||
import { View, Text, Pressable } from "react-native";
|
import { View, Text, Pressable } from "react-native";
|
||||||
import { ProposedEventChange } from "@calchat/shared";
|
import { ProposedEventChange, RecurringDeleteMode } from "@calchat/shared";
|
||||||
import { useThemeStore } from "../stores/ThemeStore";
|
import { useThemeStore } from "../stores/ThemeStore";
|
||||||
import { EventCardBase } from "./EventCardBase";
|
import { EventCardBase } from "./EventCardBase";
|
||||||
|
|
||||||
|
const DELETE_MODE_LABELS: Record<RecurringDeleteMode, string> = {
|
||||||
|
single: "Nur dieses Vorkommen",
|
||||||
|
future: "Dieses & zukuenftige",
|
||||||
|
all: "Alle Vorkommen",
|
||||||
|
};
|
||||||
|
|
||||||
type ProposedEventCardProps = {
|
type ProposedEventCardProps = {
|
||||||
proposedChange: ProposedEventChange;
|
proposedChange: ProposedEventChange;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
onReject: () => void;
|
onReject: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DeleteModeBadge = ({ mode }: { mode: RecurringDeleteMode }) => {
|
||||||
|
const { theme } = useThemeStore();
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="self-start px-2 py-1 rounded-md mb-2"
|
||||||
|
style={{ backgroundColor: theme.rejectButton }}
|
||||||
|
>
|
||||||
|
<Text style={{ color: theme.buttonText }} className="text-xs font-medium">
|
||||||
|
{DELETE_MODE_LABELS[mode]}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const ConfirmRejectButtons = ({
|
const ConfirmRejectButtons = ({
|
||||||
isDisabled,
|
isDisabled,
|
||||||
respondedAction,
|
respondedAction,
|
||||||
@@ -68,6 +88,12 @@ export const ProposedEventCard = ({
|
|||||||
// respondedAction is now part of the proposedChange
|
// respondedAction is now part of the proposedChange
|
||||||
const isDisabled = !!proposedChange.respondedAction;
|
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) {
|
if (!event) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -82,6 +108,9 @@ export const ProposedEventCard = ({
|
|||||||
description={event.description}
|
description={event.description}
|
||||||
isRecurring={event.isRecurring}
|
isRecurring={event.isRecurring}
|
||||||
>
|
>
|
||||||
|
{showDeleteModeBadge && (
|
||||||
|
<DeleteModeBadge mode={proposedChange.deleteMode!} />
|
||||||
|
)}
|
||||||
<ConfirmRejectButtons
|
<ConfirmRejectButtons
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
respondedAction={proposedChange.respondedAction}
|
respondedAction={proposedChange.respondedAction}
|
||||||
|
|||||||
@@ -50,11 +50,15 @@ async function request<T>(
|
|||||||
const duration = Math.round(performance.now() - start);
|
const duration = Math.round(performance.now() - start);
|
||||||
|
|
||||||
if (!response.ok) {
|
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}`);
|
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();
|
const text = await response.text();
|
||||||
if (!text) {
|
if (!text) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
CreateEventDTO,
|
CreateEventDTO,
|
||||||
UpdateEventDTO,
|
UpdateEventDTO,
|
||||||
EventAction,
|
EventAction,
|
||||||
|
RecurringDeleteMode,
|
||||||
} from "@calchat/shared";
|
} from "@calchat/shared";
|
||||||
import { ApiClient } from "./ApiClient";
|
import { ApiClient } from "./ApiClient";
|
||||||
|
|
||||||
@@ -16,6 +17,8 @@ interface ConfirmEventRequest {
|
|||||||
event?: CreateEventDTO;
|
event?: CreateEventDTO;
|
||||||
eventId?: string;
|
eventId?: string;
|
||||||
updates?: UpdateEventDTO;
|
updates?: UpdateEventDTO;
|
||||||
|
deleteMode?: RecurringDeleteMode;
|
||||||
|
occurrenceDate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RejectEventRequest {
|
interface RejectEventRequest {
|
||||||
@@ -35,6 +38,8 @@ export const ChatService = {
|
|||||||
event?: CreateEventDTO,
|
event?: CreateEventDTO,
|
||||||
eventId?: string,
|
eventId?: string,
|
||||||
updates?: UpdateEventDTO,
|
updates?: UpdateEventDTO,
|
||||||
|
deleteMode?: RecurringDeleteMode,
|
||||||
|
occurrenceDate?: string,
|
||||||
): Promise<ChatResponse> => {
|
): Promise<ChatResponse> => {
|
||||||
const body: ConfirmEventRequest = {
|
const body: ConfirmEventRequest = {
|
||||||
proposalId,
|
proposalId,
|
||||||
@@ -42,6 +47,8 @@ export const ChatService = {
|
|||||||
event,
|
event,
|
||||||
eventId,
|
eventId,
|
||||||
updates,
|
updates,
|
||||||
|
deleteMode,
|
||||||
|
occurrenceDate,
|
||||||
};
|
};
|
||||||
return ApiClient.post<ChatResponse>(
|
return ApiClient.post<ChatResponse>(
|
||||||
`/chat/confirm/${conversationId}/${messageId}`,
|
`/chat/confirm/${conversationId}/${messageId}`,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
CreateEventDTO,
|
CreateEventDTO,
|
||||||
UpdateEventDTO,
|
UpdateEventDTO,
|
||||||
ExpandedEvent,
|
ExpandedEvent,
|
||||||
|
RecurringDeleteMode,
|
||||||
} from "@calchat/shared";
|
} from "@calchat/shared";
|
||||||
import { ApiClient } from "./ApiClient";
|
import { ApiClient } from "./ApiClient";
|
||||||
|
|
||||||
@@ -29,7 +30,18 @@ export const EventService = {
|
|||||||
return ApiClient.put<CalendarEvent>(`/events/${id}`, data);
|
return ApiClient.put<CalendarEvent>(`/events/${id}`, data);
|
||||||
},
|
},
|
||||||
|
|
||||||
delete: async (id: string): Promise<void> => {
|
delete: async (
|
||||||
return ApiClient.delete(`/events/${id}`);
|
id: string,
|
||||||
|
mode?: RecurringDeleteMode,
|
||||||
|
occurrenceDate?: string,
|
||||||
|
): Promise<CalendarEvent | void> => {
|
||||||
|
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);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ interface ThemeState {
|
|||||||
|
|
||||||
export const useThemeStore = create<ThemeState>((set) => ({
|
export const useThemeStore = create<ThemeState>((set) => ({
|
||||||
theme: THEMES.defaultLight,
|
theme: THEMES.defaultLight,
|
||||||
setTheme: (themeName) => set({theme: THEMES[themeName]})
|
setTheme: (themeName) => set({ theme: THEMES[themeName] }),
|
||||||
}))
|
}));
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|||||||
{
|
{
|
||||||
name: "proposeDeleteEvent",
|
name: "proposeDeleteEvent",
|
||||||
description:
|
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: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -148,6 +148,17 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|||||||
type: "string",
|
type: "string",
|
||||||
description: "ID of the event to delete",
|
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"],
|
required: ["eventId"],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
getDay,
|
getDay,
|
||||||
Day,
|
Day,
|
||||||
DAY_TO_GERMAN,
|
DAY_TO_GERMAN,
|
||||||
|
RecurringDeleteMode,
|
||||||
} from "@calchat/shared";
|
} from "@calchat/shared";
|
||||||
import { AIContext } from "../../services/interfaces";
|
import { AIContext } from "../../services/interfaces";
|
||||||
import { formatDate, formatTime, formatDateTime } from "./eventFormatter";
|
import { formatDate, formatTime, formatDateTime } from "./eventFormatter";
|
||||||
@@ -111,6 +112,8 @@ export function executeToolCall(
|
|||||||
|
|
||||||
case "proposeDeleteEvent": {
|
case "proposeDeleteEvent": {
|
||||||
const eventId = args.eventId as string;
|
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(
|
const existingEvent = context.existingEvents.find(
|
||||||
(e) => e.id === eventId,
|
(e) => e.id === eventId,
|
||||||
);
|
);
|
||||||
@@ -119,8 +122,24 @@ export function executeToolCall(
|
|||||||
return { content: `Event mit ID ${eventId} nicht gefunden.` };
|
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 {
|
return {
|
||||||
content: `Lösch-Vorschlag für "${existingEvent.title}" erstellt.`,
|
content: `Lösch-Vorschlag für "${existingEvent.title}"${modeDescription} erstellt.`,
|
||||||
proposedChange: {
|
proposedChange: {
|
||||||
action: "delete",
|
action: "delete",
|
||||||
eventId,
|
eventId,
|
||||||
@@ -131,6 +150,10 @@ export function executeToolCall(
|
|||||||
description: existingEvent.description,
|
description: existingEvent.description,
|
||||||
isRecurring: existingEvent.isRecurring,
|
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",
|
"Access-Control-Allow-Methods",
|
||||||
"GET, POST, PUT, DELETE, OPTIONS",
|
"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") {
|
if (req.method === "OPTIONS") {
|
||||||
res.sendStatus(200);
|
res.sendStatus(200);
|
||||||
return;
|
return;
|
||||||
@@ -54,8 +57,13 @@ const aiProvider = new GPTAdapter();
|
|||||||
|
|
||||||
// Initialize services
|
// Initialize services
|
||||||
const authService = new AuthService(userRepo);
|
const authService = new AuthService(userRepo);
|
||||||
const chatService = new ChatService(chatRepo, eventRepo, aiProvider);
|
|
||||||
const eventService = new EventService(eventRepo);
|
const eventService = new EventService(eventRepo);
|
||||||
|
const chatService = new ChatService(
|
||||||
|
chatRepo,
|
||||||
|
eventRepo,
|
||||||
|
eventService,
|
||||||
|
aiProvider,
|
||||||
|
);
|
||||||
|
|
||||||
// Initialize controllers
|
// Initialize controllers
|
||||||
const authController = new AuthController(authService);
|
const authController = new AuthController(authService);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
UpdateEventDTO,
|
UpdateEventDTO,
|
||||||
EventAction,
|
EventAction,
|
||||||
GetMessagesOptions,
|
GetMessagesOptions,
|
||||||
|
RecurringDeleteMode,
|
||||||
} from "@calchat/shared";
|
} from "@calchat/shared";
|
||||||
import { ChatService } from "../services";
|
import { ChatService } from "../services";
|
||||||
import { createLogger } from "../logging";
|
import { createLogger } from "../logging";
|
||||||
@@ -22,7 +23,10 @@ export class ChatController {
|
|||||||
const response = await this.chatService.processMessage(userId, data);
|
const response = await this.chatService.processMessage(userId, data);
|
||||||
res.json(response);
|
res.json(response);
|
||||||
} catch (error) {
|
} 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" });
|
res.status(500).json({ error: "Failed to process message" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,12 +35,22 @@ export class ChatController {
|
|||||||
try {
|
try {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const { conversationId, messageId } = req.params;
|
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;
|
proposalId: string;
|
||||||
action: EventAction;
|
action: EventAction;
|
||||||
event?: CreateEventDTO;
|
event?: CreateEventDTO;
|
||||||
eventId?: string;
|
eventId?: string;
|
||||||
updates?: UpdateEventDTO;
|
updates?: UpdateEventDTO;
|
||||||
|
deleteMode?: RecurringDeleteMode;
|
||||||
|
occurrenceDate?: string;
|
||||||
};
|
};
|
||||||
const response = await this.chatService.confirmEvent(
|
const response = await this.chatService.confirmEvent(
|
||||||
userId,
|
userId,
|
||||||
@@ -47,10 +61,15 @@ export class ChatController {
|
|||||||
event,
|
event,
|
||||||
eventId,
|
eventId,
|
||||||
updates,
|
updates,
|
||||||
|
deleteMode,
|
||||||
|
occurrenceDate,
|
||||||
);
|
);
|
||||||
res.json(response);
|
res.json(response);
|
||||||
} catch (error) {
|
} 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" });
|
res.status(500).json({ error: "Failed to confirm event" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,7 +87,10 @@ export class ChatController {
|
|||||||
);
|
);
|
||||||
res.json(response);
|
res.json(response);
|
||||||
} catch (error) {
|
} 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" });
|
res.status(500).json({ error: "Failed to reject event" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,7 +104,10 @@ export class ChatController {
|
|||||||
const conversations = await this.chatService.getConversations(userId);
|
const conversations = await this.chatService.getConversations(userId);
|
||||||
res.json(conversations);
|
res.json(conversations);
|
||||||
} catch (error) {
|
} 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" });
|
res.status(500).json({ error: "Failed to get conversations" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,7 +138,10 @@ export class ChatController {
|
|||||||
if ((error as Error).message === "Conversation not found") {
|
if ((error as Error).message === "Conversation not found") {
|
||||||
res.status(404).json({ error: "Conversation not found" });
|
res.status(404).json({ error: "Conversation not found" });
|
||||||
} else {
|
} 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" });
|
res.status(500).json({ error: "Failed to get conversation" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Response } from "express";
|
import { Response } from "express";
|
||||||
|
import { RecurringDeleteMode } from "@calchat/shared";
|
||||||
import { EventService } from "../services";
|
import { EventService } from "../services";
|
||||||
import { createLogger } from "../logging";
|
import { createLogger } from "../logging";
|
||||||
import { AuthenticatedRequest } from "./AuthMiddleware";
|
import { AuthenticatedRequest } from "./AuthMiddleware";
|
||||||
@@ -72,7 +73,10 @@ export class EventController {
|
|||||||
);
|
);
|
||||||
res.json(events);
|
res.json(events);
|
||||||
} catch (error) {
|
} 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" });
|
res.status(500).json({ error: "Failed to get events" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,6 +101,38 @@ export class EventController {
|
|||||||
|
|
||||||
async delete(req: AuthenticatedRequest, res: Response): Promise<void> {
|
async delete(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
try {
|
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(
|
const deleted = await this.eventService.delete(
|
||||||
req.params.id,
|
req.params.id,
|
||||||
req.user!.userId,
|
req.user!.userId,
|
||||||
|
|||||||
@@ -80,7 +80,10 @@ export function Logged(name: string) {
|
|||||||
const method = String(propKey);
|
const method = String(propKey);
|
||||||
|
|
||||||
// Summarize args to avoid huge log entries
|
// 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 logCompletion = (err?: unknown) => {
|
||||||
const duration = Math.round(performance.now() - start);
|
const duration = Math.round(performance.now() - start);
|
||||||
|
|||||||
@@ -47,4 +47,17 @@ export class MongoEventRepository implements EventRepository {
|
|||||||
const result = await EventModel.findByIdAndDelete(id);
|
const result = await EventModel.findByIdAndDelete(id);
|
||||||
return result !== null;
|
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: {
|
recurrenceRule: {
|
||||||
type: String,
|
type: String,
|
||||||
},
|
},
|
||||||
|
exceptionDates: {
|
||||||
|
type: [String],
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
timestamps: true,
|
timestamps: true,
|
||||||
|
|||||||
@@ -10,11 +10,16 @@ import {
|
|||||||
UpdateEventDTO,
|
UpdateEventDTO,
|
||||||
EventAction,
|
EventAction,
|
||||||
CreateMessageDTO,
|
CreateMessageDTO,
|
||||||
|
RecurringDeleteMode,
|
||||||
} from "@calchat/shared";
|
} from "@calchat/shared";
|
||||||
import { ChatRepository, EventRepository, AIProvider } from "./interfaces";
|
import { ChatRepository, EventRepository, AIProvider } from "./interfaces";
|
||||||
|
import { EventService } from "./EventService";
|
||||||
import { getWeeksOverview, getMonthOverview } from "../utils/eventFormatters";
|
import { getWeeksOverview, getMonthOverview } from "../utils/eventFormatters";
|
||||||
|
|
||||||
type TestResponse = { content: string; proposedChanges?: ProposedEventChange[] };
|
type TestResponse = {
|
||||||
|
content: string;
|
||||||
|
proposedChanges?: ProposedEventChange[];
|
||||||
|
};
|
||||||
|
|
||||||
// Test response index (cycles through responses)
|
// Test response index (cycles through responses)
|
||||||
let responseIndex = 0;
|
let responseIndex = 0;
|
||||||
@@ -25,8 +30,7 @@ const staticResponses: TestResponse[] = [
|
|||||||
// === MULTI-EVENT TEST RESPONSES ===
|
// === MULTI-EVENT TEST RESPONSES ===
|
||||||
// Response 0: 3 Meetings an verschiedenen Tagen
|
// Response 0: 3 Meetings an verschiedenen Tagen
|
||||||
{
|
{
|
||||||
content:
|
content: "Alles klar! Ich erstelle dir 3 Team-Meetings für diese Woche:",
|
||||||
"Alles klar! Ich erstelle dir 3 Team-Meetings für diese Woche:",
|
|
||||||
proposedChanges: [
|
proposedChanges: [
|
||||||
{
|
{
|
||||||
id: "multi-1-a",
|
id: "multi-1-a",
|
||||||
@@ -62,8 +66,7 @@ const staticResponses: TestResponse[] = [
|
|||||||
},
|
},
|
||||||
// Response 1: 5 Termine für einen Projekttag
|
// Response 1: 5 Termine für einen Projekttag
|
||||||
{
|
{
|
||||||
content:
|
content: "Ich habe deinen kompletten Projekttag am Dienstag geplant:",
|
||||||
"Ich habe deinen kompletten Projekttag am Dienstag geplant:",
|
|
||||||
proposedChanges: [
|
proposedChanges: [
|
||||||
{
|
{
|
||||||
id: "multi-2-a",
|
id: "multi-2-a",
|
||||||
@@ -119,8 +122,7 @@ const staticResponses: TestResponse[] = [
|
|||||||
},
|
},
|
||||||
// Response 2: 2 wiederkehrende Termine
|
// Response 2: 2 wiederkehrende Termine
|
||||||
{
|
{
|
||||||
content:
|
content: "Ich erstelle dir zwei wiederkehrende Fitness-Termine:",
|
||||||
"Ich erstelle dir zwei wiederkehrende Fitness-Termine:",
|
|
||||||
proposedChanges: [
|
proposedChanges: [
|
||||||
{
|
{
|
||||||
id: "multi-3-a",
|
id: "multi-3-a",
|
||||||
@@ -209,7 +211,8 @@ const staticResponses: TestResponse[] = [
|
|||||||
title: "Arzttermin Dr. Müller",
|
title: "Arzttermin Dr. Müller",
|
||||||
startTime: getDay("Wednesday", 1, 9, 30),
|
startTime: getDay("Wednesday", 1, 9, 30),
|
||||||
endTime: getDay("Wednesday", 1, 10, 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(
|
constructor(
|
||||||
private chatRepo: ChatRepository,
|
private chatRepo: ChatRepository,
|
||||||
private eventRepo: EventRepository,
|
private eventRepo: EventRepository,
|
||||||
|
private eventService: EventService,
|
||||||
private aiProvider: AIProvider,
|
private aiProvider: AIProvider,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -462,9 +466,15 @@ export class ChatService {
|
|||||||
event?: CreateEventDTO,
|
event?: CreateEventDTO,
|
||||||
eventId?: string,
|
eventId?: string,
|
||||||
updates?: UpdateEventDTO,
|
updates?: UpdateEventDTO,
|
||||||
|
deleteMode?: RecurringDeleteMode,
|
||||||
|
occurrenceDate?: string,
|
||||||
): Promise<ChatResponse> {
|
): Promise<ChatResponse> {
|
||||||
// Update specific proposal with respondedAction
|
// Update specific proposal with respondedAction
|
||||||
await this.chatRepo.updateProposalResponse(messageId, proposalId, "confirm");
|
await this.chatRepo.updateProposalResponse(
|
||||||
|
messageId,
|
||||||
|
proposalId,
|
||||||
|
"confirm",
|
||||||
|
);
|
||||||
|
|
||||||
// Perform the actual event operation
|
// Perform the actual event operation
|
||||||
let content: string;
|
let content: string;
|
||||||
@@ -478,10 +488,25 @@ export class ChatService {
|
|||||||
? `Der Termin "${updatedEvent.title}" wurde aktualisiert.`
|
? `Der Termin "${updatedEvent.title}" wurde aktualisiert.`
|
||||||
: "Termin nicht gefunden.";
|
: "Termin nicht gefunden.";
|
||||||
} else if (action === "delete" && eventId) {
|
} 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
|
content = event?.title
|
||||||
? `Der Termin "${event.title}" wurde gelöscht.`
|
? `Der Termin "${event.title}"${deleteDescription} wurde gelöscht.`
|
||||||
: "Der Termin wurde gelöscht.";
|
: `Der Termin${deleteDescription} wurde gelöscht.`;
|
||||||
} else {
|
} else {
|
||||||
content = "Ungültige Aktion.";
|
content = "Ungültige Aktion.";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import {
|
|||||||
CreateEventDTO,
|
CreateEventDTO,
|
||||||
UpdateEventDTO,
|
UpdateEventDTO,
|
||||||
ExpandedEvent,
|
ExpandedEvent,
|
||||||
|
RecurringDeleteMode,
|
||||||
} from "@calchat/shared";
|
} from "@calchat/shared";
|
||||||
|
import { RRule, rrulestr } from "rrule";
|
||||||
import { EventRepository } from "./interfaces";
|
import { EventRepository } from "./interfaces";
|
||||||
import { expandRecurringEvents } from "../utils/recurrenceExpander";
|
import { expandRecurringEvents } from "../utils/recurrenceExpander";
|
||||||
|
|
||||||
@@ -67,4 +69,96 @@ export class EventService {
|
|||||||
}
|
}
|
||||||
return this.eventRepo.delete(id);
|
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>;
|
create(userId: string, data: CreateEventDTO): Promise<CalendarEvent>;
|
||||||
update(id: string, data: UpdateEventDTO): Promise<CalendarEvent | null>;
|
update(id: string, data: UpdateEventDTO): Promise<CalendarEvent | null>;
|
||||||
delete(id: string): Promise<boolean>;
|
delete(id: string): Promise<boolean>;
|
||||||
|
addExceptionDate(id: string, date: string): Promise<CalendarEvent | null>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,10 +71,19 @@ export function expandRecurringEvents(
|
|||||||
true, // inclusive
|
true, // inclusive
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Build set of exception dates for fast lookup
|
||||||
|
const exceptionSet = new Set(event.exceptionDates || []);
|
||||||
|
|
||||||
for (const occurrence of occurrences) {
|
for (const occurrence of occurrences) {
|
||||||
const occurrenceStart = fromRRuleDate(occurrence);
|
const occurrenceStart = fromRRuleDate(occurrence);
|
||||||
const occurrenceEnd = new Date(occurrenceStart.getTime() + duration);
|
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({
|
expanded.push({
|
||||||
...event,
|
...event,
|
||||||
occurrenceStart,
|
occurrenceStart,
|
||||||
@@ -113,3 +122,11 @@ function formatRRuleDateString(date: Date): string {
|
|||||||
const seconds = String(date.getSeconds()).padStart(2, "0");
|
const seconds = String(date.getSeconds()).padStart(2, "0");
|
||||||
return `${year}${month}${day}T${hours}${minutes}${seconds}`;
|
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}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,10 +8,18 @@ export interface CalendarEvent {
|
|||||||
note?: string;
|
note?: string;
|
||||||
isRecurring?: boolean;
|
isRecurring?: boolean;
|
||||||
recurrenceRule?: string;
|
recurrenceRule?: string;
|
||||||
|
exceptionDates?: string[]; // ISO date strings (YYYY-MM-DD) for excluded occurrences
|
||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
updatedAt?: 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 {
|
export interface CreateEventDTO {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -30,6 +38,7 @@ export interface UpdateEventDTO {
|
|||||||
note?: string;
|
note?: string;
|
||||||
isRecurring?: boolean;
|
isRecurring?: boolean;
|
||||||
recurrenceRule?: string;
|
recurrenceRule?: string;
|
||||||
|
exceptionDates?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExpandedEvent extends CalendarEvent {
|
export interface ExpandedEvent extends CalendarEvent {
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { CreateEventDTO, UpdateEventDTO } from "./CalendarEvent";
|
import {
|
||||||
|
CreateEventDTO,
|
||||||
|
UpdateEventDTO,
|
||||||
|
RecurringDeleteMode,
|
||||||
|
} from "./CalendarEvent";
|
||||||
|
|
||||||
export type MessageSender = "user" | "assistant";
|
export type MessageSender = "user" | "assistant";
|
||||||
|
|
||||||
@@ -13,6 +17,8 @@ export interface ProposedEventChange {
|
|||||||
event?: CreateEventDTO; // Required for create
|
event?: CreateEventDTO; // Required for create
|
||||||
updates?: UpdateEventDTO; // Required for update
|
updates?: UpdateEventDTO; // Required for update
|
||||||
respondedAction?: RespondedAction; // User's response to this specific proposal
|
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 {
|
export interface ChatMessage {
|
||||||
|
|||||||
Reference in New Issue
Block a user