From c897b6d68089c3fba6d189589dde7f6d2831ecfd Mon Sep 17 00:00:00 2001 From: Linus Waldowsky Date: Fri, 9 Jan 2026 16:21:01 +0100 Subject: [PATCH] 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 --- CLAUDE.md | 24 ++++--- apps/client/src/app/(tabs)/chat.tsx | 63 +++++++++++++--- apps/client/src/services/ChatService.ts | 15 ++-- apps/client/src/stores/ChatStore.ts | 18 ++++- apps/client/src/stores/index.ts | 2 +- apps/server/src/controllers/ChatController.ts | 35 ++++++++- .../repositories/mongo/MongoChatRepository.ts | 44 ++++++++++-- .../repositories/mongo/models/ChatModel.ts | 4 ++ apps/server/src/services/ChatService.ts | 71 +++++++++++++++---- .../src/services/interfaces/ChatRepository.ts | 7 ++ packages/shared/src/models/ChatMessage.ts | 7 ++ 11 files changed, 245 insertions(+), 45 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ea76a58..d846f89 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,7 +91,7 @@ src/ └── stores/ # Zustand state management ├── index.ts # Re-exports all stores ├── AuthStore.ts # user, token, isAuthenticated, login(), logout(), setToken() - ├── ChatStore.ts # messages[], addMessage(), updateMessage(), clearMessages() + ├── ChatStore.ts # messages[], addMessage(), addMessages(), updateMessage(), clearMessages(), chatMessageToMessageData() └── EventsStore.ts # events[], setEvents(), addEvent(), updateEvent(), deleteEvent() ``` @@ -172,7 +172,7 @@ src/ │ ├── CalendarEvent.ts # CalendarEvent, CreateEventDTO, UpdateEventDTO, ExpandedEvent │ ├── ChatMessage.ts # ChatMessage, Conversation, SendMessageDTO, CreateMessageDTO, │ │ # GetMessagesOptions, ChatResponse, ConversationSummary, -│ │ # ProposedEventChange, EventAction +│ │ # ProposedEventChange, EventAction, RespondedAction, UpdateMessageDTO │ └── Constants.ts # DAYS, MONTHS, Day, Month, DAY_INDEX, DAY_INDEX_TO_DAY, │ # DAY_TO_GERMAN, DAY_TO_GERMAN_SHORT, MONTH_TO_GERMAN └── utils/ @@ -184,12 +184,14 @@ src/ - `User`: id, email, displayName, passwordHash?, createdAt?, updatedAt? - `CalendarEvent`: id, userId, title, description?, startTime, endTime, note?, isRecurring?, recurrenceRule? - `ExpandedEvent`: Extends CalendarEvent with occurrenceStart, occurrenceEnd (for recurring event instances) -- `ChatMessage`: id, conversationId, sender ('user' | 'assistant'), content, proposedChange? +- `ChatMessage`: id, conversationId, sender ('user' | 'assistant'), content, proposedChange?, respondedAction? - `ProposedEventChange`: action ('create' | 'update' | 'delete'), eventId?, event?, updates? - `Conversation`: id, userId, createdAt?, updatedAt? (messages loaded separately via lazy loading) - `CreateEventDTO`: Used for creating events AND for AI-proposed events - `GetMessagesOptions`: Cursor-based pagination with `before?: string` and `limit?: number` - `ConversationSummary`: id, lastMessage?, createdAt? (for conversation list) +- `UpdateMessageDTO`: respondedAction? (for marking messages as confirmed/rejected) +- `RespondedAction`: 'confirm' | 'reject' (tracks user response to proposed events) - `Day`: "Monday" | "Tuesday" | ... | "Sunday" - `Month`: "January" | "February" | ... | "December" @@ -281,12 +283,14 @@ MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin - `EventService`: Full CRUD with recurring event expansion via recurrenceExpander - `utils/eventFormatters`: getWeeksOverview(), getMonthOverview() with German localization - `utils/recurrenceExpander`: expandRecurringEvents() using rrule library for RRULE parsing + - `ChatController`: getConversations(), getConversation() with cursor-based pagination support + - `ChatService`: getConversations(), getConversation(), processMessage() now persists user/assistant messages to DB, confirmEvent()/rejectEvent() update respondedAction and persist response messages + - `MongoChatRepository`: Full CRUD implemented (getConversationsByUser, createConversation, getMessages with cursor pagination, createMessage, updateMessage) + - `ChatRepository` interface: updateMessage() added for respondedAction tracking - **Stubbed (TODO):** - `AuthMiddleware.authenticate()`: Currently uses fake user for testing - `AuthController`: refresh(), logout() - `AuthService`: refreshToken() - - `ChatController`: getConversations(), getConversation() - - `MongoChatRepository`: Database persistence for chat - **Not started:** - `ClaudeAdapter` (AI integration - currently using test responses) @@ -302,20 +306,22 @@ MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin - Tap-to-open modal overlay showing EventCards for selected day - Supports events from adjacent months visible in grid - Uses `useFocusEffect` for automatic reload on tab focus -- Chat screen functional with FlashList, message sending, and event confirm/reject - - Messages persisted via ChatStore (survives tab switches) +- Chat screen fully functional with FlashList, message sending, and event confirm/reject + - Messages persisted to database via ChatService and loaded on mount + - Tracks conversationId for message continuity across sessions + - ChatStore with addMessages() for bulk loading, chatMessageToMessageData() helper - KeyboardAvoidingView for proper keyboard handling (iOS padding, Android height) - Auto-scroll to end on new messages and keyboard show - keyboardDismissMode="interactive" and keyboardShouldPersistTaps="handled" - `ApiClient`: get(), post(), put(), delete() implemented - `EventService`: getAll(), getById(), getByDateRange(), create(), update(), delete() - fully implemented -- `ChatService`: sendMessage(), confirmEvent(convId, msgId, action, event?, eventId?, updates?), rejectEvent() - supports create/update/delete actions +- `ChatService`: sendMessage(), confirmEvent(), rejectEvent(), getConversations(), getConversation() - fully implemented with cursor pagination - `EventCardBase`: Shared base component with event layout (header, date/time/recurring icons, description) - used by both EventCard and ProposedEventCard - `EventCard`: Uses EventCardBase + edit/delete buttons for calendar display - `ProposedEventCard`: Uses EventCardBase + confirm/reject buttons for chat proposals (supports create/update/delete actions) - `Themes.tsx`: Centralized color definitions including textPrimary, borderPrimary, eventIndicator, secondaryBg - `EventsStore`: Zustand store with setEvents(), addEvent(), updateEvent(), deleteEvent() - stores ExpandedEvent[] -- `ChatStore`: Zustand store with addMessage(), updateMessage(), clearMessages() - persists messages across tab switches +- `ChatStore`: Zustand store with addMessage(), addMessages(), updateMessage(), clearMessages() - loads from server on mount and persists across tab switches - Auth screens (Login, Register), Event Detail, and Note screens exist as skeletons - AuthStore defined with `throw new Error('Not implemented')` diff --git a/apps/client/src/app/(tabs)/chat.tsx b/apps/client/src/app/(tabs)/chat.tsx index d86aea9..d1a2498 100644 --- a/apps/client/src/app/(tabs)/chat.tsx +++ b/apps/client/src/app/(tabs)/chat.tsx @@ -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>(null); + const { messages, addMessage, addMessages, updateMessage } = useChatStore(); + const listRef = + useRef>>(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 = { diff --git a/apps/client/src/services/ChatService.ts b/apps/client/src/services/ChatService.ts index 71c3af0..d132d2e 100644 --- a/apps/client/src/services/ChatService.ts +++ b/apps/client/src/services/ChatService.ts @@ -47,13 +47,20 @@ export const ChatService = { }, getConversations: async (): Promise => { - throw new Error("Not implemented"); + return ApiClient.get("/chat/conversations"); }, getConversation: async ( - _id: string, - _options?: GetMessagesOptions, + id: string, + options?: GetMessagesOptions, ): Promise => { - 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(url); }, }; diff --git a/apps/client/src/stores/ChatStore.ts b/apps/client/src/stores/ChatStore.ts index 3898a5e..8e9f2a4 100644 --- a/apps/client/src/stores/ChatStore.ts +++ b/apps/client/src/stores/ChatStore.ts @@ -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) => void; clearMessages: () => void; @@ -21,6 +22,9 @@ interface ChatState { export const useChatStore = create((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((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, + }; +} diff --git a/apps/client/src/stores/index.ts b/apps/client/src/stores/index.ts index 41455b2..cdfafdb 100644 --- a/apps/client/src/stores/index.ts +++ b/apps/client/src/stores/index.ts @@ -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"; diff --git a/apps/server/src/controllers/ChatController.ts b/apps/server/src/controllers/ChatController.ts index 52a0110..a4b0703 100644 --- a/apps/server/src/controllers/ChatController.ts +++ b/apps/server/src/controllers/ChatController.ts @@ -4,6 +4,7 @@ import { CreateEventDTO, UpdateEventDTO, EventAction, + GetMessagesOptions, } from "@caldav/shared"; import { ChatService } from "../services"; import { AuthenticatedRequest } from "../middleware"; @@ -66,13 +67,43 @@ export class ChatController { req: AuthenticatedRequest, res: Response, ): Promise { - throw new Error("Not implemented"); + try { + const userId = req.user!.userId; + const conversations = await this.chatService.getConversations(userId); + res.json(conversations); + } catch (error) { + res.status(500).json({ error: "Failed to get conversations" }); + } } async getConversation( req: AuthenticatedRequest, res: Response, ): Promise { - throw new Error("Not implemented"); + try { + const userId = req.user!.userId; + const { id } = req.params; + const { before, limit } = req.query as { + before?: string; + limit?: string; + }; + + const options: GetMessagesOptions = {}; + if (before) options.before = before; + if (limit) options.limit = parseInt(limit, 10); + + const messages = await this.chatService.getConversation( + userId, + id, + options, + ); + res.json(messages); + } catch (error) { + if ((error as Error).message === "Conversation not found") { + res.status(404).json({ error: "Conversation not found" }); + } else { + res.status(500).json({ error: "Failed to get conversation" }); + } + } } } diff --git a/apps/server/src/repositories/mongo/MongoChatRepository.ts b/apps/server/src/repositories/mongo/MongoChatRepository.ts index bb4b47f..d4f9b1c 100644 --- a/apps/server/src/repositories/mongo/MongoChatRepository.ts +++ b/apps/server/src/repositories/mongo/MongoChatRepository.ts @@ -3,6 +3,7 @@ import { Conversation, CreateMessageDTO, GetMessagesOptions, + UpdateMessageDTO, } from "@caldav/shared"; import { ChatRepository } from "../../services/interfaces"; import { ChatMessageModel, ConversationModel } from "./models"; @@ -10,11 +11,15 @@ import { ChatMessageModel, ConversationModel } from "./models"; export class MongoChatRepository implements ChatRepository { // Conversations async getConversationsByUser(userId: string): Promise { - throw new Error("Not implemented"); + const conversations = await ConversationModel.find({ userId }); + return conversations.map((c) => c.toJSON() as unknown as Conversation); } async createConversation(userId: string): Promise { - throw new Error("Not implemented"); + const conversation = await ConversationModel.create({ + userId, + }); + return conversation.toJSON() as unknown as Conversation; } // Messages (cursor-based pagination) @@ -22,13 +27,44 @@ export class MongoChatRepository implements ChatRepository { conversationId: string, options?: GetMessagesOptions, ): Promise { - throw new Error("Not implemented"); + const limit = options?.limit ?? 20; + const query: Record = { conversationId }; + + // Cursor: load messages before this ID (for "load more" scrolling up) + if (options?.before) { + query._id = { $lt: options.before }; + } + + // Fetch newest first, then reverse for chronological order + const docs = await ChatMessageModel.find(query) + .sort({ _id: -1 }) + .limit(limit); + + return docs.reverse().map((doc) => doc.toJSON() as unknown as ChatMessage); } async createMessage( conversationId: string, message: CreateMessageDTO, ): Promise { - throw new Error("Not implemented"); + const repoMessage = await ChatMessageModel.create({ + conversationId: conversationId, + sender: message.sender, + content: message.content, + proposedChange: message.proposedChange, + }); + return repoMessage.toJSON() as unknown as ChatMessage; + } + + async updateMessage( + messageId: string, + updates: UpdateMessageDTO, + ): Promise { + const doc = await ChatMessageModel.findByIdAndUpdate( + messageId, + { $set: updates }, + { new: true }, + ); + return doc ? (doc.toJSON() as unknown as ChatMessage) : null; } } diff --git a/apps/server/src/repositories/mongo/models/ChatModel.ts b/apps/server/src/repositories/mongo/models/ChatModel.ts index ffca712..493672c 100644 --- a/apps/server/src/repositories/mongo/models/ChatModel.ts +++ b/apps/server/src/repositories/mongo/models/ChatModel.ts @@ -80,6 +80,10 @@ const ChatMessageSchema = new Schema< proposedChange: { type: ProposedChangeSchema, }, + respondedAction: { + type: String, + enum: ["confirm", "reject"], + }, }, { timestamps: true, diff --git a/apps/server/src/services/ChatService.ts b/apps/server/src/services/ChatService.ts index 2d69897..d973ee3 100644 --- a/apps/server/src/services/ChatService.ts +++ b/apps/server/src/services/ChatService.ts @@ -9,6 +9,7 @@ import { CreateEventDTO, UpdateEventDTO, EventAction, + CreateMessageDTO, } from "@caldav/shared"; import { ChatRepository, EventRepository, AIProvider } from "./interfaces"; import { getWeeksOverview, getMonthOverview } from "../utils/eventFormatters"; @@ -252,6 +253,18 @@ export class ChatService { userId: string, data: SendMessageDTO, ): Promise { + let conversationId = data.conversationId; + if (!conversationId) { + const conversation = await this.chatRepo.createConversation(userId); + conversationId = conversation.id; + } + + // Save user message + await this.chatRepo.createMessage(conversationId, { + sender: "user", + content: data.content, + }); + const response = await getTestResponse( responseIndex, this.eventRepo, @@ -259,15 +272,14 @@ export class ChatService { ); responseIndex++; - const message: ChatMessage = { - id: Date.now().toString(), - conversationId: data.conversationId || "temp-conv-id", + // Save and then return assistant response + const answerMessage = await this.chatRepo.createMessage(conversationId, { sender: "assistant", content: response.content, proposedChange: response.proposedChange, - }; + }); - return { message, conversationId: message.conversationId }; + return { message: answerMessage, conversationId: conversationId }; } async confirmEvent( @@ -279,6 +291,10 @@ export class ChatService { eventId?: string, updates?: UpdateEventDTO, ): Promise { + // Update original message with respondedAction + await this.chatRepo.updateMessage(messageId, { respondedAction: "confirm" }); + + // Perform the actual event operation let content: string; if (action === "create" && event) { @@ -298,12 +314,12 @@ export class ChatService { content = "Ungültige Aktion."; } - const message: ChatMessage = { - id: Date.now().toString(), - conversationId, + // Save response message to DB + const message = await this.chatRepo.createMessage(conversationId, { sender: "assistant", content, - }; + }); + return { message, conversationId }; } @@ -312,17 +328,34 @@ export class ChatService { conversationId: string, messageId: string, ): Promise { - const message: ChatMessage = { - id: Date.now().toString(), - conversationId, + // Update original message with respondedAction + await this.chatRepo.updateMessage(messageId, { respondedAction: "reject" }); + + // Save response message to DB + const message = await this.chatRepo.createMessage(conversationId, { sender: "assistant", content: "Der Vorschlag wurde abgelehnt.", - }; + }); + return { message, conversationId }; } async getConversations(userId: string): Promise { - throw new Error("Not implemented"); + const conversations = await this.chatRepo.getConversationsByUser(userId); + + // For each conversation, get the last message + const summaries: ConversationSummary[] = await Promise.all( + conversations.map(async (conv) => { + const messages = await this.chatRepo.getMessages(conv.id, { limit: 1 }); + return { + id: conv.id, + lastMessage: messages[0], + createdAt: conv.createdAt, + }; + }), + ); + + return summaries; } async getConversation( @@ -330,6 +363,14 @@ export class ChatService { conversationId: string, options?: GetMessagesOptions, ): Promise { - throw new Error("Not implemented"); + // Verify conversation belongs to user + const conversations = await this.chatRepo.getConversationsByUser(userId); + const conversation = conversations.find((c) => c.id === conversationId); + + if (!conversation) { + throw new Error("Conversation not found"); + } + + return this.chatRepo.getMessages(conversationId, options); } } diff --git a/apps/server/src/services/interfaces/ChatRepository.ts b/apps/server/src/services/interfaces/ChatRepository.ts index 6ea9cf5..f858b4d 100644 --- a/apps/server/src/services/interfaces/ChatRepository.ts +++ b/apps/server/src/services/interfaces/ChatRepository.ts @@ -3,6 +3,7 @@ import { Conversation, CreateMessageDTO, GetMessagesOptions, + UpdateMessageDTO, } from "@caldav/shared"; export interface ChatRepository { @@ -15,8 +16,14 @@ export interface ChatRepository { conversationId: string, options?: GetMessagesOptions, ): Promise; + createMessage( conversationId: string, message: CreateMessageDTO, ): Promise; + + updateMessage( + messageId: string, + updates: UpdateMessageDTO, + ): Promise; } diff --git a/packages/shared/src/models/ChatMessage.ts b/packages/shared/src/models/ChatMessage.ts index 3acda68..b51df45 100644 --- a/packages/shared/src/models/ChatMessage.ts +++ b/packages/shared/src/models/ChatMessage.ts @@ -4,6 +4,8 @@ export type MessageSender = "user" | "assistant"; export type EventAction = "create" | "update" | "delete"; +export type RespondedAction = "confirm" | "reject"; + export interface ProposedEventChange { action: EventAction; eventId?: string; // Required for update/delete @@ -17,6 +19,7 @@ export interface ChatMessage { sender: MessageSender; content: string; proposedChange?: ProposedEventChange; + respondedAction?: RespondedAction; createdAt?: Date; } @@ -43,6 +46,10 @@ export interface GetMessagesOptions { limit?: number; // Default: 20 } +export interface UpdateMessageDTO { + respondedAction?: RespondedAction; +} + export interface ChatResponse { message: ChatMessage; conversationId: string;