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:
2026-02-08 19:24:59 +01:00
parent 81221d8b70
commit 325246826a
44 changed files with 7074 additions and 126 deletions

2
.gitignore vendored
View File

@@ -3,3 +3,5 @@ node_modules
docs/praesi_2_context.md docs/praesi_2_context.md
docs/*.png docs/*.png
.env .env
apps/server/docker/radicale/config/
apps/server/docker/radicale/data/

100
CLAUDE.md
View File

@@ -51,6 +51,8 @@ npm run start -w @calchat/server # Run compiled server (port 3000)
| | X-User-Id Header | Authentication (simple, no JWT yet) | | | X-User-Id Header | Authentication (simple, no JWT yet) |
| | pino / pino-http | Structured logging | | | pino / pino-http | Structured logging |
| | react-native-logs | Client-side logging | | | react-native-logs | Client-side logging |
| | tsdav | CalDAV client library |
| | ical.js | iCalendar parsing/generation |
| Planned | iCalendar | Event export/import | | Planned | iCalendar | Event export/import |
## Architecture ## Architecture
@@ -82,7 +84,7 @@ src/
│ └── note/ │ └── note/
│ └── [id].tsx # Note editor for event (dynamic route) │ └── [id].tsx # Note editor for event (dynamic route)
├── components/ ├── components/
│ ├── AuthGuard.tsx # Auth wrapper: loads user, shows loading, redirects if unauthenticated │ ├── AuthGuard.tsx # Auth wrapper: loads user, CalDAV sync on auto-login, shows loading, redirects if unauthenticated
│ ├── BaseBackground.tsx # Common screen wrapper (themed) │ ├── BaseBackground.tsx # Common screen wrapper (themed)
│ ├── BaseButton.tsx # Reusable button component (themed, supports children) │ ├── BaseButton.tsx # Reusable button component (themed, supports children)
│ ├── Header.tsx # Header component (themed) │ ├── Header.tsx # Header component (themed)
@@ -96,6 +98,7 @@ src/
│ ├── EventConfirmDialog.tsx # AI-proposed event confirmation modal (skeleton) │ ├── EventConfirmDialog.tsx # AI-proposed event confirmation modal (skeleton)
│ ├── ProposedEventCard.tsx # Chat event proposal (uses EventCardBase + confirm/reject/edit buttons) │ ├── ProposedEventCard.tsx # Chat event proposal (uses EventCardBase + confirm/reject/edit buttons)
│ ├── DeleteEventModal.tsx # Delete confirmation modal (uses ModalBase) │ ├── DeleteEventModal.tsx # Delete confirmation modal (uses ModalBase)
│ ├── CustomTextInput.tsx # Themed text input with focus border (used in CaldavSettings)
│ ├── DateTimePicker.tsx # Date and time picker components │ ├── DateTimePicker.tsx # Date and time picker components
│ └── ScrollableDropdown.tsx # Scrollable dropdown component │ └── ScrollableDropdown.tsx # Scrollable dropdown component
├── Themes.tsx # Theme definitions: THEMES object with defaultLight/defaultDark, Theme type ├── Themes.tsx # Theme definitions: THEMES object with defaultLight/defaultDark, Theme type
@@ -107,7 +110,8 @@ src/
│ ├── ApiClient.ts # HTTP client with X-User-Id header injection, request logging, handles empty responses (204) │ ├── ApiClient.ts # HTTP client with X-User-Id header injection, request logging, handles empty responses (204)
│ ├── AuthService.ts # login(), register(), logout() - calls API and updates AuthStore │ ├── AuthService.ts # login(), register(), logout() - calls API and updates AuthStore
│ ├── EventService.ts # getAll(), getById(), getByDateRange(), create(), update(), delete(mode, occurrenceDate) │ ├── EventService.ts # getAll(), getById(), getByDateRange(), create(), update(), delete(mode, occurrenceDate)
── ChatService.ts # sendMessage(), confirmEvent(deleteMode, occurrenceDate), rejectEvent(), getConversations(), getConversation(), updateProposalEvent() ── ChatService.ts # sendMessage(), confirmEvent(deleteMode, occurrenceDate), rejectEvent(), getConversations(), getConversation(), updateProposalEvent()
│ └── CaldavConfigService.ts # saveConfig(), getConfig(), deleteConfig(), pull(), pushAll(), sync()
├── stores/ # Zustand state management ├── stores/ # Zustand state management
│ ├── index.ts # Re-exports all stores │ ├── index.ts # Re-exports all stores
│ ├── AuthStore.ts # user, isAuthenticated, isLoading, login(), logout(), loadStoredUser() │ ├── AuthStore.ts # user, isAuthenticated, isLoading, login(), logout(), loadStoredUser()
@@ -228,8 +232,9 @@ src/
├── app.ts # Entry point, DI setup, Express config ├── app.ts # Entry point, DI setup, Express config
├── controllers/ # Request handlers + middleware (per architecture diagram) ├── controllers/ # Request handlers + middleware (per architecture diagram)
│ ├── AuthController.ts # login(), register(), refresh(), logout() │ ├── AuthController.ts # login(), register(), refresh(), logout()
│ ├── ChatController.ts # sendMessage(), confirmEvent(), rejectEvent(), getConversations(), getConversation(), updateProposalEvent() │ ├── ChatController.ts # sendMessage(), confirmEvent() + CalDAV push, rejectEvent(), getConversations(), getConversation(), updateProposalEvent()
│ ├── EventController.ts # create(), getById(), getAll(), getByDateRange(), update(), delete() │ ├── EventController.ts # create(), getById(), getAll(), getByDateRange(), update(), delete() - pushes/deletes to CalDAV on mutations
│ ├── CaldavController.ts # saveConfig(), loadConfig(), deleteConfig(), pullEvents(), pushEvents(), pushEvent()
│ ├── AuthMiddleware.ts # authenticate() - X-User-Id header validation │ ├── AuthMiddleware.ts # authenticate() - X-User-Id header validation
│ └── LoggingMiddleware.ts # httpLogger - pino-http request logging │ └── LoggingMiddleware.ts # httpLogger - pino-http request logging
├── logging/ ├── logging/
@@ -240,16 +245,19 @@ src/
│ ├── index.ts # Combines all routes under /api │ ├── index.ts # Combines all routes under /api
│ ├── auth.routes.ts # /api/auth/* │ ├── auth.routes.ts # /api/auth/*
│ ├── chat.routes.ts # /api/chat/* (protected) │ ├── chat.routes.ts # /api/chat/* (protected)
── event.routes.ts # /api/events/* (protected) ── event.routes.ts # /api/events/* (protected)
│ └── caldav.routes.ts # /api/caldav/* (protected)
├── services/ # Business logic ├── services/ # Business logic
│ ├── interfaces/ # DB-agnostic interfaces (for dependency injection) │ ├── interfaces/ # DB-agnostic interfaces (for dependency injection)
│ │ ├── AIProvider.ts # processMessage() │ │ ├── AIProvider.ts # processMessage()
│ │ ├── UserRepository.ts # findById, findByEmail, findByUserName, create + CreateUserData │ │ ├── UserRepository.ts # findById, findByEmail, findByUserName, create + CreateUserData
│ │ ├── EventRepository.ts │ │ ├── EventRepository.ts
│ │ ── ChatRepository.ts │ │ ── ChatRepository.ts
│ │ └── CaldavRepository.ts
│ ├── AuthService.ts │ ├── AuthService.ts
│ ├── ChatService.ts │ ├── ChatService.ts
── EventService.ts ── EventService.ts
│ └── CaldavService.ts # connect(), pullEvents(), pushEvent(), pushAll(), deleteEvent(), sync logic
├── repositories/ # Data access (DB-specific implementations) ├── repositories/ # Data access (DB-specific implementations)
│ ├── index.ts # Re-exports from ./mongo │ ├── index.ts # Re-exports from ./mongo
│ └── mongo/ # MongoDB implementation │ └── mongo/ # MongoDB implementation
@@ -257,10 +265,12 @@ src/
│ │ ├── types.ts # Shared types (IdVirtual interface) │ │ ├── types.ts # Shared types (IdVirtual interface)
│ │ ├── UserModel.ts │ │ ├── UserModel.ts
│ │ ├── EventModel.ts │ │ ├── EventModel.ts
│ │ ── ChatModel.ts │ │ ── ChatModel.ts
│ │ └── CaldavConfigModel.ts
│ ├── MongoUserRepository.ts # findById, findByEmail, findByUserName, create │ ├── MongoUserRepository.ts # findById, findByEmail, findByUserName, create
│ ├── MongoEventRepository.ts │ ├── MongoEventRepository.ts
── MongoChatRepository.ts ── MongoChatRepository.ts
│ └── MongoCaldavRepository.ts
├── ai/ ├── ai/
│ ├── GPTAdapter.ts # Implements AIProvider using OpenAI GPT │ ├── GPTAdapter.ts # Implements AIProvider using OpenAI GPT
│ ├── index.ts # Re-exports GPTAdapter │ ├── index.ts # Re-exports GPTAdapter
@@ -296,6 +306,12 @@ src/
- `GET /api/chat/conversations` - Get all conversations (protected) - `GET /api/chat/conversations` - Get all conversations (protected)
- `GET /api/chat/conversations/:id` - Get messages of a conversation with cursor-based pagination (protected) - `GET /api/chat/conversations/:id` - Get messages of a conversation with cursor-based pagination (protected)
- `PUT /api/chat/messages/:messageId/proposal` - Update proposal event data before confirming (protected) - `PUT /api/chat/messages/:messageId/proposal` - Update proposal event data before confirming (protected)
- `PUT /api/caldav/config` - Save CalDAV config (protected)
- `GET /api/caldav/config` - Load CalDAV config (protected)
- `DELETE /api/caldav/config` - Delete CalDAV config (protected)
- `POST /api/caldav/pull` - Pull events from CalDAV server (protected)
- `POST /api/caldav/pushAll` - Push all unsynced events (protected)
- `POST /api/caldav/push/:caldavUUID` - Push single event (protected)
- `GET /health` - Health check - `GET /health` - Health check
- `POST /api/ai/test` - AI test endpoint (development only) - `POST /api/ai/test` - AI test endpoint (development only)
@@ -307,7 +323,8 @@ src/
├── models/ ├── models/
│ ├── index.ts │ ├── index.ts
│ ├── User.ts # User, CreateUserDTO, LoginDTO, AuthResponse │ ├── User.ts # User, CreateUserDTO, LoginDTO, AuthResponse
│ ├── CalendarEvent.ts # CalendarEvent, CreateEventDTO, UpdateEventDTO, ExpandedEvent │ ├── CalendarEvent.ts # CalendarEvent, CreateEventDTO, UpdateEventDTO, ExpandedEvent, CaldavSyncStatus
│ ├── CaldavConfig.ts # CaldavConfig
│ ├── ChatMessage.ts # ChatMessage, Conversation, SendMessageDTO, CreateMessageDTO, │ ├── ChatMessage.ts # ChatMessage, Conversation, SendMessageDTO, CreateMessageDTO,
│ │ # GetMessagesOptions, ChatResponse, ConversationSummary, │ │ # GetMessagesOptions, ChatResponse, ConversationSummary,
│ │ # ProposedEventChange, EventAction, RespondedAction, UpdateMessageDTO │ │ # ProposedEventChange, EventAction, RespondedAction, UpdateMessageDTO
@@ -322,7 +339,9 @@ src/
**Key Types:** **Key Types:**
- `User`: id, email, userName, passwordHash?, createdAt?, updatedAt? - `User`: id, email, userName, passwordHash?, createdAt?, updatedAt?
- `CalendarEvent`: id, userId, title, description?, startTime, endTime, note?, isRecurring?, recurrenceRule?, exceptionDates? - `CalendarEvent`: id, userId, caldavUUID?, etag?, title, description?, startTime, endTime, note?, recurrenceRule?, exceptionDates?, caldavSyncStatus?
- `CaldavConfig`: userId, serverUrl, username, password, syncIntervalSeconds?
- `CaldavSyncStatus`: 'synced' | 'error'
- `ExpandedEvent`: Extends CalendarEvent with occurrenceStart, occurrenceEnd (for recurring event instances) - `ExpandedEvent`: Extends CalendarEvent with occurrenceStart, occurrenceEnd (for recurring event instances)
- `ChatMessage`: id, conversationId, sender ('user' | 'assistant'), content, proposedChanges? - `ChatMessage`: id, conversationId, sender ('user' | 'assistant'), content, proposedChanges?
- `ProposedEventChange`: id, action ('create' | 'update' | 'delete'), eventId?, event?, updates?, respondedAction?, deleteMode?, occurrenceDate?, conflictingEvents? - `ProposedEventChange`: id, action ('create' | 'update' | 'delete'), eventId?, event?, updates?, respondedAction?, deleteMode?, occurrenceDate?, conflictingEvents?
@@ -377,6 +396,32 @@ When creating events, `toolExecutor` automatically:
3. Returns `conflictingEvents` array in the proposal for UI display 3. Returns `conflictingEvents` array in the proposal for UI display
4. Adds ⚠️ warning to tool result so AI can inform user 4. Adds ⚠️ warning to tool result so AI can inform user
### CalDAV Synchronization
CalDAV sync with external calendar servers (e.g., Radicale) using `tsdav` and `ical.js`.
**Naming Convention:** All CalDAV-related identifiers use `Caldav` (PascalCase) / `caldav` (camelCase), NOT `CalDav`. The only exception is the protocol name "CalDAV" in comments and log messages.
**Sync Triggers (client-side via `CaldavConfigService.sync()`):**
- **Login** (`login.tsx`): After successful authentication
- **Auto-login** (`AuthGuard.tsx`): After `loadStoredUser()` if authenticated
- **Calendar timer** (`calendar.tsx`): Every 10s while Calendar tab is focused, via `setInterval` in `useFocusEffect`
- **Sync button** (`settings.tsx`): Manual trigger in CaldavSettings
**Single-event sync (server-side in controllers):**
- `EventController`: `pushToCaldav()` after create/update, `deleteFromCaldav()` after delete
- `ChatController`: `pushAll()` after confirming an event proposal
**Sync Flow:**
1. `sync()` calls `pushAll` (push unsynced local events) then `pull` (fetch remote events)
2. `pullEvents`: Compares etags to skip unchanged events, creates/updates local events, deletes locally if removed remotely
3. `pushEvent`: Creates or updates remote event, fetches new etag after push
**Architecture:**
- `CaldavService` depends on `CaldavRepository` (config storage) and `EventService` (event CRUD)
- `ChatService` depends only on `EventService` (not EventRepository) for all event operations
- `EventController` and `ChatController` both receive `CaldavService` for CalDAV push on mutations
### Database Abstraction ### Database Abstraction
The repository pattern allows swapping databases: The repository pattern allows swapping databases:
@@ -460,7 +505,7 @@ The `@Logged` decorator automatically summarizes large arguments to keep logs re
### Nice-to-Have ### Nice-to-Have
- iCalendar import/export - iCalendar import/export
- Multiple calendars - Multiple calendars
- CalDAV synchronization with external services - ~~CalDAV synchronization with external services~~ (implemented)
## Development Environment ## Development Environment
@@ -473,6 +518,13 @@ docker compose down # Stop services
- MongoDB: `localhost:27017` (root/mongoose) - MongoDB: `localhost:27017` (root/mongoose)
- Mongo Express UI: `localhost:8083` (admin/admin) - Mongo Express UI: `localhost:8083` (admin/admin)
### Radicale CalDAV Server (Docker)
```bash
cd apps/server/docker/radicale
docker compose up -d # Start Radicale CalDAV server
```
- Radicale: `localhost:5232`
### Environment Variables ### Environment Variables
Server requires `.env` file in `apps/server/`: Server requires `.env` file in `apps/server/`:
``` ```
@@ -510,12 +562,20 @@ NODE_ENV=development # development = pretty logs, production = JSON
- `GPTAdapter`: Full implementation with OpenAI GPT (gpt-4o-mini model), function calling for calendar operations, collects multiple proposals per response - `GPTAdapter`: Full implementation with OpenAI GPT (gpt-4o-mini model), function calling for calendar operations, collects multiple proposals per response
- `ai/utils/`: Provider-agnostic shared utilities (systemPrompt, toolDefinitions, toolExecutor) - `ai/utils/`: Provider-agnostic shared utilities (systemPrompt, toolDefinitions, toolExecutor)
- `ai/utils/systemPrompt`: AI fetches events on-demand (no pre-loaded context), includes RRULE documentation, warns AI not to put RRULE in description field, instructs AI not to show event IDs to users - `ai/utils/systemPrompt`: AI fetches events on-demand (no pre-loaded context), includes RRULE documentation, warns AI not to put RRULE in description field, instructs AI not to show event IDs to users
- `ai/utils/toolDefinitions`: proposeUpdateEvent supports `isRecurring` and `recurrenceRule` parameters, getEventsInRange tool for on-demand event loading - `ai/utils/toolDefinitions`: proposeUpdateEvent supports `recurrenceRule` parameter, getEventsInRange tool for on-demand event loading
- `ai/utils/toolExecutor`: Async execution, conflict detection uses `occurrenceStart/occurrenceEnd` for recurring events, returns `conflictingEvents` in proposals - `ai/utils/toolExecutor`: Async execution, conflict detection uses `occurrenceStart/occurrenceEnd` for recurring events, returns `conflictingEvents` in proposals
- `MongoEventRepository`: Includes `searchByTitle()` for case-insensitive title search - `MongoEventRepository`: Includes `searchByTitle()` for case-insensitive title search
- `utils/recurrenceExpander`: Handles RRULE parsing, strips `RRULE:` prefix if present (AI may include it), filters out exceptionDates - `utils/recurrenceExpander`: Handles RRULE parsing, strips `RRULE:` prefix if present (AI may include it), filters out exceptionDates
- `logging/`: Structured logging with pino, pino-http middleware, @Logged decorator - `logging/`: Structured logging with pino, pino-http middleware, @Logged decorator
- All repositories and GPTAdapter decorated with @Logged for automatic method logging - All repositories and GPTAdapter decorated with @Logged for automatic method logging
- `CaldavService`: Full CalDAV sync (connect, pullEvents, pushEvent, pushAll, deleteEvent, getConfig, saveConfig, deleteConfig)
- `CaldavController`: REST endpoints for config CRUD, pull, push
- `MongoCaldavRepository`: Config persistence with createOrUpdate, findByUserId, deleteByUserId
- `EventController`: CalDAV push on create/update, CalDAV delete on delete (via pushToCaldav/deleteFromCaldav helpers)
- `ChatController`: CalDAV pushAll after confirmEvent (ensures chat-created events sync)
- `ChatService`: Refactored to use only EventService (no direct EventRepository dependency)
- `EventService`: Extended with searchByTitle(), findByCaldavUUID()
- `utils/eventFormatters`: Refactored to use EventService instead of EventRepository
- CORS configured to allow X-User-Id header - CORS configured to allow X-User-Id header
- **Stubbed (TODO):** - **Stubbed (TODO):**
- `AuthController`: refresh(), logout() - `AuthController`: refresh(), logout()
@@ -523,9 +583,9 @@ NODE_ENV=development # development = pretty logs, production = JSON
- JWT authentication (currently using simple X-User-Id header) - JWT authentication (currently using simple X-User-Id header)
**Shared:** **Shared:**
- Types, DTOs, constants (Day, Month with German translations), ExpandedEvent type defined and exported - Types, DTOs, constants (Day, Month with German translations), ExpandedEvent type, CaldavConfig, CaldavSyncStatus defined and exported
- `rruleHelpers.ts`: `parseRRule()` parses RRULE strings using rrule library, returns `ParsedRRule` with freq, until, count, interval, byDay - `rruleHelpers.ts`: `parseRRule()` parses RRULE strings using rrule library, returns `ParsedRRule` with freq, until, count, interval, byDay
- `formatters.ts`: German date/time formatters (`formatDate`, `formatTime`, `formatDateTime`, `formatDateWithWeekday`) used by both client and server - `formatters.ts`: German date/time formatters (`formatDate`, `formatTime`, `formatDateTime`, `formatDateWithWeekday`, `formatDateKey`) used by both client and server
- rrule library added as dependency for RRULE parsing - rrule library added as dependency for RRULE parsing
**Frontend:** **Frontend:**
@@ -533,8 +593,8 @@ NODE_ENV=development # development = pretty logs, production = JSON
- `AuthStore`: Manages user state with expo-secure-store (native) / localStorage (web) - `AuthStore`: Manages user state with expo-secure-store (native) / localStorage (web)
- `AuthService`: login(), register(), logout() - calls backend API - `AuthService`: login(), register(), logout() - calls backend API
- `ApiClient`: Automatically injects X-User-Id header for authenticated requests, handles empty responses (204) - `ApiClient`: Automatically injects X-User-Id header for authenticated requests, handles empty responses (204)
- `AuthGuard`: Reusable component that wraps protected routes - loads user, shows loading, redirects if unauthenticated - `AuthGuard`: Reusable component that wraps protected routes - loads user, triggers CalDAV sync on auto-login, shows loading, redirects if unauthenticated
- Login screen: Supports email OR userName login - Login screen: Supports email OR userName login, triggers CalDAV sync after successful login
- Register screen: Email validation, checks for existing email/userName - Register screen: Email validation, checks for existing email/userName
- `AuthButton`: Reusable button component with themed shadow - `AuthButton`: Reusable button component with themed shadow
- `Header`: Themed header component (logout moved to Settings) - `Header`: Themed header component (logout moved to Settings)
@@ -544,7 +604,7 @@ NODE_ENV=development # development = pretty logs, production = JSON
- `ThemeStore`: Zustand store with theme state and setTheme() - `ThemeStore`: Zustand store with theme state and setTheme()
- `Themes.tsx`: THEMES object with defaultLight/defaultDark variants - `Themes.tsx`: THEMES object with defaultLight/defaultDark variants
- All components use `useThemeStore()` for reactive theme colors - All components use `useThemeStore()` for reactive theme colors
- Settings screen with theme switcher (light/dark) - Settings screen with theme switcher (light/dark) and CalDAV configuration (url, username, password with save/sync buttons, loads existing config on mount)
- `BaseButton`: Reusable themed button component - `BaseButton`: Reusable themed button component
- Tab navigation (Chat, Calendar, Settings) implemented with themed UI - Tab navigation (Chat, Calendar, Settings) implemented with themed UI
- Calendar screen fully functional: - Calendar screen fully functional:
@@ -554,7 +614,7 @@ NODE_ENV=development # development = pretty logs, production = JSON
- Orange dot indicator for days with events - Orange dot indicator for days with events
- Tap-to-open modal overlay showing EventCards for selected day - Tap-to-open modal overlay showing EventCards for selected day
- Supports events from adjacent months visible in grid - Supports events from adjacent months visible in grid
- Uses `useFocusEffect` for automatic reload on tab focus - Uses `useFocusEffect` for automatic reload on tab focus with periodic CalDAV sync (10s interval while focused)
- DeleteEventModal integration for recurring event deletion with three modes - DeleteEventModal integration for recurring event deletion with three modes
- EventOverlay hides when DeleteEventModal is open (fixes modal stacking on web) - EventOverlay hides when DeleteEventModal is open (fixes modal stacking on web)
- Chat screen fully functional with FlashList, message sending, and event confirm/reject - Chat screen fully functional with FlashList, message sending, and event confirm/reject
@@ -570,6 +630,8 @@ NODE_ENV=development # development = pretty logs, production = JSON
- keyboardDismissMode="interactive" and keyboardShouldPersistTaps="handled" - keyboardDismissMode="interactive" and keyboardShouldPersistTaps="handled"
- `EventService`: getAll(), getById(), getByDateRange(), create(), update(), delete(mode, occurrenceDate) - fully implemented with recurring delete modes - `EventService`: getAll(), getById(), getByDateRange(), create(), update(), delete(mode, occurrenceDate) - fully implemented with recurring delete modes
- `ChatService`: sendMessage(), confirmEvent(deleteMode, occurrenceDate), rejectEvent(), getConversations(), getConversation(), updateProposalEvent() - fully implemented with cursor pagination, recurring delete support, and proposal editing - `ChatService`: sendMessage(), confirmEvent(deleteMode, occurrenceDate), rejectEvent(), getConversations(), getConversation(), updateProposalEvent() - fully implemented with cursor pagination, recurring delete support, and proposal editing
- `CaldavConfigService`: saveConfig(), getConfig(), deleteConfig(), pull(), pushAll(), sync() - CalDAV config management and sync trigger
- `CustomTextInput`: Themed text input component with focus border highlight, supports controlled value via `text` prop
- `CardBase`: Reusable card component with header (title/subtitle), content area, and optional footer button - configurable padding, border, text size via props, ScrollView uses `nestedScrollEnabled` for Android - `CardBase`: Reusable card component with header (title/subtitle), content area, and optional footer button - configurable padding, border, text size via props, ScrollView uses `nestedScrollEnabled` for Android
- `ModalBase`: Reusable modal wrapper with backdrop (absolute-positioned behind card), uses CardBase internally - provides click-outside-to-close, Android back button support, and proper scrolling on Android - `ModalBase`: Reusable modal wrapper with backdrop (absolute-positioned behind card), uses CardBase internally - provides click-outside-to-close, Android back button support, and proper scrolling on Android
- `EventCardBase`: Event card with date/time/recurring icons - uses CardBase for structure - `EventCardBase`: Event card with date/time/recurring icons - uses CardBase for structure

View File

@@ -22,6 +22,7 @@ import { Ionicons } from "@expo/vector-icons";
import { useThemeStore } from "../../stores/ThemeStore"; import { useThemeStore } from "../../stores/ThemeStore";
import BaseBackground from "../../components/BaseBackground"; import BaseBackground from "../../components/BaseBackground";
import { EventService } from "../../services"; import { EventService } from "../../services";
import { CaldavConfigService } from "../../services/CaldavConfigService";
import { useEventsStore } from "../../stores"; import { useEventsStore } from "../../stores";
import { useDropdownPosition } from "../../hooks/useDropdownPosition"; import { useDropdownPosition } from "../../hooks/useDropdownPosition";
@@ -84,9 +85,15 @@ const Calendar = () => {
const { events, setEvents, deleteEvent } = useEventsStore(); const { events, setEvents, deleteEvent } = useEventsStore();
// Function to load events for current view // Sync CalDAV then load events for current view
const loadEvents = useCallback(async () => { const loadEvents = useCallback(async () => {
try { 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) // Calculate first visible day (up to 6 days before month start)
const firstOfMonth = new Date(currentYear, monthIndex, 1); const firstOfMonth = new Date(currentYear, monthIndex, 1);
const dayOfWeek = firstOfMonth.getDay(); const dayOfWeek = firstOfMonth.getDay();
@@ -122,6 +129,9 @@ const Calendar = () => {
if (selectedDate) { if (selectedDate) {
setOverlayVisible(true); setOverlayVisible(true);
} }
const interval = setInterval(loadEvents, 10_000);
return () => clearInterval(interval);
}, [loadEvents, selectedDate]), }, [loadEvents, selectedDate]),
); );

View File

@@ -1,18 +1,106 @@
import { Text, View } from "react-native"; import { Text, View } from "react-native";
import BaseBackground from "../../components/BaseBackground"; import BaseBackground from "../../components/BaseBackground";
import BaseButton from "../../components/BaseButton"; import BaseButton, { BaseButtonProps } from "../../components/BaseButton";
import { useThemeStore } from "../../stores/ThemeStore"; import { useThemeStore } from "../../stores/ThemeStore";
import { AuthService } from "../../services/AuthService"; import { AuthService } from "../../services/AuthService";
import { router } from "expo-router"; import { router } from "expo-router";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { SimpleHeader } from "../../components/Header"; import { SimpleHeader } from "../../components/Header";
import { THEMES } from "../../Themes"; import { THEMES } from "../../Themes";
import CustomTextInput from "../../components/CustomTextInput";
import { useEffect, useState } from "react";
import { CaldavConfigService } from "../../services/CaldavConfigService";
const handleLogout = async () => { const handleLogout = async () => {
await AuthService.logout(); await AuthService.logout();
router.replace("/login"); 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 Settings = () => {
const { theme, setTheme } = useThemeStore(); const { theme, setTheme } = useThemeStore();
@@ -20,10 +108,10 @@ const Settings = () => {
<BaseBackground> <BaseBackground>
<SimpleHeader text="Settings" /> <SimpleHeader text="Settings" />
<View className="flex items-center mt-4"> <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} />{" "} <Ionicons name="log-out-outline" size={24} color={theme.primeFg} />{" "}
Logout Logout
</BaseButton> </SettingsButton>
<View> <View>
<Text <Text
className="text-center text-2xl" className="text-center text-2xl"
@@ -32,23 +120,24 @@ const Settings = () => {
Select Theme Select Theme
</Text> </Text>
</View> </View>
<BaseButton <SettingsButton
solid={theme == THEMES.defaultLight} solid={theme == THEMES.defaultLight}
onPress={() => { onPress={() => {
setTheme("defaultLight"); setTheme("defaultLight");
}} }}
> >
Default Light Default Light
</BaseButton> </SettingsButton>
<BaseButton <SettingsButton
solid={theme == THEMES.defaultDark} solid={theme == THEMES.defaultDark}
onPress={() => { onPress={() => {
setTheme("defaultDark"); setTheme("defaultDark");
}} }}
> >
Default Dark Default Dark
</BaseButton> </SettingsButton>
</View> </View>
<CaldavSettings />
</BaseBackground> </BaseBackground>
); );
}; };

View File

@@ -21,38 +21,27 @@ import { useDropdownPosition } from "../hooks/useDropdownPosition";
import { EventService, ChatService } from "../services"; import { EventService, ChatService } from "../services";
import { buildRRule, CreateEventDTO } from "@calchat/shared"; import { buildRRule, CreateEventDTO } from "@calchat/shared";
import { useChatStore } from "../stores"; import { useChatStore } from "../stores";
import CustomTextInput, {
CustomTextInputProps,
} from "../components/CustomTextInput";
type EditEventTextFieldProps = { type EditEventTextFieldProps = CustomTextInputProps & {
titel: string; titel: string;
text?: string;
focused?: boolean;
className?: string;
multiline?: boolean;
onValueChange?: (text: string) => void;
}; };
const EditEventTextField = (props: EditEventTextFieldProps) => { const EditEventTextField = (props: EditEventTextFieldProps) => {
const { theme } = useThemeStore(); const { theme } = useThemeStore();
const [focused, setFocused] = useState(props.focused ?? false);
return ( return (
<View className={props.className}> <View className={props.className}>
<Text className="text-xl" style={{ color: theme.textPrimary }}> <Text className="text-xl" style={{ color: theme.textPrimary }}>
{props.titel} {props.titel}
</Text> </Text>
<TextInput <CustomTextInput
onChangeText={props.onValueChange} className="flex-1"
value={props.text} text={props.text}
multiline={props.multiline} multiline={props.multiline}
className="flex-1 border border-solid rounded-2xl px-3 py-2 w-full h-11/12" onValueChange={props.onValueChange}
style={{
backgroundColor: theme.messageBorderBg,
color: theme.textPrimary,
textAlignVertical: "top",
borderColor: focused ? theme.chatBot : theme.borderPrimary,
}}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
/> />
</View> </View>
); );

View File

@@ -4,6 +4,7 @@ import { Link, router } from "expo-router";
import BaseBackground from "../components/BaseBackground"; import BaseBackground from "../components/BaseBackground";
import AuthButton from "../components/AuthButton"; import AuthButton from "../components/AuthButton";
import { AuthService } from "../services"; import { AuthService } from "../services";
import { CaldavConfigService } from "../services/CaldavConfigService";
import { useThemeStore } from "../stores/ThemeStore"; import { useThemeStore } from "../stores/ThemeStore";
const LoginScreen = () => { const LoginScreen = () => {
@@ -24,6 +25,11 @@ const LoginScreen = () => {
setIsLoading(true); setIsLoading(true);
try { try {
await AuthService.login({ identifier, password }); await AuthService.login({ identifier, password });
try {
await CaldavConfigService.sync();
} catch {
// No CalDAV config or sync failed — not critical
}
router.replace("/(tabs)/chat"); router.replace("/(tabs)/chat");
} catch { } catch {
setError("Anmeldung fehlgeschlagen. Überprüfe deine Zugangsdaten."); setError("Anmeldung fehlgeschlagen. Überprüfe deine Zugangsdaten.");

View File

@@ -3,6 +3,7 @@ import { View, ActivityIndicator } from "react-native";
import { Redirect } from "expo-router"; import { Redirect } from "expo-router";
import { useAuthStore } from "../stores"; import { useAuthStore } from "../stores";
import { useThemeStore } from "../stores/ThemeStore"; import { useThemeStore } from "../stores/ThemeStore";
import { CaldavConfigService } from "../services/CaldavConfigService";
type AuthGuardProps = { type AuthGuardProps = {
children: ReactNode; children: ReactNode;
@@ -20,7 +21,16 @@ export const AuthGuard = ({ children }: AuthGuardProps) => {
const { isAuthenticated, isLoading, loadStoredUser } = useAuthStore(); const { isAuthenticated, isLoading, loadStoredUser } = useAuthStore();
useEffect(() => { 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]); }, [loadStoredUser]);
if (isLoading) { if (isLoading) {

View File

@@ -2,17 +2,18 @@ import { Pressable, Text } from "react-native";
import { useThemeStore } from "../stores/ThemeStore"; import { useThemeStore } from "../stores/ThemeStore";
import { ReactNode } from "react"; import { ReactNode } from "react";
type BaseButtonProps = { export type BaseButtonProps = {
children?: ReactNode; children?: ReactNode;
className?: string;
onPress: () => void; onPress: () => void;
solid?: boolean; solid?: boolean;
}; };
const BaseButton = ({ children, onPress, solid = false }: BaseButtonProps) => { const BaseButton = ({ className, children, onPress, solid = false }: BaseButtonProps) => {
const { theme } = useThemeStore(); const { theme } = useThemeStore();
return ( return (
<Pressable <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} onPress={onPress}
style={{ style={{
borderColor: theme.borderPrimary, borderColor: theme.borderPrimary,

View 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;

View 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");
},
};

View 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

View File

@@ -0,0 +1,4 @@
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
};

View File

@@ -5,26 +5,33 @@
"scripts": { "scripts": {
"dev": "tsx watch src/app.ts", "dev": "tsx watch src/app.ts",
"build": "tsc", "build": "tsc",
"start": "node dist/app.js" "start": "node dist/app.js",
"test": "jest"
}, },
"dependencies": { "dependencies": {
"@calchat/shared": "*", "@calchat/shared": "*",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^5.2.1", "express": "^5.2.1",
"ical.js": "^2.2.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"mongoose": "^9.1.1", "mongoose": "^9.1.1",
"openai": "^6.15.0", "openai": "^6.15.0",
"pino": "^10.1.1", "pino": "^10.1.1",
"pino-http": "^11.0.0", "pino-http": "^11.0.0",
"rrule": "^2.8.1" "rrule": "^2.8.1",
"tsdav": "^2.1.6"
}, },
"devDependencies": { "devDependencies": {
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/ical": "^0.8.3",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"jest": "^30.2.0",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
"ts-jest": "^29.4.6",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }

View File

@@ -1,4 +0,0 @@
import { formatDate, formatTime, formatDateTime } from "@calchat/shared";
// Re-export from shared package for use in toolExecutor
export { formatDate, formatTime, formatDateTime };

View File

@@ -1,4 +1,3 @@
export { formatDate, formatTime, formatDateTime } from "./eventFormatter";
export { buildSystemPrompt } from "./systemPrompt"; export { buildSystemPrompt } from "./systemPrompt";
export { export {
TOOL_DEFINITIONS, TOOL_DEFINITIONS,

View File

@@ -6,7 +6,7 @@ import {
RecurringDeleteMode, RecurringDeleteMode,
} from "@calchat/shared"; } from "@calchat/shared";
import { AIContext } from "../../services/interfaces"; import { AIContext } from "../../services/interfaces";
import { formatDate, formatTime, formatDateTime } from "./eventFormatter"; import { formatDate, formatTime, formatDateTime } from "@calchat/shared";
/** /**
* Check if two time ranges overlap. * Check if two time ranges overlap.

View File

@@ -17,6 +17,9 @@ import {
} from "./repositories"; } from "./repositories";
import { GPTAdapter } from "./ai"; import { GPTAdapter } from "./ai";
import { logger } from "./logging"; import { logger } from "./logging";
import { MongoCaldavRepository } from "./repositories/mongo/MongoCaldavRepository";
import { CaldavService } from "./services/CaldavService";
import { CaldavController } from "./controllers/CaldavController";
const app = express(); const app = express();
const port = process.env.PORT || 3000; const port = process.env.PORT || 3000;
@@ -51,6 +54,7 @@ if (process.env.NODE_ENV !== "production") {
const userRepo = new MongoUserRepository(); const userRepo = new MongoUserRepository();
const eventRepo = new MongoEventRepository(); const eventRepo = new MongoEventRepository();
const chatRepo = new MongoChatRepository(); const chatRepo = new MongoChatRepository();
const caldavRepo = new MongoCaldavRepository();
// Initialize AI provider // Initialize AI provider
const aiProvider = new GPTAdapter(); const aiProvider = new GPTAdapter();
@@ -60,15 +64,16 @@ const authService = new AuthService(userRepo);
const eventService = new EventService(eventRepo); const eventService = new EventService(eventRepo);
const chatService = new ChatService( const chatService = new ChatService(
chatRepo, chatRepo,
eventRepo,
eventService, eventService,
aiProvider, aiProvider,
); );
const caldavService = new CaldavService(caldavRepo, eventService);
// Initialize controllers // Initialize controllers
const authController = new AuthController(authService); const authController = new AuthController(authService);
const chatController = new ChatController(chatService); const chatController = new ChatController(chatService, caldavService);
const eventController = new EventController(eventService); const eventController = new EventController(eventService, caldavService);
const caldavController = new CaldavController(caldavService);
// Setup routes // Setup routes
app.use( app.use(
@@ -77,6 +82,7 @@ app.use(
authController, authController,
chatController, chatController,
eventController, eventController,
caldavController
}), }),
); );

View 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" });
}
}
}

View File

@@ -8,13 +8,17 @@ import {
RecurringDeleteMode, RecurringDeleteMode,
} from "@calchat/shared"; } from "@calchat/shared";
import { ChatService } from "../services"; import { ChatService } from "../services";
import { CaldavService } from "../services/CaldavService";
import { createLogger } from "../logging"; import { createLogger } from "../logging";
import { AuthenticatedRequest } from "./AuthMiddleware"; import { AuthenticatedRequest } from "./AuthMiddleware";
const log = createLogger("ChatController"); const log = createLogger("ChatController");
export class ChatController { export class ChatController {
constructor(private chatService: ChatService) {} constructor(
private chatService: ChatService,
private caldavService: CaldavService,
) {}
async sendMessage(req: AuthenticatedRequest, res: Response): Promise<void> { async sendMessage(req: AuthenticatedRequest, res: Response): Promise<void> {
try { try {
@@ -68,6 +72,16 @@ export class ChatController {
deleteMode, deleteMode,
occurrenceDate, 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); res.json(response);
} catch (error) { } catch (error) {
log.error( log.error(

View File

@@ -1,17 +1,43 @@
import { Response } from "express"; import { Response } from "express";
import { RecurringDeleteMode } from "@calchat/shared"; import { CalendarEvent, RecurringDeleteMode } from "@calchat/shared";
import { EventService } from "../services"; import { EventService } from "../services";
import { createLogger } from "../logging"; import { createLogger } from "../logging";
import { AuthenticatedRequest } from "./AuthMiddleware"; import { AuthenticatedRequest } from "./AuthMiddleware";
import { CaldavService } from "../services/CaldavService";
const log = createLogger("EventController"); const log = createLogger("EventController");
export class 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> { async create(req: AuthenticatedRequest, res: Response): Promise<void> {
try { 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); res.status(201).json(event);
} catch (error) { } catch (error) {
log.error({ error, userId: req.user?.userId }, "Error creating event"); 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> { async update(req: AuthenticatedRequest, res: Response): Promise<void> {
try { try {
const userId = req.user!.userId;
const event = await this.eventService.update( const event = await this.eventService.update(
req.params.id, req.params.id,
req.user!.userId, userId,
req.body, req.body,
); );
if (!event) { if (!event) {
res.status(404).json({ error: "Event not found" }); res.status(404).json({ error: "Event not found" });
return; return;
} }
await this.pushToCaldav(userId, event);
res.json(event); res.json(event);
} catch (error) { } catch (error) {
log.error({ error, eventId: req.params.id }, "Error updating event"); 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> { async delete(req: AuthenticatedRequest, res: Response): Promise<void> {
try { try {
const userId = req.user!.userId;
const { mode, occurrenceDate } = req.query as { const { mode, occurrenceDate } = req.query as {
mode?: RecurringDeleteMode; mode?: RecurringDeleteMode;
occurrenceDate?: string; 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 is specified, use deleteRecurring
if (mode) { if (mode) {
const result = await this.eventService.deleteRecurring( const result = await this.eventService.deleteRecurring(
req.params.id, req.params.id,
req.user!.userId, userId,
mode, mode,
occurrenceDate, occurrenceDate,
); );
// For 'all' mode or when event was completely deleted, return 204 // Event was updated (single/future mode) - push update to CalDAV
if (result === null && mode === "all") {
res.status(204).send();
return;
}
// For 'single' or 'future' modes, return updated event
if (result) { if (result) {
await this.pushToCaldav(userId, result);
res.json(result); res.json(result);
return; 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(); res.status(204).send();
return; return;
} }
// Default behavior: delete completely // Default behavior: delete completely
const deleted = await this.eventService.delete( await this.eventService.delete(req.params.id, userId);
req.params.id, await this.deleteFromCaldav(userId, event);
req.user!.userId,
);
if (!deleted) {
res.status(404).json({ error: "Event not found" });
return;
}
res.status(204).send(); res.status(204).send();
} catch (error) { } catch (error) {
log.error({ error, eventId: req.params.id }, "Error deleting event"); log.error({ error, eventId: req.params.id }, "Error deleting event");

View File

@@ -3,3 +3,4 @@ export * from "./ChatController";
export * from "./EventController"; export * from "./EventController";
export * from "./AuthMiddleware"; export * from "./AuthMiddleware";
export * from "./LoggingMiddleware"; export * from "./LoggingMiddleware";
export * from "./CaldavController";

View 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;
}
}

View File

@@ -28,6 +28,12 @@ export class MongoEventRepository implements EventRepository {
return events.map((e) => e.toJSON() as unknown as CalendarEvent); 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[]> { async searchByTitle(userId: string, query: string): Promise<CalendarEvent[]> {
const events = await EventModel.find({ const events = await EventModel.find({
userId, userId,

View File

@@ -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,
);

View File

@@ -21,6 +21,12 @@ const EventSchema = new Schema<
required: true, required: true,
index: true, index: true,
}, },
caldavUUID: {
type: String,
},
etag: {
type: String,
},
title: { title: {
type: String, type: String,
required: true, required: true,

View 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;
}

View File

@@ -6,12 +6,15 @@ import {
AuthController, AuthController,
ChatController, ChatController,
EventController, EventController,
CaldavController
} from "../controllers"; } from "../controllers";
import { createCaldavRoutes } from "./caldav.routes";
export interface Controllers { export interface Controllers {
authController: AuthController; authController: AuthController;
chatController: ChatController; chatController: ChatController;
eventController: EventController; eventController: EventController;
caldavController: CaldavController;
} }
export function createRoutes(controllers: Controllers): Router { export function createRoutes(controllers: Controllers): Router {
@@ -20,6 +23,7 @@ export function createRoutes(controllers: Controllers): Router {
router.use("/auth", createAuthRoutes(controllers.authController)); router.use("/auth", createAuthRoutes(controllers.authController));
router.use("/chat", createChatRoutes(controllers.chatController)); router.use("/chat", createChatRoutes(controllers.chatController));
router.use("/events", createEventRoutes(controllers.eventController)); router.use("/events", createEventRoutes(controllers.eventController));
router.use("/caldav", createCaldavRoutes(controllers.caldavController));
return router; return router;
} }

View 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);
// });

View 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);
}
}

View File

@@ -12,7 +12,7 @@ import {
RecurringDeleteMode, RecurringDeleteMode,
ConflictingEvent, ConflictingEvent,
} from "@calchat/shared"; } from "@calchat/shared";
import { ChatRepository, EventRepository, AIProvider } from "./interfaces"; import { ChatRepository, AIProvider } from "./interfaces";
import { EventService } from "./EventService"; import { EventService } from "./EventService";
import { getWeeksOverview, getMonthOverview } from "../utils/eventFormatters"; import { getWeeksOverview, getMonthOverview } from "../utils/eventFormatters";
@@ -333,7 +333,7 @@ const staticResponses: TestResponse[] = [
async function getTestResponse( async function getTestResponse(
index: number, index: number,
eventRepo: EventRepository, eventService: EventService,
userId: string, userId: string,
): Promise<TestResponse> { ): Promise<TestResponse> {
const responseIdx = index % staticResponses.length; const responseIdx = index % staticResponses.length;
@@ -341,7 +341,7 @@ async function getTestResponse(
// === SPORT TEST SCENARIO (Dynamic responses) === // === SPORT TEST SCENARIO (Dynamic responses) ===
// Response 1: Add exception to "Sport" (2 weeks later) // Response 1: Add exception to "Sport" (2 weeks later)
if (responseIdx === 1) { if (responseIdx === 1) {
const events = await eventRepo.findByUserId(userId); const events = await eventService.getAll(userId);
const sportEvent = events.find((e) => e.title === "Sport"); const sportEvent = events.find((e) => e.title === "Sport");
if (sportEvent) { if (sportEvent) {
// Calculate date 2 weeks from the first occurrence // 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) // Response 2: Add UNTIL to "Sport" (after 6 weeks total)
if (responseIdx === 2) { if (responseIdx === 2) {
const events = await eventRepo.findByUserId(userId); const events = await eventService.getAll(userId);
const sportEvent = events.find((e) => e.title === "Sport"); const sportEvent = events.find((e) => e.title === "Sport");
if (sportEvent) { if (sportEvent) {
// Calculate UNTIL date: 6 weeks from start // 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) // Response 3: Add another exception to "Sport" (2 weeks after the first exception)
if (responseIdx === 3) { if (responseIdx === 3) {
const events = await eventRepo.findByUserId(userId); const events = await eventService.getAll(userId);
const sportEvent = events.find((e) => e.title === "Sport"); const sportEvent = events.find((e) => e.title === "Sport");
if (sportEvent) { if (sportEvent) {
// Calculate date 4 weeks from the first occurrence (2 weeks after the first exception) // 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 // Dynamic responses: fetch events from DB and format
// (Note: indices shifted by +3 due to new sport responses) // (Note: indices shifted by +3 due to new sport responses)
if (responseIdx === 6) { if (responseIdx === 6) {
return { content: await getWeeksOverview(eventRepo, userId, 2) }; return { content: await getWeeksOverview(eventService, userId, 2) };
} }
if (responseIdx === 7) { if (responseIdx === 7) {
// Delete "Meeting mit Jens" // 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"); const jensEvent = events.find((e) => e.title === "Meeting mit Jens");
if (jensEvent) { if (jensEvent) {
return { return {
@@ -487,12 +487,12 @@ async function getTestResponse(
} }
if (responseIdx === 11) { if (responseIdx === 11) {
return { content: await getWeeksOverview(eventRepo, userId, 1) }; return { content: await getWeeksOverview(eventService, userId, 1) };
} }
if (responseIdx === 13) { if (responseIdx === 13) {
// Update "Telefonat mit Mama" +3 days and change time to 13:00 // 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"); const mamaEvent = events.find((e) => e.title === "Telefonat mit Mama");
if (mamaEvent) { if (mamaEvent) {
const newStart = new Date(mamaEvent.startTime); const newStart = new Date(mamaEvent.startTime);
@@ -527,7 +527,7 @@ async function getTestResponse(
const now = new Date(); const now = new Date();
return { return {
content: await getMonthOverview( content: await getMonthOverview(
eventRepo, eventService,
userId, userId,
now.getFullYear(), now.getFullYear(),
now.getMonth(), now.getMonth(),
@@ -541,7 +541,6 @@ async function getTestResponse(
export class ChatService { export class ChatService {
constructor( constructor(
private chatRepo: ChatRepository, private chatRepo: ChatRepository,
private eventRepo: EventRepository,
private eventService: EventService, private eventService: EventService,
private aiProvider: AIProvider, private aiProvider: AIProvider,
) {} ) {}
@@ -566,7 +565,7 @@ export class ChatService {
if (process.env.USE_TEST_RESPONSES === "true") { if (process.env.USE_TEST_RESPONSES === "true") {
// Test mode: use static responses // Test mode: use static responses
response = await getTestResponse(responseIndex, this.eventRepo, userId); response = await getTestResponse(responseIndex, this.eventService, userId);
responseIndex++; responseIndex++;
} else { } else {
// Production mode: use real AI // Production mode: use real AI
@@ -582,7 +581,7 @@ export class ChatService {
return this.eventService.getByDateRange(userId, start, end); return this.eventService.getByDateRange(userId, start, end);
}, },
searchEvents: async (query) => { searchEvents: async (query) => {
return this.eventRepo.searchByTitle(userId, query); return this.eventService.searchByTitle(userId, query);
}, },
fetchEventById: async (eventId) => { fetchEventById: async (eventId) => {
return this.eventService.getById(eventId, userId); return this.eventService.getById(eventId, userId);
@@ -623,10 +622,10 @@ export class ChatService {
let content: string; let content: string;
if (action === "create" && event) { 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.`; content = `Der Termin "${createdEvent.title}" wurde erstellt.`;
} else if (action === "update" && eventId && updates) { } 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 content = updatedEvent
? `Der Termin "${updatedEvent.title}" wurde aktualisiert.` ? `Der Termin "${updatedEvent.title}" wurde aktualisiert.`
: "Termin nicht gefunden."; : "Termin nicht gefunden.";

View File

@@ -24,10 +24,18 @@ export class EventService {
return event; return event;
} }
async findByCaldavUUID(userId: string, caldavUUID: string): Promise<CalendarEvent | null> {
return this.eventRepo.findByCaldavUUID(userId, caldavUUID);
}
async getAll(userId: string): Promise<CalendarEvent[]> { async getAll(userId: string): Promise<CalendarEvent[]> {
return this.eventRepo.findByUserId(userId); return this.eventRepo.findByUserId(userId);
} }
async searchByTitle(userId: string, query: string): Promise<CalendarEvent[]> {
return this.eventRepo.searchByTitle(userId, query);
}
async getByDateRange( async getByDateRange(
userId: string, userId: string,
startDate: Date, startDate: Date,

View 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>;
}

View File

@@ -8,6 +8,7 @@ export interface EventRepository {
startDate: Date, startDate: Date,
endDate: Date, endDate: Date,
): Promise<CalendarEvent[]>; ): Promise<CalendarEvent[]>;
findByCaldavUUID(userId: string, caldavUUID: string): Promise<CalendarEvent | null>;
searchByTitle(userId: string, query: string): Promise<CalendarEvent[]>; searchByTitle(userId: string, query: string): Promise<CalendarEvent[]>;
create(userId: string, data: CreateEventDTO): Promise<CalendarEvent>; create(userId: string, data: CreateEventDTO): Promise<CalendarEvent>;
update(id: string, data: UpdateEventDTO): Promise<CalendarEvent | null>; update(id: string, data: UpdateEventDTO): Promise<CalendarEvent | null>;

View File

@@ -6,7 +6,7 @@ import {
MONTH_TO_GERMAN, MONTH_TO_GERMAN,
ExpandedEvent, ExpandedEvent,
} from "@calchat/shared"; } from "@calchat/shared";
import { EventRepository } from "../services/interfaces"; import { EventService } from "../services/EventService";
import { expandRecurringEvents } from "./recurrenceExpander"; import { expandRecurringEvents } from "./recurrenceExpander";
// Private formatting helpers // 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. * Recurring events are expanded to show all occurrences within the range.
*/ */
export async function getWeeksOverview( export async function getWeeksOverview(
eventRepo: EventRepository, eventService: EventService,
userId: string, userId: string,
weeks: number, weeks: number,
): Promise<string> { ): Promise<string> {
const now = new Date(); const now = new Date();
const endDate = new Date(now.getTime() + weeks * 7 * 24 * 60 * 60 * 1000); 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); const expanded = expandRecurringEvents(events, now, endDate);
return formatWeeksText(expanded, weeks); return formatWeeksText(expanded, weeks);
} }
@@ -123,14 +123,14 @@ export async function getWeeksOverview(
* Recurring events are expanded to show all occurrences within the month. * Recurring events are expanded to show all occurrences within the month.
*/ */
export async function getMonthOverview( export async function getMonthOverview(
eventRepo: EventRepository, eventService: EventService,
userId: string, userId: string,
year: number, year: number,
month: number, month: number,
): Promise<string> { ): Promise<string> {
const startOfMonth = new Date(year, month, 1); const startOfMonth = new Date(year, month, 1);
const endOfMonth = new Date(year, month + 1, 0, 23, 59, 59); 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 expanded = expandRecurringEvents(events, startOfMonth, endOfMonth);
const monthName = MONTH_TO_GERMAN[MONTHS[month]]; const monthName = MONTH_TO_GERMAN[MONTHS[month]];
return formatMonthText(expanded, monthName); return formatMonthText(expanded, monthName);

View File

@@ -1,5 +1,5 @@
import { RRule, rrulestr } from "rrule"; 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 // Convert local time to "fake UTC" for rrule
// rrule interprets all dates as UTC internally, so we need to trick it // 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"); const seconds = String(date.getSeconds()).padStart(2, "0");
return `${year}${month}${day}T${hours}${minutes}${seconds}`; 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}`;
}

View File

@@ -8,7 +8,8 @@
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"emitDecoratorMetadata": true "emitDecoratorMetadata": true,
"skipLibCheck": true
}, },
"include": ["src"] "include": ["src"]
} }

View File

@@ -30,6 +30,8 @@ package "Controller Layer" #ADD8E6 {
} }
class EventController { class EventController {
' -pushToCaldav()
' -deleteFromCaldav()
' +create() ' +create()
' +getById() ' +getById()
' +getAll() ' +getAll()
@@ -38,6 +40,15 @@ package "Controller Layer" #ADD8E6 {
' +delete() ' +delete()
} }
class CaldavController {
' +saveConfig()
' +loadConfig()
' +deleteConfig()
' +pullEvents()
' +pushEvents()
' +pushEvent()
}
class AuthMiddleware { class AuthMiddleware {
' +authenticate() ' +authenticate()
} }
@@ -59,9 +70,12 @@ package "Service Layer" #90EE90 {
' +findById() ' +findById()
' +findByUserId() ' +findByUserId()
' +findByDateRange() ' +findByDateRange()
' +findByCaldavUUID()
' +searchByTitle()
' +create() ' +create()
' +update() ' +update()
' +delete() ' +delete()
' +addExceptionDate()
} }
interface ChatRepository { interface ChatRepository {
@@ -69,6 +83,14 @@ package "Service Layer" #90EE90 {
' +createConversation() ' +createConversation()
' +getMessages() ' +getMessages()
' +createMessage() ' +createMessage()
' +updateProposalResponse()
' +updateProposalEvent()
}
interface CaldavRepository {
' +findByUserId()
' +createOrUpdate()
' +deleteByUserId()
} }
} }
@@ -80,7 +102,7 @@ package "Service Layer" #90EE90 {
class ChatService { class ChatService {
' -chatRepo: ChatRepository ' -chatRepo: ChatRepository
' -eventRepo: EventRepository ' -eventService: EventService
' -aiProvider: AIProvider ' -aiProvider: AIProvider
' +processMessage() ' +processMessage()
' +confirmEvent() ' +confirmEvent()
@@ -95,13 +117,29 @@ package "Service Layer" #90EE90 {
' +getById() ' +getById()
' +getAll() ' +getAll()
' +getByDateRange() ' +getByDateRange()
' +searchByTitle()
' +findByCaldavUUID()
' +update() ' +update()
' +delete() ' +delete()
' +deleteRecurring()
}
class CaldavService {
' -caldavRepo: CaldavRepository
' -eventService: EventService
' +connect()
' +pullEvents()
' +pushEvent()
' +pushAll()
' +deleteEvent()
' +getConfig()
' +saveConfig()
' +deleteConfig()
} }
} }
package "AI Implementations" #FFA07A { package "AI Implementations" #FFA07A {
class ClaudeAdapter implements AIProvider { class GPTAdapter implements AIProvider {
' -apiKey: string ' -apiKey: string
' +processMessage() ' +processMessage()
} }
@@ -119,6 +157,10 @@ package "Data Access Implementations" #FFD700 {
class MongoChatRepository implements ChatRepository { class MongoChatRepository implements ChatRepository {
' -model: ChatModel ' -model: ChatModel
} }
class MongoCaldavRepository implements CaldavRepository {
' -model: CaldavConfigModel
}
} }
package "Models" #D3D3D3 { package "Models" #D3D3D3 {
@@ -169,15 +211,20 @@ package "Utils" #DDA0DD {
' Controller -> Service ' Controller -> Service
AuthController --> AuthService AuthController --> AuthService
ChatController --> ChatService ChatController --> ChatService
ChatController --> CaldavService
EventController --> EventService EventController --> EventService
EventController --> CaldavService
CaldavController --> CaldavService
AuthMiddleware --> JWT AuthMiddleware --> JWT
' Service -> Interfaces (intern) ' Service -> Interfaces (intern)
AuthService --> UserRepository AuthService --> UserRepository
ChatService --> ChatRepository ChatService --> ChatRepository
ChatService --> EventRepository ChatService --> EventService
ChatService --> AIProvider ChatService --> AIProvider
EventService --> EventRepository EventService --> EventRepository
CaldavService --> CaldavRepository
CaldavService --> EventService
' Auth uses Utils ' Auth uses Utils
AuthService --> JWT AuthService --> JWT

View File

@@ -16,6 +16,8 @@ package "apps/client (Expo React Native)" as ClientPkg #87CEEB {
[Login/Register] as AuthScreens [Login/Register] as AuthScreens
[Calendar View] as CalendarScreen [Calendar View] as CalendarScreen
[Chat View] as ChatScreen [Chat View] as ChatScreen
[Settings] as SettingsScreen
[Edit Event] as EditEventScreen
[Event Detail] as EventDetail [Event Detail] as EventDetail
[Note Editor] as NoteScreen [Note Editor] as NoteScreen
} }
@@ -25,17 +27,20 @@ package "apps/client (Expo React Native)" as ClientPkg #87CEEB {
[Auth Service] as ClientAuth [Auth Service] as ClientAuth
[Event Service] as ClientEvent [Event Service] as ClientEvent
[Chat Service] as ClientChat [Chat Service] as ClientChat
[Caldav Config Service] as ClientCaldav
} }
package "Components" { package "Components" {
[UI Components] as UIComponents [UI Components] as UIComponents
[Event Cards] as EventCards [Event Cards] as EventCards
[Auth Guard] as AuthGuard
} }
package "Stores" { package "Stores" {
[Auth Store] as AuthStore [Auth Store] as AuthStore
[Events Store] as EventsStore [Events Store] as EventsStore
[Chat Store] as ChatStore [Chat Store] as ChatStore
[Theme Store] as ThemeStore
} }
} }
@@ -59,10 +64,11 @@ package "apps/server (Express.js)" as ServerPkg #98FB98 {
[AuthService] as AuthSvc [AuthService] as AuthSvc
[ChatService] as ChatSvc [ChatService] as ChatSvc
[EventService] as EventSvc [EventService] as EventSvc
[CaldavService] as CaldavSvc
} }
package "AI Implementations" { package "AI Implementations" {
[ClaudeAdapter] as Claude [GPTAdapter] as GPT
} }
package "Data Access Implementations" { package "Data Access Implementations" {
@@ -80,25 +86,35 @@ package "apps/server (Express.js)" as ServerPkg #98FB98 {
' ===== ROW 4: EXTERNAL ===== ' ===== ROW 4: EXTERNAL =====
database "MongoDB" as MongoDB database "MongoDB" as MongoDB
cloud "Claude API" as ClaudeAPI cloud "OpenAI API" as OpenAIAPI
cloud "CalDAV Server" as CaldavServer
' ===== CONNECTIONS ===== ' ===== CONNECTIONS =====
' Frontend: Screens -> Services ' Frontend: Screens -> Services
AuthScreens --> ClientAuth AuthScreens --> ClientAuth
CalendarScreen --> ClientEvent CalendarScreen --> ClientEvent
CalendarScreen --> ClientCaldav
ChatScreen --> ClientChat ChatScreen --> ClientChat
SettingsScreen --> ClientCaldav
EditEventScreen --> ClientEvent
EventDetail --> ClientEvent EventDetail --> ClientEvent
NoteScreen --> ClientEvent NoteScreen --> ClientEvent
ClientAuth --> ApiClient ClientAuth --> ApiClient
ClientEvent --> ApiClient ClientEvent --> ApiClient
ClientChat --> ApiClient ClientChat --> ApiClient
ClientCaldav --> ApiClient
ApiClient --> AuthStore ApiClient --> AuthStore
ClientEvent --> EventsStore ClientEvent --> EventsStore
ClientChat --> ChatStore ClientChat --> ChatStore
' Frontend: Auth
AuthGuard --> AuthStore
AuthGuard --> ClientCaldav
AuthScreens --> ClientCaldav
' Frontend: Screens -> Components ' Frontend: Screens -> Components
CalendarScreen --> EventCards CalendarScreen --> EventCards
ChatScreen --> EventCards ChatScreen --> EventCards
@@ -121,14 +137,20 @@ Routes --> Controllers
Controllers --> AuthSvc Controllers --> AuthSvc
Controllers --> ChatSvc Controllers --> ChatSvc
Controllers --> EventSvc Controllers --> EventSvc
Controllers --> CaldavSvc
' Backend: Service -> Interfaces ' Backend: Service -> Interfaces
AuthSvc --> Interfaces AuthSvc --> Interfaces
ChatSvc --> Interfaces ChatSvc --> Interfaces
EventSvc --> Interfaces EventSvc --> Interfaces
CaldavSvc --> Interfaces
' Backend: Service dependencies
ChatSvc --> EventSvc
CaldavSvc --> EventSvc
' Backend: AI & Data Access implement Interfaces ' Backend: AI & Data Access implement Interfaces
Claude ..|> Interfaces GPT ..|> Interfaces
Repos ..|> Interfaces Repos ..|> Interfaces
' Backend: Service -> Utils ' Backend: Service -> Utils
@@ -143,6 +165,7 @@ Repos --> Schemas
' Backend -> External ' Backend -> External
Schemas --> MongoDB Schemas --> MongoDB
Claude --> ClaudeAPI GPT --> OpenAIAPI
CaldavSvc --> CaldavServer
@enduml @enduml

View File

@@ -22,18 +22,26 @@ package "Screens" #87CEEB {
class RegisterScreen class RegisterScreen
class CalendarScreen class CalendarScreen
class ChatScreen class ChatScreen
class SettingsScreen
class EditEventScreen
class EventDetailScreen class EventDetailScreen
class NoteScreen class NoteScreen
} }
' ===== COMPONENTS ===== ' ===== COMPONENTS =====
package "Components" #FFA07A { package "Components" #FFA07A {
class AuthGuard
class BaseBackground class BaseBackground
class Header class Header
class BaseButton
class CardBase
class ModalBase
class EventCardBase class EventCardBase
class EventCard class EventCard
class ProposedEventCard class ProposedEventCard
class EventConfirmDialog class DeleteEventModal
class ChatBubble
class TypingIndicator
} }
' ===== SERVICES ===== ' ===== SERVICES =====
@@ -64,6 +72,13 @@ package "Services" #90EE90 {
+rejectEvent() +rejectEvent()
+getConversations() +getConversations()
+getConversation() +getConversation()
+updateProposalEvent()
}
class CaldavConfigService {
+getConfig()
+saveConfig()
+deleteConfig()
+sync()
} }
} }
@@ -71,11 +86,10 @@ package "Services" #90EE90 {
package "Stores" #FFD700 { package "Stores" #FFD700 {
class AuthStore { class AuthStore {
' +user ' +user
' +token
' +isAuthenticated ' +isAuthenticated
' +login() ' +login()
' +logout() ' +logout()
' +setToken() ' +loadStoredUser()
} }
class EventsStore { class EventsStore {
' +events ' +events
@@ -86,10 +100,16 @@ package "Stores" #FFD700 {
} }
class ChatStore { class ChatStore {
' +messages ' +messages
' +isWaitingForResponse
' +addMessage() ' +addMessage()
' +addMessages()
' +updateMessage() ' +updateMessage()
' +clearMessages() ' +clearMessages()
} }
class ThemeStore {
' +theme
' +setTheme()
}
} }
' ===== MODELS ===== ' ===== MODELS =====
@@ -97,6 +117,7 @@ package "Models (shared)" #D3D3D3 {
class User class User
class CalendarEvent class CalendarEvent
class ChatMessage class ChatMessage
class CaldavConfig
} }
' ===== RELATIONSHIPS ===== ' ===== RELATIONSHIPS =====
@@ -104,24 +125,39 @@ package "Models (shared)" #D3D3D3 {
' Screens -> Services ' Screens -> Services
LoginScreen --> AuthService LoginScreen --> AuthService
CalendarScreen --> EventService CalendarScreen --> EventService
CalendarScreen --> CaldavConfigService
ChatScreen --> ChatService ChatScreen --> ChatService
NoteScreen --> EventService NoteScreen --> EventService
EditEventScreen --> EventService
EditEventScreen --> ChatService
SettingsScreen --> CaldavConfigService
' Screens -> Components ' Screens -> Components
CalendarScreen --> EventCard CalendarScreen --> EventCard
ChatScreen --> ProposedEventCard ChatScreen --> ProposedEventCard
ChatScreen --> EventConfirmDialog ChatScreen --> ChatBubble
ChatScreen --> TypingIndicator
EventCard --> EventCardBase EventCard --> EventCardBase
ProposedEventCard --> EventCardBase ProposedEventCard --> EventCardBase
EventCardBase --> CardBase
ModalBase --> CardBase
DeleteEventModal --> ModalBase
' Auth
AuthGuard --> AuthStore
AuthGuard --> CaldavConfigService
LoginScreen --> CaldavConfigService
' Services -> ApiClient ' Services -> ApiClient
AuthService --> ApiClient AuthService --> ApiClient
EventService --> ApiClient EventService --> ApiClient
ChatService --> ApiClient ChatService --> ApiClient
CaldavConfigService --> ApiClient
' Services/Screens -> Stores ' Services/Screens -> Stores
AuthService --> AuthStore AuthService --> AuthStore
EventService --> EventsStore CalendarScreen --> EventsStore
ChatScreen --> ChatStore ChatScreen --> ChatStore
SettingsScreen --> ThemeStore
@enduml @enduml

6047
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
export interface CaldavConfig {
userId: string;
serverUrl: string;
username: string;
password: string;
syncIntervalSeconds?: number;
}

View File

@@ -1,6 +1,8 @@
export interface CalendarEvent { export interface CalendarEvent {
id: string; id: string;
userId: string; userId: string;
caldavUUID?: string;
etag?: string;
title: string; title: string;
description?: string; description?: string;
startTime: Date; startTime: Date;
@@ -10,9 +12,11 @@ export interface CalendarEvent {
exceptionDates?: string[]; // ISO date strings (YYYY-MM-DD) for excluded occurrences exceptionDates?: string[]; // ISO date strings (YYYY-MM-DD) for excluded occurrences
createdAt?: Date; createdAt?: Date;
updatedAt?: Date; updatedAt?: Date;
caldavSyncStatus?: CaldavSyncStatus;
} }
export type RecurringDeleteMode = "single" | "future" | "all"; export type RecurringDeleteMode = "single" | "future" | "all";
export type CaldavSyncStatus = "synced" | "error";
export interface DeleteRecurringEventDTO { export interface DeleteRecurringEventDTO {
mode: RecurringDeleteMode; mode: RecurringDeleteMode;
@@ -20,6 +24,8 @@ export interface DeleteRecurringEventDTO {
} }
export interface CreateEventDTO { export interface CreateEventDTO {
caldavUUID?: string;
etag?: string;
title: string; title: string;
description?: string; description?: string;
startTime: Date; startTime: Date;
@@ -27,9 +33,12 @@ export interface CreateEventDTO {
note?: string; note?: string;
recurrenceRule?: string; recurrenceRule?: string;
exceptionDates?: string[]; // For display in proposals exceptionDates?: string[]; // For display in proposals
caldavSyncStatus?: CaldavSyncStatus;
} }
export interface UpdateEventDTO { export interface UpdateEventDTO {
caldavUUID?: string;
etag?: string;
title?: string; title?: string;
description?: string; description?: string;
startTime?: Date; startTime?: Date;
@@ -37,6 +46,7 @@ export interface UpdateEventDTO {
note?: string; note?: string;
recurrenceRule?: string; recurrenceRule?: string;
exceptionDates?: string[]; exceptionDates?: string[];
caldavSyncStatus?: CaldavSyncStatus;
} }
export interface ExpandedEvent extends CalendarEvent { export interface ExpandedEvent extends CalendarEvent {

View File

@@ -2,3 +2,4 @@ export * from "./User";
export * from "./CalendarEvent"; export * from "./CalendarEvent";
export * from "./ChatMessage"; export * from "./ChatMessage";
export * from "./Constants"; export * from "./Constants";
export * from "./CaldavConfig";

View File

@@ -69,3 +69,11 @@ export function formatDateWithWeekdayShort(date: Date): string {
month: "2-digit", month: "2-digit",
}); });
} }
// Format date as YYYY-MM-DD for exception date comparison
export 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}`;
}