From 9fecf94c7dfce6ad0c2da92c80300914cd2d31e5 Mon Sep 17 00:00:00 2001 From: Linus Waldowsky Date: Sun, 4 Jan 2026 11:52:05 +0100 Subject: [PATCH] implement event persistence and improve Mongoose TypeScript patterns - Add event persistence: confirmed events are now saved to MongoDB - Refactor Mongoose models to use virtuals for id field with IdVirtual interface - Update repositories to use toJSON() with consistent type casting - Add more test responses for chat (doctor, birthday, gym, etc.) - Show event description in ProposedEventCard - Change mongo-express port to 8083 - Update CLAUDE.md with Mongoose model pattern documentation --- CLAUDE.md | 42 +++++- apps/client/src/app/(tabs)/chat.tsx | 11 +- .../src/components/ProposedEventCard.tsx | 5 + apps/client/src/services/ChatService.ts | 6 +- .../mongo/{compose.yml => docker-compose.yml} | 2 +- apps/server/src/controllers/ChatController.ts | 5 +- .../mongo/MongoEventRepository.ts | 4 +- .../repositories/mongo/MongoUserRepository.ts | 18 +-- .../repositories/mongo/models/ChatModel.ts | 33 ++++- .../repositories/mongo/models/EventModel.ts | 18 ++- .../repositories/mongo/models/UserModel.ts | 18 ++- .../src/repositories/mongo/models/types.ts | 4 + apps/server/src/services/ChatService.ts | 122 +++++++++++++++++- 13 files changed, 240 insertions(+), 48 deletions(-) rename apps/server/docker/mongo/{compose.yml => docker-compose.yml} (97%) create mode 100644 apps/server/src/repositories/mongo/models/types.ts diff --git a/CLAUDE.md b/CLAUDE.md index b52be64..45ec031 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,6 +124,7 @@ src/ │ ├── index.ts # Re-exports from ./mongo │ └── mongo/ # MongoDB implementation │ ├── models/ # Mongoose schemas +│ │ ├── types.ts # Shared types (IdVirtual interface) │ │ ├── UserModel.ts │ │ ├── EventModel.ts │ │ └── ChatModel.ts @@ -193,6 +194,35 @@ The repository pattern allows swapping databases: - **Implementations** (`repositories/mongo/`) are DB-specific - To add MySQL: create `repositories/mysql/` with TypeORM entities +### Mongoose Model Pattern + +All Mongoose models use a consistent pattern for TypeScript-safe `id` virtuals: + +```typescript +import { IdVirtual } from './types'; + +const Schema = new Schema, {}, {}, IdVirtual>( + { /* fields */ }, + { + virtuals: { + id: { + get() { return this._id.toString(); } + } + }, + toJSON: { + virtuals: true, + transform: (_, ret) => { + delete ret._id; + delete ret.__v; + return ret; + } + } + } +); +``` + +Repositories use `doc.toJSON() as unknown as Type` casting (required because Mongoose's TypeScript types don't reflect virtual fields in toJSON output). + ## MVP Feature Scope ### Must-Have @@ -218,7 +248,7 @@ docker compose up -d # Start MongoDB + Mongo Express docker compose down # Stop services ``` - MongoDB: `localhost:27017` (root/mongoose) -- Mongo Express UI: `localhost:8081` (admin/admin) +- Mongo Express UI: `localhost:8083` (admin/admin) ### Environment Variables Server requires `.env` file in `apps/server/`: @@ -239,16 +269,16 @@ MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin - `utils/jwt`: signToken() (verifyToken() pending) - `dotenv` integration for environment variables - `ChatController`: sendMessage(), confirmEvent(), rejectEvent() - - `ChatService`: processMessage() with test responses, confirmEvent(), rejectEvent() + - `ChatService`: processMessage() with test responses, confirmEvent() saves events to DB, rejectEvent() - **Stubbed (TODO):** - `AuthMiddleware.authenticate()`: Currently uses fake user for testing - `AuthController`: refresh(), logout() - `AuthService`: refreshToken() - `ChatController`: getConversations(), getConversation() - `MongoChatRepository`: Database persistence for chat - - All Event functionality + - `MongoEventRepository`: Only create() implemented, rest stubbed - **Not started:** - - `EventController`, `EventService`, `MongoEventRepository` + - `EventController`, `EventService` - `ClaudeAdapter` (AI integration - currently using test responses) **Shared:** Types, DTOs, constants (Day, Month), and date utilities defined and exported. @@ -258,8 +288,8 @@ MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin - Calendar screen has month navigation and grid display (partially functional) - Chat screen functional with FlashList, message sending, and event confirm/reject - `ApiClient`: get(), post() implemented -- `ChatService`: sendMessage(), confirmEvent(), rejectEvent() implemented -- `ProposedEventCard`: Displays proposed events with confirm/reject buttons, theming support +- `ChatService`: sendMessage(), confirmEvent(convId, msgId, event), rejectEvent() - confirmEvent sends CreateEventDTO in body +- `ProposedEventCard`: Displays proposed events (title, date, description, recurring indicator) with confirm/reject buttons - `Themes.tsx`: Centralized color definitions including button colors - Auth screens (Login, Register), Event Detail, and Note screens exist as skeletons - Zustand stores (AuthStore, EventsStore) 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 66b261b..43711b0 100644 --- a/apps/client/src/app/(tabs)/chat.tsx +++ b/apps/client/src/app/(tabs)/chat.tsx @@ -5,7 +5,7 @@ import Header from "../../components/Header"; import BaseBackground from "../../components/BaseBackground"; import { FlashList } from "@shopify/flash-list"; import { ChatService } from "../../services"; -import { ProposedEventChange } from "@caldav/shared"; +import { ProposedEventChange, CreateEventDTO } from "@caldav/shared"; import { ProposedEventCard } from "../../components/ProposedEventCard"; // TODO: better shadows for everything @@ -39,7 +39,8 @@ const Chat = () => { const handleEventResponse = async ( action: "confirm" | "reject", messageId: string, - conversationId: string + conversationId: string, + event?: CreateEventDTO ) => { // Mark message as responded (optimistic update) setMessages((prev) => @@ -50,8 +51,8 @@ const Chat = () => { try { const response = - action === "confirm" - ? await ChatService.confirmEvent(conversationId, messageId) + action === "confirm" && event + ? await ChatService.confirmEvent(conversationId, messageId, event) : await ChatService.rejectEvent(conversationId, messageId); const botMessage: MessageData = { @@ -111,7 +112,7 @@ const Chat = () => { proposedChange={item.proposedChange} respondedAction={item.respondedAction} onConfirm={() => - handleEventResponse("confirm", item.id, item.conversationId!) + handleEventResponse("confirm", item.id, item.conversationId!, item.proposedChange?.event) } onReject={() => handleEventResponse("reject", item.id, item.conversationId!) diff --git a/apps/client/src/components/ProposedEventCard.tsx b/apps/client/src/components/ProposedEventCard.tsx index 68efb8a..6e28c98 100644 --- a/apps/client/src/components/ProposedEventCard.tsx +++ b/apps/client/src/components/ProposedEventCard.tsx @@ -40,6 +40,11 @@ export const ProposedEventCard = ({ {formatDateTime(event?.startTime)} + {event?.description && ( + + {event.description} + + )} {event?.isRecurring && ( Wiederkehrend diff --git a/apps/client/src/services/ChatService.ts b/apps/client/src/services/ChatService.ts index bafa44b..dfb50a2 100644 --- a/apps/client/src/services/ChatService.ts +++ b/apps/client/src/services/ChatService.ts @@ -4,6 +4,7 @@ import { ChatMessage, ConversationSummary, GetMessagesOptions, + CreateEventDTO, } from "@caldav/shared"; import { ApiClient } from "./ApiClient"; @@ -14,9 +15,10 @@ export const ChatService = { confirmEvent: async ( conversationId: string, - messageId: string + messageId: string, + event: CreateEventDTO ): Promise => { - return ApiClient.post(`/chat/confirm/${conversationId}/${messageId}`); + return ApiClient.post(`/chat/confirm/${conversationId}/${messageId}`, event); }, rejectEvent: async ( diff --git a/apps/server/docker/mongo/compose.yml b/apps/server/docker/mongo/docker-compose.yml similarity index 97% rename from apps/server/docker/mongo/compose.yml rename to apps/server/docker/mongo/docker-compose.yml index bc3d9cb..f9b05a3 100644 --- a/apps/server/docker/mongo/compose.yml +++ b/apps/server/docker/mongo/docker-compose.yml @@ -19,7 +19,7 @@ services: image: mongo-express:latest restart: always ports: - - "8083:8083" + - "8083:8081" environment: ME_CONFIG_MONGODB_URL: mongodb://root:mongoose@mongo:27017/ ME_CONFIG_BASICAUTH_ENABLED: true diff --git a/apps/server/src/controllers/ChatController.ts b/apps/server/src/controllers/ChatController.ts index 2f0712e..dde2336 100644 --- a/apps/server/src/controllers/ChatController.ts +++ b/apps/server/src/controllers/ChatController.ts @@ -1,5 +1,5 @@ import { Response } from 'express'; -import { SendMessageDTO } from '@caldav/shared'; +import { SendMessageDTO, CreateEventDTO } from '@caldav/shared'; import { ChatService } from '../services'; import { AuthenticatedRequest } from '../middleware'; @@ -21,7 +21,8 @@ export class ChatController { try { const userId = req.user!.userId; const { conversationId, messageId } = req.params; - const response = await this.chatService.confirmEvent(userId, conversationId, messageId); + const event: CreateEventDTO = req.body; + const response = await this.chatService.confirmEvent(userId, conversationId, messageId, event); res.json(response); } catch (error) { res.status(500).json({ error: 'Failed to confirm event' }); diff --git a/apps/server/src/repositories/mongo/MongoEventRepository.ts b/apps/server/src/repositories/mongo/MongoEventRepository.ts index ff4f442..8312abb 100644 --- a/apps/server/src/repositories/mongo/MongoEventRepository.ts +++ b/apps/server/src/repositories/mongo/MongoEventRepository.ts @@ -16,7 +16,9 @@ export class MongoEventRepository implements EventRepository { } async create(userId: string, data: CreateEventDTO): Promise { - throw new Error('Not implemented'); + const event = await EventModel.create({ userId, ...data }); + // NOTE: Casting required because Mongoose's toJSON() type doesn't reflect our virtual 'id' field + return event.toJSON() as unknown as CalendarEvent; } async update(id: string, data: UpdateEventDTO): Promise { diff --git a/apps/server/src/repositories/mongo/MongoUserRepository.ts b/apps/server/src/repositories/mongo/MongoUserRepository.ts index 6e7fd46..95cb8e8 100644 --- a/apps/server/src/repositories/mongo/MongoUserRepository.ts +++ b/apps/server/src/repositories/mongo/MongoUserRepository.ts @@ -1,29 +1,21 @@ import { User } from '@caldav/shared'; import { UserRepository, CreateUserData } from '../../services/interfaces'; -import { UserModel, UserDocument } from './models'; +import { UserModel } from './models'; export class MongoUserRepository implements UserRepository { - private toUser(doc: UserDocument): User { - return { - id: doc._id.toString(), - email: doc.email, - displayName: doc.displayName, - createdAt: doc.createdAt, - updatedAt: doc.updatedAt, - }; - } - async findById(id: string): Promise { throw new Error('Not implemented'); } async findByEmail(email: string): Promise { const user = await UserModel.findOne({ email: email.toLowerCase() }); - return user ? this.toUser(user) : null; + // NOTE: Casting required because Mongoose's toJSON() type doesn't reflect our virtual 'id' field + return (user?.toJSON() as unknown as User) ?? null; } async create(data: CreateUserData): Promise { const user = await UserModel.create(data); - return this.toUser(user); + // NOTE: Casting required because Mongoose's toJSON() type doesn't reflect our virtual 'id' field + return user.toJSON() as unknown as User; } } diff --git a/apps/server/src/repositories/mongo/models/ChatModel.ts b/apps/server/src/repositories/mongo/models/ChatModel.ts index c4f4304..d1fa8bc 100644 --- a/apps/server/src/repositories/mongo/models/ChatModel.ts +++ b/apps/server/src/repositories/mongo/models/ChatModel.ts @@ -1,8 +1,13 @@ -import mongoose, { Schema, Document } from 'mongoose'; +import mongoose, { Schema, Document, Model } from 'mongoose'; import { ChatMessage, Conversation, CreateEventDTO, UpdateEventDTO, ProposedEventChange } from '@caldav/shared'; +import { IdVirtual } from './types'; -export interface ChatMessageDocument extends Omit, Document {} -export interface ConversationDocument extends Omit, Document {} +export interface ChatMessageDocument extends Omit, Document { + toJSON(): ChatMessage; +} +export interface ConversationDocument extends Omit, Document { + toJSON(): Conversation; +} const EventSchema = new Schema( { @@ -40,7 +45,7 @@ const ProposedChangeSchema = new Schema( { _id: false } ); -const ChatMessageSchema = new Schema( +const ChatMessageSchema = new Schema, {}, {}, IdVirtual>( { conversationId: { type: String, @@ -61,9 +66,16 @@ const ChatMessageSchema = new Schema( }, { timestamps: true, + virtuals: { + id: { + get() { + return this._id.toString(); + }, + }, + }, toJSON: { + virtuals: true, transform: (_, ret: Record) => { - ret.id = String(ret._id); delete ret._id; delete ret.__v; return ret; @@ -72,7 +84,7 @@ const ChatMessageSchema = new Schema( } ); -const ConversationSchema = new Schema( +const ConversationSchema = new Schema, {}, {}, IdVirtual>( { userId: { type: String, @@ -82,9 +94,16 @@ const ConversationSchema = new Schema( }, { timestamps: true, + virtuals: { + id: { + get() { + return this._id.toString(); + }, + }, + }, toJSON: { + virtuals: true, transform: (_, ret: Record) => { - ret.id = String(ret._id); delete ret._id; delete ret.__v; return ret; diff --git a/apps/server/src/repositories/mongo/models/EventModel.ts b/apps/server/src/repositories/mongo/models/EventModel.ts index 6544a2e..efb87b3 100644 --- a/apps/server/src/repositories/mongo/models/EventModel.ts +++ b/apps/server/src/repositories/mongo/models/EventModel.ts @@ -1,9 +1,12 @@ -import mongoose, { Schema, Document } from 'mongoose'; +import mongoose, { Schema, Document, Model } from 'mongoose'; import { CalendarEvent } from '@caldav/shared'; +import { IdVirtual } from './types'; -export interface EventDocument extends Omit, Document {} +export interface EventDocument extends Omit, Document { + toJSON(): CalendarEvent; +} -const EventSchema = new Schema( +const EventSchema = new Schema, {}, {}, IdVirtual>( { userId: { type: String, @@ -40,9 +43,16 @@ const EventSchema = new Schema( }, { timestamps: true, + virtuals: { + id: { + get() { + return this._id.toString(); + }, + }, + }, toJSON: { + virtuals: true, transform: (_, ret: Record) => { - ret.id = String(ret._id); delete ret._id; delete ret.__v; return ret; diff --git a/apps/server/src/repositories/mongo/models/UserModel.ts b/apps/server/src/repositories/mongo/models/UserModel.ts index c256ebc..ba1e40b 100644 --- a/apps/server/src/repositories/mongo/models/UserModel.ts +++ b/apps/server/src/repositories/mongo/models/UserModel.ts @@ -1,9 +1,12 @@ -import mongoose, { Schema, Document } from 'mongoose'; +import mongoose, { Schema, Document, Model } from 'mongoose'; import { User } from '@caldav/shared'; +import { IdVirtual } from './types'; -export interface UserDocument extends Omit, Document {} +export interface UserDocument extends Omit, Document { + toJSON(): User; +} -const UserSchema = new Schema( +const UserSchema = new Schema, {}, {}, IdVirtual>( { email: { type: String, @@ -24,9 +27,16 @@ const UserSchema = new Schema( }, { timestamps: true, + virtuals: { + id: { + get() { + return this._id.toString(); + }, + }, + }, toJSON: { + virtuals: true, transform: (_, ret: Record) => { - ret.id = String(ret._id); delete ret._id; delete ret.__v; delete ret.passwordHash; diff --git a/apps/server/src/repositories/mongo/models/types.ts b/apps/server/src/repositories/mongo/models/types.ts new file mode 100644 index 0000000..3b4af19 --- /dev/null +++ b/apps/server/src/repositories/mongo/models/types.ts @@ -0,0 +1,4 @@ +// Common virtual interface for all Mongoose models +export interface IdVirtual { + id: string; +} diff --git a/apps/server/src/services/ChatService.ts b/apps/server/src/services/ChatService.ts index e6b9a6e..038ac15 100644 --- a/apps/server/src/services/ChatService.ts +++ b/apps/server/src/services/ChatService.ts @@ -1,4 +1,4 @@ -import { ChatMessage, ChatResponse, SendMessageDTO, ConversationSummary, GetMessagesOptions, ProposedEventChange, getDay } from '@caldav/shared'; +import { ChatMessage, ChatResponse, SendMessageDTO, ConversationSummary, GetMessagesOptions, ProposedEventChange, getDay, CreateEventDTO } from '@caldav/shared'; import { ChatRepository, EventRepository, AIProvider } from './interfaces'; // Test responses array (cycles through responses) @@ -41,6 +41,120 @@ const testResponses: Array<{ content: string; proposedChange?: ProposedEventChan "Samstag, 18.01. - 10:00 Uhr: Badezimmer putzen\n\n" + "Insgesamt 3 Termine.", }, + // Response 4: Doctor appointment with description + { + content: "Ich habe dir einen Arzttermin eingetragen. Denk daran, deine Versichertenkarte mitzunehmen!", + proposedChange: { + action: 'create', + event: { + title: "Arzttermin Dr. Müller", + startTime: getDay('Wednesday', 1, 9, 30), + endTime: getDay('Wednesday', 1, 10, 30), + description: "Routineuntersuchung - Versichertenkarte nicht vergessen", + } + } + }, + // Response 5: Birthday - yearly recurring + { + content: "Geburtstage vergisst man leicht - aber nicht mit mir! Ich habe Mamas Geburtstag eingetragen:", + proposedChange: { + action: 'create', + event: { + title: "Mamas Geburtstag", + startTime: getDay('Thursday', 2, 0, 0), + endTime: getDay('Thursday', 2, 23, 59), + isRecurring: true, + recurrenceRule: "FREQ=YEARLY", + } + } + }, + // Response 6: Gym - recurring for 2 months (8 weeks) + { + content: "Perfekt! Ich habe dein Probetraining eingetragen - jeden Dienstag für die nächsten 2 Monate:", + proposedChange: { + action: 'create', + event: { + title: "Fitnessstudio Probetraining", + startTime: getDay('Tuesday', 1, 18, 0), + endTime: getDay('Tuesday', 1, 19, 30), + isRecurring: true, + recurrenceRule: "FREQ=WEEKLY;BYDAY=TU;COUNT=8", + } + } + }, + // Response 7: Weekly calendar overview (text only) + { + content: "Hier ist dein Überblick für nächste Woche:\n\n" + + "Montag - Keine Termine\n" + + "Dienstag, 18:00 Uhr: Fitnessstudio\n" + + "Mittwoch, 09:30 Uhr: Arzttermin Dr. Müller\n" + + "Donnerstag - Keine Termine\n" + + "Freitag, 14:00 Uhr: Meeting mit Jens\n" + + "Samstag, 10:00 Uhr: Badezimmer putzen\n" + + "Sonntag, 11:00 Uhr: Telefonat mit Mama\n\n" + + "Insgesamt 5 Termine nächste Woche.", + }, + // Response 8: Help response (text only) + { + 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" + + "• \"Erstelle einen Termin für morgen um 15 Uhr\"\n" + + "• \"Was habe ich nächste Woche vor?\"\n" + + "• \"Verschiebe das Meeting auf Donnerstag\"\n\n" + + "Wie kann ich dir helfen?", + }, + // Response 9: Phone call - short appointment + { + content: "Alles klar! Ich habe das Telefonat mit deiner Mutter eingetragen:", + proposedChange: { + action: 'create', + event: { + title: "Telefonat mit Mama", + startTime: getDay('Sunday', 0, 11, 0), + endTime: getDay('Sunday', 0, 11, 30), + } + } + }, + // Response 10: Birthday party - evening event + { + content: "Super! Die Geburtstagsfeier ist eingetragen. Viel Spaß!", + proposedChange: { + action: 'create', + event: { + title: "Geburtstagsfeier Lisa", + startTime: getDay('Saturday', 2, 19, 0), + endTime: getDay('Saturday', 2, 23, 0), + description: "Geschenk: Buch über Fotografie", + } + } + }, + // Response 11: Language course - limited to 8 weeks + { + content: "Dein Spanischkurs ist eingetragen! Er läuft jeden Donnerstag für die nächsten 8 Wochen:", + proposedChange: { + action: 'create', + event: { + title: "Spanischkurs VHS", + startTime: getDay('Thursday', 1, 19, 0), + endTime: getDay('Thursday', 1, 20, 30), + isRecurring: true, + recurrenceRule: "FREQ=WEEKLY;BYDAY=TH;COUNT=8", + } + } + }, + // Response 12: Monthly calendar overview (text only) + { + content: "Hier ist deine Monatsübersicht für Januar:\n\n" + + "KW 2: 3 Termine\n" + + " • Mi 08.01., 09:30: Arzttermin Dr. Müller\n" + + " • Fr 10.01., 14:00: Meeting mit Jens\n" + + " • Sa 11.01., 10:00: Badezimmer putzen\n\n" + + "KW 3: 4 Termine\n" + + " • Di 14.01., 18:00: Fitnessstudio\n" + + " • Do 16.01., 19:00: Spanischkurs VHS\n" + + " • Sa 18.01., 10:00: Badezimmer putzen\n" + + " • Sa 18.01., 19:00: Geburtstagsfeier Lisa\n\n" + + "Insgesamt 7 Termine im Januar.", + }, // }}} ]; @@ -66,12 +180,14 @@ export class ChatService { return { message, conversationId: message.conversationId }; } - async confirmEvent(userId: string, conversationId: string, messageId: string): Promise { + async confirmEvent(userId: string, conversationId: string, messageId: string, event: CreateEventDTO): Promise { + const createdEvent = await this.eventRepo.create(userId, event); + const message: ChatMessage = { id: Date.now().toString(), conversationId, sender: 'assistant', - content: 'Der Vorschlag wurde angenommen.', + content: `Der Termin "${createdEvent.title}" wurde erstellt.`, }; return { message, conversationId }; }