implement chat messaging with event proposals

- Add functional chat with server communication and test responses
- Add ProposedEventCard component for confirm/reject actions
- Move Constants (Day, Month) from client to shared package
- Add dateHelpers utility for weekday calculations
- Extend Themes.tsx with button and text colors
- Update CLAUDE.md with current implementation status
- Add *.tsbuildinfo to .gitignore
This commit is contained in:
2026-01-04 00:01:26 +01:00
parent e553103470
commit c33508a227
17 changed files with 456 additions and 295 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
node_modules node_modules
*.tsbuildinfo

View File

@@ -78,7 +78,9 @@ src/
│ ├── BaseBackground.tsx # Common screen wrapper │ ├── BaseBackground.tsx # Common screen wrapper
│ ├── Header.tsx # Header component │ ├── Header.tsx # Header component
│ ├── EventCard.tsx # Event card for calendar display │ ├── EventCard.tsx # Event card for calendar display
── EventConfirmDialog.tsx # AI-proposed event confirmation modal ── EventConfirmDialog.tsx # AI-proposed event confirmation modal
│ └── ProposedEventCard.tsx # Inline event proposal with confirm/reject buttons
├── Themes.tsx # Centralized color/theme definitions
├── services/ ├── services/
│ ├── index.ts # Re-exports all services │ ├── index.ts # Re-exports all services
│ ├── ApiClient.ts # HTTP client (get, post, put, delete) │ ├── ApiClient.ts # HTTP client (get, post, put, delete)
@@ -159,13 +161,17 @@ src/
``` ```
src/ src/
├── index.ts ├── index.ts
── models/ ── models/
│ ├── index.ts
│ ├── User.ts # User, CreateUserDTO, LoginDTO, AuthResponse
│ ├── CalendarEvent.ts # CalendarEvent, CreateEventDTO, UpdateEventDTO
│ ├── ChatMessage.ts # ChatMessage, Conversation, SendMessageDTO, CreateMessageDTO,
│ │ # GetMessagesOptions, ChatResponse, ConversationSummary,
│ │ # ProposedEventChange, EventAction
│ └── Constants.ts # DAYS, MONTHS, Day, Month, DAY_INDEX
└── utils/
├── index.ts ├── index.ts
── User.ts # User, CreateUserDTO, LoginDTO, AuthResponse ── dateHelpers.ts # getDay() - get date for specific weekday relative to today
├── CalendarEvent.ts # CalendarEvent, CreateEventDTO, UpdateEventDTO
└── ChatMessage.ts # ChatMessage, Conversation, SendMessageDTO, CreateMessageDTO,
# GetMessagesOptions, ChatResponse, ConversationSummary,
# ProposedEventChange, EventAction
``` ```
**Key Types:** **Key Types:**
@@ -177,6 +183,8 @@ src/
- `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)
- `Day`: "Monday" | "Tuesday" | ... | "Sunday"
- `Month`: "January" | "February" | ... | "December"
### Database Abstraction ### Database Abstraction
@@ -230,24 +238,30 @@ MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin
- `utils/password`: hash(), compare() using bcrypt - `utils/password`: hash(), compare() using bcrypt
- `utils/jwt`: signToken() (verifyToken() pending) - `utils/jwt`: signToken() (verifyToken() pending)
- `dotenv` integration for environment variables - `dotenv` integration for environment variables
- `ChatController`: sendMessage(), confirmEvent(), rejectEvent()
- `ChatService`: processMessage() with test responses, confirmEvent(), rejectEvent()
- **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()
- All Chat and Event functionality - `ChatController`: getConversations(), getConversation()
- `MongoChatRepository`: Database persistence for chat
- All Event functionality
- **Not started:** - **Not started:**
- `ChatController`, `ChatService`, `MongoChatRepository`
- `EventController`, `EventService`, `MongoEventRepository` - `EventController`, `EventService`, `MongoEventRepository`
- `ClaudeAdapter` (AI integration) - `ClaudeAdapter` (AI integration - currently using test responses)
**Shared:** Types and DTOs defined and exported. **Shared:** Types, DTOs, constants (Day, Month), and date utilities defined and exported.
**Frontend:** Skeleton complete with file-based routing structure: **Frontend:**
- Tab navigation (Chat, Calendar) implemented with basic UI - Tab navigation (Chat, Calendar) implemented with basic UI
- Calendar screen has month navigation and grid display (partially functional) - Calendar screen has month navigation and grid display (partially functional)
- Chat screen has message list UI with FlashList (mock data only) - 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
- `Themes.tsx`: Centralized color definitions including button colors
- Auth screens (Login, Register), Event Detail, and Note screens exist as skeletons - Auth screens (Login, Register), Event Detail, and Note screens exist as skeletons
- Services (ApiClient, AuthService, EventService, ChatService) defined with `throw new Error('Not implemented')`
- Zustand stores (AuthStore, EventsStore) defined with `throw new Error('Not implemented')` - Zustand stores (AuthStore, EventsStore) defined with `throw new Error('Not implemented')`
- Components (EventCard, EventConfirmDialog) exist as skeletons - Components (EventCard, EventConfirmDialog) exist as skeletons

View File

@@ -5,6 +5,12 @@ type Theme = {
messageBorderBg: string, messageBorderBg: string,
placeholderBg: string, placeholderBg: string,
calenderBg: string, calenderBg: string,
confirmButton: string,
rejectButton: string,
disabledButton: string,
buttonText: string,
textSecondary: string,
textMuted: string,
} }
const defaultLight: Theme = { const defaultLight: Theme = {
@@ -14,6 +20,12 @@ const defaultLight: Theme = {
messageBorderBg: "#FFFFFF", messageBorderBg: "#FFFFFF",
placeholderBg: "#D9D9D9", placeholderBg: "#D9D9D9",
calenderBg: "#FBD5B2", calenderBg: "#FBD5B2",
confirmButton: "#22c55e",
rejectButton: "#ef4444",
disabledButton: "#ccc",
buttonText: "#fff",
textSecondary: "#666",
textMuted: "#888",
} }
let currentTheme: Theme = defaultLight; let currentTheme: Theme = defaultLight;

View File

@@ -1,5 +1,5 @@
import { Animated, Modal, Pressable, Text, View } from "react-native"; import { Animated, Modal, Pressable, Text, View } from "react-native";
import { DAYS, MONTHS, Month } from "../../Constants"; import { DAYS, MONTHS, Month } from "@caldav/shared";
import Header from "../../components/Header"; import Header from "../../components/Header";
import React, { useEffect, useMemo, useRef, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import currentTheme from "../../Themes"; import currentTheme from "../../Themes";

View File

@@ -1,9 +1,12 @@
import { View, Text, TextInput } from "react-native"; import { View, Text, TextInput, Pressable } from "react-native";
import currentTheme from "../../Themes"; import currentTheme from "../../Themes";
import { useState } from "react"; import { useState } 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 { ProposedEventChange } from "@caldav/shared";
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)
@@ -11,239 +14,91 @@ import { FlashList } from "@shopify/flash-list";
// TODO: create new messages // TODO: create new messages
type BubbleSide = "left" | "right"; type BubbleSide = "left" | "right";
type ChatMessageProps = { type ChatMessageProps = {
side: BubbleSide; side: BubbleSide;
width: number; content: string;
height: number; proposedChange?: ProposedEventChange;
respondedAction?: "confirm" | "reject";
onConfirm?: () => void;
onReject?: () => void;
}; };
type MessageData = { type MessageData = ChatMessageProps & {
id: string; id: string;
side: BubbleSide; conversationId?: string;
width: number;
height: number;
}; };
// NOTE: only for testing type ChatInputProps = {
const getRandomInt = (min: number, max: number) => { onSend: (text: string) => void;
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min + 1)) + min;
}; };
const randomWidth = () => getRandomInt(100, 400);
const randomHeight = () => getRandomInt(50, 100);
const messages: MessageData[] = [
// {{{
{
id: "1",
side: "left",
width: randomWidth(),
height: randomHeight(),
},
{
id: "2",
side: "right",
width: randomWidth(),
height: randomHeight(),
},
{
id: "3",
side: "left",
width: randomWidth(),
height: randomHeight(),
},
{
id: "4",
side: "right",
width: randomWidth(),
height: randomHeight(),
},
{
id: "5",
side: "left",
width: randomWidth(),
height: randomHeight(),
},
{
id: "6",
side: "right",
width: randomWidth(),
height: randomHeight(),
},
{
id: "7",
side: "left",
width: randomWidth(),
height: randomHeight(),
},
{
id: "8",
side: "right",
width: randomWidth(),
height: randomHeight(),
},
{
id: "9",
side: "left",
width: randomWidth(),
height: randomHeight(),
},
{
id: "10",
side: "right",
width: randomWidth(),
height: randomHeight(),
},
// {
// id: "11",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "12",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "13",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "14",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "15",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "16",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "17",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "18",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "19",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "20",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "21",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "22",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "23",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "24",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "25",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "26",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "27",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "28",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "29",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "30",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "31",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "32",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "33",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "34",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
//, width: randomWidth, height: getRandomInt(50, 500) }}}
];
const Chat = () => { const Chat = () => {
const [messages, setMessages] = useState<MessageData[]>([]);
const handleEventResponse = async (
action: "confirm" | "reject",
messageId: string,
conversationId: string
) => {
// Mark message as responded (optimistic update)
setMessages((prev) =>
prev.map((msg) =>
msg.id === messageId ? { ...msg, respondedAction: action } : msg
)
);
try {
const response =
action === "confirm"
? await ChatService.confirmEvent(conversationId, messageId)
: await ChatService.rejectEvent(conversationId, messageId);
const botMessage: MessageData = {
id: response.message.id,
side: "left",
content: response.message.content,
conversationId: response.conversationId,
};
setMessages((prev) => [...prev, botMessage]);
} catch (error) {
console.error(`Failed to ${action} event:`, error);
// Revert on error
setMessages((prev) =>
prev.map((msg) =>
msg.id === messageId ? { ...msg, respondedAction: undefined } : msg
)
);
}
};
const handleSend = async (text: string) => {
// Show user message immediately
const userMessage: MessageData = {
id: Date.now().toString(),
side: "right",
content: text,
};
setMessages((prev) => [...prev, userMessage]);
try {
// Fetch server response
const response = await ChatService.sendMessage({ content: text });
// Show bot response
const botMessage: MessageData = {
id: response.message.id,
side: "left",
content: response.message.content,
proposedChange: response.message.proposedChange,
conversationId: response.conversationId,
};
setMessages((prev) => [...prev, botMessage]);
} catch (error) {
console.error("Failed to send message:", error);
}
};
return ( return (
<BaseBackground> <BaseBackground>
<ChatHeader /> <ChatHeader />
@@ -252,18 +107,20 @@ const Chat = () => {
renderItem={({ item }) => ( renderItem={({ item }) => (
<ChatMessage <ChatMessage
side={item.side} side={item.side}
width={item.width} content={item.content}
height={item.height} proposedChange={item.proposedChange}
respondedAction={item.respondedAction}
onConfirm={() =>
handleEventResponse("confirm", item.id, item.conversationId!)
}
onReject={() =>
handleEventResponse("reject", item.id, item.conversationId!)
}
/> />
)} )}
maintainVisibleContentPosition={{
autoscrollToBottomThreshold: 0.2,
startRenderingFromBottom: true,
}}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
// extraData={selectedId} might need this later for re-rendering
/> />
<ChatInput /> <ChatInput onSend={handleSend} />
</BaseBackground> </BaseBackground>
); );
}; };
@@ -297,51 +154,81 @@ const ChatHeader = () => {
); );
}; };
const ChatInput = () => { const MIN_INPUT_HEIGHT = 40;
const [text, onChangeText] = useState("Nachricht"); const MAX_INPUT_HEIGHT = 150;
const ChatInput = ({ onSend }: ChatInputProps) => {
const [text, setText] = useState("");
const handleSend = () => {
if (text.trim()) {
onSend(text.trim());
setText("");
}
};
return ( return (
<View className="flex flex-row w-full h-8 my-2"> <View className="flex flex-row w-full items-end my-2 px-2">
<TextInput <TextInput
className="w-4/5 h-full border border-solid rounded-2xl mx-2 px-2" className="flex-1 border border-solid rounded-2xl px-3 py-2 mr-2"
style={{ style={{
backgroundColor: currentTheme.messageBorderBg, backgroundColor: currentTheme.messageBorderBg,
minHeight: MIN_INPUT_HEIGHT,
maxHeight: MAX_INPUT_HEIGHT,
textAlignVertical: "top",
}} }}
onChangeText={onChangeText} onChangeText={setText}
value={text} value={text}
placeholder="Nachricht..."
placeholderTextColor="#999"
multiline
/> />
<Pressable onPress={handleSend}>
<View <View
className="w-8 h-full rounded-2xl" className="w-10 h-10 rounded-full items-center justify-center"
style={{ style={{
backgroundColor: currentTheme.placeholderBg, backgroundColor: currentTheme.placeholderBg,
}} }}
></View> />
</Pressable>
</View> </View>
); );
}; };
const ChatMessage = (props: ChatMessageProps) => { const ChatMessage = ({
side,
content,
proposedChange,
respondedAction,
onConfirm,
onReject,
}: ChatMessageProps) => {
const borderColor = const borderColor =
props.side === "left" ? currentTheme.chatBot : currentTheme.primeFg; side === "left" ? currentTheme.chatBot : currentTheme.primeFg;
const selfSide = const selfSide =
props.side === "left" side === "left"
? "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";
return ( return (
<View <View
className={ className={`bg-white border-2 border-solid rounded-xl my-2 ${selfSide}`}
`bg-white border-2 border-solid rounded-xl my-2 ` + `${selfSide}`
}
style={{ style={{
width: props.width,
height: props.height,
borderColor: borderColor, borderColor: borderColor,
maxWidth: "80%",
elevation: 8, elevation: 8,
}} }}
> >
<Text className="p-1">Lorem Ipsum Dolor sit amet</Text> <Text className="p-2">{content}</Text>
{proposedChange && onConfirm && onReject && (
<ProposedEventCard
proposedChange={proposedChange}
respondedAction={respondedAction}
onConfirm={onConfirm}
onReject={onReject}
/>
)}
</View> </View>
); );
}; };

View File

@@ -0,0 +1,86 @@
import { View, Text, Pressable } from "react-native";
import { ProposedEventChange } from "@caldav/shared";
import currentTheme from "../Themes";
type ProposedEventCardProps = {
proposedChange: ProposedEventChange;
respondedAction?: "confirm" | "reject";
onConfirm: () => void;
onReject: () => void;
};
function formatDateTime(date?: Date): string {
if (!date) return "";
const d = new Date(date);
return d.toLocaleDateString("de-DE", {
weekday: "long",
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
export const ProposedEventCard = ({
proposedChange,
respondedAction,
onConfirm,
onReject,
}: ProposedEventCardProps) => {
const event = proposedChange.event;
const isDisabled = !!respondedAction;
return (
<View
className="border-t p-2 mt-2"
style={{ borderTopColor: currentTheme.placeholderBg }}
>
{/* Event Details */}
<Text className="font-bold text-base">{event?.title}</Text>
<Text style={{ color: currentTheme.textSecondary }}>
{formatDateTime(event?.startTime)}
</Text>
{event?.isRecurring && (
<Text style={{ color: currentTheme.textMuted }} className="text-sm">
Wiederkehrend
</Text>
)}
{/* Buttons */}
<View className="flex-row mt-3 gap-2">
<Pressable
onPress={onConfirm}
disabled={isDisabled}
className="flex-1 py-2 rounded-lg items-center"
style={{
backgroundColor: isDisabled
? currentTheme.disabledButton
: currentTheme.confirmButton,
borderWidth: respondedAction === "confirm" ? 2 : 0,
borderColor: currentTheme.confirmButton,
}}
>
<Text style={{ color: currentTheme.buttonText }} className="font-medium">
Annehmen
</Text>
</Pressable>
<Pressable
onPress={onReject}
disabled={isDisabled}
className="flex-1 py-2 rounded-lg items-center"
style={{
backgroundColor: isDisabled
? currentTheme.disabledButton
: currentTheme.rejectButton,
borderWidth: respondedAction === "reject" ? 2 : 0,
borderColor: currentTheme.rejectButton,
}}
>
<Text style={{ color: currentTheme.buttonText }} className="font-medium">
Ablehnen
</Text>
</Pressable>
</View>
</View>
);
};

View File

@@ -1,5 +1,5 @@
const API_BASE_URL = const API_BASE_URL =
process.env.EXPO_PUBLIC_API_URL || "http://localhost:3000/api"; process.env.EXPO_PUBLIC_API_URL || "http://localhost:3001/api";
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE"; type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
@@ -9,11 +9,24 @@ interface RequestOptions {
} }
async function request<T>( async function request<T>(
_method: HttpMethod, method: HttpMethod,
_endpoint: string, endpoint: string,
_options?: RequestOptions, options?: RequestOptions,
): Promise<T> { ): Promise<T> {
throw new Error("Not implemented"); const response = await fetch(`${API_BASE_URL}${endpoint}`, {
method,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
body: options?.body ? JSON.stringify(options.body) : undefined,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
} }
export const ApiClient = { export const ApiClient = {

View File

@@ -4,26 +4,26 @@ import {
ChatMessage, ChatMessage,
ConversationSummary, ConversationSummary,
GetMessagesOptions, GetMessagesOptions,
CalendarEvent, } from "@caldav/shared";
} from '@caldav/shared'; import { ApiClient } from "./ApiClient";
export const ChatService = { export const ChatService = {
sendMessage: async (_data: SendMessageDTO): Promise<ChatResponse> => { sendMessage: async (data: SendMessageDTO): Promise<ChatResponse> => {
throw new Error('Not implemented'); return ApiClient.post<ChatResponse>("/chat/message", data);
}, },
confirmEvent: async ( confirmEvent: async (
_conversationId: string, conversationId: string,
_messageId: string messageId: string
): Promise<CalendarEvent> => { ): Promise<ChatResponse> => {
throw new Error('Not implemented'); return ApiClient.post<ChatResponse>(`/chat/confirm/${conversationId}/${messageId}`);
}, },
rejectEvent: async ( rejectEvent: async (
_conversationId: string, conversationId: string,
_messageId: string messageId: string
): Promise<void> => { ): Promise<ChatResponse> => {
throw new Error('Not implemented'); return ApiClient.post<ChatResponse>(`/chat/reject/${conversationId}/${messageId}`);
}, },
getConversations: async (): Promise<ConversationSummary[]> => { getConversations: async (): Promise<ConversationSummary[]> => {

View File

@@ -19,7 +19,7 @@ services:
image: mongo-express:latest image: mongo-express:latest
restart: always restart: always
ports: ports:
- "8081:8081" - "8083:8083"
environment: environment:
ME_CONFIG_MONGODB_URL: mongodb://root:mongoose@mongo:27017/ ME_CONFIG_MONGODB_URL: mongodb://root:mongoose@mongo:27017/
ME_CONFIG_BASICAUTH_ENABLED: true ME_CONFIG_BASICAUTH_ENABLED: true

View File

@@ -15,6 +15,21 @@ const mongoUri = process.env.MONGODB_URI || 'mongodb://localhost:27017/caldav';
// Middleware // Middleware
app.use(express.json()); app.use(express.json());
// CORS - only needed for web browser development
// Native mobile apps don't send Origin headers and aren't affected by CORS
if (process.env.NODE_ENV !== 'production') {
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') {
res.sendStatus(200);
return;
}
next();
});
}
// Initialize repositories // Initialize repositories
const userRepo = new MongoUserRepository(); const userRepo = new MongoUserRepository();
const eventRepo = new MongoEventRepository(); const eventRepo = new MongoEventRepository();

View File

@@ -1,4 +1,5 @@
import { Response } from 'express'; import { Response } from 'express';
import { SendMessageDTO } from '@caldav/shared';
import { ChatService } from '../services'; import { ChatService } from '../services';
import { AuthenticatedRequest } from '../middleware'; import { AuthenticatedRequest } from '../middleware';
@@ -6,15 +7,36 @@ export class ChatController {
constructor(private chatService: ChatService) {} constructor(private chatService: ChatService) {}
async sendMessage(req: AuthenticatedRequest, res: Response): Promise<void> { async sendMessage(req: AuthenticatedRequest, res: Response): Promise<void> {
throw new Error('Not implemented'); try {
const userId = req.user!.userId;
const data: SendMessageDTO = req.body;
const response = await this.chatService.processMessage(userId, data);
res.json(response);
} catch (error) {
res.status(500).json({ error: 'Failed to process message' });
}
} }
async confirmEvent(req: AuthenticatedRequest, res: Response): Promise<void> { async confirmEvent(req: AuthenticatedRequest, res: Response): Promise<void> {
throw new Error('Not implemented'); try {
const userId = req.user!.userId;
const { conversationId, messageId } = req.params;
const response = await this.chatService.confirmEvent(userId, conversationId, messageId);
res.json(response);
} catch (error) {
res.status(500).json({ error: 'Failed to confirm event' });
}
} }
async rejectEvent(req: AuthenticatedRequest, res: Response): Promise<void> { async rejectEvent(req: AuthenticatedRequest, res: Response): Promise<void> {
throw new Error('Not implemented'); try {
const userId = req.user!.userId;
const { conversationId, messageId } = req.params;
const response = await this.chatService.rejectEvent(userId, conversationId, messageId);
res.json(response);
} catch (error) {
res.status(500).json({ error: 'Failed to reject event' });
}
} }
async getConversations(req: AuthenticatedRequest, res: Response): Promise<void> { async getConversations(req: AuthenticatedRequest, res: Response): Promise<void> {

View File

@@ -1,6 +1,49 @@
import { ChatMessage, ChatResponse, SendMessageDTO, CalendarEvent, ConversationSummary, GetMessagesOptions } from '@caldav/shared'; import { ChatMessage, ChatResponse, SendMessageDTO, ConversationSummary, GetMessagesOptions, ProposedEventChange, getDay } from '@caldav/shared';
import { ChatRepository, EventRepository, AIProvider } from './interfaces'; import { ChatRepository, EventRepository, AIProvider } from './interfaces';
// Test responses array (cycles through responses)
let responseIndex = 0;
const testResponses: Array<{ content: string; proposedChange?: ProposedEventChange }> = [
// {{{
// Response 1: Meeting mit Jens - next Friday 14:00
{
content: "Alles klar! Ich erstelle dir einen Termin für das Meeting mit Jens am nächsten Freitag um 14:00 Uhr:",
proposedChange: {
action: 'create',
event: {
title: "Meeting mit Jens",
startTime: getDay('Friday', 1, 14, 0),
endTime: getDay('Friday', 1, 15, 0),
description: "Arbeitstreffen",
}
}
},
// Response 2: Recurring event - every Saturday 10:00
{
content: "Verstanden! Ich erstelle einen wiederkehrenden Termin: Jeden Samstag um 10:00 Uhr Badezimmer putzen:",
proposedChange: {
action: 'create',
event: {
title: "Badezimmer putzen",
startTime: getDay('Saturday', 1, 10, 0),
endTime: getDay('Saturday', 1, 11, 0),
isRecurring: true,
recurrenceRule: "FREQ=WEEKLY;BYDAY=SA",
}
}
},
// Response 3: Calendar overview (text only, no proposedChange)
{
content: "Hier sind deine Termine für die nächsten 2 Wochen:\n\n" +
"Freitag, 10.01. - 14:00 Uhr: Meeting mit Jens\n" +
"Samstag, 11.01. - 10:00 Uhr: Badezimmer putzen\n" +
"Samstag, 18.01. - 10:00 Uhr: Badezimmer putzen\n\n" +
"Insgesamt 3 Termine.",
},
// }}}
];
export class ChatService { export class ChatService {
constructor( constructor(
private chatRepo: ChatRepository, private chatRepo: ChatRepository,
@@ -9,15 +52,38 @@ export class ChatService {
) {} ) {}
async processMessage(userId: string, data: SendMessageDTO): Promise<ChatResponse> { async processMessage(userId: string, data: SendMessageDTO): Promise<ChatResponse> {
throw new Error('Not implemented'); const response = testResponses[responseIndex % testResponses.length];
responseIndex++;
const message: ChatMessage = {
id: Date.now().toString(),
conversationId: data.conversationId || 'temp-conv-id',
sender: 'assistant',
content: response.content,
proposedChange: response.proposedChange,
};
return { message, conversationId: message.conversationId };
} }
async confirmEvent(userId: string, conversationId: string, messageId: string): Promise<CalendarEvent> { async confirmEvent(userId: string, conversationId: string, messageId: string): Promise<ChatResponse> {
throw new Error('Not implemented'); const message: ChatMessage = {
id: Date.now().toString(),
conversationId,
sender: 'assistant',
content: 'Der Vorschlag wurde angenommen.',
};
return { message, conversationId };
} }
async rejectEvent(conversationId: string, messageId: string): Promise<void> { async rejectEvent(userId: string, conversationId: string, messageId: string): Promise<ChatResponse> {
throw new Error('Not implemented'); const message: ChatMessage = {
id: Date.now().toString(),
conversationId,
sender: 'assistant',
content: 'Der Vorschlag wurde abgelehnt.',
};
return { message, conversationId };
} }
async getConversations(userId: string): Promise<ConversationSummary[]> { async getConversations(userId: string): Promise<ConversationSummary[]> {

View File

@@ -1 +1,2 @@
export * from './models'; export * from './models';
export * from './utils';

View File

@@ -26,3 +26,14 @@ export const DAYS = [
] as const; ] as const;
export type Day = (typeof DAYS)[number]; export type Day = (typeof DAYS)[number];
// Mapping for Date.getDay() which returns 0=Sunday, 1=Monday, etc.
export const DAY_INDEX: Record<Day, number> = {
Sunday: 0,
Monday: 1,
Tuesday: 2,
Wednesday: 3,
Thursday: 4,
Friday: 5,
Saturday: 6,
};

View File

@@ -1,3 +1,4 @@
export * from './User'; export * from './User';
export * from './CalendarEvent'; export * from './CalendarEvent';
export * from './ChatMessage'; export * from './ChatMessage';
export * from './Constants';

View File

@@ -0,0 +1,31 @@
import { Day, DAY_INDEX } from '../models/Constants';
/**
* Get a date for a specific weekday relative to today.
* @param day - The target day (e.g., "Friday")
* @param offset - 1 = next occurrence, 2 = the one after, -1 = last occurrence, etc.
* @param hour - Hour of day (0-23)
* @param minute - Minute (0-59)
*/
export function getDay(day: Day, offset: number, hour: number, minute: number): Date {
const today = new Date();
const currentDay = today.getDay();
const targetDay = DAY_INDEX[day];
let daysUntil = targetDay - currentDay;
if (offset > 0) {
// Future: if target is today or past, move to next week
if (daysUntil <= 0) daysUntil += 7;
daysUntil += (offset - 1) * 7;
} else if (offset < 0) {
// Past: if target is today or future, move to last week
if (daysUntil >= 0) daysUntil -= 7;
daysUntil += (offset + 1) * 7;
}
const result = new Date(today);
result.setDate(today.getDate() + daysUntil);
result.setHours(hour, minute, 0, 0);
return result;
}

View File

@@ -0,0 +1 @@
export * from './dateHelpers';