add event CRUD actions and recurring event expansion
- Implement full CRUD in MongoEventRepository (findById, findByUserId, findByDateRange, update, delete) - Extend ChatService to handle create/update/delete actions with dynamic test responses - Add recurrenceExpander utility using rrule library for RRULE parsing - Add eventFormatters utility for German-localized week/month overviews - Add German translations for days and months in shared Constants - Update client ChatService to support all event actions (action, eventId, updates params)
This commit is contained in:
17
CLAUDE.md
17
CLAUDE.md
@@ -135,7 +135,9 @@ src/
|
|||||||
│ └── ClaudeAdapter.ts # Implements AIProvider
|
│ └── ClaudeAdapter.ts # Implements AIProvider
|
||||||
└── utils/
|
└── utils/
|
||||||
├── jwt.ts # signToken(), verifyToken()
|
├── jwt.ts # signToken(), verifyToken()
|
||||||
└── password.ts # hash(), compare()
|
├── password.ts # hash(), compare()
|
||||||
|
├── eventFormatters.ts # getWeeksOverview(), getMonthOverview() - formatted event listings
|
||||||
|
└── recurrenceExpander.ts # expandRecurringEvents() - expand recurring events into occurrences
|
||||||
```
|
```
|
||||||
|
|
||||||
**API Endpoints:**
|
**API Endpoints:**
|
||||||
@@ -169,7 +171,8 @@ src/
|
|||||||
│ ├── ChatMessage.ts # ChatMessage, Conversation, SendMessageDTO, CreateMessageDTO,
|
│ ├── ChatMessage.ts # ChatMessage, Conversation, SendMessageDTO, CreateMessageDTO,
|
||||||
│ │ # GetMessagesOptions, ChatResponse, ConversationSummary,
|
│ │ # GetMessagesOptions, ChatResponse, ConversationSummary,
|
||||||
│ │ # ProposedEventChange, EventAction
|
│ │ # ProposedEventChange, EventAction
|
||||||
│ └── Constants.ts # DAYS, MONTHS, Day, Month, DAY_INDEX
|
│ └── Constants.ts # DAYS, MONTHS, Day, Month, DAY_INDEX, DAY_INDEX_TO_DAY,
|
||||||
|
│ # DAY_TO_GERMAN, DAY_TO_GERMAN_SHORT, MONTH_TO_GERMAN
|
||||||
└── utils/
|
└── utils/
|
||||||
├── index.ts
|
├── index.ts
|
||||||
└── dateHelpers.ts # getDay() - get date for specific weekday relative to today
|
└── dateHelpers.ts # getDay() - get date for specific weekday relative to today
|
||||||
@@ -269,26 +272,28 @@ MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin
|
|||||||
- `utils/jwt`: signToken() (verifyToken() pending)
|
- `utils/jwt`: signToken() (verifyToken() pending)
|
||||||
- `dotenv` integration for environment variables
|
- `dotenv` integration for environment variables
|
||||||
- `ChatController`: sendMessage(), confirmEvent(), rejectEvent()
|
- `ChatController`: sendMessage(), confirmEvent(), rejectEvent()
|
||||||
- `ChatService`: processMessage() with test responses, confirmEvent() saves events to DB, rejectEvent()
|
- `ChatService`: processMessage() with test responses (create, update, delete actions), confirmEvent() handles all CRUD actions
|
||||||
|
- `MongoEventRepository`: Full CRUD implemented (findById, findByUserId, findByDateRange, create, update, delete)
|
||||||
|
- `utils/eventFormatters`: getWeeksOverview(), getMonthOverview() with German localization
|
||||||
|
- `utils/recurrenceExpander`: expandRecurringEvents() using rrule library for RRULE parsing
|
||||||
- **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()
|
- `ChatController`: getConversations(), getConversation()
|
||||||
- `MongoChatRepository`: Database persistence for chat
|
- `MongoChatRepository`: Database persistence for chat
|
||||||
- `MongoEventRepository`: Only create() implemented, rest stubbed
|
|
||||||
- **Not started:**
|
- **Not started:**
|
||||||
- `EventController`, `EventService`
|
- `EventController`, `EventService`
|
||||||
- `ClaudeAdapter` (AI integration - currently using test responses)
|
- `ClaudeAdapter` (AI integration - currently using test responses)
|
||||||
|
|
||||||
**Shared:** Types, DTOs, constants (Day, Month), and date utilities defined and exported.
|
**Shared:** Types, DTOs, constants (Day, Month with German translations), and date utilities defined and exported.
|
||||||
|
|
||||||
**Frontend:**
|
**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 functional with FlashList, message sending, and event confirm/reject
|
- Chat screen functional with FlashList, message sending, and event confirm/reject
|
||||||
- `ApiClient`: get(), post() implemented
|
- `ApiClient`: get(), post() implemented
|
||||||
- `ChatService`: sendMessage(), confirmEvent(convId, msgId, event), rejectEvent() - confirmEvent sends CreateEventDTO in body
|
- `ChatService`: sendMessage(), confirmEvent(convId, msgId, action, event?, eventId?, updates?), rejectEvent() - supports create/update/delete actions
|
||||||
- `ProposedEventCard`: Displays proposed events (title, date, description, recurring indicator) with confirm/reject buttons
|
- `ProposedEventCard`: Displays proposed events (title, date, description, recurring indicator) with confirm/reject buttons
|
||||||
- `Themes.tsx`: Centralized color definitions including button colors
|
- `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
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ 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 { ProposedEventChange, CreateEventDTO } 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
|
||||||
@@ -40,7 +40,7 @@ const Chat = () => {
|
|||||||
action: "confirm" | "reject",
|
action: "confirm" | "reject",
|
||||||
messageId: string,
|
messageId: string,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
event?: CreateEventDTO
|
proposedChange?: ProposedEventChange
|
||||||
) => {
|
) => {
|
||||||
// Mark message as responded (optimistic update)
|
// Mark message as responded (optimistic update)
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
@@ -51,8 +51,15 @@ const Chat = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response =
|
const response =
|
||||||
action === "confirm" && event
|
action === "confirm" && proposedChange
|
||||||
? await ChatService.confirmEvent(conversationId, messageId, event)
|
? await ChatService.confirmEvent(
|
||||||
|
conversationId,
|
||||||
|
messageId,
|
||||||
|
proposedChange.action,
|
||||||
|
proposedChange.event,
|
||||||
|
proposedChange.eventId,
|
||||||
|
proposedChange.updates
|
||||||
|
)
|
||||||
: await ChatService.rejectEvent(conversationId, messageId);
|
: await ChatService.rejectEvent(conversationId, messageId);
|
||||||
|
|
||||||
const botMessage: MessageData = {
|
const botMessage: MessageData = {
|
||||||
@@ -112,7 +119,7 @@ const Chat = () => {
|
|||||||
proposedChange={item.proposedChange}
|
proposedChange={item.proposedChange}
|
||||||
respondedAction={item.respondedAction}
|
respondedAction={item.respondedAction}
|
||||||
onConfirm={() =>
|
onConfirm={() =>
|
||||||
handleEventResponse("confirm", item.id, item.conversationId!, item.proposedChange?.event)
|
handleEventResponse("confirm", item.id, item.conversationId!, item.proposedChange)
|
||||||
}
|
}
|
||||||
onReject={() =>
|
onReject={() =>
|
||||||
handleEventResponse("reject", item.id, item.conversationId!)
|
handleEventResponse("reject", item.id, item.conversationId!)
|
||||||
|
|||||||
@@ -5,9 +5,18 @@ import {
|
|||||||
ConversationSummary,
|
ConversationSummary,
|
||||||
GetMessagesOptions,
|
GetMessagesOptions,
|
||||||
CreateEventDTO,
|
CreateEventDTO,
|
||||||
|
UpdateEventDTO,
|
||||||
|
EventAction,
|
||||||
} from "@caldav/shared";
|
} from "@caldav/shared";
|
||||||
import { ApiClient } from "./ApiClient";
|
import { ApiClient } from "./ApiClient";
|
||||||
|
|
||||||
|
interface ConfirmEventRequest {
|
||||||
|
action: EventAction;
|
||||||
|
event?: CreateEventDTO;
|
||||||
|
eventId?: string;
|
||||||
|
updates?: UpdateEventDTO;
|
||||||
|
}
|
||||||
|
|
||||||
export const ChatService = {
|
export const ChatService = {
|
||||||
sendMessage: async (data: SendMessageDTO): Promise<ChatResponse> => {
|
sendMessage: async (data: SendMessageDTO): Promise<ChatResponse> => {
|
||||||
return ApiClient.post<ChatResponse>("/chat/message", data);
|
return ApiClient.post<ChatResponse>("/chat/message", data);
|
||||||
@@ -16,9 +25,13 @@ export const ChatService = {
|
|||||||
confirmEvent: async (
|
confirmEvent: async (
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
messageId: string,
|
messageId: string,
|
||||||
event: CreateEventDTO
|
action: EventAction,
|
||||||
|
event?: CreateEventDTO,
|
||||||
|
eventId?: string,
|
||||||
|
updates?: UpdateEventDTO
|
||||||
): Promise<ChatResponse> => {
|
): Promise<ChatResponse> => {
|
||||||
return ApiClient.post<ChatResponse>(`/chat/confirm/${conversationId}/${messageId}`, event);
|
const body: ConfirmEventRequest = { action, event, eventId, updates };
|
||||||
|
return ApiClient.post<ChatResponse>(`/chat/confirm/${conversationId}/${messageId}`, body);
|
||||||
},
|
},
|
||||||
|
|
||||||
rejectEvent: async (
|
rejectEvent: async (
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"mongoose": "^9.1.1"
|
"mongoose": "^9.1.1",
|
||||||
|
"rrule": "^2.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { SendMessageDTO, CreateEventDTO } from '@caldav/shared';
|
import { SendMessageDTO, CreateEventDTO, UpdateEventDTO, EventAction } from '@caldav/shared';
|
||||||
import { ChatService } from '../services';
|
import { ChatService } from '../services';
|
||||||
import { AuthenticatedRequest } from '../middleware';
|
import { AuthenticatedRequest } from '../middleware';
|
||||||
|
|
||||||
@@ -21,8 +21,21 @@ export class ChatController {
|
|||||||
try {
|
try {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const { conversationId, messageId } = req.params;
|
const { conversationId, messageId } = req.params;
|
||||||
const event: CreateEventDTO = req.body;
|
const { action, event, eventId, updates } = req.body as {
|
||||||
const response = await this.chatService.confirmEvent(userId, conversationId, messageId, event);
|
action: EventAction;
|
||||||
|
event?: CreateEventDTO;
|
||||||
|
eventId?: string;
|
||||||
|
updates?: UpdateEventDTO;
|
||||||
|
};
|
||||||
|
const response = await this.chatService.confirmEvent(
|
||||||
|
userId,
|
||||||
|
conversationId,
|
||||||
|
messageId,
|
||||||
|
action,
|
||||||
|
event,
|
||||||
|
eventId,
|
||||||
|
updates
|
||||||
|
);
|
||||||
res.json(response);
|
res.json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to confirm event' });
|
res.status(500).json({ error: 'Failed to confirm event' });
|
||||||
|
|||||||
@@ -4,15 +4,22 @@ import { EventModel } from './models';
|
|||||||
|
|
||||||
export class MongoEventRepository implements EventRepository {
|
export class MongoEventRepository implements EventRepository {
|
||||||
async findById(id: string): Promise<CalendarEvent | null> {
|
async findById(id: string): Promise<CalendarEvent | null> {
|
||||||
throw new Error('Not implemented');
|
const event = await EventModel.findById(id);
|
||||||
|
if (!event) return null;
|
||||||
|
return event.toJSON() as unknown as CalendarEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByUserId(userId: string): Promise<CalendarEvent[]> {
|
async findByUserId(userId: string): Promise<CalendarEvent[]> {
|
||||||
throw new Error('Not implemented');
|
const events = await EventModel.find({ userId }).sort({ startTime: 1 });
|
||||||
|
return events.map(e => e.toJSON() as unknown as CalendarEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByDateRange(userId: string, startDate: Date, endDate: Date): Promise<CalendarEvent[]> {
|
async findByDateRange(userId: string, startDate: Date, endDate: Date): Promise<CalendarEvent[]> {
|
||||||
throw new Error('Not implemented');
|
const events = await EventModel.find({
|
||||||
|
userId,
|
||||||
|
startTime: { $gte: startDate, $lte: endDate }
|
||||||
|
}).sort({ startTime: 1 });
|
||||||
|
return events.map(e => e.toJSON() as unknown as CalendarEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(userId: string, data: CreateEventDTO): Promise<CalendarEvent> {
|
async create(userId: string, data: CreateEventDTO): Promise<CalendarEvent> {
|
||||||
@@ -22,10 +29,13 @@ export class MongoEventRepository implements EventRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, data: UpdateEventDTO): Promise<CalendarEvent | null> {
|
async update(id: string, data: UpdateEventDTO): Promise<CalendarEvent | null> {
|
||||||
throw new Error('Not implemented');
|
const event = await EventModel.findByIdAndUpdate(id, data, { new: true });
|
||||||
|
if (!event) return null;
|
||||||
|
return event.toJSON() as unknown as CalendarEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(id: string): Promise<boolean> {
|
async delete(id: string): Promise<boolean> {
|
||||||
throw new Error('Not implemented');
|
const result = await EventModel.findByIdAndDelete(id);
|
||||||
|
return result !== null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { ChatMessage, ChatResponse, SendMessageDTO, ConversationSummary, GetMessagesOptions, ProposedEventChange, getDay, CreateEventDTO } from '@caldav/shared';
|
import { ChatMessage, ChatResponse, SendMessageDTO, ConversationSummary, GetMessagesOptions, ProposedEventChange, getDay, CreateEventDTO, UpdateEventDTO, EventAction } from '@caldav/shared';
|
||||||
import { ChatRepository, EventRepository, AIProvider } from './interfaces';
|
import { ChatRepository, EventRepository, AIProvider } from './interfaces';
|
||||||
|
import { getWeeksOverview, getMonthOverview } from '../utils/eventFormatters';
|
||||||
|
|
||||||
// Test responses array (cycles through responses)
|
type TestResponse = { content: string; proposedChange?: ProposedEventChange };
|
||||||
let responseIndex = 0;
|
|
||||||
|
|
||||||
const testResponses: Array<{ content: string; proposedChange?: ProposedEventChange }> = [
|
// Test response index (cycles through responses)
|
||||||
|
let responseIndex = 8;
|
||||||
|
|
||||||
|
// Static test responses (event proposals)
|
||||||
|
const staticResponses: TestResponse[] = [
|
||||||
// {{{
|
// {{{
|
||||||
// Response 1: Meeting mit Jens - next Friday 14:00
|
// Response 0: 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:",
|
content: "Alles klar! Ich erstelle dir einen Termin für das Meeting mit Jens am nächsten Freitag um 14:00 Uhr:",
|
||||||
proposedChange: {
|
proposedChange: {
|
||||||
@@ -19,7 +23,7 @@ const testResponses: Array<{ content: string; proposedChange?: ProposedEventChan
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Response 2: Recurring event - every Saturday 10:00
|
// Response 1: Recurring event - every Saturday 10:00
|
||||||
{
|
{
|
||||||
content: "Verstanden! Ich erstelle einen wiederkehrenden Termin: Jeden Samstag um 10:00 Uhr Badezimmer putzen:",
|
content: "Verstanden! Ich erstelle einen wiederkehrenden Termin: Jeden Samstag um 10:00 Uhr Badezimmer putzen:",
|
||||||
proposedChange: {
|
proposedChange: {
|
||||||
@@ -33,14 +37,10 @@ const testResponses: Array<{ content: string; proposedChange?: ProposedEventChan
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Response 3: Calendar overview (text only, no proposedChange)
|
// Response 2: 2-week overview (DYNAMIC - placeholder)
|
||||||
{
|
{ content: '' },
|
||||||
content: "Hier sind deine Termine für die nächsten 2 Wochen:\n\n" +
|
// Response 3: Delete "Meeting mit Jens" (DYNAMIC - placeholder)
|
||||||
"Freitag, 10.01. - 14:00 Uhr: Meeting mit Jens\n" +
|
{ content: '' },
|
||||||
"Samstag, 11.01. - 10:00 Uhr: Badezimmer putzen\n" +
|
|
||||||
"Samstag, 18.01. - 10:00 Uhr: Badezimmer putzen\n\n" +
|
|
||||||
"Insgesamt 3 Termine.",
|
|
||||||
},
|
|
||||||
// Response 4: Doctor appointment with description
|
// Response 4: Doctor appointment with description
|
||||||
{
|
{
|
||||||
content: "Ich habe dir einen Arzttermin eingetragen. Denk daran, deine Versichertenkarte mitzunehmen!",
|
content: "Ich habe dir einen Arzttermin eingetragen. Denk daran, deine Versichertenkarte mitzunehmen!",
|
||||||
@@ -82,18 +82,8 @@ const testResponses: Array<{ content: string; proposedChange?: ProposedEventChan
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Response 7: Weekly calendar overview (text only)
|
// Response 7: 1-week overview (DYNAMIC - placeholder)
|
||||||
{
|
{ content: '' },
|
||||||
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)
|
// 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" +
|
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" +
|
||||||
@@ -114,7 +104,9 @@ const testResponses: Array<{ content: string; proposedChange?: ProposedEventChan
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Response 10: Birthday party - evening event
|
// Response 10: Update "Telefonat mit Mama" +2 days (DYNAMIC - placeholder)
|
||||||
|
{ content: '' },
|
||||||
|
// Response 11: Birthday party - evening event
|
||||||
{
|
{
|
||||||
content: "Super! Die Geburtstagsfeier ist eingetragen. Viel Spaß!",
|
content: "Super! Die Geburtstagsfeier ist eingetragen. Viel Spaß!",
|
||||||
proposedChange: {
|
proposedChange: {
|
||||||
@@ -127,7 +119,7 @@ const testResponses: Array<{ content: string; proposedChange?: ProposedEventChan
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Response 11: Language course - limited to 8 weeks
|
// Response 12: Language course - limited to 8 weeks
|
||||||
{
|
{
|
||||||
content: "Dein Spanischkurs ist eingetragen! Er läuft jeden Donnerstag für die nächsten 8 Wochen:",
|
content: "Dein Spanischkurs ist eingetragen! Er läuft jeden Donnerstag für die nächsten 8 Wochen:",
|
||||||
proposedChange: {
|
proposedChange: {
|
||||||
@@ -141,23 +133,79 @@ const testResponses: Array<{ content: string; proposedChange?: ProposedEventChan
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Response 12: Monthly calendar overview (text only)
|
// Response 13: Monthly overview (DYNAMIC - placeholder)
|
||||||
{
|
{ content: '' },
|
||||||
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.",
|
|
||||||
},
|
|
||||||
// }}}
|
// }}}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
async function getTestResponse(
|
||||||
|
index: number,
|
||||||
|
eventRepo: EventRepository,
|
||||||
|
userId: string
|
||||||
|
): Promise<TestResponse> {
|
||||||
|
const responseIdx = index % staticResponses.length;
|
||||||
|
|
||||||
|
// Dynamic responses: fetch events from DB and format
|
||||||
|
if (responseIdx === 2) {
|
||||||
|
return { content: await getWeeksOverview(eventRepo, userId, 2) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseIdx === 3) {
|
||||||
|
// Delete "Meeting mit Jens"
|
||||||
|
const events = await eventRepo.findByUserId(userId);
|
||||||
|
const jensEvent = events.find(e => e.title === 'Meeting mit Jens');
|
||||||
|
if (jensEvent) {
|
||||||
|
return {
|
||||||
|
content: "Alles klar, ich lösche den Termin 'Meeting mit Jens' für dich:",
|
||||||
|
proposedChange: {
|
||||||
|
action: 'delete',
|
||||||
|
eventId: jensEvent.id,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { content: "Ich konnte keinen Termin 'Meeting mit Jens' finden." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseIdx === 7) {
|
||||||
|
return { content: await getWeeksOverview(eventRepo, userId, 1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseIdx === 10) {
|
||||||
|
// Update "Telefonat mit Mama" +2 days
|
||||||
|
const events = await eventRepo.findByUserId(userId);
|
||||||
|
const mamaEvent = events.find(e => e.title === 'Telefonat mit Mama');
|
||||||
|
if (mamaEvent) {
|
||||||
|
const newStart = new Date(mamaEvent.startTime);
|
||||||
|
newStart.setDate(newStart.getDate() + 2);
|
||||||
|
const newEnd = new Date(mamaEvent.endTime);
|
||||||
|
newEnd.setDate(newEnd.getDate() + 2);
|
||||||
|
return {
|
||||||
|
content: "Alles klar, ich verschiebe das Telefonat mit Mama um 2 Tage nach hinten:",
|
||||||
|
proposedChange: {
|
||||||
|
action: 'update',
|
||||||
|
eventId: mamaEvent.id,
|
||||||
|
updates: { startTime: newStart, endTime: newEnd },
|
||||||
|
// Include event with new times for display
|
||||||
|
event: {
|
||||||
|
title: mamaEvent.title,
|
||||||
|
startTime: newStart,
|
||||||
|
endTime: newEnd,
|
||||||
|
description: mamaEvent.description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { content: "Ich konnte keinen Termin 'Telefonat mit Mama' finden." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseIdx === 13) {
|
||||||
|
const now = new Date();
|
||||||
|
return { content: await getMonthOverview(eventRepo, userId, now.getFullYear(), now.getMonth()) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return staticResponses[responseIdx];
|
||||||
|
}
|
||||||
|
|
||||||
export class ChatService {
|
export class ChatService {
|
||||||
constructor(
|
constructor(
|
||||||
private chatRepo: ChatRepository,
|
private chatRepo: ChatRepository,
|
||||||
@@ -166,7 +214,7 @@ export class ChatService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async processMessage(userId: string, data: SendMessageDTO): Promise<ChatResponse> {
|
async processMessage(userId: string, data: SendMessageDTO): Promise<ChatResponse> {
|
||||||
const response = testResponses[responseIndex % testResponses.length];
|
const response = await getTestResponse(responseIndex, this.eventRepo, userId);
|
||||||
responseIndex++;
|
responseIndex++;
|
||||||
|
|
||||||
const message: ChatMessage = {
|
const message: ChatMessage = {
|
||||||
@@ -180,14 +228,37 @@ export class ChatService {
|
|||||||
return { message, conversationId: message.conversationId };
|
return { message, conversationId: message.conversationId };
|
||||||
}
|
}
|
||||||
|
|
||||||
async confirmEvent(userId: string, conversationId: string, messageId: string, event: CreateEventDTO): Promise<ChatResponse> {
|
async confirmEvent(
|
||||||
|
userId: string,
|
||||||
|
conversationId: string,
|
||||||
|
messageId: string,
|
||||||
|
action: EventAction,
|
||||||
|
event?: CreateEventDTO,
|
||||||
|
eventId?: string,
|
||||||
|
updates?: UpdateEventDTO
|
||||||
|
): Promise<ChatResponse> {
|
||||||
|
let content: string;
|
||||||
|
|
||||||
|
if (action === 'create' && event) {
|
||||||
const createdEvent = await this.eventRepo.create(userId, event);
|
const createdEvent = await this.eventRepo.create(userId, event);
|
||||||
|
content = `Der Termin "${createdEvent.title}" wurde erstellt.`;
|
||||||
|
} else if (action === 'update' && eventId && updates) {
|
||||||
|
const updatedEvent = await this.eventRepo.update(eventId, updates);
|
||||||
|
content = updatedEvent
|
||||||
|
? `Der Termin "${updatedEvent.title}" wurde aktualisiert.`
|
||||||
|
: 'Termin nicht gefunden.';
|
||||||
|
} else if (action === 'delete' && eventId) {
|
||||||
|
await this.eventRepo.delete(eventId);
|
||||||
|
content = 'Der Termin wurde gelöscht.';
|
||||||
|
} else {
|
||||||
|
content = 'Ungültige Aktion.';
|
||||||
|
}
|
||||||
|
|
||||||
const message: ChatMessage = {
|
const message: ChatMessage = {
|
||||||
id: Date.now().toString(),
|
id: Date.now().toString(),
|
||||||
conversationId,
|
conversationId,
|
||||||
sender: 'assistant',
|
sender: 'assistant',
|
||||||
content: `Der Termin "${createdEvent.title}" wurde erstellt.`,
|
content,
|
||||||
};
|
};
|
||||||
return { message, conversationId };
|
return { message, conversationId };
|
||||||
}
|
}
|
||||||
|
|||||||
125
apps/server/src/utils/eventFormatters.ts
Normal file
125
apps/server/src/utils/eventFormatters.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import {
|
||||||
|
MONTHS,
|
||||||
|
DAY_INDEX_TO_DAY,
|
||||||
|
DAY_TO_GERMAN,
|
||||||
|
DAY_TO_GERMAN_SHORT,
|
||||||
|
MONTH_TO_GERMAN,
|
||||||
|
} from '@caldav/shared';
|
||||||
|
import { EventRepository } from '../services/interfaces';
|
||||||
|
import { expandRecurringEvents, ExpandedEvent } from './recurrenceExpander';
|
||||||
|
|
||||||
|
// Private formatting helpers
|
||||||
|
|
||||||
|
function formatTime(date: Date): string {
|
||||||
|
const hours = date.getHours().toString().padStart(2, '0');
|
||||||
|
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||||
|
return `${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateShort(date: Date): string {
|
||||||
|
const day = date.getDate().toString().padStart(2, '0');
|
||||||
|
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||||
|
return `${day}.${month}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWeekNumber(date: Date): number {
|
||||||
|
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||||
|
const dayNum = d.getUTCDay() || 7;
|
||||||
|
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||||||
|
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||||
|
return Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWeeksText(events: ExpandedEvent[], weeks: number): string {
|
||||||
|
const weeksText = weeks === 1 ? 'die nächste Woche' : `die nächsten ${weeks} Wochen`;
|
||||||
|
|
||||||
|
if (events.length === 0) {
|
||||||
|
return `Du hast für ${weeksText} keine Termine.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = [`Hier sind deine Termine für ${weeksText}:\n`];
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
const day = DAY_INDEX_TO_DAY[event.occurrenceStart.getDay()];
|
||||||
|
const weekday = DAY_TO_GERMAN[day];
|
||||||
|
const dateStr = formatDateShort(event.occurrenceStart);
|
||||||
|
const timeStr = formatTime(event.occurrenceStart);
|
||||||
|
lines.push(`${weekday}, ${dateStr} - ${timeStr} Uhr: ${event.title}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`\nInsgesamt ${events.length} Termin${events.length === 1 ? '' : 'e'}.`);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMonthText(events: ExpandedEvent[], monthName: string): string {
|
||||||
|
if (events.length === 0) {
|
||||||
|
return `Du hast im ${monthName} keine Termine.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group events by calendar week
|
||||||
|
const weekGroups = new Map<number, ExpandedEvent[]>();
|
||||||
|
for (const event of events) {
|
||||||
|
const weekNum = getWeekNumber(event.occurrenceStart);
|
||||||
|
if (!weekGroups.has(weekNum)) {
|
||||||
|
weekGroups.set(weekNum, []);
|
||||||
|
}
|
||||||
|
weekGroups.get(weekNum)!.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = [`Hier ist deine Monatsübersicht für ${monthName}:\n`];
|
||||||
|
|
||||||
|
// Sort weeks and format
|
||||||
|
const sortedWeeks = Array.from(weekGroups.keys()).sort((a, b) => a - b);
|
||||||
|
for (const weekNum of sortedWeeks) {
|
||||||
|
const weekEvents = weekGroups.get(weekNum)!;
|
||||||
|
lines.push(`KW ${weekNum}: ${weekEvents.length} Termin${weekEvents.length === 1 ? '' : 'e'}`);
|
||||||
|
|
||||||
|
for (const event of weekEvents) {
|
||||||
|
const day = DAY_INDEX_TO_DAY[event.occurrenceStart.getDay()];
|
||||||
|
const weekdayShort = DAY_TO_GERMAN_SHORT[day];
|
||||||
|
const dateStr = formatDateShort(event.occurrenceStart);
|
||||||
|
const timeStr = formatTime(event.occurrenceStart);
|
||||||
|
lines.push(` • ${weekdayShort} ${dateStr}, ${timeStr}: ${event.title}`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`Insgesamt ${events.length} Termin${events.length === 1 ? '' : 'e'} im ${monthName}.`);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a formatted overview of events for the next x weeks.
|
||||||
|
* Recurring events are expanded to show all occurrences within the range.
|
||||||
|
*/
|
||||||
|
export async function getWeeksOverview(
|
||||||
|
eventRepo: EventRepository,
|
||||||
|
userId: string,
|
||||||
|
weeks: number
|
||||||
|
): Promise<string> {
|
||||||
|
const now = new Date();
|
||||||
|
const endDate = new Date(now.getTime() + weeks * 7 * 24 * 60 * 60 * 1000);
|
||||||
|
const events = await eventRepo.findByUserId(userId);
|
||||||
|
const expanded = expandRecurringEvents(events, now, endDate);
|
||||||
|
return formatWeeksText(expanded, weeks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a formatted overview of events for a specific month.
|
||||||
|
* Recurring events are expanded to show all occurrences within the month.
|
||||||
|
*/
|
||||||
|
export async function getMonthOverview(
|
||||||
|
eventRepo: EventRepository,
|
||||||
|
userId: string,
|
||||||
|
year: number,
|
||||||
|
month: number
|
||||||
|
): Promise<string> {
|
||||||
|
const startOfMonth = new Date(year, month, 1);
|
||||||
|
const endOfMonth = new Date(year, month + 1, 0, 23, 59, 59);
|
||||||
|
const events = await eventRepo.findByUserId(userId);
|
||||||
|
const expanded = expandRecurringEvents(events, startOfMonth, endOfMonth);
|
||||||
|
const monthName = MONTH_TO_GERMAN[MONTHS[month]];
|
||||||
|
return formatMonthText(expanded, monthName);
|
||||||
|
}
|
||||||
109
apps/server/src/utils/recurrenceExpander.ts
Normal file
109
apps/server/src/utils/recurrenceExpander.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { RRule, rrulestr } from 'rrule';
|
||||||
|
import { CalendarEvent } from '@caldav/shared';
|
||||||
|
|
||||||
|
export interface ExpandedEvent extends CalendarEvent {
|
||||||
|
occurrenceStart: Date;
|
||||||
|
occurrenceEnd: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert local time to "fake UTC" for rrule
|
||||||
|
// rrule interprets all dates as UTC internally, so we need to trick it
|
||||||
|
function toRRuleDate(date: Date): Date {
|
||||||
|
return new Date(Date.UTC(
|
||||||
|
date.getFullYear(),
|
||||||
|
date.getMonth(),
|
||||||
|
date.getDate(),
|
||||||
|
date.getHours(),
|
||||||
|
date.getMinutes(),
|
||||||
|
date.getSeconds()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert rrule result back to local time
|
||||||
|
function fromRRuleDate(date: Date): Date {
|
||||||
|
return new Date(
|
||||||
|
date.getUTCFullYear(),
|
||||||
|
date.getUTCMonth(),
|
||||||
|
date.getUTCDate(),
|
||||||
|
date.getUTCHours(),
|
||||||
|
date.getUTCMinutes(),
|
||||||
|
date.getUTCSeconds()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand recurring events into individual occurrences within a date range.
|
||||||
|
* Non-recurring events are returned as-is with occurrenceStart/End = startTime/endTime.
|
||||||
|
*/
|
||||||
|
export function expandRecurringEvents(
|
||||||
|
events: CalendarEvent[],
|
||||||
|
rangeStart: Date,
|
||||||
|
rangeEnd: Date
|
||||||
|
): ExpandedEvent[] {
|
||||||
|
const expanded: ExpandedEvent[] = [];
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
const startTime = new Date(event.startTime);
|
||||||
|
const endTime = new Date(event.endTime);
|
||||||
|
const duration = endTime.getTime() - startTime.getTime();
|
||||||
|
|
||||||
|
if (!event.isRecurring || !event.recurrenceRule) {
|
||||||
|
// Non-recurring event: add as-is if within range
|
||||||
|
if (startTime >= rangeStart && startTime <= rangeEnd) {
|
||||||
|
expanded.push({
|
||||||
|
...event,
|
||||||
|
occurrenceStart: startTime,
|
||||||
|
occurrenceEnd: endTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurring event: parse RRULE and expand
|
||||||
|
try {
|
||||||
|
const rule = rrulestr(`DTSTART:${formatRRuleDateString(startTime)}\nRRULE:${event.recurrenceRule}`);
|
||||||
|
|
||||||
|
// Get occurrences within the range (using fake UTC dates)
|
||||||
|
const occurrences = rule.between(
|
||||||
|
toRRuleDate(rangeStart),
|
||||||
|
toRRuleDate(rangeEnd),
|
||||||
|
true // inclusive
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const occurrence of occurrences) {
|
||||||
|
const occurrenceStart = fromRRuleDate(occurrence);
|
||||||
|
const occurrenceEnd = new Date(occurrenceStart.getTime() + duration);
|
||||||
|
|
||||||
|
expanded.push({
|
||||||
|
...event,
|
||||||
|
occurrenceStart,
|
||||||
|
occurrenceEnd,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// If RRULE parsing fails, include the event as a single occurrence
|
||||||
|
console.error(`Failed to parse recurrence rule for event ${event.id}:`, error);
|
||||||
|
expanded.push({
|
||||||
|
...event,
|
||||||
|
occurrenceStart: startTime,
|
||||||
|
occurrenceEnd: endTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by occurrence start time
|
||||||
|
expanded.sort((a, b) => a.occurrenceStart.getTime() - b.occurrenceStart.getTime());
|
||||||
|
|
||||||
|
return expanded;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format date as RRULE DTSTART string (YYYYMMDDTHHMMSS)
|
||||||
|
function formatRRuleDateString(date: Date): string {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||||
|
return `${year}${month}${day}T${hours}${minutes}${seconds}`;
|
||||||
|
}
|
||||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -67,7 +67,8 @@
|
|||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"mongoose": "^9.1.1"
|
"mongoose": "^9.1.1",
|
||||||
|
"rrule": "^2.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
@@ -13124,6 +13125,15 @@
|
|||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rrule": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/rrule/-/rrule-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/run-parallel": {
|
"node_modules/run-parallel": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||||
|
|||||||
@@ -37,3 +37,42 @@ export const DAY_INDEX: Record<Day, number> = {
|
|||||||
Friday: 5,
|
Friday: 5,
|
||||||
Saturday: 6,
|
Saturday: 6,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Mapping from Date.getDay() index (0=Sunday) to Day type
|
||||||
|
export const DAY_INDEX_TO_DAY: Day[] = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||||
|
|
||||||
|
// German translations
|
||||||
|
export const DAY_TO_GERMAN: Record<Day, string> = {
|
||||||
|
Monday: 'Montag',
|
||||||
|
Tuesday: 'Dienstag',
|
||||||
|
Wednesday: 'Mittwoch',
|
||||||
|
Thursday: 'Donnerstag',
|
||||||
|
Friday: 'Freitag',
|
||||||
|
Saturday: 'Samstag',
|
||||||
|
Sunday: 'Sonntag',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DAY_TO_GERMAN_SHORT: Record<Day, string> = {
|
||||||
|
Monday: 'Mo',
|
||||||
|
Tuesday: 'Di',
|
||||||
|
Wednesday: 'Mi',
|
||||||
|
Thursday: 'Do',
|
||||||
|
Friday: 'Fr',
|
||||||
|
Saturday: 'Sa',
|
||||||
|
Sunday: 'So',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MONTH_TO_GERMAN: Record<Month, string> = {
|
||||||
|
January: 'Januar',
|
||||||
|
February: 'Februar',
|
||||||
|
March: 'März',
|
||||||
|
April: 'April',
|
||||||
|
May: 'Mai',
|
||||||
|
June: 'Juni',
|
||||||
|
July: 'Juli',
|
||||||
|
August: 'August',
|
||||||
|
September: 'September',
|
||||||
|
October: 'Oktober',
|
||||||
|
November: 'November',
|
||||||
|
December: 'Dezember',
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user