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

@@ -91,7 +91,7 @@ src/
└── stores/ # Zustand state management └── stores/ # Zustand state management
├── index.ts # Re-exports all stores ├── index.ts # Re-exports all stores
├── AuthStore.ts # user, token, isAuthenticated, login(), logout(), setToken() ├── 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() └── EventsStore.ts # events[], setEvents(), addEvent(), updateEvent(), deleteEvent()
``` ```
@@ -172,7 +172,7 @@ src/
│ ├── CalendarEvent.ts # CalendarEvent, CreateEventDTO, UpdateEventDTO, ExpandedEvent │ ├── CalendarEvent.ts # CalendarEvent, CreateEventDTO, UpdateEventDTO, ExpandedEvent
│ ├── ChatMessage.ts # ChatMessage, Conversation, SendMessageDTO, CreateMessageDTO, │ ├── ChatMessage.ts # ChatMessage, Conversation, SendMessageDTO, CreateMessageDTO,
│ │ # GetMessagesOptions, ChatResponse, ConversationSummary, │ │ # GetMessagesOptions, ChatResponse, ConversationSummary,
│ │ # ProposedEventChange, EventAction │ │ # ProposedEventChange, EventAction, RespondedAction, UpdateMessageDTO
│ └── Constants.ts # DAYS, MONTHS, Day, Month, DAY_INDEX, DAY_INDEX_TO_DAY, │ └── Constants.ts # DAYS, MONTHS, Day, Month, DAY_INDEX, DAY_INDEX_TO_DAY,
│ # DAY_TO_GERMAN, DAY_TO_GERMAN_SHORT, MONTH_TO_GERMAN │ # DAY_TO_GERMAN, DAY_TO_GERMAN_SHORT, MONTH_TO_GERMAN
└── utils/ └── utils/
@@ -184,12 +184,14 @@ src/
- `User`: id, email, displayName, passwordHash?, createdAt?, updatedAt? - `User`: id, email, displayName, 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? - `ChatMessage`: id, conversationId, sender ('user' | 'assistant'), content, proposedChange?, respondedAction?
- `ProposedEventChange`: action ('create' | 'update' | 'delete'), eventId?, event?, updates? - `ProposedEventChange`: action ('create' | 'update' | 'delete'), eventId?, event?, updates?
- `Conversation`: id, userId, createdAt?, updatedAt? (messages loaded separately via lazy loading) - `Conversation`: id, userId, createdAt?, updatedAt? (messages loaded separately via lazy loading)
- `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)
- `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"
@@ -281,12 +283,14 @@ MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin
- `EventService`: Full CRUD with recurring event expansion via recurrenceExpander - `EventService`: Full CRUD with recurring event expansion via recurrenceExpander
- `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
- `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):** - **Stubbed (TODO):**
- `AuthMiddleware.authenticate()`: Currently uses fake user for testing - `AuthMiddleware.authenticate()`: Currently uses fake user for testing
- `AuthController`: refresh(), logout() - `AuthController`: refresh(), logout()
- `AuthService`: refreshToken() - `AuthService`: refreshToken()
- `ChatController`: getConversations(), getConversation()
- `MongoChatRepository`: Database persistence for chat
- **Not started:** - **Not started:**
- `ClaudeAdapter` (AI integration - currently using test responses) - `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 - 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
- Chat screen functional with FlashList, message sending, and event confirm/reject - Chat screen fully functional with FlashList, message sending, and event confirm/reject
- Messages persisted via ChatStore (survives tab switches) - 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) - 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"
- `ApiClient`: get(), post(), put(), delete() implemented - `ApiClient`: get(), post(), put(), delete() implemented
- `EventService`: getAll(), getById(), getByDateRange(), create(), update(), delete() - fully 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 - `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)
- `Themes.tsx`: Centralized color definitions including textPrimary, borderPrimary, eventIndicator, secondaryBg - `Themes.tsx`: Centralized color definitions including textPrimary, borderPrimary, eventIndicator, secondaryBg
- `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(), 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 - Auth screens (Login, Register), Event Detail, and Note screens exist as skeletons
- AuthStore defined with `throw new Error('Not implemented')` - AuthStore defined with `throw new Error('Not implemented')`

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 currentTheme from "../../Themes";
import { useState, useRef, useEffect } from "react"; import React, { useState, useRef, useEffect } from "react";
import Header from "../../components/Header"; import Header from "../../components/Header";
import BaseBackground from "../../components/BaseBackground"; import BaseBackground from "../../components/BaseBackground";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { ChatService } from "../../services"; import { ChatService } from "../../services";
import { useChatStore, MessageData } from "../../stores"; import { useChatStore, chatMessageToMessageData, MessageData } from "../../stores";
import { ProposedEventChange } from "@caldav/shared"; import { ProposedEventChange } from "@caldav/shared";
import { ProposedEventCard } from "../../components/ProposedEventCard"; import { ProposedEventCard } from "../../components/ProposedEventCard";
// 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)
// TODO: max width for messages // TODO: max width for messages
// TODO: create new messages
type BubbleSide = "left" | "right"; type BubbleSide = "left" | "right";
@@ -30,14 +37,43 @@ type ChatInputProps = {
}; };
const Chat = () => { const Chat = () => {
const { messages, addMessage, updateMessage } = useChatStore(); const { messages, addMessage, addMessages, updateMessage } = useChatStore();
const listRef = useRef<FlashList<MessageData>>(null); const listRef =
useRef<React.ComponentRef<typeof FlashList<MessageData>>>(null);
const [currentConversationId, setCurrentConversationId] = useState<
string | undefined
>();
useEffect(() => { useEffect(() => {
const keyboardDidShow = Keyboard.addListener("keyboardDidShow", scrollToEnd); const keyboardDidShow = Keyboard.addListener(
"keyboardDidShow",
scrollToEnd,
);
return () => keyboardDidShow.remove(); 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 = () => { const scrollToEnd = () => {
setTimeout(() => { setTimeout(() => {
listRef.current?.scrollToEnd({ animated: true }); listRef.current?.scrollToEnd({ animated: true });
@@ -87,13 +123,22 @@ const Chat = () => {
id: Date.now().toString(), id: Date.now().toString(),
side: "right", side: "right",
content: text, content: text,
conversationId: currentConversationId,
}; };
addMessage(userMessage); addMessage(userMessage);
scrollToEnd(); scrollToEnd();
try { try {
// Fetch server response // Fetch server response (include conversationId for existing conversations)
const response = await ChatService.sendMessage({ content: text }); const response = await ChatService.sendMessage({
content: text,
conversationId: currentConversationId,
});
// Track conversation ID for subsequent messages
if (!currentConversationId) {
setCurrentConversationId(response.conversationId);
}
// Show bot response // Show bot response
const botMessage: MessageData = { const botMessage: MessageData = {

View File

@@ -47,13 +47,20 @@ export const ChatService = {
}, },
getConversations: async (): Promise<ConversationSummary[]> => { getConversations: async (): Promise<ConversationSummary[]> => {
throw new Error("Not implemented"); return ApiClient.get<ConversationSummary[]>("/chat/conversations");
}, },
getConversation: async ( getConversation: async (
_id: string, id: string,
_options?: GetMessagesOptions, options?: GetMessagesOptions,
): Promise<ChatMessage[]> => { ): 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 { create } from "zustand";
import { ProposedEventChange } from "@caldav/shared"; import { ChatMessage, ProposedEventChange } from "@caldav/shared";
type BubbleSide = "left" | "right"; type BubbleSide = "left" | "right";
@@ -14,6 +14,7 @@ export type MessageData = {
interface ChatState { interface ChatState {
messages: MessageData[]; messages: MessageData[];
addMessages: (messages: MessageData[]) => void;
addMessage: (message: MessageData) => void; addMessage: (message: MessageData) => void;
updateMessage: (id: string, updates: Partial<MessageData>) => void; updateMessage: (id: string, updates: Partial<MessageData>) => void;
clearMessages: () => void; clearMessages: () => void;
@@ -21,6 +22,9 @@ interface ChatState {
export const useChatStore = create<ChatState>((set) => ({ export const useChatStore = create<ChatState>((set) => ({
messages: [], messages: [],
addMessages(messages) {
set((state) => ({messages: [...state.messages, ...messages]}))
},
addMessage: (message: MessageData) => { addMessage: (message: MessageData) => {
set((state) => ({ messages: [...state.messages, message] })); set((state) => ({ messages: [...state.messages, message] }));
}, },
@@ -35,3 +39,15 @@ export const useChatStore = create<ChatState>((set) => ({
set({ messages: [] }); 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 { useAuthStore } from "./AuthStore";
export { useChatStore, type MessageData } from "./ChatStore"; export { useChatStore, chatMessageToMessageData, type MessageData } from "./ChatStore";
export { useEventsStore } from "./EventsStore"; export { useEventsStore } from "./EventsStore";

View File

@@ -4,6 +4,7 @@ import {
CreateEventDTO, CreateEventDTO,
UpdateEventDTO, UpdateEventDTO,
EventAction, EventAction,
GetMessagesOptions,
} from "@caldav/shared"; } from "@caldav/shared";
import { ChatService } from "../services"; import { ChatService } from "../services";
import { AuthenticatedRequest } from "../middleware"; import { AuthenticatedRequest } from "../middleware";
@@ -66,13 +67,43 @@ export class ChatController {
req: AuthenticatedRequest, req: AuthenticatedRequest,
res: Response, res: Response,
): Promise<void> { ): Promise<void> {
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( async getConversation(
req: AuthenticatedRequest, req: AuthenticatedRequest,
res: Response, res: Response,
): Promise<void> { ): Promise<void> {
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" });
}
}
} }
} }

View File

@@ -3,6 +3,7 @@ import {
Conversation, Conversation,
CreateMessageDTO, CreateMessageDTO,
GetMessagesOptions, GetMessagesOptions,
UpdateMessageDTO,
} from "@caldav/shared"; } from "@caldav/shared";
import { ChatRepository } from "../../services/interfaces"; import { ChatRepository } from "../../services/interfaces";
import { ChatMessageModel, ConversationModel } from "./models"; import { ChatMessageModel, ConversationModel } from "./models";
@@ -10,11 +11,15 @@ import { ChatMessageModel, ConversationModel } from "./models";
export class MongoChatRepository implements ChatRepository { export class MongoChatRepository implements ChatRepository {
// Conversations // Conversations
async getConversationsByUser(userId: string): Promise<Conversation[]> { async getConversationsByUser(userId: string): Promise<Conversation[]> {
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<Conversation> { async createConversation(userId: string): Promise<Conversation> {
throw new Error("Not implemented"); const conversation = await ConversationModel.create({
userId,
});
return conversation.toJSON() as unknown as Conversation;
} }
// Messages (cursor-based pagination) // Messages (cursor-based pagination)
@@ -22,13 +27,44 @@ export class MongoChatRepository implements ChatRepository {
conversationId: string, conversationId: string,
options?: GetMessagesOptions, options?: GetMessagesOptions,
): Promise<ChatMessage[]> { ): Promise<ChatMessage[]> {
throw new Error("Not implemented"); const limit = options?.limit ?? 20;
const query: Record<string, unknown> = { 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( async createMessage(
conversationId: string, conversationId: string,
message: CreateMessageDTO, message: CreateMessageDTO,
): Promise<ChatMessage> { ): Promise<ChatMessage> {
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<ChatMessage | null> {
const doc = await ChatMessageModel.findByIdAndUpdate(
messageId,
{ $set: updates },
{ new: true },
);
return doc ? (doc.toJSON() as unknown as ChatMessage) : null;
} }
} }

View File

@@ -80,6 +80,10 @@ const ChatMessageSchema = new Schema<
proposedChange: { proposedChange: {
type: ProposedChangeSchema, type: ProposedChangeSchema,
}, },
respondedAction: {
type: String,
enum: ["confirm", "reject"],
},
}, },
{ {
timestamps: true, timestamps: true,

View File

@@ -9,6 +9,7 @@ import {
CreateEventDTO, CreateEventDTO,
UpdateEventDTO, UpdateEventDTO,
EventAction, EventAction,
CreateMessageDTO,
} from "@caldav/shared"; } from "@caldav/shared";
import { ChatRepository, EventRepository, AIProvider } from "./interfaces"; import { ChatRepository, EventRepository, AIProvider } from "./interfaces";
import { getWeeksOverview, getMonthOverview } from "../utils/eventFormatters"; import { getWeeksOverview, getMonthOverview } from "../utils/eventFormatters";
@@ -252,6 +253,18 @@ export class ChatService {
userId: string, userId: string,
data: SendMessageDTO, data: SendMessageDTO,
): Promise<ChatResponse> { ): Promise<ChatResponse> {
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( const response = await getTestResponse(
responseIndex, responseIndex,
this.eventRepo, this.eventRepo,
@@ -259,15 +272,14 @@ export class ChatService {
); );
responseIndex++; responseIndex++;
const message: ChatMessage = { // Save and then return assistant response
id: Date.now().toString(), const answerMessage = await this.chatRepo.createMessage(conversationId, {
conversationId: data.conversationId || "temp-conv-id",
sender: "assistant", sender: "assistant",
content: response.content, content: response.content,
proposedChange: response.proposedChange, proposedChange: response.proposedChange,
}; });
return { message, conversationId: message.conversationId }; return { message: answerMessage, conversationId: conversationId };
} }
async confirmEvent( async confirmEvent(
@@ -279,6 +291,10 @@ export class ChatService {
eventId?: string, eventId?: string,
updates?: UpdateEventDTO, updates?: UpdateEventDTO,
): Promise<ChatResponse> { ): Promise<ChatResponse> {
// Update original message with respondedAction
await this.chatRepo.updateMessage(messageId, { respondedAction: "confirm" });
// Perform the actual event operation
let content: string; let content: string;
if (action === "create" && event) { if (action === "create" && event) {
@@ -298,12 +314,12 @@ export class ChatService {
content = "Ungültige Aktion."; content = "Ungültige Aktion.";
} }
const message: ChatMessage = { // Save response message to DB
id: Date.now().toString(), const message = await this.chatRepo.createMessage(conversationId, {
conversationId,
sender: "assistant", sender: "assistant",
content, content,
}; });
return { message, conversationId }; return { message, conversationId };
} }
@@ -312,17 +328,34 @@ export class ChatService {
conversationId: string, conversationId: string,
messageId: string, messageId: string,
): Promise<ChatResponse> { ): Promise<ChatResponse> {
const message: ChatMessage = { // Update original message with respondedAction
id: Date.now().toString(), await this.chatRepo.updateMessage(messageId, { respondedAction: "reject" });
conversationId,
// Save response message to DB
const message = await this.chatRepo.createMessage(conversationId, {
sender: "assistant", sender: "assistant",
content: "Der Vorschlag wurde abgelehnt.", content: "Der Vorschlag wurde abgelehnt.",
}; });
return { message, conversationId }; return { message, conversationId };
} }
async getConversations(userId: string): Promise<ConversationSummary[]> { async getConversations(userId: string): Promise<ConversationSummary[]> {
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( async getConversation(
@@ -330,6 +363,14 @@ export class ChatService {
conversationId: string, conversationId: string,
options?: GetMessagesOptions, options?: GetMessagesOptions,
): Promise<ChatMessage[]> { ): Promise<ChatMessage[]> {
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);
} }
} }

View File

@@ -3,6 +3,7 @@ import {
Conversation, Conversation,
CreateMessageDTO, CreateMessageDTO,
GetMessagesOptions, GetMessagesOptions,
UpdateMessageDTO,
} from "@caldav/shared"; } from "@caldav/shared";
export interface ChatRepository { export interface ChatRepository {
@@ -15,8 +16,14 @@ export interface ChatRepository {
conversationId: string, conversationId: string,
options?: GetMessagesOptions, options?: GetMessagesOptions,
): Promise<ChatMessage[]>; ): Promise<ChatMessage[]>;
createMessage( createMessage(
conversationId: string, conversationId: string,
message: CreateMessageDTO, message: CreateMessageDTO,
): Promise<ChatMessage>; ): Promise<ChatMessage>;
updateMessage(
messageId: string,
updates: UpdateMessageDTO,
): Promise<ChatMessage | null>;
} }

View File

@@ -4,6 +4,8 @@ export type MessageSender = "user" | "assistant";
export type EventAction = "create" | "update" | "delete"; export type EventAction = "create" | "update" | "delete";
export type RespondedAction = "confirm" | "reject";
export interface ProposedEventChange { export interface ProposedEventChange {
action: EventAction; action: EventAction;
eventId?: string; // Required for update/delete eventId?: string; // Required for update/delete
@@ -17,6 +19,7 @@ export interface ChatMessage {
sender: MessageSender; sender: MessageSender;
content: string; content: string;
proposedChange?: ProposedEventChange; proposedChange?: ProposedEventChange;
respondedAction?: RespondedAction;
createdAt?: Date; createdAt?: Date;
} }
@@ -43,6 +46,10 @@ export interface GetMessagesOptions {
limit?: number; // Default: 20 limit?: number; // Default: 20
} }
export interface UpdateMessageDTO {
respondedAction?: RespondedAction;
}
export interface ChatResponse { export interface ChatResponse {
message: ChatMessage; message: ChatMessage;
conversationId: string; conversationId: string;