feat: add CalDAV synchronization with automatic sync
- Add CaldavService with tsdav/ical.js for CalDAV server communication - Add CaldavController, CaldavRepository, and caldav routes - Add client-side CaldavConfigService with sync(), config CRUD - Add CalDAV settings UI with config load/save in settings screen - Sync on login, auto-login (AuthGuard), periodic timer (calendar), and sync button - Push single events to CalDAV on server-side create/update/delete - Push all events to CalDAV after chat event confirmation - Refactor ChatService to use EventService instead of direct EventRepository - Rename CalDav/calDav to Caldav/caldav for consistent naming - Add Radicale Docker setup for local CalDAV testing - Update PlantUML diagrams and CLAUDE.md with CalDAV architecture
This commit is contained in:
@@ -22,6 +22,7 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import { useThemeStore } from "../../stores/ThemeStore";
|
||||
import BaseBackground from "../../components/BaseBackground";
|
||||
import { EventService } from "../../services";
|
||||
import { CaldavConfigService } from "../../services/CaldavConfigService";
|
||||
import { useEventsStore } from "../../stores";
|
||||
import { useDropdownPosition } from "../../hooks/useDropdownPosition";
|
||||
|
||||
@@ -84,9 +85,15 @@ const Calendar = () => {
|
||||
|
||||
const { events, setEvents, deleteEvent } = useEventsStore();
|
||||
|
||||
// Function to load events for current view
|
||||
// Sync CalDAV then load events for current view
|
||||
const loadEvents = useCallback(async () => {
|
||||
try {
|
||||
try {
|
||||
await CaldavConfigService.sync();
|
||||
} catch {
|
||||
// No CalDAV config or sync failed — not critical
|
||||
}
|
||||
|
||||
// Calculate first visible day (up to 6 days before month start)
|
||||
const firstOfMonth = new Date(currentYear, monthIndex, 1);
|
||||
const dayOfWeek = firstOfMonth.getDay();
|
||||
@@ -122,6 +129,9 @@ const Calendar = () => {
|
||||
if (selectedDate) {
|
||||
setOverlayVisible(true);
|
||||
}
|
||||
|
||||
const interval = setInterval(loadEvents, 10_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadEvents, selectedDate]),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,18 +1,106 @@
|
||||
import { Text, View } from "react-native";
|
||||
import BaseBackground from "../../components/BaseBackground";
|
||||
import BaseButton from "../../components/BaseButton";
|
||||
import BaseButton, { BaseButtonProps } from "../../components/BaseButton";
|
||||
import { useThemeStore } from "../../stores/ThemeStore";
|
||||
import { AuthService } from "../../services/AuthService";
|
||||
import { router } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { SimpleHeader } from "../../components/Header";
|
||||
import { THEMES } from "../../Themes";
|
||||
import CustomTextInput from "../../components/CustomTextInput";
|
||||
import { useEffect, useState } from "react";
|
||||
import { CaldavConfigService } from "../../services/CaldavConfigService";
|
||||
|
||||
const handleLogout = async () => {
|
||||
await AuthService.logout();
|
||||
router.replace("/login");
|
||||
};
|
||||
|
||||
const SettingsButton = (props: BaseButtonProps) => {
|
||||
return (
|
||||
<BaseButton
|
||||
onPress={props.onPress}
|
||||
solid={props.solid}
|
||||
className={"w-11/12"}
|
||||
>
|
||||
{props.children}
|
||||
</BaseButton>
|
||||
);
|
||||
};
|
||||
|
||||
type CaldavTextInputProps = {
|
||||
title: string;
|
||||
value: string;
|
||||
onValueChange: (text: string) => void;
|
||||
};
|
||||
|
||||
const CaldavTextInput = ({ title, value, onValueChange }: CaldavTextInputProps) => {
|
||||
return (
|
||||
<View className="flex flex-row items-center py-1">
|
||||
<Text className="ml-4 w-24">{title}:</Text>
|
||||
<CustomTextInput className="flex-1 mr-4" text={value} onValueChange={onValueChange} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const CaldavSettings = () => {
|
||||
const { theme } = useThemeStore();
|
||||
|
||||
const [serverUrl, setServerUrl] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const config = await CaldavConfigService.getConfig();
|
||||
setServerUrl(config.serverUrl);
|
||||
setUsername(config.username);
|
||||
setPassword(config.password);
|
||||
} catch {
|
||||
// No config saved yet
|
||||
}
|
||||
};
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
const saveConfig = async () => {
|
||||
await CaldavConfigService.saveConfig(serverUrl, username, password);
|
||||
};
|
||||
|
||||
const sync = async () => {
|
||||
await CaldavConfigService.sync();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<View>
|
||||
<Text
|
||||
className="text-center text-2xl"
|
||||
style={{ color: theme.textPrimary }}
|
||||
>
|
||||
Caldav Config
|
||||
</Text>
|
||||
</View>
|
||||
<View>
|
||||
<View className="pb-1">
|
||||
<CaldavTextInput title="url" value={serverUrl} onValueChange={setServerUrl} />
|
||||
<CaldavTextInput title="username" value={username} onValueChange={setUsername} />
|
||||
<CaldavTextInput title="password" value={password} onValueChange={setPassword} />
|
||||
</View>
|
||||
<View className="flex flex-row">
|
||||
<BaseButton className="mx-4 w-1/5" solid={true} onPress={saveConfig}>
|
||||
Save
|
||||
</BaseButton>
|
||||
<BaseButton className="w-1/5" solid={true} onPress={sync}>
|
||||
Sync
|
||||
</BaseButton>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Settings = () => {
|
||||
const { theme, setTheme } = useThemeStore();
|
||||
|
||||
@@ -20,10 +108,10 @@ const Settings = () => {
|
||||
<BaseBackground>
|
||||
<SimpleHeader text="Settings" />
|
||||
<View className="flex items-center mt-4">
|
||||
<BaseButton onPress={handleLogout} solid={true}>
|
||||
<SettingsButton onPress={handleLogout} solid={true}>
|
||||
<Ionicons name="log-out-outline" size={24} color={theme.primeFg} />{" "}
|
||||
Logout
|
||||
</BaseButton>
|
||||
</SettingsButton>
|
||||
<View>
|
||||
<Text
|
||||
className="text-center text-2xl"
|
||||
@@ -32,23 +120,24 @@ const Settings = () => {
|
||||
Select Theme
|
||||
</Text>
|
||||
</View>
|
||||
<BaseButton
|
||||
<SettingsButton
|
||||
solid={theme == THEMES.defaultLight}
|
||||
onPress={() => {
|
||||
setTheme("defaultLight");
|
||||
}}
|
||||
>
|
||||
Default Light
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
</SettingsButton>
|
||||
<SettingsButton
|
||||
solid={theme == THEMES.defaultDark}
|
||||
onPress={() => {
|
||||
setTheme("defaultDark");
|
||||
}}
|
||||
>
|
||||
Default Dark
|
||||
</BaseButton>
|
||||
</SettingsButton>
|
||||
</View>
|
||||
<CaldavSettings />
|
||||
</BaseBackground>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,38 +21,27 @@ import { useDropdownPosition } from "../hooks/useDropdownPosition";
|
||||
import { EventService, ChatService } from "../services";
|
||||
import { buildRRule, CreateEventDTO } from "@calchat/shared";
|
||||
import { useChatStore } from "../stores";
|
||||
import CustomTextInput, {
|
||||
CustomTextInputProps,
|
||||
} from "../components/CustomTextInput";
|
||||
|
||||
type EditEventTextFieldProps = {
|
||||
type EditEventTextFieldProps = CustomTextInputProps & {
|
||||
titel: string;
|
||||
text?: string;
|
||||
focused?: boolean;
|
||||
className?: string;
|
||||
multiline?: boolean;
|
||||
onValueChange?: (text: string) => void;
|
||||
};
|
||||
|
||||
const EditEventTextField = (props: EditEventTextFieldProps) => {
|
||||
const { theme } = useThemeStore();
|
||||
const [focused, setFocused] = useState(props.focused ?? false);
|
||||
|
||||
return (
|
||||
<View className={props.className}>
|
||||
<Text className="text-xl" style={{ color: theme.textPrimary }}>
|
||||
{props.titel}
|
||||
</Text>
|
||||
<TextInput
|
||||
onChangeText={props.onValueChange}
|
||||
value={props.text}
|
||||
<CustomTextInput
|
||||
className="flex-1"
|
||||
text={props.text}
|
||||
multiline={props.multiline}
|
||||
className="flex-1 border border-solid rounded-2xl px-3 py-2 w-full h-11/12"
|
||||
style={{
|
||||
backgroundColor: theme.messageBorderBg,
|
||||
color: theme.textPrimary,
|
||||
textAlignVertical: "top",
|
||||
borderColor: focused ? theme.chatBot : theme.borderPrimary,
|
||||
}}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
onValueChange={props.onValueChange}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Link, router } from "expo-router";
|
||||
import BaseBackground from "../components/BaseBackground";
|
||||
import AuthButton from "../components/AuthButton";
|
||||
import { AuthService } from "../services";
|
||||
import { CaldavConfigService } from "../services/CaldavConfigService";
|
||||
import { useThemeStore } from "../stores/ThemeStore";
|
||||
|
||||
const LoginScreen = () => {
|
||||
@@ -24,6 +25,11 @@ const LoginScreen = () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await AuthService.login({ identifier, password });
|
||||
try {
|
||||
await CaldavConfigService.sync();
|
||||
} catch {
|
||||
// No CalDAV config or sync failed — not critical
|
||||
}
|
||||
router.replace("/(tabs)/chat");
|
||||
} catch {
|
||||
setError("Anmeldung fehlgeschlagen. Überprüfe deine Zugangsdaten.");
|
||||
|
||||
@@ -3,6 +3,7 @@ import { View, ActivityIndicator } from "react-native";
|
||||
import { Redirect } from "expo-router";
|
||||
import { useAuthStore } from "../stores";
|
||||
import { useThemeStore } from "../stores/ThemeStore";
|
||||
import { CaldavConfigService } from "../services/CaldavConfigService";
|
||||
|
||||
type AuthGuardProps = {
|
||||
children: ReactNode;
|
||||
@@ -20,7 +21,16 @@ export const AuthGuard = ({ children }: AuthGuardProps) => {
|
||||
const { isAuthenticated, isLoading, loadStoredUser } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
loadStoredUser();
|
||||
const init = async () => {
|
||||
await loadStoredUser();
|
||||
if (!useAuthStore.getState().isAuthenticated) return;
|
||||
try {
|
||||
await CaldavConfigService.sync();
|
||||
} catch {
|
||||
// No CalDAV config or sync failed — not critical
|
||||
}
|
||||
};
|
||||
init();
|
||||
}, [loadStoredUser]);
|
||||
|
||||
if (isLoading) {
|
||||
|
||||
@@ -2,17 +2,18 @@ import { Pressable, Text } from "react-native";
|
||||
import { useThemeStore } from "../stores/ThemeStore";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type BaseButtonProps = {
|
||||
export type BaseButtonProps = {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
onPress: () => void;
|
||||
solid?: boolean;
|
||||
};
|
||||
|
||||
const BaseButton = ({ children, onPress, solid = false }: BaseButtonProps) => {
|
||||
const BaseButton = ({ className, children, onPress, solid = false }: BaseButtonProps) => {
|
||||
const { theme } = useThemeStore();
|
||||
return (
|
||||
<Pressable
|
||||
className="w-11/12 rounded-lg p-4 mb-4 border-4"
|
||||
className={`rounded-lg p-4 mb-4 border-4 ${className}`}
|
||||
onPress={onPress}
|
||||
style={{
|
||||
borderColor: theme.borderPrimary,
|
||||
|
||||
35
apps/client/src/components/CustomTextInput.tsx
Normal file
35
apps/client/src/components/CustomTextInput.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { TextInput } from "react-native";
|
||||
import { useThemeStore } from "../stores/ThemeStore";
|
||||
import { useState } from "react";
|
||||
|
||||
export type CustomTextInputProps = {
|
||||
text?: string;
|
||||
focused?: boolean;
|
||||
className?: string;
|
||||
multiline?: boolean;
|
||||
onValueChange?: (text: string) => void;
|
||||
};
|
||||
|
||||
const CustomTextInput = (props: CustomTextInputProps) => {
|
||||
const { theme } = useThemeStore();
|
||||
const [focused, setFocused] = useState(props.focused ?? false);
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
className={`border border-solid rounded-2xl px-3 py-2 h-11/12 ${props.className}`}
|
||||
onChangeText={props.onValueChange}
|
||||
value={props.text}
|
||||
multiline={props.multiline}
|
||||
style={{
|
||||
backgroundColor: theme.messageBorderBg,
|
||||
color: theme.textPrimary,
|
||||
textAlignVertical: "top",
|
||||
borderColor: focused ? theme.chatBot : theme.borderPrimary,
|
||||
}}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomTextInput;
|
||||
32
apps/client/src/services/CaldavConfigService.ts
Normal file
32
apps/client/src/services/CaldavConfigService.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { CalendarEvent, CaldavConfig } from "@calchat/shared";
|
||||
import { ApiClient } from "./ApiClient";
|
||||
|
||||
export const CaldavConfigService = {
|
||||
saveConfig: async (
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<CaldavConfig> => {
|
||||
return ApiClient.put<CaldavConfig>("/caldav/config", {
|
||||
serverUrl,
|
||||
username,
|
||||
password,
|
||||
});
|
||||
},
|
||||
getConfig: async (): Promise<CaldavConfig> => {
|
||||
return ApiClient.get<CaldavConfig>("/caldav/config");
|
||||
},
|
||||
deleteConfig: async (): Promise<void> => {
|
||||
return ApiClient.delete<void>("/caldav/config");
|
||||
},
|
||||
pull: async (): Promise<CalendarEvent[]> => {
|
||||
return ApiClient.post<CalendarEvent[]>("/caldav/pull");
|
||||
},
|
||||
pushAll: async (): Promise<void> => {
|
||||
return ApiClient.post<void>("/caldav/pushAll");
|
||||
},
|
||||
sync: async (): Promise<void> => {
|
||||
await ApiClient.post<void>("/caldav/pushAll");
|
||||
await ApiClient.post<CalendarEvent[]>("/caldav/pull");
|
||||
},
|
||||
};
|
||||
25
apps/server/docker/radicale/docker-compose.yml
Normal file
25
apps/server/docker/radicale/docker-compose.yml
Normal file
@@ -0,0 +1,25 @@
|
||||
name: Radicale
|
||||
services:
|
||||
radicale:
|
||||
image: ghcr.io/kozea/radicale:stable
|
||||
ports:
|
||||
- 5232:5232
|
||||
volumes:
|
||||
- config:/etc/radicale
|
||||
- data:/var/lib/radicale
|
||||
|
||||
volumes:
|
||||
config:
|
||||
name: radicale-config
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: ./config
|
||||
data:
|
||||
name: radicale-data
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: ./data
|
||||
4
apps/server/jest.config.js
Normal file
4
apps/server/jest.config.js
Normal file
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "node",
|
||||
};
|
||||
@@ -5,26 +5,33 @@
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/app.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/app.js"
|
||||
"start": "node dist/app.js",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@calchat/shared": "*",
|
||||
"bcrypt": "^6.0.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^5.2.1",
|
||||
"ical.js": "^2.2.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"mongoose": "^9.1.1",
|
||||
"openai": "^6.15.0",
|
||||
"pino": "^10.1.1",
|
||||
"pino-http": "^11.0.0",
|
||||
"rrule": "^2.8.1"
|
||||
"rrule": "^2.8.1",
|
||||
"tsdav": "^2.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/ical": "^0.8.3",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^24.10.1",
|
||||
"jest": "^30.2.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"ts-jest": "^29.4.6",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import { formatDate, formatTime, formatDateTime } from "@calchat/shared";
|
||||
|
||||
// Re-export from shared package for use in toolExecutor
|
||||
export { formatDate, formatTime, formatDateTime };
|
||||
@@ -1,4 +1,3 @@
|
||||
export { formatDate, formatTime, formatDateTime } from "./eventFormatter";
|
||||
export { buildSystemPrompt } from "./systemPrompt";
|
||||
export {
|
||||
TOOL_DEFINITIONS,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
RecurringDeleteMode,
|
||||
} from "@calchat/shared";
|
||||
import { AIContext } from "../../services/interfaces";
|
||||
import { formatDate, formatTime, formatDateTime } from "./eventFormatter";
|
||||
import { formatDate, formatTime, formatDateTime } from "@calchat/shared";
|
||||
|
||||
/**
|
||||
* Check if two time ranges overlap.
|
||||
|
||||
@@ -17,6 +17,9 @@ import {
|
||||
} from "./repositories";
|
||||
import { GPTAdapter } from "./ai";
|
||||
import { logger } from "./logging";
|
||||
import { MongoCaldavRepository } from "./repositories/mongo/MongoCaldavRepository";
|
||||
import { CaldavService } from "./services/CaldavService";
|
||||
import { CaldavController } from "./controllers/CaldavController";
|
||||
|
||||
const app = express();
|
||||
const port = process.env.PORT || 3000;
|
||||
@@ -51,6 +54,7 @@ if (process.env.NODE_ENV !== "production") {
|
||||
const userRepo = new MongoUserRepository();
|
||||
const eventRepo = new MongoEventRepository();
|
||||
const chatRepo = new MongoChatRepository();
|
||||
const caldavRepo = new MongoCaldavRepository();
|
||||
|
||||
// Initialize AI provider
|
||||
const aiProvider = new GPTAdapter();
|
||||
@@ -60,15 +64,16 @@ const authService = new AuthService(userRepo);
|
||||
const eventService = new EventService(eventRepo);
|
||||
const chatService = new ChatService(
|
||||
chatRepo,
|
||||
eventRepo,
|
||||
eventService,
|
||||
aiProvider,
|
||||
);
|
||||
const caldavService = new CaldavService(caldavRepo, eventService);
|
||||
|
||||
// Initialize controllers
|
||||
const authController = new AuthController(authService);
|
||||
const chatController = new ChatController(chatService);
|
||||
const eventController = new EventController(eventService);
|
||||
const chatController = new ChatController(chatService, caldavService);
|
||||
const eventController = new EventController(eventService, caldavService);
|
||||
const caldavController = new CaldavController(caldavService);
|
||||
|
||||
// Setup routes
|
||||
app.use(
|
||||
@@ -77,6 +82,7 @@ app.use(
|
||||
authController,
|
||||
chatController,
|
||||
eventController,
|
||||
caldavController
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
85
apps/server/src/controllers/CaldavController.ts
Normal file
85
apps/server/src/controllers/CaldavController.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Response } from "express";
|
||||
import { createLogger } from "../logging/logger";
|
||||
import { AuthenticatedRequest } from "./AuthMiddleware";
|
||||
import { CaldavConfig } from "@calchat/shared";
|
||||
import { CaldavService } from "../services/CaldavService";
|
||||
|
||||
const log = createLogger("CaldavController");
|
||||
|
||||
export class CaldavController {
|
||||
constructor(private caldavService: CaldavService) {}
|
||||
|
||||
async saveConfig(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const config: CaldavConfig = { userId: req.user!.userId, ...req.body };
|
||||
const response = await this.caldavService.saveConfig(config);
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
log.error({ error, userId: req.user?.userId }, "Error saving config");
|
||||
res.status(500).json({ error: "Failed to save config" });
|
||||
}
|
||||
}
|
||||
|
||||
async loadConfig(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const config = await this.caldavService.getConfig(req.user!.userId);
|
||||
if (!config) {
|
||||
res.status(404).json({ error: "No CalDAV config found" });
|
||||
return;
|
||||
}
|
||||
// Don't expose the password to the client
|
||||
res.json(config);
|
||||
} catch (error) {
|
||||
log.error({ error, userId: req.user?.userId }, "Error loading config");
|
||||
res.status(500).json({ error: "Failed to load config" });
|
||||
}
|
||||
}
|
||||
|
||||
async deleteConfig(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
await this.caldavService.deleteConfig(req.user!.userId);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
log.error({ error, userId: req.user?.userId }, "Error deleting config");
|
||||
res.status(500).json({ error: "Failed to delete config" });
|
||||
}
|
||||
}
|
||||
|
||||
async pullEvents(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const events = await this.caldavService.pullEvents(req.user!.userId);
|
||||
res.json(events);
|
||||
} catch (error) {
|
||||
log.error({ error, userId: req.user?.userId }, "Error pulling events");
|
||||
res.status(500).json({ error: "Failed to pull events" });
|
||||
}
|
||||
}
|
||||
|
||||
async pushEvents(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
await this.caldavService.pushAll(req.user!.userId);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
log.error({ error, userId: req.user?.userId }, "Error pushing events");
|
||||
res.status(500).json({ error: "Failed to push events" });
|
||||
}
|
||||
}
|
||||
|
||||
async pushEvent(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const event = await this.caldavService.findEventByCaldavUUID(
|
||||
req.user!.userId,
|
||||
req.params.caldavUUID,
|
||||
);
|
||||
if (!event) {
|
||||
res.status(404).json({ error: "Event not found" });
|
||||
return;
|
||||
}
|
||||
await this.caldavService.pushEvent(req.user!.userId, event);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
log.error({ error, userId: req.user?.userId }, "Error pushing event");
|
||||
res.status(500).json({ error: "Failed to push event" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,17 @@ import {
|
||||
RecurringDeleteMode,
|
||||
} from "@calchat/shared";
|
||||
import { ChatService } from "../services";
|
||||
import { CaldavService } from "../services/CaldavService";
|
||||
import { createLogger } from "../logging";
|
||||
import { AuthenticatedRequest } from "./AuthMiddleware";
|
||||
|
||||
const log = createLogger("ChatController");
|
||||
|
||||
export class ChatController {
|
||||
constructor(private chatService: ChatService) {}
|
||||
constructor(
|
||||
private chatService: ChatService,
|
||||
private caldavService: CaldavService,
|
||||
) {}
|
||||
|
||||
async sendMessage(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
@@ -68,6 +72,16 @@ export class ChatController {
|
||||
deleteMode,
|
||||
occurrenceDate,
|
||||
);
|
||||
|
||||
// Sync confirmed event to CalDAV
|
||||
try {
|
||||
if (await this.caldavService.getConfig(userId)) {
|
||||
await this.caldavService.pushAll(userId);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error({ error, userId }, "CalDAV push after confirm failed");
|
||||
}
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
|
||||
@@ -1,17 +1,43 @@
|
||||
import { Response } from "express";
|
||||
import { RecurringDeleteMode } from "@calchat/shared";
|
||||
import { CalendarEvent, RecurringDeleteMode } from "@calchat/shared";
|
||||
import { EventService } from "../services";
|
||||
import { createLogger } from "../logging";
|
||||
import { AuthenticatedRequest } from "./AuthMiddleware";
|
||||
import { CaldavService } from "../services/CaldavService";
|
||||
|
||||
const log = createLogger("EventController");
|
||||
|
||||
export class EventController {
|
||||
constructor(private eventService: EventService) {}
|
||||
constructor(
|
||||
private eventService: EventService,
|
||||
private caldavService: CaldavService,
|
||||
) {}
|
||||
|
||||
private async pushToCaldav(userId: string, event: CalendarEvent) {
|
||||
if (await this.caldavService.getConfig(userId)) {
|
||||
try {
|
||||
await this.caldavService.pushEvent(userId, event);
|
||||
} catch (error) {
|
||||
log.error({ error, userId }, "Error pushing event to CalDAV");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteFromCaldav(userId: string, event: CalendarEvent) {
|
||||
if (event.caldavUUID && (await this.caldavService.getConfig(userId))) {
|
||||
try {
|
||||
await this.caldavService.deleteEvent(userId, event.caldavUUID);
|
||||
} catch (error) {
|
||||
log.error({ error, userId }, "Error deleting event from CalDAV");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async create(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const event = await this.eventService.create(req.user!.userId, req.body);
|
||||
const userId = req.user!.userId;
|
||||
const event = await this.eventService.create(userId, req.body);
|
||||
await this.pushToCaldav(userId, event);
|
||||
res.status(201).json(event);
|
||||
} catch (error) {
|
||||
log.error({ error, userId: req.user?.userId }, "Error creating event");
|
||||
@@ -83,15 +109,19 @@ export class EventController {
|
||||
|
||||
async update(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user!.userId;
|
||||
const event = await this.eventService.update(
|
||||
req.params.id,
|
||||
req.user!.userId,
|
||||
userId,
|
||||
req.body,
|
||||
);
|
||||
if (!event) {
|
||||
res.status(404).json({ error: "Event not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await this.pushToCaldav(userId, event);
|
||||
|
||||
res.json(event);
|
||||
} catch (error) {
|
||||
log.error({ error, eventId: req.params.id }, "Error updating event");
|
||||
@@ -101,46 +131,44 @@ export class EventController {
|
||||
|
||||
async delete(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user!.userId;
|
||||
const { mode, occurrenceDate } = req.query as {
|
||||
mode?: RecurringDeleteMode;
|
||||
occurrenceDate?: string;
|
||||
};
|
||||
|
||||
// Fetch event before deletion to get caldavUUID for sync
|
||||
const event = await this.eventService.getById(req.params.id, userId);
|
||||
if (!event) {
|
||||
res.status(404).json({ error: "Event not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
// If mode is specified, use deleteRecurring
|
||||
if (mode) {
|
||||
const result = await this.eventService.deleteRecurring(
|
||||
req.params.id,
|
||||
req.user!.userId,
|
||||
userId,
|
||||
mode,
|
||||
occurrenceDate,
|
||||
);
|
||||
|
||||
// For 'all' mode or when event was completely deleted, return 204
|
||||
if (result === null && mode === "all") {
|
||||
res.status(204).send();
|
||||
return;
|
||||
}
|
||||
|
||||
// For 'single' or 'future' modes, return updated event
|
||||
// Event was updated (single/future mode) - push update to CalDAV
|
||||
if (result) {
|
||||
await this.pushToCaldav(userId, result);
|
||||
res.json(result);
|
||||
return;
|
||||
}
|
||||
|
||||
// result is null but mode wasn't 'all' - event not found or was deleted
|
||||
// Event was fully deleted (all mode, or future from first occurrence)
|
||||
await this.deleteFromCaldav(userId, event);
|
||||
res.status(204).send();
|
||||
return;
|
||||
}
|
||||
|
||||
// Default behavior: delete completely
|
||||
const deleted = await this.eventService.delete(
|
||||
req.params.id,
|
||||
req.user!.userId,
|
||||
);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: "Event not found" });
|
||||
return;
|
||||
}
|
||||
await this.eventService.delete(req.params.id, userId);
|
||||
await this.deleteFromCaldav(userId, event);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
log.error({ error, eventId: req.params.id }, "Error deleting event");
|
||||
|
||||
@@ -3,3 +3,4 @@ export * from "./ChatController";
|
||||
export * from "./EventController";
|
||||
export * from "./AuthMiddleware";
|
||||
export * from "./LoggingMiddleware";
|
||||
export * from "./CaldavController";
|
||||
|
||||
31
apps/server/src/repositories/mongo/MongoCaldavRepository.ts
Normal file
31
apps/server/src/repositories/mongo/MongoCaldavRepository.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { CaldavConfig } from "@calchat/shared";
|
||||
import { Logged } from "../../logging/Logged";
|
||||
import { CaldavRepository } from "../../services/interfaces/CaldavRepository";
|
||||
import { CaldavConfigModel } from "./models/CaldavConfigModel";
|
||||
|
||||
@Logged("MongoCaldavRepository")
|
||||
export class MongoCaldavRepository implements CaldavRepository {
|
||||
async findByUserId(userId: string): Promise<CaldavConfig | null> {
|
||||
const config = await CaldavConfigModel.findOne({ userId });
|
||||
if (!config) return null;
|
||||
return config.toJSON() as unknown as CaldavConfig;
|
||||
}
|
||||
|
||||
async createOrUpdate(config: CaldavConfig): Promise<CaldavConfig> {
|
||||
const caldavConfig = await CaldavConfigModel.findOneAndUpdate(
|
||||
{ userId: config.userId },
|
||||
config,
|
||||
{
|
||||
upsert: true,
|
||||
new: true,
|
||||
},
|
||||
);
|
||||
// NOTE: Casting required because Mongoose's toJSON() type doesn't reflect our virtual 'id' field
|
||||
return caldavConfig.toJSON() as unknown as CaldavConfig;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<boolean> {
|
||||
const result = await CaldavConfigModel.findOneAndDelete({userId});
|
||||
return result !== null;
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,12 @@ export class MongoEventRepository implements EventRepository {
|
||||
return events.map((e) => e.toJSON() as unknown as CalendarEvent);
|
||||
}
|
||||
|
||||
async findByCaldavUUID(userId: string, caldavUUID: string): Promise<CalendarEvent | null> {
|
||||
const event = await EventModel.findOne({ userId, caldavUUID });
|
||||
if (!event) return null;
|
||||
return event.toJSON() as unknown as CalendarEvent;
|
||||
}
|
||||
|
||||
async searchByTitle(userId: string, query: string): Promise<CalendarEvent[]> {
|
||||
const events = await EventModel.find({
|
||||
userId,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { CaldavConfig } from "@calchat/shared";
|
||||
import mongoose, { Document, Schema } from "mongoose";
|
||||
|
||||
export interface CaldavConfigDocument extends CaldavConfig, Document {
|
||||
toJSON(): CaldavConfig;
|
||||
}
|
||||
|
||||
const CaldavConfigSchema = new Schema<CaldavConfigDocument>(
|
||||
{
|
||||
userId: { type: String, required: true, index: true },
|
||||
serverUrl: { type: String, required: true },
|
||||
username: { type: String, required: true },
|
||||
password: { type: String, required: true },
|
||||
syncIntervalSeconds: { type: Number },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
export const CaldavConfigModel = mongoose.model<CaldavConfigDocument>(
|
||||
"CaldavConfig",
|
||||
CaldavConfigSchema,
|
||||
);
|
||||
@@ -21,6 +21,12 @@ const EventSchema = new Schema<
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
caldavUUID: {
|
||||
type: String,
|
||||
},
|
||||
etag: {
|
||||
type: String,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
|
||||
22
apps/server/src/routes/caldav.routes.ts
Normal file
22
apps/server/src/routes/caldav.routes.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Router } from "express";
|
||||
import { authenticate } from "../controllers";
|
||||
import { CaldavController } from "../controllers/CaldavController";
|
||||
|
||||
export function createCaldavRoutes(caldavController: CaldavController): Router {
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
router.put("/config", (req, res) => caldavController.saveConfig(req, res));
|
||||
router.get("/config", (req, res) => caldavController.loadConfig(req, res));
|
||||
router.delete("/config", (req, res) =>
|
||||
caldavController.deleteConfig(req, res),
|
||||
);
|
||||
router.post("/pull", (req, res) => caldavController.pullEvents(req, res));
|
||||
router.post("/pushAll", (req, res) => caldavController.pushEvents(req, res));
|
||||
router.post("/push/:caldavUUID", (req, res) =>
|
||||
caldavController.pushEvent(req, res),
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -6,12 +6,15 @@ import {
|
||||
AuthController,
|
||||
ChatController,
|
||||
EventController,
|
||||
CaldavController
|
||||
} from "../controllers";
|
||||
import { createCaldavRoutes } from "./caldav.routes";
|
||||
|
||||
export interface Controllers {
|
||||
authController: AuthController;
|
||||
chatController: ChatController;
|
||||
eventController: EventController;
|
||||
caldavController: CaldavController;
|
||||
}
|
||||
|
||||
export function createRoutes(controllers: Controllers): Router {
|
||||
@@ -20,6 +23,7 @@ export function createRoutes(controllers: Controllers): Router {
|
||||
router.use("/auth", createAuthRoutes(controllers.authController));
|
||||
router.use("/chat", createChatRoutes(controllers.chatController));
|
||||
router.use("/events", createEventRoutes(controllers.eventController));
|
||||
router.use("/caldav", createCaldavRoutes(controllers.caldavController));
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
11
apps/server/src/services/CaldavService.test.ts
Normal file
11
apps/server/src/services/CaldavService.test.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// import { createLogger } from "../logging";
|
||||
// import { CaldavService } from "./CaldavService";
|
||||
//
|
||||
// const logger = createLogger("CaldavService-Test");
|
||||
//
|
||||
// const cdService = new CaldavService();
|
||||
//
|
||||
// test("print events", async () => {
|
||||
// const client = await cdService.login();
|
||||
// await cdService.pullEvents(client);
|
||||
// });
|
||||
260
apps/server/src/services/CaldavService.ts
Normal file
260
apps/server/src/services/CaldavService.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import crypto from "crypto";
|
||||
import { DAVClient } from "tsdav";
|
||||
import ICAL from "ical.js";
|
||||
import { createLogger } from "../logging/logger";
|
||||
import { CaldavRepository } from "./interfaces/CaldavRepository";
|
||||
import {
|
||||
CalendarEvent,
|
||||
CreateEventDTO,
|
||||
} from "@calchat/shared/src/models/CalendarEvent";
|
||||
import { EventService } from "./EventService";
|
||||
import { CaldavConfig, formatDateKey } from "@calchat/shared";
|
||||
|
||||
const logger = createLogger("CaldavService");
|
||||
|
||||
export class CaldavService {
|
||||
constructor(
|
||||
private caldavRepo: CaldavRepository,
|
||||
private eventService: EventService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Login to CalDAV server and return client + first calendar.
|
||||
*/
|
||||
async connect(userId: string) {
|
||||
const config = await this.caldavRepo.findByUserId(userId);
|
||||
if (config === null) {
|
||||
throw new Error(`Coudn't find config by user id ${userId}`);
|
||||
}
|
||||
const client = new DAVClient({
|
||||
serverUrl: config.serverUrl,
|
||||
credentials: {
|
||||
username: config.username,
|
||||
password: config.password,
|
||||
},
|
||||
authMethod: "Basic",
|
||||
defaultAccountType: "caldav",
|
||||
});
|
||||
|
||||
try {
|
||||
await client.login();
|
||||
} catch (error) {
|
||||
throw new Error("Caldav login failed");
|
||||
}
|
||||
|
||||
const calendars = await client.fetchCalendars();
|
||||
if (calendars.length === 0) {
|
||||
throw new Error("No calendars found on CalDAV server");
|
||||
}
|
||||
|
||||
return { client, calendar: calendars[0] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull events from CalDAV server and sync with local database.
|
||||
* - Compares etags to skip unchanged events
|
||||
* - Creates new or updates existing events in the database
|
||||
* - Deletes local events that were removed on the CalDAV server
|
||||
*
|
||||
* @returns List of newly created or updated events
|
||||
*/
|
||||
async pullEvents(userId: string): Promise<CalendarEvent[]> {
|
||||
const { client, calendar } = await this.connect(userId);
|
||||
const calendarEvents: CalendarEvent[] = [];
|
||||
const caldavEventUUIDs = new Set<string>();
|
||||
|
||||
const events = await client.fetchCalendarObjects({ calendar });
|
||||
for (const event of events) {
|
||||
const etag = event.etag;
|
||||
const jcal = ICAL.parse(event.data);
|
||||
const comp = new ICAL.Component(jcal);
|
||||
// A CalendarObject (.ics file) can contain multiple VEVENTs (e.g.
|
||||
// recurring events with RECURRENCE-ID exceptions), but the etag belongs
|
||||
// to the whole file, not individual VEVENTs. We only need the first
|
||||
// VEVENT since we handle recurrence via RRULE/exceptionDates, not as
|
||||
// separate events.
|
||||
const vevent = comp.getFirstSubcomponent("vevent");
|
||||
if (!vevent) continue;
|
||||
|
||||
const icalEvent = new ICAL.Event(vevent);
|
||||
caldavEventUUIDs.add(icalEvent.uid);
|
||||
|
||||
const exceptionDates = vevent
|
||||
.getAllProperties("exdate")
|
||||
.flatMap((prop) => prop.getValues())
|
||||
.map((time: ICAL.Time) => formatDateKey(time.toJSDate()));
|
||||
|
||||
const existingEvent = await this.eventService.findByCaldavUUID(
|
||||
userId,
|
||||
icalEvent.uid,
|
||||
);
|
||||
|
||||
const didChange = existingEvent?.etag !== etag;
|
||||
|
||||
if (existingEvent && !didChange) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const eventObject: CreateEventDTO = {
|
||||
caldavUUID: icalEvent.uid,
|
||||
etag,
|
||||
title: icalEvent.summary,
|
||||
description: icalEvent.description,
|
||||
startTime: icalEvent.startDate.toJSDate(),
|
||||
endTime: icalEvent.endDate.toJSDate(),
|
||||
recurrenceRule: vevent.getFirstPropertyValue("rrule")?.toString(),
|
||||
exceptionDates,
|
||||
caldavSyncStatus: "synced",
|
||||
};
|
||||
|
||||
const calendarEvent = existingEvent
|
||||
? await this.eventService.update(existingEvent.id, userId, eventObject)
|
||||
: await this.eventService.create(userId, eventObject);
|
||||
|
||||
if (calendarEvent) {
|
||||
calendarEvents.push(calendarEvent);
|
||||
}
|
||||
}
|
||||
|
||||
// delete all events, that got deleted remotely
|
||||
const localEvents = await this.eventService.getAll(userId);
|
||||
for (const localEvent of localEvents) {
|
||||
if (
|
||||
localEvent.caldavUUID &&
|
||||
!caldavEventUUIDs.has(localEvent.caldavUUID)
|
||||
) {
|
||||
await this.eventService.delete(localEvent.id, userId);
|
||||
}
|
||||
}
|
||||
|
||||
return calendarEvents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a single event to the CalDAV server.
|
||||
* Creates a new event if no caldavUUID exists, updates otherwise.
|
||||
*/
|
||||
async pushEvent(userId: string, event: CalendarEvent): Promise<void> {
|
||||
const { client, calendar } = await this.connect(userId);
|
||||
|
||||
try {
|
||||
if (event.caldavUUID) {
|
||||
await client.updateCalendarObject({
|
||||
calendarObject: {
|
||||
url: `${calendar.url}${event.caldavUUID}.ics`,
|
||||
data: this.toICalString(event.caldavUUID, event),
|
||||
etag: event.etag || "",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const uid = crypto.randomUUID();
|
||||
await client.createCalendarObject({
|
||||
calendar,
|
||||
filename: `${uid}.ics`,
|
||||
iCalString: this.toICalString(uid, event),
|
||||
});
|
||||
await this.eventService.update(event.id, userId, { caldavUUID: uid });
|
||||
}
|
||||
|
||||
// Fetch updated etag from server
|
||||
const objects = await client.fetchCalendarObjects({ calendar });
|
||||
const caldavUUID =
|
||||
event.caldavUUID ||
|
||||
(await this.eventService.getById(event.id, userId))?.caldavUUID;
|
||||
const pushed = objects.find((o) => o.data?.includes(caldavUUID!));
|
||||
|
||||
await this.eventService.update(event.id, userId, {
|
||||
etag: pushed?.etag || undefined,
|
||||
caldavSyncStatus: "synced",
|
||||
});
|
||||
} catch (error) {
|
||||
await this.eventService.update(event.id, userId, {
|
||||
caldavSyncStatus: "error",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an iCalendar string from a CalendarEvent using ical.js.
|
||||
*/
|
||||
private toICalString(uid: string, event: CalendarEvent): string {
|
||||
const vcalendar = new ICAL.Component("vcalendar");
|
||||
vcalendar.addPropertyWithValue("version", "2.0");
|
||||
vcalendar.addPropertyWithValue("prodid", "-//CalChat//EN");
|
||||
|
||||
const vevent = new ICAL.Component("vevent");
|
||||
vevent.addPropertyWithValue("uid", uid);
|
||||
vevent.addPropertyWithValue("summary", event.title);
|
||||
vevent.addPropertyWithValue(
|
||||
"dtstart",
|
||||
ICAL.Time.fromJSDate(new Date(event.startTime)),
|
||||
);
|
||||
vevent.addPropertyWithValue(
|
||||
"dtend",
|
||||
ICAL.Time.fromJSDate(new Date(event.endTime)),
|
||||
);
|
||||
|
||||
if (event.description) {
|
||||
vevent.addPropertyWithValue("description", event.description);
|
||||
}
|
||||
|
||||
if (event.recurrenceRule) {
|
||||
// Strip RRULE: prefix if present — fromString expects only the value part,
|
||||
// and addPropertyWithValue("rrule", ...) adds the RRULE: prefix automatically.
|
||||
const rule = event.recurrenceRule.replace(/^RRULE:/i, "");
|
||||
vevent.addPropertyWithValue("rrule", ICAL.Recur.fromString(rule));
|
||||
}
|
||||
|
||||
if (event.exceptionDates?.length) {
|
||||
for (const exdate of event.exceptionDates) {
|
||||
vevent.addPropertyWithValue("exdate", ICAL.Time.fromDateString(exdate));
|
||||
}
|
||||
}
|
||||
|
||||
vcalendar.addSubcomponent(vevent);
|
||||
return vcalendar.toString();
|
||||
}
|
||||
|
||||
async pushAll(userId: string): Promise<void> {
|
||||
const allEvents = await this.eventService.getAll(userId);
|
||||
for (const event of allEvents) {
|
||||
if (event.caldavSyncStatus !== "synced") {
|
||||
await this.pushEvent(userId, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async deleteEvent(userId: string, caldavUUID: string) {
|
||||
const { client, calendar } = await this.connect(userId);
|
||||
|
||||
await client.deleteCalendarObject({
|
||||
calendarObject: {
|
||||
url: `${calendar.url}${caldavUUID}.ics`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findEventByCaldavUUID(userId: string, caldavUUID: string) {
|
||||
return this.eventService.findByCaldavUUID(userId, caldavUUID);
|
||||
}
|
||||
|
||||
async getConfig(userId: string): Promise<CaldavConfig | null> {
|
||||
return this.caldavRepo.findByUserId(userId);
|
||||
}
|
||||
|
||||
async saveConfig(config: CaldavConfig): Promise<CaldavConfig> {
|
||||
const savedConfig = await this.caldavRepo.createOrUpdate(config);
|
||||
try {
|
||||
await this.connect(savedConfig.userId);
|
||||
} catch (error) {
|
||||
await this.caldavRepo.deleteByUserId(savedConfig.userId);
|
||||
throw new Error("failed to connect");
|
||||
}
|
||||
return savedConfig;
|
||||
}
|
||||
|
||||
async deleteConfig(userId: string) {
|
||||
return await this.caldavRepo.deleteByUserId(userId);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
RecurringDeleteMode,
|
||||
ConflictingEvent,
|
||||
} from "@calchat/shared";
|
||||
import { ChatRepository, EventRepository, AIProvider } from "./interfaces";
|
||||
import { ChatRepository, AIProvider } from "./interfaces";
|
||||
import { EventService } from "./EventService";
|
||||
import { getWeeksOverview, getMonthOverview } from "../utils/eventFormatters";
|
||||
|
||||
@@ -333,7 +333,7 @@ const staticResponses: TestResponse[] = [
|
||||
|
||||
async function getTestResponse(
|
||||
index: number,
|
||||
eventRepo: EventRepository,
|
||||
eventService: EventService,
|
||||
userId: string,
|
||||
): Promise<TestResponse> {
|
||||
const responseIdx = index % staticResponses.length;
|
||||
@@ -341,7 +341,7 @@ async function getTestResponse(
|
||||
// === SPORT TEST SCENARIO (Dynamic responses) ===
|
||||
// Response 1: Add exception to "Sport" (2 weeks later)
|
||||
if (responseIdx === 1) {
|
||||
const events = await eventRepo.findByUserId(userId);
|
||||
const events = await eventService.getAll(userId);
|
||||
const sportEvent = events.find((e) => e.title === "Sport");
|
||||
if (sportEvent) {
|
||||
// Calculate date 2 weeks from the first occurrence
|
||||
@@ -380,7 +380,7 @@ async function getTestResponse(
|
||||
|
||||
// Response 2: Add UNTIL to "Sport" (after 6 weeks total)
|
||||
if (responseIdx === 2) {
|
||||
const events = await eventRepo.findByUserId(userId);
|
||||
const events = await eventService.getAll(userId);
|
||||
const sportEvent = events.find((e) => e.title === "Sport");
|
||||
if (sportEvent) {
|
||||
// Calculate UNTIL date: 6 weeks from start
|
||||
@@ -418,7 +418,7 @@ async function getTestResponse(
|
||||
|
||||
// Response 3: Add another exception to "Sport" (2 weeks after the first exception)
|
||||
if (responseIdx === 3) {
|
||||
const events = await eventRepo.findByUserId(userId);
|
||||
const events = await eventService.getAll(userId);
|
||||
const sportEvent = events.find((e) => e.title === "Sport");
|
||||
if (sportEvent) {
|
||||
// Calculate date 4 weeks from the first occurrence (2 weeks after the first exception)
|
||||
@@ -458,12 +458,12 @@ async function getTestResponse(
|
||||
// Dynamic responses: fetch events from DB and format
|
||||
// (Note: indices shifted by +3 due to new sport responses)
|
||||
if (responseIdx === 6) {
|
||||
return { content: await getWeeksOverview(eventRepo, userId, 2) };
|
||||
return { content: await getWeeksOverview(eventService, userId, 2) };
|
||||
}
|
||||
|
||||
if (responseIdx === 7) {
|
||||
// Delete "Meeting mit Jens"
|
||||
const events = await eventRepo.findByUserId(userId);
|
||||
const events = await eventService.getAll(userId);
|
||||
const jensEvent = events.find((e) => e.title === "Meeting mit Jens");
|
||||
if (jensEvent) {
|
||||
return {
|
||||
@@ -487,12 +487,12 @@ async function getTestResponse(
|
||||
}
|
||||
|
||||
if (responseIdx === 11) {
|
||||
return { content: await getWeeksOverview(eventRepo, userId, 1) };
|
||||
return { content: await getWeeksOverview(eventService, userId, 1) };
|
||||
}
|
||||
|
||||
if (responseIdx === 13) {
|
||||
// Update "Telefonat mit Mama" +3 days and change time to 13:00
|
||||
const events = await eventRepo.findByUserId(userId);
|
||||
const events = await eventService.getAll(userId);
|
||||
const mamaEvent = events.find((e) => e.title === "Telefonat mit Mama");
|
||||
if (mamaEvent) {
|
||||
const newStart = new Date(mamaEvent.startTime);
|
||||
@@ -527,7 +527,7 @@ async function getTestResponse(
|
||||
const now = new Date();
|
||||
return {
|
||||
content: await getMonthOverview(
|
||||
eventRepo,
|
||||
eventService,
|
||||
userId,
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
@@ -541,7 +541,6 @@ async function getTestResponse(
|
||||
export class ChatService {
|
||||
constructor(
|
||||
private chatRepo: ChatRepository,
|
||||
private eventRepo: EventRepository,
|
||||
private eventService: EventService,
|
||||
private aiProvider: AIProvider,
|
||||
) {}
|
||||
@@ -566,7 +565,7 @@ export class ChatService {
|
||||
|
||||
if (process.env.USE_TEST_RESPONSES === "true") {
|
||||
// Test mode: use static responses
|
||||
response = await getTestResponse(responseIndex, this.eventRepo, userId);
|
||||
response = await getTestResponse(responseIndex, this.eventService, userId);
|
||||
responseIndex++;
|
||||
} else {
|
||||
// Production mode: use real AI
|
||||
@@ -582,7 +581,7 @@ export class ChatService {
|
||||
return this.eventService.getByDateRange(userId, start, end);
|
||||
},
|
||||
searchEvents: async (query) => {
|
||||
return this.eventRepo.searchByTitle(userId, query);
|
||||
return this.eventService.searchByTitle(userId, query);
|
||||
},
|
||||
fetchEventById: async (eventId) => {
|
||||
return this.eventService.getById(eventId, userId);
|
||||
@@ -623,10 +622,10 @@ export class ChatService {
|
||||
let content: string;
|
||||
|
||||
if (action === "create" && event) {
|
||||
const createdEvent = await this.eventRepo.create(userId, event);
|
||||
const createdEvent = await this.eventService.create(userId, event);
|
||||
content = `Der Termin "${createdEvent.title}" wurde erstellt.`;
|
||||
} else if (action === "update" && eventId && updates) {
|
||||
const updatedEvent = await this.eventRepo.update(eventId, updates);
|
||||
const updatedEvent = await this.eventService.update(eventId, userId, updates);
|
||||
content = updatedEvent
|
||||
? `Der Termin "${updatedEvent.title}" wurde aktualisiert.`
|
||||
: "Termin nicht gefunden.";
|
||||
|
||||
@@ -24,10 +24,18 @@ export class EventService {
|
||||
return event;
|
||||
}
|
||||
|
||||
async findByCaldavUUID(userId: string, caldavUUID: string): Promise<CalendarEvent | null> {
|
||||
return this.eventRepo.findByCaldavUUID(userId, caldavUUID);
|
||||
}
|
||||
|
||||
async getAll(userId: string): Promise<CalendarEvent[]> {
|
||||
return this.eventRepo.findByUserId(userId);
|
||||
}
|
||||
|
||||
async searchByTitle(userId: string, query: string): Promise<CalendarEvent[]> {
|
||||
return this.eventRepo.searchByTitle(userId, query);
|
||||
}
|
||||
|
||||
async getByDateRange(
|
||||
userId: string,
|
||||
startDate: Date,
|
||||
|
||||
7
apps/server/src/services/interfaces/CaldavRepository.ts
Normal file
7
apps/server/src/services/interfaces/CaldavRepository.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { CaldavConfig } from "@calchat/shared/src/models/CaldavConfig";
|
||||
|
||||
export interface CaldavRepository {
|
||||
findByUserId(userId: string): Promise<CaldavConfig | null>;
|
||||
createOrUpdate(config: CaldavConfig): Promise<CaldavConfig>;
|
||||
deleteByUserId(userId: string): Promise<boolean>;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export interface EventRepository {
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
): Promise<CalendarEvent[]>;
|
||||
findByCaldavUUID(userId: string, caldavUUID: string): Promise<CalendarEvent | null>;
|
||||
searchByTitle(userId: string, query: string): Promise<CalendarEvent[]>;
|
||||
create(userId: string, data: CreateEventDTO): Promise<CalendarEvent>;
|
||||
update(id: string, data: UpdateEventDTO): Promise<CalendarEvent | null>;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
MONTH_TO_GERMAN,
|
||||
ExpandedEvent,
|
||||
} from "@calchat/shared";
|
||||
import { EventRepository } from "../services/interfaces";
|
||||
import { EventService } from "../services/EventService";
|
||||
import { expandRecurringEvents } from "./recurrenceExpander";
|
||||
|
||||
// Private formatting helpers
|
||||
@@ -107,13 +107,13 @@ function formatMonthText(events: ExpandedEvent[], monthName: string): string {
|
||||
* Recurring events are expanded to show all occurrences within the range.
|
||||
*/
|
||||
export async function getWeeksOverview(
|
||||
eventRepo: EventRepository,
|
||||
eventService: EventService,
|
||||
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 events = await eventService.getAll(userId);
|
||||
const expanded = expandRecurringEvents(events, now, endDate);
|
||||
return formatWeeksText(expanded, weeks);
|
||||
}
|
||||
@@ -123,14 +123,14 @@ export async function getWeeksOverview(
|
||||
* Recurring events are expanded to show all occurrences within the month.
|
||||
*/
|
||||
export async function getMonthOverview(
|
||||
eventRepo: EventRepository,
|
||||
eventService: EventService,
|
||||
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 events = await eventService.getAll(userId);
|
||||
const expanded = expandRecurringEvents(events, startOfMonth, endOfMonth);
|
||||
const monthName = MONTH_TO_GERMAN[MONTHS[month]];
|
||||
return formatMonthText(expanded, monthName);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { RRule, rrulestr } from "rrule";
|
||||
import { CalendarEvent, ExpandedEvent } from "@calchat/shared";
|
||||
import { CalendarEvent, ExpandedEvent, formatDateKey } from "@calchat/shared";
|
||||
|
||||
// Convert local time to "fake UTC" for rrule
|
||||
// rrule interprets all dates as UTC internally, so we need to trick it
|
||||
@@ -133,11 +133,3 @@ function formatRRuleDateString(date: Date): string {
|
||||
const seconds = String(date.getSeconds()).padStart(2, "0");
|
||||
return `${year}${month}${day}T${hours}${minutes}${seconds}`;
|
||||
}
|
||||
|
||||
// Format date as YYYY-MM-DD for exception date comparison
|
||||
function formatDateKey(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
"emitDecoratorMetadata": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user