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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
100
CLAUDE.md
@@ -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
|
||||||
|
|||||||
@@ -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]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
35
apps/client/src/components/CustomTextInput.tsx
Normal file
35
apps/client/src/components/CustomTextInput.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { TextInput } from "react-native";
|
||||||
|
import { useThemeStore } from "../stores/ThemeStore";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export type CustomTextInputProps = {
|
||||||
|
text?: string;
|
||||||
|
focused?: boolean;
|
||||||
|
className?: string;
|
||||||
|
multiline?: boolean;
|
||||||
|
onValueChange?: (text: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CustomTextInput = (props: CustomTextInputProps) => {
|
||||||
|
const { theme } = useThemeStore();
|
||||||
|
const [focused, setFocused] = useState(props.focused ?? false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
className={`border border-solid rounded-2xl px-3 py-2 h-11/12 ${props.className}`}
|
||||||
|
onChangeText={props.onValueChange}
|
||||||
|
value={props.text}
|
||||||
|
multiline={props.multiline}
|
||||||
|
style={{
|
||||||
|
backgroundColor: theme.messageBorderBg,
|
||||||
|
color: theme.textPrimary,
|
||||||
|
textAlignVertical: "top",
|
||||||
|
borderColor: focused ? theme.chatBot : theme.borderPrimary,
|
||||||
|
}}
|
||||||
|
onFocus={() => setFocused(true)}
|
||||||
|
onBlur={() => setFocused(false)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomTextInput;
|
||||||
32
apps/client/src/services/CaldavConfigService.ts
Normal file
32
apps/client/src/services/CaldavConfigService.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { CalendarEvent, CaldavConfig } from "@calchat/shared";
|
||||||
|
import { ApiClient } from "./ApiClient";
|
||||||
|
|
||||||
|
export const CaldavConfigService = {
|
||||||
|
saveConfig: async (
|
||||||
|
serverUrl: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<CaldavConfig> => {
|
||||||
|
return ApiClient.put<CaldavConfig>("/caldav/config", {
|
||||||
|
serverUrl,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getConfig: async (): Promise<CaldavConfig> => {
|
||||||
|
return ApiClient.get<CaldavConfig>("/caldav/config");
|
||||||
|
},
|
||||||
|
deleteConfig: async (): Promise<void> => {
|
||||||
|
return ApiClient.delete<void>("/caldav/config");
|
||||||
|
},
|
||||||
|
pull: async (): Promise<CalendarEvent[]> => {
|
||||||
|
return ApiClient.post<CalendarEvent[]>("/caldav/pull");
|
||||||
|
},
|
||||||
|
pushAll: async (): Promise<void> => {
|
||||||
|
return ApiClient.post<void>("/caldav/pushAll");
|
||||||
|
},
|
||||||
|
sync: async (): Promise<void> => {
|
||||||
|
await ApiClient.post<void>("/caldav/pushAll");
|
||||||
|
await ApiClient.post<CalendarEvent[]>("/caldav/pull");
|
||||||
|
},
|
||||||
|
};
|
||||||
25
apps/server/docker/radicale/docker-compose.yml
Normal file
25
apps/server/docker/radicale/docker-compose.yml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
name: Radicale
|
||||||
|
services:
|
||||||
|
radicale:
|
||||||
|
image: ghcr.io/kozea/radicale:stable
|
||||||
|
ports:
|
||||||
|
- 5232:5232
|
||||||
|
volumes:
|
||||||
|
- config:/etc/radicale
|
||||||
|
- data:/var/lib/radicale
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
config:
|
||||||
|
name: radicale-config
|
||||||
|
driver: local
|
||||||
|
driver_opts:
|
||||||
|
type: none
|
||||||
|
o: bind
|
||||||
|
device: ./config
|
||||||
|
data:
|
||||||
|
name: radicale-data
|
||||||
|
driver: local
|
||||||
|
driver_opts:
|
||||||
|
type: none
|
||||||
|
o: bind
|
||||||
|
device: ./data
|
||||||
4
apps/server/jest.config.js
Normal file
4
apps/server/jest.config.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
module.exports = {
|
||||||
|
preset: "ts-jest",
|
||||||
|
testEnvironment: "node",
|
||||||
|
};
|
||||||
@@ -5,26 +5,33 @@
|
|||||||
"scripts": {
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
import { formatDate, formatTime, formatDateTime } from "@calchat/shared";
|
|
||||||
|
|
||||||
// Re-export from shared package for use in toolExecutor
|
|
||||||
export { formatDate, formatTime, formatDateTime };
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
export { formatDate, formatTime, formatDateTime } from "./eventFormatter";
|
|
||||||
export { buildSystemPrompt } from "./systemPrompt";
|
export { buildSystemPrompt } from "./systemPrompt";
|
||||||
export {
|
export {
|
||||||
TOOL_DEFINITIONS,
|
TOOL_DEFINITIONS,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
85
apps/server/src/controllers/CaldavController.ts
Normal file
85
apps/server/src/controllers/CaldavController.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { Response } from "express";
|
||||||
|
import { createLogger } from "../logging/logger";
|
||||||
|
import { AuthenticatedRequest } from "./AuthMiddleware";
|
||||||
|
import { CaldavConfig } from "@calchat/shared";
|
||||||
|
import { CaldavService } from "../services/CaldavService";
|
||||||
|
|
||||||
|
const log = createLogger("CaldavController");
|
||||||
|
|
||||||
|
export class CaldavController {
|
||||||
|
constructor(private caldavService: CaldavService) {}
|
||||||
|
|
||||||
|
async saveConfig(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const config: CaldavConfig = { userId: req.user!.userId, ...req.body };
|
||||||
|
const response = await this.caldavService.saveConfig(config);
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
log.error({ error, userId: req.user?.userId }, "Error saving config");
|
||||||
|
res.status(500).json({ error: "Failed to save config" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadConfig(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const config = await this.caldavService.getConfig(req.user!.userId);
|
||||||
|
if (!config) {
|
||||||
|
res.status(404).json({ error: "No CalDAV config found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Don't expose the password to the client
|
||||||
|
res.json(config);
|
||||||
|
} catch (error) {
|
||||||
|
log.error({ error, userId: req.user?.userId }, "Error loading config");
|
||||||
|
res.status(500).json({ error: "Failed to load config" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteConfig(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.caldavService.deleteConfig(req.user!.userId);
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
log.error({ error, userId: req.user?.userId }, "Error deleting config");
|
||||||
|
res.status(500).json({ error: "Failed to delete config" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async pullEvents(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const events = await this.caldavService.pullEvents(req.user!.userId);
|
||||||
|
res.json(events);
|
||||||
|
} catch (error) {
|
||||||
|
log.error({ error, userId: req.user?.userId }, "Error pulling events");
|
||||||
|
res.status(500).json({ error: "Failed to pull events" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async pushEvents(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.caldavService.pushAll(req.user!.userId);
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
log.error({ error, userId: req.user?.userId }, "Error pushing events");
|
||||||
|
res.status(500).json({ error: "Failed to push events" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async pushEvent(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const event = await this.caldavService.findEventByCaldavUUID(
|
||||||
|
req.user!.userId,
|
||||||
|
req.params.caldavUUID,
|
||||||
|
);
|
||||||
|
if (!event) {
|
||||||
|
res.status(404).json({ error: "Event not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.caldavService.pushEvent(req.user!.userId, event);
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
log.error({ error, userId: req.user?.userId }, "Error pushing event");
|
||||||
|
res.status(500).json({ error: "Failed to push event" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,13 +8,17 @@ import {
|
|||||||
RecurringDeleteMode,
|
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(
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
31
apps/server/src/repositories/mongo/MongoCaldavRepository.ts
Normal file
31
apps/server/src/repositories/mongo/MongoCaldavRepository.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { CaldavConfig } from "@calchat/shared";
|
||||||
|
import { Logged } from "../../logging/Logged";
|
||||||
|
import { CaldavRepository } from "../../services/interfaces/CaldavRepository";
|
||||||
|
import { CaldavConfigModel } from "./models/CaldavConfigModel";
|
||||||
|
|
||||||
|
@Logged("MongoCaldavRepository")
|
||||||
|
export class MongoCaldavRepository implements CaldavRepository {
|
||||||
|
async findByUserId(userId: string): Promise<CaldavConfig | null> {
|
||||||
|
const config = await CaldavConfigModel.findOne({ userId });
|
||||||
|
if (!config) return null;
|
||||||
|
return config.toJSON() as unknown as CaldavConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOrUpdate(config: CaldavConfig): Promise<CaldavConfig> {
|
||||||
|
const caldavConfig = await CaldavConfigModel.findOneAndUpdate(
|
||||||
|
{ userId: config.userId },
|
||||||
|
config,
|
||||||
|
{
|
||||||
|
upsert: true,
|
||||||
|
new: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// NOTE: Casting required because Mongoose's toJSON() type doesn't reflect our virtual 'id' field
|
||||||
|
return caldavConfig.toJSON() as unknown as CaldavConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteByUserId(userId: string): Promise<boolean> {
|
||||||
|
const result = await CaldavConfigModel.findOneAndDelete({userId});
|
||||||
|
return result !== null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,12 @@ export class MongoEventRepository implements EventRepository {
|
|||||||
return events.map((e) => e.toJSON() as unknown as CalendarEvent);
|
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,
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { CaldavConfig } from "@calchat/shared";
|
||||||
|
import mongoose, { Document, Schema } from "mongoose";
|
||||||
|
|
||||||
|
export interface CaldavConfigDocument extends CaldavConfig, Document {
|
||||||
|
toJSON(): CaldavConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CaldavConfigSchema = new Schema<CaldavConfigDocument>(
|
||||||
|
{
|
||||||
|
userId: { type: String, required: true, index: true },
|
||||||
|
serverUrl: { type: String, required: true },
|
||||||
|
username: { type: String, required: true },
|
||||||
|
password: { type: String, required: true },
|
||||||
|
syncIntervalSeconds: { type: Number },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CaldavConfigModel = mongoose.model<CaldavConfigDocument>(
|
||||||
|
"CaldavConfig",
|
||||||
|
CaldavConfigSchema,
|
||||||
|
);
|
||||||
@@ -21,6 +21,12 @@ const EventSchema = new Schema<
|
|||||||
required: true,
|
required: true,
|
||||||
index: true,
|
index: true,
|
||||||
},
|
},
|
||||||
|
caldavUUID: {
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
etag: {
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
title: {
|
title: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
|
|||||||
22
apps/server/src/routes/caldav.routes.ts
Normal file
22
apps/server/src/routes/caldav.routes.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { authenticate } from "../controllers";
|
||||||
|
import { CaldavController } from "../controllers/CaldavController";
|
||||||
|
|
||||||
|
export function createCaldavRoutes(caldavController: CaldavController): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
router.put("/config", (req, res) => caldavController.saveConfig(req, res));
|
||||||
|
router.get("/config", (req, res) => caldavController.loadConfig(req, res));
|
||||||
|
router.delete("/config", (req, res) =>
|
||||||
|
caldavController.deleteConfig(req, res),
|
||||||
|
);
|
||||||
|
router.post("/pull", (req, res) => caldavController.pullEvents(req, res));
|
||||||
|
router.post("/pushAll", (req, res) => caldavController.pushEvents(req, res));
|
||||||
|
router.post("/push/:caldavUUID", (req, res) =>
|
||||||
|
caldavController.pushEvent(req, res),
|
||||||
|
);
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -6,12 +6,15 @@ import {
|
|||||||
AuthController,
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
11
apps/server/src/services/CaldavService.test.ts
Normal file
11
apps/server/src/services/CaldavService.test.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// import { createLogger } from "../logging";
|
||||||
|
// import { CaldavService } from "./CaldavService";
|
||||||
|
//
|
||||||
|
// const logger = createLogger("CaldavService-Test");
|
||||||
|
//
|
||||||
|
// const cdService = new CaldavService();
|
||||||
|
//
|
||||||
|
// test("print events", async () => {
|
||||||
|
// const client = await cdService.login();
|
||||||
|
// await cdService.pullEvents(client);
|
||||||
|
// });
|
||||||
260
apps/server/src/services/CaldavService.ts
Normal file
260
apps/server/src/services/CaldavService.ts
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import crypto from "crypto";
|
||||||
|
import { DAVClient } from "tsdav";
|
||||||
|
import ICAL from "ical.js";
|
||||||
|
import { createLogger } from "../logging/logger";
|
||||||
|
import { CaldavRepository } from "./interfaces/CaldavRepository";
|
||||||
|
import {
|
||||||
|
CalendarEvent,
|
||||||
|
CreateEventDTO,
|
||||||
|
} from "@calchat/shared/src/models/CalendarEvent";
|
||||||
|
import { EventService } from "./EventService";
|
||||||
|
import { CaldavConfig, formatDateKey } from "@calchat/shared";
|
||||||
|
|
||||||
|
const logger = createLogger("CaldavService");
|
||||||
|
|
||||||
|
export class CaldavService {
|
||||||
|
constructor(
|
||||||
|
private caldavRepo: CaldavRepository,
|
||||||
|
private eventService: EventService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login to CalDAV server and return client + first calendar.
|
||||||
|
*/
|
||||||
|
async connect(userId: string) {
|
||||||
|
const config = await this.caldavRepo.findByUserId(userId);
|
||||||
|
if (config === null) {
|
||||||
|
throw new Error(`Coudn't find config by user id ${userId}`);
|
||||||
|
}
|
||||||
|
const client = new DAVClient({
|
||||||
|
serverUrl: config.serverUrl,
|
||||||
|
credentials: {
|
||||||
|
username: config.username,
|
||||||
|
password: config.password,
|
||||||
|
},
|
||||||
|
authMethod: "Basic",
|
||||||
|
defaultAccountType: "caldav",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.login();
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error("Caldav login failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendars = await client.fetchCalendars();
|
||||||
|
if (calendars.length === 0) {
|
||||||
|
throw new Error("No calendars found on CalDAV server");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { client, calendar: calendars[0] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull events from CalDAV server and sync with local database.
|
||||||
|
* - Compares etags to skip unchanged events
|
||||||
|
* - Creates new or updates existing events in the database
|
||||||
|
* - Deletes local events that were removed on the CalDAV server
|
||||||
|
*
|
||||||
|
* @returns List of newly created or updated events
|
||||||
|
*/
|
||||||
|
async pullEvents(userId: string): Promise<CalendarEvent[]> {
|
||||||
|
const { client, calendar } = await this.connect(userId);
|
||||||
|
const calendarEvents: CalendarEvent[] = [];
|
||||||
|
const caldavEventUUIDs = new Set<string>();
|
||||||
|
|
||||||
|
const events = await client.fetchCalendarObjects({ calendar });
|
||||||
|
for (const event of events) {
|
||||||
|
const etag = event.etag;
|
||||||
|
const jcal = ICAL.parse(event.data);
|
||||||
|
const comp = new ICAL.Component(jcal);
|
||||||
|
// A CalendarObject (.ics file) can contain multiple VEVENTs (e.g.
|
||||||
|
// recurring events with RECURRENCE-ID exceptions), but the etag belongs
|
||||||
|
// to the whole file, not individual VEVENTs. We only need the first
|
||||||
|
// VEVENT since we handle recurrence via RRULE/exceptionDates, not as
|
||||||
|
// separate events.
|
||||||
|
const vevent = comp.getFirstSubcomponent("vevent");
|
||||||
|
if (!vevent) continue;
|
||||||
|
|
||||||
|
const icalEvent = new ICAL.Event(vevent);
|
||||||
|
caldavEventUUIDs.add(icalEvent.uid);
|
||||||
|
|
||||||
|
const exceptionDates = vevent
|
||||||
|
.getAllProperties("exdate")
|
||||||
|
.flatMap((prop) => prop.getValues())
|
||||||
|
.map((time: ICAL.Time) => formatDateKey(time.toJSDate()));
|
||||||
|
|
||||||
|
const existingEvent = await this.eventService.findByCaldavUUID(
|
||||||
|
userId,
|
||||||
|
icalEvent.uid,
|
||||||
|
);
|
||||||
|
|
||||||
|
const didChange = existingEvent?.etag !== etag;
|
||||||
|
|
||||||
|
if (existingEvent && !didChange) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventObject: CreateEventDTO = {
|
||||||
|
caldavUUID: icalEvent.uid,
|
||||||
|
etag,
|
||||||
|
title: icalEvent.summary,
|
||||||
|
description: icalEvent.description,
|
||||||
|
startTime: icalEvent.startDate.toJSDate(),
|
||||||
|
endTime: icalEvent.endDate.toJSDate(),
|
||||||
|
recurrenceRule: vevent.getFirstPropertyValue("rrule")?.toString(),
|
||||||
|
exceptionDates,
|
||||||
|
caldavSyncStatus: "synced",
|
||||||
|
};
|
||||||
|
|
||||||
|
const calendarEvent = existingEvent
|
||||||
|
? await this.eventService.update(existingEvent.id, userId, eventObject)
|
||||||
|
: await this.eventService.create(userId, eventObject);
|
||||||
|
|
||||||
|
if (calendarEvent) {
|
||||||
|
calendarEvents.push(calendarEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete all events, that got deleted remotely
|
||||||
|
const localEvents = await this.eventService.getAll(userId);
|
||||||
|
for (const localEvent of localEvents) {
|
||||||
|
if (
|
||||||
|
localEvent.caldavUUID &&
|
||||||
|
!caldavEventUUIDs.has(localEvent.caldavUUID)
|
||||||
|
) {
|
||||||
|
await this.eventService.delete(localEvent.id, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return calendarEvents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push a single event to the CalDAV server.
|
||||||
|
* Creates a new event if no caldavUUID exists, updates otherwise.
|
||||||
|
*/
|
||||||
|
async pushEvent(userId: string, event: CalendarEvent): Promise<void> {
|
||||||
|
const { client, calendar } = await this.connect(userId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (event.caldavUUID) {
|
||||||
|
await client.updateCalendarObject({
|
||||||
|
calendarObject: {
|
||||||
|
url: `${calendar.url}${event.caldavUUID}.ics`,
|
||||||
|
data: this.toICalString(event.caldavUUID, event),
|
||||||
|
etag: event.etag || "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const uid = crypto.randomUUID();
|
||||||
|
await client.createCalendarObject({
|
||||||
|
calendar,
|
||||||
|
filename: `${uid}.ics`,
|
||||||
|
iCalString: this.toICalString(uid, event),
|
||||||
|
});
|
||||||
|
await this.eventService.update(event.id, userId, { caldavUUID: uid });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch updated etag from server
|
||||||
|
const objects = await client.fetchCalendarObjects({ calendar });
|
||||||
|
const caldavUUID =
|
||||||
|
event.caldavUUID ||
|
||||||
|
(await this.eventService.getById(event.id, userId))?.caldavUUID;
|
||||||
|
const pushed = objects.find((o) => o.data?.includes(caldavUUID!));
|
||||||
|
|
||||||
|
await this.eventService.update(event.id, userId, {
|
||||||
|
etag: pushed?.etag || undefined,
|
||||||
|
caldavSyncStatus: "synced",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await this.eventService.update(event.id, userId, {
|
||||||
|
caldavSyncStatus: "error",
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an iCalendar string from a CalendarEvent using ical.js.
|
||||||
|
*/
|
||||||
|
private toICalString(uid: string, event: CalendarEvent): string {
|
||||||
|
const vcalendar = new ICAL.Component("vcalendar");
|
||||||
|
vcalendar.addPropertyWithValue("version", "2.0");
|
||||||
|
vcalendar.addPropertyWithValue("prodid", "-//CalChat//EN");
|
||||||
|
|
||||||
|
const vevent = new ICAL.Component("vevent");
|
||||||
|
vevent.addPropertyWithValue("uid", uid);
|
||||||
|
vevent.addPropertyWithValue("summary", event.title);
|
||||||
|
vevent.addPropertyWithValue(
|
||||||
|
"dtstart",
|
||||||
|
ICAL.Time.fromJSDate(new Date(event.startTime)),
|
||||||
|
);
|
||||||
|
vevent.addPropertyWithValue(
|
||||||
|
"dtend",
|
||||||
|
ICAL.Time.fromJSDate(new Date(event.endTime)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (event.description) {
|
||||||
|
vevent.addPropertyWithValue("description", event.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.recurrenceRule) {
|
||||||
|
// Strip RRULE: prefix if present — fromString expects only the value part,
|
||||||
|
// and addPropertyWithValue("rrule", ...) adds the RRULE: prefix automatically.
|
||||||
|
const rule = event.recurrenceRule.replace(/^RRULE:/i, "");
|
||||||
|
vevent.addPropertyWithValue("rrule", ICAL.Recur.fromString(rule));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.exceptionDates?.length) {
|
||||||
|
for (const exdate of event.exceptionDates) {
|
||||||
|
vevent.addPropertyWithValue("exdate", ICAL.Time.fromDateString(exdate));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vcalendar.addSubcomponent(vevent);
|
||||||
|
return vcalendar.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async pushAll(userId: string): Promise<void> {
|
||||||
|
const allEvents = await this.eventService.getAll(userId);
|
||||||
|
for (const event of allEvents) {
|
||||||
|
if (event.caldavSyncStatus !== "synced") {
|
||||||
|
await this.pushEvent(userId, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteEvent(userId: string, caldavUUID: string) {
|
||||||
|
const { client, calendar } = await this.connect(userId);
|
||||||
|
|
||||||
|
await client.deleteCalendarObject({
|
||||||
|
calendarObject: {
|
||||||
|
url: `${calendar.url}${caldavUUID}.ics`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findEventByCaldavUUID(userId: string, caldavUUID: string) {
|
||||||
|
return this.eventService.findByCaldavUUID(userId, caldavUUID);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getConfig(userId: string): Promise<CaldavConfig | null> {
|
||||||
|
return this.caldavRepo.findByUserId(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveConfig(config: CaldavConfig): Promise<CaldavConfig> {
|
||||||
|
const savedConfig = await this.caldavRepo.createOrUpdate(config);
|
||||||
|
try {
|
||||||
|
await this.connect(savedConfig.userId);
|
||||||
|
} catch (error) {
|
||||||
|
await this.caldavRepo.deleteByUserId(savedConfig.userId);
|
||||||
|
throw new Error("failed to connect");
|
||||||
|
}
|
||||||
|
return savedConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteConfig(userId: string) {
|
||||||
|
return await this.caldavRepo.deleteByUserId(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
RecurringDeleteMode,
|
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.";
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
7
apps/server/src/services/interfaces/CaldavRepository.ts
Normal file
7
apps/server/src/services/interfaces/CaldavRepository.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { CaldavConfig } from "@calchat/shared/src/models/CaldavConfig";
|
||||||
|
|
||||||
|
export interface CaldavRepository {
|
||||||
|
findByUserId(userId: string): Promise<CaldavConfig | null>;
|
||||||
|
createOrUpdate(config: CaldavConfig): Promise<CaldavConfig>;
|
||||||
|
deleteByUserId(userId: string): Promise<boolean>;
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ export interface EventRepository {
|
|||||||
startDate: Date,
|
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>;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
6047
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
7
packages/shared/src/models/CaldavConfig.ts
Normal file
7
packages/shared/src/models/CaldavConfig.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export interface CaldavConfig {
|
||||||
|
userId: string;
|
||||||
|
serverUrl: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
syncIntervalSeconds?: number;
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user