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:
2026-01-04 16:15:30 +01:00
parent 9fecf94c7d
commit 77f15b6dd1
11 changed files with 577 additions and 174 deletions

View File

@@ -1,163 +1,211 @@
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 { getWeeksOverview, getMonthOverview } from '../utils/eventFormatters';
// Test responses array (cycles through responses)
let responseIndex = 0;
type TestResponse = { content: string; proposedChange?: ProposedEventChange };
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",
// Test response index (cycles through responses)
let responseIndex = 8;
// Static test responses (event proposals)
const staticResponses: TestResponse[] = [
// {{{
// 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:",
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 1: 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.",
},
// 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 2: 2-week overview (DYNAMIC - placeholder)
{ content: '' },
// Response 3: Delete "Meeting mit Jens" (DYNAMIC - placeholder)
{ content: '' },
// 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 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 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 7: 1-week overview (DYNAMIC - placeholder)
{ content: '' },
// 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 10: Update "Telefonat mit Mama" +2 days (DYNAMIC - placeholder)
{ content: '' },
// Response 11: 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: 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.",
},
// }}}
},
// Response 13: Monthly overview (DYNAMIC - placeholder)
{ content: '' },
// }}}
];
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 {
constructor(
private chatRepo: ChatRepository,
@@ -166,7 +214,7 @@ export class ChatService {
) {}
async processMessage(userId: string, data: SendMessageDTO): Promise<ChatResponse> {
const response = testResponses[responseIndex % testResponses.length];
const response = await getTestResponse(responseIndex, this.eventRepo, userId);
responseIndex++;
const message: ChatMessage = {
@@ -180,14 +228,37 @@ export class ChatService {
return { message, conversationId: message.conversationId };
}
async confirmEvent(userId: string, conversationId: string, messageId: string, event: CreateEventDTO): Promise<ChatResponse> {
const createdEvent = await this.eventRepo.create(userId, event);
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);
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 = {
id: Date.now().toString(),
conversationId,
sender: 'assistant',
content: `Der Termin "${createdEvent.title}" wurde erstellt.`,
content,
};
return { message, conversationId };
}