feat: support multiple event proposals in single AI response

- Change proposedChange to proposedChanges array in ChatMessage type
- Add unique id and individual respondedAction to each ProposedEventChange
- Implement arrow navigation UI for multiple proposals with "Event X von Y" counter
- Add updateProposalResponse() method for per-proposal confirm/reject tracking
- GPTAdapter now collects multiple tool call results into proposals array
- Add RRULE documentation to system prompt (separate events for different times)
- Fix RRULE parsing to strip RRULE: prefix if present
- Add log summarization for large args (conversationHistory, existingEvents)
- Keep proposedChanges logged in full for debugging AI issues
This commit is contained in:
2026-01-10 23:30:32 +01:00
parent 8efe6c304e
commit e6b9dd9d34
18 changed files with 533 additions and 158 deletions

View File

@@ -205,15 +205,17 @@ src/
- `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?
- `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, proposedChange?, respondedAction? - `ChatMessage`: id, conversationId, sender ('user' | 'assistant'), content, proposedChanges?
- `ProposedEventChange`: action ('create' | 'update' | 'delete'), eventId?, event?, updates? - `ProposedEventChange`: id, action ('create' | 'update' | 'delete'), eventId?, event?, updates?, respondedAction?
- Each proposal has unique `id` (e.g., "proposal-0") for individual confirm/reject
- `respondedAction` tracks user response per proposal (not per message)
- `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
- `CreateEventDTO`: Used for creating events AND for AI-proposed events - `CreateEventDTO`: Used for creating events AND for AI-proposed events
- `GetMessagesOptions`: Cursor-based pagination with `before?: string` and `limit?: number` - `GetMessagesOptions`: Cursor-based pagination with `before?: string` and `limit?: number`
- `ConversationSummary`: id, lastMessage?, createdAt? (for conversation list) - `ConversationSummary`: id, lastMessage?, createdAt? (for conversation list)
- `UpdateMessageDTO`: respondedAction? (for marking messages as confirmed/rejected) - `UpdateMessageDTO`: proposalId?, respondedAction? (for marking individual proposals as confirmed/rejected)
- `RespondedAction`: 'confirm' | 'reject' (tracks user response to proposed events) - `RespondedAction`: 'confirm' | 'reject' (tracks user response to proposed events)
- `Day`: "Monday" | "Tuesday" | ... | "Sunday" - `Day`: "Monday" | "Tuesday" | ... | "Sunday"
- `Month`: "January" | "February" | ... | "December" - `Month`: "January" | "February" | ... | "December"
@@ -275,6 +277,14 @@ export class GPTAdapter implements AIProvider { ... }
The decorator uses a Proxy to intercept method calls lazily, preserves sync/async nature, and logs start/completion/failure with duration. The decorator uses a Proxy to intercept method calls lazily, preserves sync/async nature, and logs start/completion/failure with duration.
**Log Summarization:**
The `@Logged` decorator automatically summarizes large arguments to keep logs readable:
- `conversationHistory``"[5 messages]"`
- `existingEvents``"[3 events]"`
- `proposedChanges` → logged in full (for debugging AI issues)
- Long strings (>100 chars) → truncated
- Arrays → `"[Array(n)]"`
**Client Logging:** **Client Logging:**
- `react-native-logs` with namespaced loggers (apiLogger, storeLogger) - `react-native-logs` with namespaced loggers (apiLogger, storeLogger)
- ApiClient logs all requests with method, endpoint, status, duration - ApiClient logs all requests with method, endpoint, status, duration
@@ -339,10 +349,12 @@ NODE_ENV=development # development = pretty logs, production = JSON
- `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
- `ChatService`: getConversations(), getConversation(), processMessage() uses real AI or test responses (via USE_TEST_RESPONSES), confirmEvent()/rejectEvent() update respondedAction and persist response messages - `ChatService`: getConversations(), getConversation(), processMessage() uses real AI or test responses (via USE_TEST_RESPONSES), confirmEvent()/rejectEvent() update respondedAction and persist response messages
- `MongoChatRepository`: Full CRUD implemented (getConversationsByUser, createConversation, getMessages with cursor pagination, createMessage, updateMessage) - `MongoChatRepository`: Full CRUD implemented (getConversationsByUser, createConversation, getMessages with cursor pagination, createMessage, updateMessage, updateProposalResponse)
- `ChatRepository` interface: updateMessage() added for respondedAction tracking - `ChatRepository` interface: updateMessage() and updateProposalResponse() for per-proposal respondedAction tracking
- `GPTAdapter`: Full implementation with OpenAI GPT (gpt-5-mini model), function calling for calendar operations - `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
- `utils/recurrenceExpander`: Handles RRULE parsing, strips `RRULE:` prefix if present (AI may include it)
- `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
@@ -373,6 +385,9 @@ NODE_ENV=development # development = pretty logs, production = JSON
- 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
- 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
- Arrow navigation between proposals with "Event X von Y" counter
- Each proposal individually confirmable/rejectable
- Messages persisted to database via ChatService and loaded on mount - Messages persisted to database via ChatService and loaded on mount
- Tracks conversationId for message continuity across sessions - Tracks conversationId for message continuity across sessions
- ChatStore with addMessages() for bulk loading, chatMessageToMessageData() helper - ChatStore with addMessages() for bulk loading, chatMessageToMessageData() helper

View File

@@ -20,6 +20,7 @@ import {
} from "../../stores"; } from "../../stores";
import { ProposedEventChange } from "@caldav/shared"; import { ProposedEventChange } from "@caldav/shared";
import { ProposedEventCard } from "../../components/ProposedEventCard"; import { ProposedEventCard } from "../../components/ProposedEventCard";
import { Ionicons } from "@expo/vector-icons";
// TODO: better shadows for everything // TODO: better shadows for everything
// (maybe with extra library because of differences between android and ios) // (maybe with extra library because of differences between android and ios)
@@ -30,10 +31,9 @@ type BubbleSide = "left" | "right";
type ChatMessageProps = { type ChatMessageProps = {
side: BubbleSide; side: BubbleSide;
content: string; content: string;
proposedChange?: ProposedEventChange; proposedChanges?: ProposedEventChange[];
respondedAction?: "confirm" | "reject"; onConfirm?: (proposalId: string, proposal: ProposedEventChange) => void;
onConfirm?: () => void; onReject?: (proposalId: string) => void;
onReject?: () => void;
}; };
type ChatInputProps = { type ChatInputProps = {
@@ -88,10 +88,17 @@ const Chat = () => {
action: "confirm" | "reject", action: "confirm" | "reject",
messageId: string, messageId: string,
conversationId: string, conversationId: string,
proposalId: string,
proposedChange?: ProposedEventChange, proposedChange?: ProposedEventChange,
) => { ) => {
// Mark message as responded (optimistic update) // Mark proposal as responded (optimistic update)
updateMessage(messageId, { respondedAction: action }); 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,
);
updateMessage(messageId, { proposedChanges: updatedProposals });
}
try { try {
const response = const response =
@@ -99,12 +106,13 @@ const Chat = () => {
? await ChatService.confirmEvent( ? await ChatService.confirmEvent(
conversationId, conversationId,
messageId, messageId,
proposalId,
proposedChange.action, proposedChange.action,
proposedChange.event, proposedChange.event,
proposedChange.eventId, proposedChange.eventId,
proposedChange.updates, proposedChange.updates,
) )
: await ChatService.rejectEvent(conversationId, messageId); : await ChatService.rejectEvent(conversationId, messageId, proposalId);
const botMessage: MessageData = { const botMessage: MessageData = {
id: response.message.id, id: response.message.id,
@@ -117,7 +125,12 @@ const Chat = () => {
} catch (error) { } catch (error) {
console.error(`Failed to ${action} event:`, error); console.error(`Failed to ${action} event:`, error);
// Revert on error // Revert on error
updateMessage(messageId, { respondedAction: undefined }); if (message?.proposedChanges) {
const revertedProposals = message.proposedChanges.map((p) =>
p.id === proposalId ? { ...p, respondedAction: undefined } : p,
);
updateMessage(messageId, { proposedChanges: revertedProposals });
}
} }
}; };
@@ -149,7 +162,7 @@ const Chat = () => {
id: response.message.id, id: response.message.id,
side: "left", side: "left",
content: response.message.content, content: response.message.content,
proposedChange: response.message.proposedChange, proposedChanges: response.message.proposedChanges,
conversationId: response.conversationId, conversationId: response.conversationId,
}; };
addMessage(botMessage); addMessage(botMessage);
@@ -173,18 +186,18 @@ const Chat = () => {
<ChatMessage <ChatMessage
side={item.side} side={item.side}
content={item.content} content={item.content}
proposedChange={item.proposedChange} proposedChanges={item.proposedChanges}
respondedAction={item.respondedAction} onConfirm={(proposalId, proposal) =>
onConfirm={() =>
handleEventResponse( handleEventResponse(
"confirm", "confirm",
item.id, item.id,
item.conversationId!, item.conversationId!,
item.proposedChange, proposalId,
proposal,
) )
} }
onReject={() => onReject={(proposalId) =>
handleEventResponse("reject", item.id, item.conversationId!) handleEventResponse("reject", item.id, item.conversationId!, proposalId)
} }
/> />
)} )}
@@ -271,11 +284,12 @@ const ChatInput = ({ onSend }: ChatInputProps) => {
const ChatMessage = ({ const ChatMessage = ({
side, side,
content, content,
proposedChange, proposedChanges,
respondedAction,
onConfirm, onConfirm,
onReject, onReject,
}: ChatMessageProps) => { }: ChatMessageProps) => {
const [currentIndex, setCurrentIndex] = useState(0);
const borderColor = const borderColor =
side === "left" ? currentTheme.chatBot : currentTheme.primeFg; side === "left" ? currentTheme.chatBot : currentTheme.primeFg;
const selfSide = const selfSide =
@@ -283,24 +297,87 @@ const ChatMessage = ({
? "self-start ml-2 rounded-bl-sm" ? "self-start ml-2 rounded-bl-sm"
: "self-end mr-2 rounded-br-sm"; : "self-end mr-2 rounded-br-sm";
const hasProposals = proposedChanges && proposedChanges.length > 0;
const hasMultiple = proposedChanges && proposedChanges.length > 1;
const currentProposal = proposedChanges?.[currentIndex];
const goToPrev = () => setCurrentIndex((i) => Math.max(0, i - 1));
const goToNext = () =>
setCurrentIndex((i) =>
Math.min((proposedChanges?.length || 1) - 1, i + 1),
);
const canGoPrev = currentIndex > 0;
const canGoNext = currentIndex < (proposedChanges?.length || 1) - 1;
return ( return (
<View <View
className={`bg-white border-2 border-solid rounded-xl my-2 ${selfSide}`} className={`bg-white border-2 border-solid rounded-xl my-2 ${selfSide}`}
style={{ style={{
borderColor: borderColor, borderColor: borderColor,
maxWidth: "80%", maxWidth: "80%",
minWidth: hasProposals ? "75%" : undefined,
elevation: 8, elevation: 8,
}} }}
> >
<Text className="p-2">{content}</Text> <Text className="p-2">{content}</Text>
{proposedChange && onConfirm && onReject && ( {hasProposals && currentProposal && onConfirm && onReject && (
<ProposedEventCard <View>
proposedChange={proposedChange} {/* Event card with optional navigation arrows */}
respondedAction={respondedAction} <View className="flex-row items-center">
onConfirm={onConfirm} {/* Left arrow */}
onReject={onReject} {hasMultiple && (
/> <Pressable
onPress={goToPrev}
disabled={!canGoPrev}
className="p-1"
style={{ opacity: canGoPrev ? 1 : 0.3 }}
>
<Ionicons
name="chevron-back"
size={24}
color={currentTheme.primeFg}
/>
</Pressable>
)}
{/* Event Card */}
<View className="flex-1">
<ProposedEventCard
proposedChange={currentProposal}
onConfirm={() => onConfirm(currentProposal.id, currentProposal)}
onReject={() => onReject(currentProposal.id)}
/>
</View>
{/* Right arrow */}
{hasMultiple && (
<Pressable
onPress={goToNext}
disabled={!canGoNext}
className="p-1"
style={{ opacity: canGoNext ? 1 : 0.3 }}
>
<Ionicons
name="chevron-forward"
size={24}
color={currentTheme.primeFg}
/>
</Pressable>
)}
</View>
{/* Event counter */}
{hasMultiple && (
<Text
className="text-center text-sm pb-2"
style={{ color: currentTheme.textSecondary || "#666" }}
>
Event {currentIndex + 1} von {proposedChanges.length}
</Text>
)}
</View>
)} )}
</View> </View>
); );

View File

@@ -27,7 +27,7 @@ const Header = (props: HeaderProps) => {
{props.children} {props.children}
<Pressable <Pressable
onPress={handleLogout} onPress={handleLogout}
className="absolute right-1 top-0 p-2" className="absolute left-1 bottom-0 p-2"
hitSlop={8} hitSlop={8}
> >
<Ionicons name="log-out-outline" size={24} color={currentTheme.primeFg} /> <Ionicons name="log-out-outline" size={24} color={currentTheme.primeFg} />

View File

@@ -5,7 +5,6 @@ import { EventCardBase } from "./EventCardBase";
type ProposedEventCardProps = { type ProposedEventCardProps = {
proposedChange: ProposedEventChange; proposedChange: ProposedEventChange;
respondedAction?: "confirm" | "reject";
onConfirm: () => void; onConfirm: () => void;
onReject: () => void; onReject: () => void;
}; };
@@ -59,12 +58,12 @@ const ConfirmRejectButtons = ({
export const ProposedEventCard = ({ export const ProposedEventCard = ({
proposedChange, proposedChange,
respondedAction,
onConfirm, onConfirm,
onReject, onReject,
}: ProposedEventCardProps) => { }: ProposedEventCardProps) => {
const event = proposedChange.event; const event = proposedChange.event;
const isDisabled = !!respondedAction; // respondedAction is now part of the proposedChange
const isDisabled = !!proposedChange.respondedAction;
if (!event) { if (!event) {
return null; return null;
@@ -82,7 +81,7 @@ export const ProposedEventCard = ({
> >
<ConfirmRejectButtons <ConfirmRejectButtons
isDisabled={isDisabled} isDisabled={isDisabled}
respondedAction={respondedAction} respondedAction={proposedChange.respondedAction}
onConfirm={onConfirm} onConfirm={onConfirm}
onReject={onReject} onReject={onReject}
/> />

View File

@@ -11,12 +11,17 @@ import {
import { ApiClient } from "./ApiClient"; import { ApiClient } from "./ApiClient";
interface ConfirmEventRequest { interface ConfirmEventRequest {
proposalId: string;
action: EventAction; action: EventAction;
event?: CreateEventDTO; event?: CreateEventDTO;
eventId?: string; eventId?: string;
updates?: UpdateEventDTO; updates?: UpdateEventDTO;
} }
interface RejectEventRequest {
proposalId: string;
}
export const ChatService = { export const ChatService = {
sendMessage: async (data: SendMessageDTO): Promise<ChatResponse> => { sendMessage: async (data: SendMessageDTO): Promise<ChatResponse> => {
return ApiClient.post<ChatResponse>("/chat/message", data); return ApiClient.post<ChatResponse>("/chat/message", data);
@@ -25,12 +30,19 @@ export const ChatService = {
confirmEvent: async ( confirmEvent: async (
conversationId: string, conversationId: string,
messageId: string, messageId: string,
proposalId: string,
action: EventAction, action: EventAction,
event?: CreateEventDTO, event?: CreateEventDTO,
eventId?: string, eventId?: string,
updates?: UpdateEventDTO, updates?: UpdateEventDTO,
): Promise<ChatResponse> => { ): Promise<ChatResponse> => {
const body: ConfirmEventRequest = { action, event, eventId, updates }; const body: ConfirmEventRequest = {
proposalId,
action,
event,
eventId,
updates,
};
return ApiClient.post<ChatResponse>( return ApiClient.post<ChatResponse>(
`/chat/confirm/${conversationId}/${messageId}`, `/chat/confirm/${conversationId}/${messageId}`,
body, body,
@@ -40,9 +52,12 @@ export const ChatService = {
rejectEvent: async ( rejectEvent: async (
conversationId: string, conversationId: string,
messageId: string, messageId: string,
proposalId: string,
): Promise<ChatResponse> => { ): Promise<ChatResponse> => {
const body: RejectEventRequest = { proposalId };
return ApiClient.post<ChatResponse>( return ApiClient.post<ChatResponse>(
`/chat/reject/${conversationId}/${messageId}`, `/chat/reject/${conversationId}/${messageId}`,
body,
); );
}, },

View File

@@ -7,8 +7,7 @@ export type MessageData = {
id: string; id: string;
side: BubbleSide; side: BubbleSide;
content: string; content: string;
proposedChange?: ProposedEventChange; proposedChanges?: ProposedEventChange[];
respondedAction?: "confirm" | "reject";
conversationId?: string; conversationId?: string;
}; };
@@ -46,8 +45,7 @@ export function chatMessageToMessageData(msg: ChatMessage): MessageData {
id: msg.id, id: msg.id,
side: msg.sender === "assistant" ? "left" : "right", side: msg.sender === "assistant" ? "left" : "right",
content: msg.content, content: msg.content,
proposedChange: msg.proposedChange, proposedChanges: msg.proposedChanges,
respondedAction: msg.respondedAction,
conversationId: msg.conversationId, conversationId: msg.conversationId,
}; };
} }

View File

@@ -61,7 +61,8 @@ export class GPTAdapter implements AIProvider {
// Add current user message // Add current user message
messages.push({ role: "user", content: message }); messages.push({ role: "user", content: message });
let proposedChange: ProposedEventChange | undefined; const proposedChanges: ProposedEventChange[] = [];
let proposalIndex = 0;
// Tool calling loop // Tool calling loop
while (true) { while (true) {
@@ -81,7 +82,8 @@ export class GPTAdapter implements AIProvider {
return { return {
content: content:
assistantMessage.content || "Ich konnte keine Antwort generieren.", assistantMessage.content || "Ich konnte keine Antwort generieren.",
proposedChange, proposedChanges:
proposedChanges.length > 0 ? proposedChanges : undefined,
}; };
} }
@@ -95,9 +97,12 @@ export class GPTAdapter implements AIProvider {
const result = executeToolCall(name, args, context); const result = executeToolCall(name, args, context);
// If the tool returned a proposedChange, capture it // If the tool returned a proposedChange, add it to the array with unique ID
if (result.proposedChange) { if (result.proposedChange) {
proposedChange = result.proposedChange; proposedChanges.push({
id: `proposal-${proposalIndex++}`,
...result.proposedChange,
});
} }
// Add assistant message with tool call // Add assistant message with tool call

View File

@@ -28,10 +28,33 @@ Wichtige Regeln:
- Wenn der Benutzer einen Termin erstellen will, nutze proposeCreateEvent - Wenn der Benutzer einen Termin erstellen will, nutze proposeCreateEvent
- Wenn der Benutzer einen Termin ändern will, nutze proposeUpdateEvent mit der Event-ID - Wenn der Benutzer einen Termin ändern will, nutze proposeUpdateEvent mit der Event-ID
- Wenn der Benutzer einen Termin löschen will, nutze proposeDeleteEvent mit der Event-ID - Wenn der Benutzer einen Termin löschen will, nutze proposeDeleteEvent mit der Event-ID
- Du kannst NUR EINEN Event-Vorschlag pro Antwort machen - Du kannst mehrere Event-Vorschläge in einer Antwort machen (z.B. für mehrere Termine auf einmal)
- Wenn der Benutzer nach seinen Terminen fragt, nutze die unten stehende Liste - Wenn der Benutzer nach seinen Terminen fragt, nutze die unten stehende Liste
- Nutze searchEvents um nach Terminen zu suchen, wenn du die genaue ID brauchst - Nutze searchEvents um nach Terminen zu suchen, wenn du die genaue ID brauchst
WICHTIG - Tool-Verwendung:
- Du MUSST die proposeCreateEvent/proposeUpdateEvent/proposeDeleteEvent Tools verwenden, um Termine vorzuschlagen!
- Sage NIEMALS einfach nur "ich habe einen Termin erstellt" ohne das Tool zu verwenden
- Die Tools erzeugen Karten, die dem Benutzer angezeigt werden - ohne Tool-Aufruf sieht er nichts
WICHTIG - Wiederkehrende Termine (RRULE):
- Ein wiederkehrendes Event hat EINE FESTE Start- und Endzeit
- RRULE bestimmt NUR an welchen Tagen das Event wiederholt wird, NICHT unterschiedliche Uhrzeiten pro Tag!
- Wenn der Benutzer UNTERSCHIEDLICHE ZEITEN an verschiedenen Tagen will, MUSST du SEPARATE Events erstellen
- Beispiel: "Arbeit Mo+Do 9-17:30, Fr 9-13" → ZWEI Events:
1. "Arbeit" Mo+Do 9:00-17:30 (RRULE mit BYDAY=MO,TH)
2. "Arbeit" Fr 9:00-13:00 (RRULE mit BYDAY=FR)
- Nutze NIEMALS BYHOUR/BYMINUTE in RRULE - diese überschreiben die Startzeit nicht wie erwartet!
- Gültige RRULE-Optionen: FREQ (DAILY/WEEKLY/MONTHLY/YEARLY), BYDAY (MO,TU,WE,TH,FR,SA,SU), INTERVAL, COUNT, UNTIL
WICHTIG - Antwortformat:
- Halte deine Textantworten SEHR KURZ (1-2 Sätze maximal)
- Die Event-Details (Titel, Datum, Uhrzeit, Beschreibung) werden dem Benutzer automatisch in separaten Karten angezeigt
- Wiederhole NIEMALS die Event-Details im Text! Der Benutzer sieht sie bereits in den Karten
- Gute Beispiele: "Alles klar!" oder "Hier sind deine Termine:"
- Schlechte Beispiele: Lange Listen mit allen Terminen und ihren Details im Text
- Bei Rückfragen oder wenn keine Termine erstellt werden, kannst du ausführlicher antworten
Existierende Termine des Benutzers: Existierende Termine des Benutzers:
${eventsText}`; ${eventsText}`;
} }

View File

@@ -7,12 +7,17 @@ import {
import { AIContext } from "../../services/interfaces"; import { AIContext } from "../../services/interfaces";
import { formatDate, formatTime, formatDateTime } from "./eventFormatter"; import { formatDate, formatTime, formatDateTime } from "./eventFormatter";
/**
* Proposed change without ID - ID is added by GPTAdapter when collecting proposals
*/
type ToolProposedChange = Omit<ProposedEventChange, "id" | "respondedAction">;
/** /**
* Result of executing a tool call. * Result of executing a tool call.
*/ */
export interface ToolResult { export interface ToolResult {
content: string; content: string;
proposedChange?: ProposedEventChange; proposedChange?: ToolProposedChange;
} }
/** /**

View File

@@ -31,7 +31,8 @@ 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 { action, event, eventId, updates } = req.body as { const { proposalId, action, event, eventId, updates } = req.body as {
proposalId: string;
action: EventAction; action: EventAction;
event?: CreateEventDTO; event?: CreateEventDTO;
eventId?: string; eventId?: string;
@@ -41,6 +42,7 @@ export class ChatController {
userId, userId,
conversationId, conversationId,
messageId, messageId,
proposalId,
action, action,
event, event,
eventId, eventId,
@@ -57,10 +59,12 @@ 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 } = req.body as { proposalId: string };
const response = await this.chatService.rejectEvent( const response = await this.chatService.rejectEvent(
userId, userId,
conversationId, conversationId,
messageId, messageId,
proposalId,
); );
res.json(response); res.json(response);
} catch (error) { } catch (error) {

View File

@@ -1,5 +1,57 @@
import { createLogger } from "./logger"; import { createLogger } from "./logger";
/**
* Summarize args for logging to avoid huge log entries.
* - Arrays: show length only
* - Long strings: truncate
* - Objects with conversationHistory/existingEvents: summarize
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function summarizeArgs(args: any[]): any[] {
return args.map((arg) => summarizeValue(arg));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function summarizeValue(value: any, depth = 0): any {
if (depth > 2) return "[...]";
if (value === null || value === undefined) return value;
if (Array.isArray(value)) {
return `[Array(${value.length})]`;
}
if (typeof value === "string" && value.length > 100) {
return value.substring(0, 100) + "...";
}
if (typeof value === "object") {
// Summarize known large fields
const summarized: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value)) {
if (key === "conversationHistory" && Array.isArray(val)) {
summarized[key] = `[${val.length} messages]`;
} else if (key === "existingEvents" && Array.isArray(val)) {
summarized[key] = `[${val.length} events]`;
} else if (key === "proposedChanges" && Array.isArray(val)) {
// Log full proposedChanges for debugging AI issues
summarized[key] = val.map((p) => summarizeValue(p, depth + 1));
} else if (Array.isArray(val)) {
summarized[key] = `[Array(${val.length})]`;
} else if (typeof val === "object" && val !== null) {
summarized[key] = summarizeValue(val, depth + 1);
} else if (typeof val === "string" && val.length > 100) {
summarized[key] = val.substring(0, 100) + "...";
} else {
summarized[key] = val;
}
}
return summarized;
}
return value;
}
export function Logged(name: string) { export function Logged(name: string) {
const log = createLogger(name); const log = createLogger(name);
@@ -27,8 +79,8 @@ export function Logged(name: string) {
const start = performance.now(); const start = performance.now();
const method = String(propKey); const method = String(propKey);
// Pino's redact handles sanitization - just pass args directly // Summarize args to avoid huge log entries
log.debug({ method, args: 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);

View File

@@ -53,7 +53,7 @@ export class MongoChatRepository implements ChatRepository {
conversationId: conversationId, conversationId: conversationId,
sender: message.sender, sender: message.sender,
content: message.content, content: message.content,
proposedChange: message.proposedChange, proposedChanges: message.proposedChanges,
}); });
return repoMessage.toJSON() as unknown as ChatMessage; return repoMessage.toJSON() as unknown as ChatMessage;
} }
@@ -69,4 +69,17 @@ export class MongoChatRepository implements ChatRepository {
); );
return doc ? (doc.toJSON() as unknown as ChatMessage) : null; return doc ? (doc.toJSON() as unknown as ChatMessage) : null;
} }
async updateProposalResponse(
messageId: string,
proposalId: string,
respondedAction: "confirm" | "reject",
): Promise<ChatMessage | null> {
const doc = await ChatMessageModel.findOneAndUpdate(
{ _id: messageId, "proposedChanges.id": proposalId },
{ $set: { "proposedChanges.$.respondedAction": respondedAction } },
{ new: true },
);
return doc ? (doc.toJSON() as unknown as ChatMessage) : null;
}
} }

View File

@@ -44,6 +44,7 @@ const UpdatesSchema = new Schema<UpdateEventDTO>(
const ProposedChangeSchema = new Schema<ProposedEventChange>( const ProposedChangeSchema = new Schema<ProposedEventChange>(
{ {
id: { type: String, required: true },
action: { action: {
type: String, type: String,
enum: ["create", "update", "delete"], enum: ["create", "update", "delete"],
@@ -52,6 +53,10 @@ const ProposedChangeSchema = new Schema<ProposedEventChange>(
eventId: { type: String }, eventId: { type: String },
event: { type: EventSchema }, event: { type: EventSchema },
updates: { type: UpdatesSchema }, updates: { type: UpdatesSchema },
respondedAction: {
type: String,
enum: ["confirm", "reject"],
},
}, },
{ _id: false }, { _id: false },
); );
@@ -77,12 +82,9 @@ const ChatMessageSchema = new Schema<
type: String, type: String,
required: true, required: true,
}, },
proposedChange: { proposedChanges: {
type: ProposedChangeSchema, type: [ProposedChangeSchema],
}, default: undefined,
respondedAction: {
type: String,
enum: ["confirm", "reject"],
}, },
}, },
{ {

View File

@@ -14,7 +14,7 @@ import {
import { ChatRepository, EventRepository, AIProvider } from "./interfaces"; import { ChatRepository, EventRepository, AIProvider } from "./interfaces";
import { getWeeksOverview, getMonthOverview } from "../utils/eventFormatters"; import { getWeeksOverview, getMonthOverview } from "../utils/eventFormatters";
type TestResponse = { content: string; proposedChange?: 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;
@@ -22,7 +22,134 @@ let responseIndex = 0;
// Static test responses (event proposals) // Static test responses (event proposals)
const staticResponses: TestResponse[] = [ const staticResponses: TestResponse[] = [
// {{{ // {{{
// Response 0: Help response (text only) // === MULTI-EVENT TEST RESPONSES ===
// Response 0: 3 Meetings an verschiedenen Tagen
{
content:
"Alles klar! Ich erstelle dir 3 Team-Meetings für diese Woche:",
proposedChanges: [
{
id: "multi-1-a",
action: "create",
event: {
title: "Team-Meeting Montag",
startTime: getDay("Monday", 1, 10, 0),
endTime: getDay("Monday", 1, 11, 0),
description: "Wöchentliches Standup",
},
},
{
id: "multi-1-b",
action: "create",
event: {
title: "Team-Meeting Mittwoch",
startTime: getDay("Wednesday", 1, 10, 0),
endTime: getDay("Wednesday", 1, 11, 0),
description: "Sprint Planning",
},
},
{
id: "multi-1-c",
action: "create",
event: {
title: "Team-Meeting Freitag",
startTime: getDay("Friday", 1, 10, 0),
endTime: getDay("Friday", 1, 11, 0),
description: "Retrospektive",
},
},
],
},
// Response 1: 5 Termine für einen Projekttag
{
content:
"Ich habe deinen kompletten Projekttag am Dienstag geplant:",
proposedChanges: [
{
id: "multi-2-a",
action: "create",
event: {
title: "Kickoff-Meeting",
startTime: getDay("Tuesday", 1, 9, 0),
endTime: getDay("Tuesday", 1, 10, 0),
description: "Projektstart mit dem Team",
},
},
{
id: "multi-2-b",
action: "create",
event: {
title: "Design Review",
startTime: getDay("Tuesday", 1, 10, 30),
endTime: getDay("Tuesday", 1, 11, 30),
description: "UI/UX Besprechung",
},
},
{
id: "multi-2-c",
action: "create",
event: {
title: "Mittagspause",
startTime: getDay("Tuesday", 1, 12, 0),
endTime: getDay("Tuesday", 1, 13, 0),
description: "Team-Lunch",
},
},
{
id: "multi-2-d",
action: "create",
event: {
title: "Tech Review",
startTime: getDay("Tuesday", 1, 14, 0),
endTime: getDay("Tuesday", 1, 15, 30),
description: "Architektur-Diskussion",
},
},
{
id: "multi-2-e",
action: "create",
event: {
title: "Wrap-up",
startTime: getDay("Tuesday", 1, 16, 0),
endTime: getDay("Tuesday", 1, 16, 30),
description: "Zusammenfassung und nächste Schritte",
},
},
],
},
// Response 2: 2 wiederkehrende Termine
{
content:
"Ich erstelle dir zwei wiederkehrende Fitness-Termine:",
proposedChanges: [
{
id: "multi-3-a",
action: "create",
event: {
title: "Yoga",
startTime: getDay("Monday", 1, 7, 0),
endTime: getDay("Monday", 1, 8, 0),
description: "Morgen-Yoga",
isRecurring: true,
recurrenceRule: "FREQ=WEEKLY;BYDAY=MO,WE,FR",
},
},
{
id: "multi-3-b",
action: "create",
event: {
title: "Laufen",
startTime: getDay("Tuesday", 1, 18, 0),
endTime: getDay("Tuesday", 1, 19, 0),
description: "Abendlauf im Park",
isRecurring: true,
recurrenceRule: "FREQ=WEEKLY;BYDAY=TU,TH",
},
},
],
},
// === ORIGINAL RESPONSES ===
// Response 3: Help response (text only)
{ {
content: content:
"Ich bin dein Kalender-Assistent! Du kannst mir einfach sagen, welche Termine du erstellen, ändern oder löschen möchtest. Zum Beispiel:\n\n" + "Ich bin dein Kalender-Assistent! Du kannst mir einfach sagen, welche Termine du erstellen, ändern oder löschen möchtest. Zum Beispiel:\n\n" +
@@ -35,30 +162,36 @@ const staticResponses: TestResponse[] = [
{ {
content: content:
"Alles klar! Ich erstelle dir einen Termin für das Meeting mit Jens am nächsten Freitag um 14:00 Uhr:", "Alles klar! Ich erstelle dir einen Termin für das Meeting mit Jens am nächsten Freitag um 14:00 Uhr:",
proposedChange: { proposedChanges: [
action: "create", {
event: { id: "test-1",
title: "Meeting mit Jens", action: "create",
startTime: getDay("Friday", 1, 14, 0), event: {
endTime: getDay("Friday", 1, 15, 0), title: "Meeting mit Jens",
description: "Arbeitstreffen", startTime: getDay("Friday", 1, 14, 0),
endTime: getDay("Friday", 1, 15, 0),
description: "Arbeitstreffen",
},
}, },
}, ],
}, },
// Response 2: Recurring event - every Saturday 10:00 // Response 2: Recurring event - every Saturday 10:00
{ {
content: content:
"Verstanden! Ich erstelle einen wiederkehrenden Termin: Jeden Samstag um 10:00 Uhr Badezimmer putzen:", "Verstanden! Ich erstelle einen wiederkehrenden Termin: Jeden Samstag um 10:00 Uhr Badezimmer putzen:",
proposedChange: { proposedChanges: [
action: "create", {
event: { id: "test-2",
title: "Badezimmer putzen", action: "create",
startTime: getDay("Saturday", 1, 10, 0), event: {
endTime: getDay("Saturday", 1, 11, 0), title: "Badezimmer putzen",
isRecurring: true, startTime: getDay("Saturday", 1, 10, 0),
recurrenceRule: "FREQ=WEEKLY;BYDAY=SA", endTime: getDay("Saturday", 1, 11, 0),
isRecurring: true,
recurrenceRule: "FREQ=WEEKLY;BYDAY=SA",
},
}, },
}, ],
}, },
// Response 3: 2-week overview (DYNAMIC - placeholder) // Response 3: 2-week overview (DYNAMIC - placeholder)
{ content: "" }, { content: "" },
@@ -68,45 +201,54 @@ const staticResponses: TestResponse[] = [
{ {
content: content:
"Ich habe dir einen Arzttermin eingetragen. Denk daran, deine Versichertenkarte mitzunehmen!", "Ich habe dir einen Arzttermin eingetragen. Denk daran, deine Versichertenkarte mitzunehmen!",
proposedChange: { proposedChanges: [
action: "create", {
event: { id: "test-5",
title: "Arzttermin Dr. Müller", action: "create",
startTime: getDay("Wednesday", 1, 9, 30), event: {
endTime: getDay("Wednesday", 1, 10, 30), title: "Arzttermin Dr. Müller",
description: "Routineuntersuchung - Versichertenkarte nicht vergessen", startTime: getDay("Wednesday", 1, 9, 30),
endTime: getDay("Wednesday", 1, 10, 30),
description: "Routineuntersuchung - Versichertenkarte nicht vergessen",
},
}, },
}, ],
}, },
// Response 6: Birthday - yearly recurring // Response 6: Birthday - yearly recurring
{ {
content: content:
"Geburtstage vergisst man leicht - aber nicht mit mir! Ich habe Mamas Geburtstag eingetragen:", "Geburtstage vergisst man leicht - aber nicht mit mir! Ich habe Mamas Geburtstag eingetragen:",
proposedChange: { proposedChanges: [
action: "create", {
event: { id: "test-6",
title: "Mamas Geburtstag", action: "create",
startTime: getDay("Thursday", 2, 0, 0), event: {
endTime: getDay("Thursday", 2, 23, 59), title: "Mamas Geburtstag",
isRecurring: true, startTime: getDay("Thursday", 2, 0, 0),
recurrenceRule: "FREQ=YEARLY", endTime: getDay("Thursday", 2, 23, 59),
isRecurring: true,
recurrenceRule: "FREQ=YEARLY",
},
}, },
}, ],
}, },
// Response 7: Gym - recurring for 2 months (8 weeks) // Response 7: Gym - recurring for 2 months (8 weeks)
{ {
content: content:
"Perfekt! Ich habe dein Probetraining eingetragen - jeden Dienstag für die nächsten 2 Monate:", "Perfekt! Ich habe dein Probetraining eingetragen - jeden Dienstag für die nächsten 2 Monate:",
proposedChange: { proposedChanges: [
action: "create", {
event: { id: "test-7",
title: "Fitnessstudio Probetraining", action: "create",
startTime: getDay("Tuesday", 1, 18, 0), event: {
endTime: getDay("Tuesday", 1, 19, 30), title: "Fitnessstudio Probetraining",
isRecurring: true, startTime: getDay("Tuesday", 1, 18, 0),
recurrenceRule: "FREQ=WEEKLY;BYDAY=TU;COUNT=8", endTime: getDay("Tuesday", 1, 19, 30),
isRecurring: true,
recurrenceRule: "FREQ=WEEKLY;BYDAY=TU;COUNT=8",
},
}, },
}, ],
}, },
// Response 8: 1-week overview (DYNAMIC - placeholder) // Response 8: 1-week overview (DYNAMIC - placeholder)
{ content: "" }, { content: "" },
@@ -114,44 +256,53 @@ const staticResponses: TestResponse[] = [
{ {
content: content:
"Alles klar! Ich habe das Telefonat mit deiner Mutter eingetragen:", "Alles klar! Ich habe das Telefonat mit deiner Mutter eingetragen:",
proposedChange: { proposedChanges: [
action: "create", {
event: { id: "test-9",
title: "Telefonat mit Mama", action: "create",
startTime: getDay("Wednesday", 1, 11, 0), event: {
endTime: getDay("Wednesday", 1, 11, 30), title: "Telefonat mit Mama",
startTime: getDay("Wednesday", 1, 11, 0),
endTime: getDay("Wednesday", 1, 11, 30),
},
}, },
}, ],
}, },
// Response 10: Update "Telefonat mit Mama" +3 days (DYNAMIC - placeholder) // Response 10: Update "Telefonat mit Mama" +3 days (DYNAMIC - placeholder)
{ content: "" }, { content: "" },
// Response 11: Birthday party - evening event // Response 11: Birthday party - evening event
{ {
content: "Super! Die Geburtstagsfeier ist eingetragen. Viel Spaß!", content: "Super! Die Geburtstagsfeier ist eingetragen. Viel Spaß!",
proposedChange: { proposedChanges: [
action: "create", {
event: { id: "test-11",
title: "Geburtstagsfeier Lisa", action: "create",
startTime: getDay("Saturday", 2, 19, 0), event: {
endTime: getDay("Saturday", 2, 23, 0), title: "Geburtstagsfeier Lisa",
description: "Geschenk: Buch über Fotografie", startTime: getDay("Saturday", 2, 19, 0),
endTime: getDay("Saturday", 2, 23, 0),
description: "Geschenk: Buch über Fotografie",
},
}, },
}, ],
}, },
// Response 12: Language course - limited to 8 weeks (Thu + Sat) // Response 12: Language course - limited to 8 weeks (Thu + Sat)
{ {
content: content:
"Dein Spanischkurs ist eingetragen! Er läuft jeden Donnerstag und Samstag für die nächsten 8 Wochen:", "Dein Spanischkurs ist eingetragen! Er läuft jeden Donnerstag und Samstag für die nächsten 8 Wochen:",
proposedChange: { proposedChanges: [
action: "create", {
event: { id: "test-12",
title: "Spanischkurs VHS", action: "create",
startTime: getDay("Thursday", 1, 19, 0), event: {
endTime: getDay("Thursday", 1, 20, 30), title: "Spanischkurs VHS",
isRecurring: true, startTime: getDay("Thursday", 1, 19, 0),
recurrenceRule: "FREQ=WEEKLY;BYDAY=TH,SA;COUNT=8", endTime: getDay("Thursday", 1, 20, 30),
isRecurring: true,
recurrenceRule: "FREQ=WEEKLY;BYDAY=TH,SA;COUNT=8",
},
}, },
}, ],
}, },
// Response 13: Monthly overview (DYNAMIC - placeholder) // Response 13: Monthly overview (DYNAMIC - placeholder)
{ content: "" }, { content: "" },
@@ -177,17 +328,20 @@ async function getTestResponse(
if (jensEvent) { if (jensEvent) {
return { return {
content: "Soll ich diesen Termin wirklich löschen?", content: "Soll ich diesen Termin wirklich löschen?",
proposedChange: { proposedChanges: [
action: "delete", {
eventId: jensEvent.id, id: "test-4",
event: { action: "delete",
title: jensEvent.title, eventId: jensEvent.id,
startTime: jensEvent.startTime, event: {
endTime: jensEvent.endTime, title: jensEvent.title,
description: jensEvent.description, startTime: jensEvent.startTime,
isRecurring: jensEvent.isRecurring, endTime: jensEvent.endTime,
description: jensEvent.description,
isRecurring: jensEvent.isRecurring,
},
}, },
}, ],
}; };
} }
return { content: "Ich konnte keinen Termin 'Meeting mit Jens' finden." }; return { content: "Ich konnte keinen Termin 'Meeting mit Jens' finden." };
@@ -210,18 +364,21 @@ async function getTestResponse(
return { return {
content: content:
"Alles klar, ich verschiebe das Telefonat mit Mama auf Samstag um 13:00 Uhr:", "Alles klar, ich verschiebe das Telefonat mit Mama auf Samstag um 13:00 Uhr:",
proposedChange: { proposedChanges: [
action: "update", {
eventId: mamaEvent.id, id: "test-10",
updates: { startTime: newStart, endTime: newEnd }, action: "update",
// Include event with new times for display eventId: mamaEvent.id,
event: { updates: { startTime: newStart, endTime: newEnd },
title: mamaEvent.title, // Include event with new times for display
startTime: newStart, event: {
endTime: newEnd, title: mamaEvent.title,
description: mamaEvent.description, startTime: newStart,
endTime: newEnd,
description: mamaEvent.description,
},
}, },
}, ],
}; };
} }
return { content: "Ich konnte keinen Termin 'Telefonat mit Mama' finden." }; return { content: "Ich konnte keinen Termin 'Telefonat mit Mama' finden." };
@@ -290,7 +447,7 @@ export class ChatService {
const answerMessage = await this.chatRepo.createMessage(conversationId, { const answerMessage = await this.chatRepo.createMessage(conversationId, {
sender: "assistant", sender: "assistant",
content: response.content, content: response.content,
proposedChange: response.proposedChange, proposedChanges: response.proposedChanges,
}); });
return { message: answerMessage, conversationId: conversationId }; return { message: answerMessage, conversationId: conversationId };
@@ -300,15 +457,14 @@ export class ChatService {
userId: string, userId: string,
conversationId: string, conversationId: string,
messageId: string, messageId: string,
proposalId: string,
action: EventAction, action: EventAction,
event?: CreateEventDTO, event?: CreateEventDTO,
eventId?: string, eventId?: string,
updates?: UpdateEventDTO, updates?: UpdateEventDTO,
): Promise<ChatResponse> { ): Promise<ChatResponse> {
// Update original message with respondedAction // Update specific proposal with respondedAction
await this.chatRepo.updateMessage(messageId, { await this.chatRepo.updateProposalResponse(messageId, proposalId, "confirm");
respondedAction: "confirm",
});
// Perform the actual event operation // Perform the actual event operation
let content: string; let content: string;
@@ -343,9 +499,10 @@ export class ChatService {
userId: string, userId: string,
conversationId: string, conversationId: string,
messageId: string, messageId: string,
proposalId: string,
): Promise<ChatResponse> { ): Promise<ChatResponse> {
// Update original message with respondedAction // Update specific proposal with respondedAction
await this.chatRepo.updateMessage(messageId, { respondedAction: "reject" }); await this.chatRepo.updateProposalResponse(messageId, proposalId, "reject");
// Save response message to DB // Save response message to DB
const message = await this.chatRepo.createMessage(conversationId, { const message = await this.chatRepo.createMessage(conversationId, {

View File

@@ -13,7 +13,7 @@ export interface AIContext {
export interface AIResponse { export interface AIResponse {
content: string; content: string;
proposedChange?: ProposedEventChange; proposedChanges?: ProposedEventChange[];
} }
export interface AIProvider { export interface AIProvider {

View File

@@ -26,4 +26,10 @@ export interface ChatRepository {
messageId: string, messageId: string,
updates: UpdateMessageDTO, updates: UpdateMessageDTO,
): Promise<ChatMessage | null>; ): Promise<ChatMessage | null>;
updateProposalResponse(
messageId: string,
proposalId: string,
respondedAction: "confirm" | "reject",
): Promise<ChatMessage | null>;
} }

View File

@@ -58,8 +58,10 @@ export function expandRecurringEvents(
// Recurring event: parse RRULE and expand // Recurring event: parse RRULE and expand
try { try {
// Strip RRULE: prefix if present (AI may include it)
const ruleString = event.recurrenceRule.replace(/^RRULE:/i, "");
const rule = rrulestr( const rule = rrulestr(
`DTSTART:${formatRRuleDateString(startTime)}\nRRULE:${event.recurrenceRule}`, `DTSTART:${formatRRuleDateString(startTime)}\nRRULE:${ruleString}`,
); );
// Get occurrences within the range (using fake UTC dates) // Get occurrences within the range (using fake UTC dates)

View File

@@ -7,10 +7,12 @@ export type EventAction = "create" | "update" | "delete";
export type RespondedAction = "confirm" | "reject"; export type RespondedAction = "confirm" | "reject";
export interface ProposedEventChange { export interface ProposedEventChange {
id: string; // Unique ID for each proposal
action: EventAction; action: EventAction;
eventId?: string; // Required for update/delete eventId?: string; // Required for update/delete
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
} }
export interface ChatMessage { export interface ChatMessage {
@@ -18,8 +20,7 @@ export interface ChatMessage {
conversationId: string; conversationId: string;
sender: MessageSender; sender: MessageSender;
content: string; content: string;
proposedChange?: ProposedEventChange; proposedChanges?: ProposedEventChange[]; // Array of event proposals
respondedAction?: RespondedAction;
createdAt?: Date; createdAt?: Date;
} }
@@ -38,7 +39,7 @@ export interface SendMessageDTO {
export interface CreateMessageDTO { export interface CreateMessageDTO {
sender: MessageSender; sender: MessageSender;
content: string; content: string;
proposedChange?: ProposedEventChange; proposedChanges?: ProposedEventChange[];
} }
export interface GetMessagesOptions { export interface GetMessagesOptions {
@@ -47,6 +48,7 @@ export interface GetMessagesOptions {
} }
export interface UpdateMessageDTO { export interface UpdateMessageDTO {
proposalId?: string; // Identifies which proposal to update
respondedAction?: RespondedAction; respondedAction?: RespondedAction;
} }