Implement three deletion modes for recurring events: - single: exclude specific occurrence via EXDATE mechanism - future: set RRULE UNTIL to stop future occurrences - all: delete entire event series Changes include: - Add exceptionDates field to CalendarEvent model - Add RecurringDeleteMode type and DeleteRecurringEventDTO - EventService.deleteRecurring() with mode-based logic using rrule library - EventController DELETE endpoint accepts mode/occurrenceDate query params - recurrenceExpander filters out exception dates during expansion - AI tools support deleteMode and occurrenceDate for proposed deletions - ChatService.confirmEvent() handles recurring delete modes - New DeleteEventModal component for unified delete confirmation UI - Calendar screen integrates modal for both recurring and non-recurring events
575 lines
16 KiB
TypeScript
575 lines
16 KiB
TypeScript
import {
|
|
ChatMessage,
|
|
ChatResponse,
|
|
SendMessageDTO,
|
|
ConversationSummary,
|
|
GetMessagesOptions,
|
|
ProposedEventChange,
|
|
getDay,
|
|
CreateEventDTO,
|
|
UpdateEventDTO,
|
|
EventAction,
|
|
CreateMessageDTO,
|
|
RecurringDeleteMode,
|
|
} from "@calchat/shared";
|
|
import { ChatRepository, EventRepository, AIProvider } from "./interfaces";
|
|
import { EventService } from "./EventService";
|
|
import { getWeeksOverview, getMonthOverview } from "../utils/eventFormatters";
|
|
|
|
type TestResponse = {
|
|
content: string;
|
|
proposedChanges?: ProposedEventChange[];
|
|
};
|
|
|
|
// Test response index (cycles through responses)
|
|
let responseIndex = 0;
|
|
|
|
// Static test responses (event proposals)
|
|
const staticResponses: TestResponse[] = [
|
|
// {{{
|
|
// === MULTI-EVENT TEST RESPONSES ===
|
|
// Response 0: 3 Meetings an verschiedenen Tagen
|
|
{
|
|
content: "Alles klar! Ich erstelle dir 3 Team-Meetings für diese Woche:",
|
|
proposedChanges: [
|
|
{
|
|
id: "multi-1-a",
|
|
action: "create",
|
|
event: {
|
|
title: "Team-Meeting Montag",
|
|
startTime: getDay("Monday", 1, 10, 0),
|
|
endTime: getDay("Monday", 1, 11, 0),
|
|
description: "Wöchentliches Standup",
|
|
},
|
|
},
|
|
{
|
|
id: "multi-1-b",
|
|
action: "create",
|
|
event: {
|
|
title: "Team-Meeting Mittwoch",
|
|
startTime: getDay("Wednesday", 1, 10, 0),
|
|
endTime: getDay("Wednesday", 1, 11, 0),
|
|
description: "Sprint Planning",
|
|
},
|
|
},
|
|
{
|
|
id: "multi-1-c",
|
|
action: "create",
|
|
event: {
|
|
title: "Team-Meeting Freitag",
|
|
startTime: getDay("Friday", 1, 10, 0),
|
|
endTime: getDay("Friday", 1, 11, 0),
|
|
description: "Retrospektive",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
// Response 1: 5 Termine für einen Projekttag
|
|
{
|
|
content: "Ich habe deinen kompletten Projekttag am Dienstag geplant:",
|
|
proposedChanges: [
|
|
{
|
|
id: "multi-2-a",
|
|
action: "create",
|
|
event: {
|
|
title: "Kickoff-Meeting",
|
|
startTime: getDay("Tuesday", 1, 9, 0),
|
|
endTime: getDay("Tuesday", 1, 10, 0),
|
|
description: "Projektstart mit dem Team",
|
|
},
|
|
},
|
|
{
|
|
id: "multi-2-b",
|
|
action: "create",
|
|
event: {
|
|
title: "Design Review",
|
|
startTime: getDay("Tuesday", 1, 10, 30),
|
|
endTime: getDay("Tuesday", 1, 11, 30),
|
|
description: "UI/UX Besprechung",
|
|
},
|
|
},
|
|
{
|
|
id: "multi-2-c",
|
|
action: "create",
|
|
event: {
|
|
title: "Mittagspause",
|
|
startTime: getDay("Tuesday", 1, 12, 0),
|
|
endTime: getDay("Tuesday", 1, 13, 0),
|
|
description: "Team-Lunch",
|
|
},
|
|
},
|
|
{
|
|
id: "multi-2-d",
|
|
action: "create",
|
|
event: {
|
|
title: "Tech Review",
|
|
startTime: getDay("Tuesday", 1, 14, 0),
|
|
endTime: getDay("Tuesday", 1, 15, 30),
|
|
description: "Architektur-Diskussion",
|
|
},
|
|
},
|
|
{
|
|
id: "multi-2-e",
|
|
action: "create",
|
|
event: {
|
|
title: "Wrap-up",
|
|
startTime: getDay("Tuesday", 1, 16, 0),
|
|
endTime: getDay("Tuesday", 1, 16, 30),
|
|
description: "Zusammenfassung und nächste Schritte",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
// Response 2: 2 wiederkehrende Termine
|
|
{
|
|
content: "Ich erstelle dir zwei wiederkehrende Fitness-Termine:",
|
|
proposedChanges: [
|
|
{
|
|
id: "multi-3-a",
|
|
action: "create",
|
|
event: {
|
|
title: "Yoga",
|
|
startTime: getDay("Monday", 1, 7, 0),
|
|
endTime: getDay("Monday", 1, 8, 0),
|
|
description: "Morgen-Yoga",
|
|
isRecurring: true,
|
|
recurrenceRule: "FREQ=WEEKLY;BYDAY=MO,WE,FR",
|
|
},
|
|
},
|
|
{
|
|
id: "multi-3-b",
|
|
action: "create",
|
|
event: {
|
|
title: "Laufen",
|
|
startTime: getDay("Tuesday", 1, 18, 0),
|
|
endTime: getDay("Tuesday", 1, 19, 0),
|
|
description: "Abendlauf im Park",
|
|
isRecurring: true,
|
|
recurrenceRule: "FREQ=WEEKLY;BYDAY=TU,TH",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
// === ORIGINAL RESPONSES ===
|
|
// Response 3: 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 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:",
|
|
proposedChanges: [
|
|
{
|
|
id: "test-1",
|
|
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:",
|
|
proposedChanges: [
|
|
{
|
|
id: "test-2",
|
|
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: 2-week overview (DYNAMIC - placeholder)
|
|
{ content: "" },
|
|
// Response 4: Delete "Meeting mit Jens" (DYNAMIC - placeholder)
|
|
{ content: "" },
|
|
// Response 5: Doctor appointment with description
|
|
{
|
|
content:
|
|
"Ich habe dir einen Arzttermin eingetragen. Denk daran, deine Versichertenkarte mitzunehmen!",
|
|
proposedChanges: [
|
|
{
|
|
id: "test-5",
|
|
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 6: Birthday - yearly recurring
|
|
{
|
|
content:
|
|
"Geburtstage vergisst man leicht - aber nicht mit mir! Ich habe Mamas Geburtstag eingetragen:",
|
|
proposedChanges: [
|
|
{
|
|
id: "test-6",
|
|
action: "create",
|
|
event: {
|
|
title: "Mamas Geburtstag",
|
|
startTime: getDay("Thursday", 2, 0, 0),
|
|
endTime: getDay("Thursday", 2, 23, 59),
|
|
isRecurring: true,
|
|
recurrenceRule: "FREQ=YEARLY",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
// Response 7: Gym - recurring for 2 months (8 weeks)
|
|
{
|
|
content:
|
|
"Perfekt! Ich habe dein Probetraining eingetragen - jeden Dienstag für die nächsten 2 Monate:",
|
|
proposedChanges: [
|
|
{
|
|
id: "test-7",
|
|
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 8: 1-week overview (DYNAMIC - placeholder)
|
|
{ content: "" },
|
|
// Response 9: Phone call - short appointment (Wednesday, so +2 days = Friday)
|
|
{
|
|
content:
|
|
"Alles klar! Ich habe das Telefonat mit deiner Mutter eingetragen:",
|
|
proposedChanges: [
|
|
{
|
|
id: "test-9",
|
|
action: "create",
|
|
event: {
|
|
title: "Telefonat mit Mama",
|
|
startTime: getDay("Wednesday", 1, 11, 0),
|
|
endTime: getDay("Wednesday", 1, 11, 30),
|
|
},
|
|
},
|
|
],
|
|
},
|
|
// Response 10: Update "Telefonat mit Mama" +3 days (DYNAMIC - placeholder)
|
|
{ content: "" },
|
|
// Response 11: Birthday party - evening event
|
|
{
|
|
content: "Super! Die Geburtstagsfeier ist eingetragen. Viel Spaß!",
|
|
proposedChanges: [
|
|
{
|
|
id: "test-11",
|
|
action: "create",
|
|
event: {
|
|
title: "Geburtstagsfeier Lisa",
|
|
startTime: getDay("Saturday", 2, 19, 0),
|
|
endTime: getDay("Saturday", 2, 23, 0),
|
|
description: "Geschenk: Buch über Fotografie",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
// Response 12: Language course - limited to 8 weeks (Thu + Sat)
|
|
{
|
|
content:
|
|
"Dein Spanischkurs ist eingetragen! Er läuft jeden Donnerstag und Samstag für die nächsten 8 Wochen:",
|
|
proposedChanges: [
|
|
{
|
|
id: "test-12",
|
|
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,SA;COUNT=8",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
// 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 === 3) {
|
|
return { content: await getWeeksOverview(eventRepo, userId, 2) };
|
|
}
|
|
|
|
if (responseIdx === 4) {
|
|
// Delete "Meeting mit Jens"
|
|
const events = await eventRepo.findByUserId(userId);
|
|
const jensEvent = events.find((e) => e.title === "Meeting mit Jens");
|
|
if (jensEvent) {
|
|
return {
|
|
content: "Soll ich diesen Termin wirklich löschen?",
|
|
proposedChanges: [
|
|
{
|
|
id: "test-4",
|
|
action: "delete",
|
|
eventId: jensEvent.id,
|
|
event: {
|
|
title: jensEvent.title,
|
|
startTime: jensEvent.startTime,
|
|
endTime: jensEvent.endTime,
|
|
description: jensEvent.description,
|
|
isRecurring: jensEvent.isRecurring,
|
|
},
|
|
},
|
|
],
|
|
};
|
|
}
|
|
return { content: "Ich konnte keinen Termin 'Meeting mit Jens' finden." };
|
|
}
|
|
|
|
if (responseIdx === 8) {
|
|
return { content: await getWeeksOverview(eventRepo, userId, 1) };
|
|
}
|
|
|
|
if (responseIdx === 10) {
|
|
// Update "Telefonat mit Mama" +3 days and change time to 13:00
|
|
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() + 3);
|
|
newStart.setHours(13, 0, 0, 0);
|
|
const newEnd = new Date(newStart);
|
|
newEnd.setMinutes(30);
|
|
return {
|
|
content:
|
|
"Alles klar, ich verschiebe das Telefonat mit Mama auf Samstag um 13:00 Uhr:",
|
|
proposedChanges: [
|
|
{
|
|
id: "test-10",
|
|
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,
|
|
private eventRepo: EventRepository,
|
|
private eventService: EventService,
|
|
private aiProvider: AIProvider,
|
|
) {}
|
|
|
|
async processMessage(
|
|
userId: string,
|
|
data: SendMessageDTO,
|
|
): Promise<ChatResponse> {
|
|
let conversationId = data.conversationId;
|
|
if (!conversationId) {
|
|
const conversation = await this.chatRepo.createConversation(userId);
|
|
conversationId = conversation.id;
|
|
}
|
|
|
|
// Save user message
|
|
await this.chatRepo.createMessage(conversationId, {
|
|
sender: "user",
|
|
content: data.content,
|
|
});
|
|
|
|
let response: TestResponse;
|
|
|
|
if (process.env.USE_TEST_RESPONSES === "true") {
|
|
// Test mode: use static responses
|
|
response = await getTestResponse(responseIndex, this.eventRepo, userId);
|
|
responseIndex++;
|
|
} else {
|
|
// Production mode: use real AI
|
|
const events = await this.eventRepo.findByUserId(userId);
|
|
const history = await this.chatRepo.getMessages(conversationId, {
|
|
limit: 20,
|
|
});
|
|
|
|
response = await this.aiProvider.processMessage(data.content, {
|
|
userId,
|
|
conversationHistory: history,
|
|
existingEvents: events,
|
|
currentDate: new Date(),
|
|
});
|
|
}
|
|
|
|
// Save and then return assistant response
|
|
const answerMessage = await this.chatRepo.createMessage(conversationId, {
|
|
sender: "assistant",
|
|
content: response.content,
|
|
proposedChanges: response.proposedChanges,
|
|
});
|
|
|
|
return { message: answerMessage, conversationId: conversationId };
|
|
}
|
|
|
|
async confirmEvent(
|
|
userId: string,
|
|
conversationId: string,
|
|
messageId: string,
|
|
proposalId: string,
|
|
action: EventAction,
|
|
event?: CreateEventDTO,
|
|
eventId?: string,
|
|
updates?: UpdateEventDTO,
|
|
deleteMode?: RecurringDeleteMode,
|
|
occurrenceDate?: string,
|
|
): Promise<ChatResponse> {
|
|
// Update specific proposal with respondedAction
|
|
await this.chatRepo.updateProposalResponse(
|
|
messageId,
|
|
proposalId,
|
|
"confirm",
|
|
);
|
|
|
|
// Perform the actual event operation
|
|
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) {
|
|
// Use deleteRecurring for proper handling of recurring events
|
|
const mode = deleteMode || "all";
|
|
await this.eventService.deleteRecurring(
|
|
eventId,
|
|
userId,
|
|
mode,
|
|
occurrenceDate,
|
|
);
|
|
|
|
// Build appropriate response message
|
|
let deleteDescription = "";
|
|
if (deleteMode === "single") {
|
|
deleteDescription = " (dieses Vorkommen)";
|
|
} else if (deleteMode === "future") {
|
|
deleteDescription = " (dieses und zukünftige Vorkommen)";
|
|
}
|
|
content = event?.title
|
|
? `Der Termin "${event.title}"${deleteDescription} wurde gelöscht.`
|
|
: `Der Termin${deleteDescription} wurde gelöscht.`;
|
|
} else {
|
|
content = "Ungültige Aktion.";
|
|
}
|
|
|
|
// Save response message to DB
|
|
const message = await this.chatRepo.createMessage(conversationId, {
|
|
sender: "assistant",
|
|
content,
|
|
});
|
|
|
|
return { message, conversationId };
|
|
}
|
|
|
|
async rejectEvent(
|
|
userId: string,
|
|
conversationId: string,
|
|
messageId: string,
|
|
proposalId: string,
|
|
): Promise<ChatResponse> {
|
|
// Update specific proposal with respondedAction
|
|
await this.chatRepo.updateProposalResponse(messageId, proposalId, "reject");
|
|
|
|
// Save response message to DB
|
|
const message = await this.chatRepo.createMessage(conversationId, {
|
|
sender: "assistant",
|
|
content: "Der Vorschlag wurde abgelehnt.",
|
|
});
|
|
|
|
return { message, conversationId };
|
|
}
|
|
|
|
async getConversations(userId: string): Promise<ConversationSummary[]> {
|
|
const conversations = await this.chatRepo.getConversationsByUser(userId);
|
|
|
|
// For each conversation, get the last message
|
|
const summaries: ConversationSummary[] = await Promise.all(
|
|
conversations.map(async (conv) => {
|
|
const messages = await this.chatRepo.getMessages(conv.id, { limit: 1 });
|
|
return {
|
|
id: conv.id,
|
|
lastMessage: messages[0],
|
|
createdAt: conv.createdAt,
|
|
};
|
|
}),
|
|
);
|
|
|
|
return summaries;
|
|
}
|
|
|
|
async getConversation(
|
|
userId: string,
|
|
conversationId: string,
|
|
options?: GetMessagesOptions,
|
|
): Promise<ChatMessage[]> {
|
|
// Verify conversation belongs to user
|
|
const conversations = await this.chatRepo.getConversationsByUser(userId);
|
|
const conversation = conversations.find((c) => c.id === conversationId);
|
|
|
|
if (!conversation) {
|
|
throw new Error("Conversation not found");
|
|
}
|
|
|
|
return this.chatRepo.getMessages(conversationId, options);
|
|
}
|
|
}
|