feat: implement chat persistence with MongoDB

- Add full chat persistence to database (conversations and messages)
- Implement MongoChatRepository with cursor-based pagination
- Add getConversations/getConversation endpoints in ChatController
- Save user and assistant messages in ChatService.processMessage()
- Track respondedAction (confirm/reject) on proposed event messages
- Load existing messages on chat screen mount
- Add addMessages() bulk action and chatMessageToMessageData() helper to ChatStore
- Add RespondedAction type and UpdateMessageDTO to shared types
This commit is contained in:
2026-01-09 16:21:01 +01:00
parent d86b18173f
commit c897b6d680
11 changed files with 245 additions and 45 deletions

View File

@@ -1,18 +1,25 @@
import { View, Text, TextInput, Pressable, KeyboardAvoidingView, Platform, Keyboard } from "react-native";
import {
View,
Text,
TextInput,
Pressable,
KeyboardAvoidingView,
Platform,
Keyboard,
} from "react-native";
import currentTheme from "../../Themes";
import { useState, useRef, useEffect } from "react";
import React, { useState, useRef, useEffect } from "react";
import Header from "../../components/Header";
import BaseBackground from "../../components/BaseBackground";
import { FlashList } from "@shopify/flash-list";
import { ChatService } from "../../services";
import { useChatStore, MessageData } from "../../stores";
import { useChatStore, chatMessageToMessageData, MessageData } from "../../stores";
import { ProposedEventChange } from "@caldav/shared";
import { ProposedEventCard } from "../../components/ProposedEventCard";
// TODO: better shadows for everything
// (maybe with extra library because of differences between android and ios)
// TODO: max width for messages
// TODO: create new messages
type BubbleSide = "left" | "right";
@@ -30,14 +37,43 @@ type ChatInputProps = {
};
const Chat = () => {
const { messages, addMessage, updateMessage } = useChatStore();
const listRef = useRef<FlashList<MessageData>>(null);
const { messages, addMessage, addMessages, updateMessage } = useChatStore();
const listRef =
useRef<React.ComponentRef<typeof FlashList<MessageData>>>(null);
const [currentConversationId, setCurrentConversationId] = useState<
string | undefined
>();
useEffect(() => {
const keyboardDidShow = Keyboard.addListener("keyboardDidShow", scrollToEnd);
const keyboardDidShow = Keyboard.addListener(
"keyboardDidShow",
scrollToEnd,
);
return () => keyboardDidShow.remove();
}, []);
// Load existing messages from database on mount
useEffect(() => {
const fetchMessages = async () => {
try {
const conversationSummaries = await ChatService.getConversations();
if (conversationSummaries.length > 0) {
const conversationId = conversationSummaries[0].id;
setCurrentConversationId(conversationId);
const serverMessages =
await ChatService.getConversation(conversationId);
const clientMessages = serverMessages.map(chatMessageToMessageData);
addMessages(clientMessages);
scrollToEnd();
}
} catch (error) {
console.error("Failed to load messages:", error);
}
};
fetchMessages();
}, []);
const scrollToEnd = () => {
setTimeout(() => {
listRef.current?.scrollToEnd({ animated: true });
@@ -87,13 +123,22 @@ const Chat = () => {
id: Date.now().toString(),
side: "right",
content: text,
conversationId: currentConversationId,
};
addMessage(userMessage);
scrollToEnd();
try {
// Fetch server response
const response = await ChatService.sendMessage({ content: text });
// Fetch server response (include conversationId for existing conversations)
const response = await ChatService.sendMessage({
content: text,
conversationId: currentConversationId,
});
// Track conversation ID for subsequent messages
if (!currentConversationId) {
setCurrentConversationId(response.conversationId);
}
// Show bot response
const botMessage: MessageData = {

View File

@@ -47,13 +47,20 @@ export const ChatService = {
},
getConversations: async (): Promise<ConversationSummary[]> => {
throw new Error("Not implemented");
return ApiClient.get<ConversationSummary[]>("/chat/conversations");
},
getConversation: async (
_id: string,
_options?: GetMessagesOptions,
id: string,
options?: GetMessagesOptions,
): Promise<ChatMessage[]> => {
throw new Error("Not implemented");
const params = new URLSearchParams();
if (options?.before) params.append("before", options.before);
if (options?.limit) params.append("limit", options.limit.toString());
const queryString = params.toString();
const url = `/chat/conversations/${id}${queryString ? `?${queryString}` : ""}`;
return ApiClient.get<ChatMessage[]>(url);
},
};

View File

@@ -1,5 +1,5 @@
import { create } from "zustand";
import { ProposedEventChange } from "@caldav/shared";
import { ChatMessage, ProposedEventChange } from "@caldav/shared";
type BubbleSide = "left" | "right";
@@ -14,6 +14,7 @@ export type MessageData = {
interface ChatState {
messages: MessageData[];
addMessages: (messages: MessageData[]) => void;
addMessage: (message: MessageData) => void;
updateMessage: (id: string, updates: Partial<MessageData>) => void;
clearMessages: () => void;
@@ -21,6 +22,9 @@ interface ChatState {
export const useChatStore = create<ChatState>((set) => ({
messages: [],
addMessages(messages) {
set((state) => ({messages: [...state.messages, ...messages]}))
},
addMessage: (message: MessageData) => {
set((state) => ({ messages: [...state.messages, message] }));
},
@@ -35,3 +39,15 @@ export const useChatStore = create<ChatState>((set) => ({
set({ messages: [] });
},
}));
// Helper to convert server ChatMessage to client MessageData
export function chatMessageToMessageData(msg: ChatMessage): MessageData {
return {
id: msg.id,
side: msg.sender === "assistant" ? "left" : "right",
content: msg.content,
proposedChange: msg.proposedChange,
respondedAction: msg.respondedAction,
conversationId: msg.conversationId,
};
}

View File

@@ -1,3 +1,3 @@
export { useAuthStore } from "./AuthStore";
export { useChatStore, type MessageData } from "./ChatStore";
export { useChatStore, chatMessageToMessageData, type MessageData } from "./ChatStore";
export { useEventsStore } from "./EventsStore";