Compare commits

...

29 Commits

Author SHA1 Message Date
Gilmour109 d7902deeb4 pipeline
continuous-integration/drone/push Build was killed
continuous-integration/drone/tag Build is passing
2026-02-25 20:09:52 +01:00
Gilmour109 7fefb9a153 pino shoudn't be a dev dependency; hopefully fixed pipeline
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is passing
2026-02-25 19:08:32 +01:00
Gilmour109 565cb0a044 sanitize tag-names for kubernetes
continuous-integration/drone/push Build was killed
continuous-integration/drone/tag Build is failing
2026-02-25 18:39:00 +01:00
Gilmour109 6463100fbd feat: restore CI pipelines and add k3s deployment
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is failing
Re-enable build/test/format pipelines, rename deploy_server to
deploy_latest, add upload_tag (tag-triggered k3s deploy) and
upload_commit (promote-triggered k3s deploy). Update CLAUDE.md.
2026-02-25 17:58:03 +01:00
Gilmour109 b088e380a4 typo
continuous-integration/drone/push Build is failing
continuous-integration/drone Build is passing
2026-02-24 18:11:56 +01:00
Gilmour109 54936f1b96 added tsconfig.json in Dockerfile
continuous-integration/drone/push Build is failing
2026-02-24 18:08:59 +01:00
Gilmour109 e732305d99 feat: add deploy pipeline and switch Dockerfile to COPY-based build
continuous-integration/drone/push Build is failing
Add deploy_server Drone pipeline that builds and pushes the Docker image
to Gitea Container Registry, then deploys to VPS via SSH. Switch
Dockerfile from git clone to COPY-based build for CI compatibility and
better layer caching. Change exposed port to 3001.
2026-02-24 17:52:48 +01:00
Gilmour109 93a0928928 hopefully final pipeline fix for now
continuous-integration/drone/push Build is passing
2026-02-24 12:57:35 +01:00
Gilmour109 68a49712bc another pipeline fix
continuous-integration/drone/push Build encountered an error
2026-02-24 12:52:09 +01:00
Gilmour109 602e4e1413 added types in pipeline
continuous-integration/drone/push Build encountered an error
2026-02-24 12:47:14 +01:00
Gilmour109 bf8bb3cfb8 feat: add Drone CI pipelines, Jest unit tests, and Prettier check
continuous-integration/drone/push Build encountered an error
Add Drone CI with server build/test and format check pipelines.
Add unit tests for password utils and recurrenceExpander.
Add check_format script, fix Jest config to ignore dist/,
remove dead CaldavService.test.ts, apply Prettier formatting.
2026-02-24 12:43:31 +01:00
Gilmour109 16848bfdf0 refactor: clone repo from Gitea in Dockerfile instead of COPY
Replace local COPY with git clone --depth 1 so the image can be built
without a local source context. Adds BRANCH build arg (default: main).
2026-02-18 20:12:05 +01:00
Gilmour109 a3e7f0288e feat: add Docker support and compile shared package to dist
- Add multi-stage Dockerfile for server containerization
- Add .dockerignore to exclude unnecessary files from build context
- Switch shared package from source to compiled CommonJS output (dist/)
- Server dev/build scripts now build shared package first
- Fix deep imports to use @calchat/shared barrel export
- Update CLAUDE.md with Docker and shared package documentation
2026-02-18 19:37:27 +01:00
Gilmour109 0c157da817 update README 2026-02-10 01:10:44 +01:00
Gilmour109 e5cd64367d feat: add sync and logout toolbar to calendar screen
- Add CalendarToolbar component between header and weekdays in calendar.tsx
- Sync button with CalDAV sync, spinner during sync, green checkmark on success, red X on error (3s feedback)
- Sync button disabled/greyed out when no CalDAV config present
- Logout button with redirect to login screen
- Buttons styled with border and shadow
- Update CLAUDE.md with CalendarToolbar documentation
2026-02-09 23:51:43 +01:00
Gilmour109 b9ffc6c908 refactor: reduce CalDAV sync to login and manual sync button only
- Remove auto-login sync in AuthGuard
- Remove 10s interval sync and syncAndReload in calendar tab
- Remove lazy syncOnce pattern in ChatService AI callbacks
- Remove CaldavService dependency from ChatService constructor
2026-02-09 23:32:04 +01:00
Gilmour109 5a9485acfc fix: use pino err key for proper Error serialization in controllers
Error objects logged as { error } were serialized as {} because pino
only applies its error serializer to the err key.
2026-02-09 22:41:46 +01:00
Gilmour109 189c38dc2b docs: clean up frontend class diagram layout
Comment out service methods for consistency with stores and switch to
left-to-right direction for a more vertical package arrangement.
2026-02-09 22:00:13 +01:00
Gilmour109 73e768a0ad refactor: remove all JWT-related code and references
JWT was never used - auth uses X-User-Id header. Removes jwt.ts utility,
jsonwebtoken dependency, stubbed refresh/logout endpoints, and updates
all docs (PUML diagrams, api-routes, tex, CLAUDE.md) accordingly.
2026-02-09 20:02:05 +01:00
Gilmour109 cb32bd23ca docs: add .env.example files for client and server 2026-02-09 19:57:55 +01:00
Gilmour109 cbf123ddd6 feat: add visual feedback for CalDAV save & sync actions
- Show spinner + loading text while request is in progress
- Display success (green) or error (red) message, auto-clears after 3s
- Save and Sync have independent feedback rows (both visible at once)
- Fix CaldavTextInput theming and add secureTextEntry for password
- Reset CustomTextInput cursor to start when unfocused
2026-02-09 19:53:51 +01:00
Gilmour109 3ad4a77951 fix: chat starts scrolled to bottom instead of visibly scrolling down
- Use onContentSizeChange to scroll after FlashList renders content
- Scroll without animation on initial load via needsInitialScroll ref
- Remove unreliable 100ms timeout scrollToEnd from message loading
2026-02-09 19:23:45 +01:00
Gilmour109 aabce1a5b0 refactor: use CustomTextInput in login and register screens
- Replace raw TextInput with CustomTextInput in login and register
  for consistent focus border effect across the app
- Add placeholder, secureTextEntry, autoCapitalize, keyboardType
  props to CustomTextInput
- Remove hardcoded default padding (px-3 py-2) and h-11/12 from
  CustomTextInput, callers now set padding via className
- Add explicit px-3 py-2 to existing callers (settings, editEvent)
- Update CLAUDE.md with new CustomTextInput usage and props
2026-02-09 19:15:41 +01:00
Gilmour109 868e1ba68d perf: preload events and CalDAV config to avoid empty screens
Add CaldavConfigStore and preloadAppData() to load events (current month)
and CalDAV config into stores before dismissing the auth loading spinner.
This prevents the brief empty flash when first navigating to Calendar or
Settings tabs. Also applies Prettier formatting across codebase.
2026-02-09 18:59:03 +01:00
Gilmour109 0e406e4dca perf: load calendar events instantly, sync CalDAV in background
Split loadEvents into two functions: loadEvents (instant DB read) and
syncAndReload (background CalDAV sync + reload). Events now appear
immediately when switching to the Calendar tab instead of waiting for
the CalDAV sync to complete.
2026-02-09 18:37:14 +01:00
Gilmour109 b94b5f5ed8 Merge branch 'main' of https://gitea.gilmour109.de/Gilmour109/calchat 2026-02-09 18:18:25 +01:00
Gilmour109 0a2aef2098 fix: recurring event display and AI query improvements
- Use occurrenceStart instead of startTime in getEventsInRange so
  recurring events show their actual occurrence date to the AI
- Add lazy CalDAV sync in ChatService (syncOnce before first DB access)
- Add CaldavService.sync() with internal config check (silent no-op)
- Show German recurrence description (e.g. "Jede Woche") instead of
  generic "Wiederkehrend" in EventCardBase via formatRecurrenceRule()
- Move RepeatType and REPEAT_TYPE_LABELS from editEvent to shared
- Separate calendar overlay useFocusEffect from event loading
2026-02-09 18:17:39 +01:00
Gilmour109 325246826a 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
2026-02-08 19:24:59 +01:00
Gilmour109 81221d8b70 refactor: remove redundant isRecurring property, use recurrenceRule instead
isRecurring was redundant since recurrenceRule as truthy/falsy check suffices.
Removed from shared CalendarEvent type, Mongoose virtual, and all usages.
2026-02-07 16:16:35 +01:00
72 changed files with 8308 additions and 537 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules
*/node_modules
*/*/node_modules
**/dist
apps/client
.git
.env
*.md
+171
View File
@@ -0,0 +1,171 @@
kind: pipeline
type: docker
name: server_build_and_test
trigger:
branch:
- main
event:
- push
steps:
- name: build_server
image: node
commands:
- npm ci -w @calchat/shared
- npm ci -w @calchat/server
- npm run build -w @calchat/server
- name: jest_server
image: node
commands:
- npm run test -w @calchat/server
---
kind: pipeline
type: docker
name: check_for_formatting
trigger:
branch:
- main
event:
- push
steps:
- name: format_check
image: node
commands:
- npm ci
- npm run check_format
---
kind: pipeline
type: docker
name: deploy_latest
trigger:
branch:
- main
event:
- push
steps:
- name: upload_latest
image: plugins/docker
settings:
registry: gitea.gilmour109.de
repo: gitea.gilmour109.de/gilmour109/calchat-server
dockerfile: apps/server/docker/Dockerfile
tags:
- latest
username:
from_secret: gitea_username
password:
from_secret: gitea_password
- name: deploy_to_vps
image: appleboy/drone-ssh
settings:
host:
- 10.0.0.1
username: root
password:
from_secret: vps_ssh_password
envs:
- gitea_username
- gitea_password
port: 22
command_timeout: 2m
script:
- docker login -u $GITEA_USERNAME -p $GITEA_PASSWORD gitea.gilmour109.de
- docker pull gitea.gilmour109.de/gilmour109/calchat-server:latest
- docker compose -f /root/calchat-mongo/docker-compose.yml up -d
depends_on:
- server_build_and_test
- check_for_formatting
---
kind: pipeline
type: docker
name: upload_tag
trigger:
event:
- tag
steps:
- name: upload_tag
image: plugins/docker
settings:
registry: gitea.gilmour109.de
repo: gitea.gilmour109.de/gilmour109/calchat-server
dockerfile: apps/server/docker/Dockerfile
tags:
- ${DRONE_TAG}
username:
from_secret: gitea_username
password:
from_secret: gitea_password
- name: deploy_to_k3s
image: appleboy/drone-ssh
settings:
host:
- 192.168.178.201
username: debian
password:
from_secret: k3s_ssh_password
envs:
- drone_tag
port: 22
command_timeout: 2m
script:
- export TAG=$DRONE_TAG
- export NAME=$(echo $DRONE_TAG | tr -d '.')
- envsubst < /home/debian/manifest.yml | sudo kubectl apply -f -
---
kind: pipeline
type: docker
name: upload_commit
trigger:
event:
- promote
steps:
- name: upload_commit
image: plugins/docker
settings:
registry: gitea.gilmour109.de
repo: gitea.gilmour109.de/gilmour109/calchat-server
dockerfile: apps/server/docker/Dockerfile
tags:
- ${DRONE_COMMIT_SHA:0:8}
username:
from_secret: gitea_username
password:
from_secret: gitea_password
- name: deploy_to_k3s
image: appleboy/drone-ssh
settings:
host:
- 192.168.178.201
username: debian
password:
from_secret: k3s_ssh_password
envs:
- drone_commit_sha
port: 22
command_timeout: 2m
script:
- export TAG=$(echo $DRONE_COMMIT_SHA | cut -c1-8)
- export NAME=$TAG
- envsubst < /home/debian/manifest.yml | sudo kubectl apply -f -
+2
View File
@@ -3,3 +3,5 @@ node_modules
docs/praesi_2_context.md docs/praesi_2_context.md
docs/*.png docs/*.png
.env .env
apps/server/docker/radicale/config/
apps/server/docker/radicale/data/
+146 -40
View File
@@ -14,6 +14,7 @@ This is a fullstack TypeScript monorepo with npm workspaces.
```bash ```bash
npm install # Install all dependencies for all workspaces npm install # Install all dependencies for all workspaces
npm run format # Format all TypeScript files with Prettier npm run format # Format all TypeScript files with Prettier
npm run check_format # Check formatting without modifying files (used in CI)
``` ```
### Client (apps/client) - Expo React Native app ### Client (apps/client) - Expo React Native app
@@ -26,11 +27,17 @@ npm run lint -w @calchat/client # Run ESLint
npm run build:apk -w @calchat/client # Build APK locally with EAS npm run build:apk -w @calchat/client # Build APK locally with EAS
``` ```
### Shared (packages/shared)
```bash
npm run build -w @calchat/shared # Compile shared types to dist/
```
### Server (apps/server) - Express.js backend ### Server (apps/server) - Express.js backend
```bash ```bash
npm run dev -w @calchat/server # Start dev server with hot reload (tsx watch) npm run dev -w @calchat/server # Build shared + start dev server with hot reload (tsx watch)
npm run build -w @calchat/server # Compile TypeScript npm run build -w @calchat/server # Build shared + compile TypeScript
npm run start -w @calchat/server # Run compiled server (port 3000) npm run start -w @calchat/server # Run compiled server (port 3000)
npm run test -w @calchat/server # Run Jest unit tests
``` ```
## Technology Stack ## Technology Stack
@@ -48,9 +55,14 @@ npm run start -w @calchat/server # Run compiled server (port 3000)
| | MongoDB | Database | | | MongoDB | Database |
| | Mongoose | ODM | | | Mongoose | ODM |
| | GPT (OpenAI) | AI/LLM for chat | | | GPT (OpenAI) | AI/LLM for chat |
| | X-User-Id Header | Authentication (simple, no JWT yet) | | | X-User-Id Header | Authentication |
| | 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 |
| Testing | Jest / ts-jest | Server unit tests |
| Deployment | Docker | Server containerization (multi-stage build) |
| | Drone CI | CI/CD pipelines (build, test, format check, deploy) |
| Planned | iCalendar | Event export/import | | Planned | iCalendar | Event export/import |
## Architecture ## Architecture
@@ -74,15 +86,15 @@ src/
│ ├── (tabs)/ # Tab navigation group │ ├── (tabs)/ # Tab navigation group
│ │ ├── _layout.tsx # Tab bar configuration (themed) │ │ ├── _layout.tsx # Tab bar configuration (themed)
│ │ ├── chat.tsx # Chat screen (AI conversation) │ │ ├── chat.tsx # Chat screen (AI conversation)
│ │ ├── calendar.tsx # Calendar overview │ │ ├── calendar.tsx # Calendar overview (with CalendarToolbar: sync + logout)
│ │ └── settings.tsx # Settings screen (theme switcher, logout) │ │ └── settings.tsx # Settings screen (theme switcher, logout, CalDAV config with feedback)
│ ├── editEvent.tsx # Event edit screen (dual-mode: calendar/chat) │ ├── editEvent.tsx # Event edit screen (dual-mode: calendar/chat)
│ ├── event/ │ ├── event/
│ │ └── [id].tsx # Event detail screen (dynamic route) │ │ └── [id].tsx # Event detail screen (dynamic route)
│ └── 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, preloads app data (events + CalDAV config), CalDAV sync, shows loading, redirects if unauthenticated. Exports preloadAppData()
│ ├── 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 +108,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 login, register, CaldavSettings, editEvent)
│ ├── 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,13 +120,15 @@ 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()
│ │ # Uses expo-secure-store (native) / localStorage (web) │ │ # Uses expo-secure-store (native) / localStorage (web)
│ ├── ChatStore.ts # messages[], isWaitingForResponse, addMessage(), addMessages(), updateMessage(), clearMessages(), setWaitingForResponse(), chatMessageToMessageData() │ ├── ChatStore.ts # messages[], isWaitingForResponse, addMessage(), addMessages(), updateMessage(), clearMessages(), setWaitingForResponse(), chatMessageToMessageData()
│ ├── EventsStore.ts # events[], setEvents(), addEvent(), updateEvent(), deleteEvent() │ ├── EventsStore.ts # events[], setEvents(), addEvent(), updateEvent(), deleteEvent()
│ ├── CaldavConfigStore.ts # config (CaldavConfig | null), setConfig() - cached CalDAV config
│ └── ThemeStore.ts # theme, setTheme() - reactive theme switching with Zustand │ └── ThemeStore.ts # theme, setTheme() - reactive theme switching with Zustand
└── hooks/ └── hooks/
└── useDropdownPosition.ts # Hook for positioning dropdowns relative to trigger element └── useDropdownPosition.ts # Hook for positioning dropdowns relative to trigger element
@@ -124,9 +139,12 @@ src/
**Authentication Flow:** **Authentication Flow:**
- `AuthGuard` component wraps the tab layout in `(tabs)/_layout.tsx` - `AuthGuard` component wraps the tab layout in `(tabs)/_layout.tsx`
- On app start, `AuthGuard` calls `loadStoredUser()` and shows loading indicator - On app start, `AuthGuard` calls `loadStoredUser()` and shows loading indicator
- After auth, `preloadAppData()` loads events (current month) + CalDAV config into stores before dismissing spinner
- If not authenticated, redirects to `/login` - If not authenticated, redirects to `/login`
- `login.tsx` also calls `preloadAppData()` after successful login (spinner stays visible during preload)
- `index.tsx` simply redirects to `/(tabs)/chat` - AuthGuard handles the rest - `index.tsx` simply redirects to `/(tabs)/chat` - AuthGuard handles the rest
- This pattern handles Expo Router's navigation state caching (avoids race conditions) - This pattern handles Expo Router's navigation state caching (avoids race conditions)
- Preloading prevents empty screens when navigating to Calendar or Settings tabs for the first time
### Theme System ### Theme System
@@ -227,9 +245,10 @@ CardBase
src/ 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()
│ ├── 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 +259,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 +279,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
@@ -271,7 +295,6 @@ src/
│ ├── toolDefinitions.ts # TOOL_DEFINITIONS - provider-agnostic tool specs │ ├── toolDefinitions.ts # TOOL_DEFINITIONS - provider-agnostic tool specs
│ └── toolExecutor.ts # executeToolCall() - handles getDay, proposeCreate/Update/Delete, searchEvents, getEventsInRange │ └── toolExecutor.ts # executeToolCall() - handles getDay, proposeCreate/Update/Delete, searchEvents, getEventsInRange
├── utils/ ├── utils/
│ ├── jwt.ts # signToken(), verifyToken() - NOT USED YET (no JWT)
│ ├── password.ts # hash(), compare() using bcrypt │ ├── password.ts # hash(), compare() using bcrypt
│ ├── eventFormatters.ts # getWeeksOverview(), getMonthOverview() - formatted event listings │ ├── eventFormatters.ts # getWeeksOverview(), getMonthOverview() - formatted event listings
│ └── recurrenceExpander.ts # expandRecurringEvents() - expand recurring events into occurrences │ └── recurrenceExpander.ts # expandRecurringEvents() - expand recurring events into occurrences
@@ -282,8 +305,6 @@ src/
**API Endpoints:** **API Endpoints:**
- `POST /api/auth/login` - User login - `POST /api/auth/login` - User login
- `POST /api/auth/register` - User registration - `POST /api/auth/register` - User registration
- `POST /api/auth/refresh` - Refresh JWT token
- `POST /api/auth/logout` - User logout
- `GET /api/events` - Get all events (protected) - `GET /api/events` - Get all events (protected)
- `GET /api/events/range` - Get events by date range (protected) - `GET /api/events/range` - Get events by date range (protected)
- `GET /api/events/:id` - Get single event (protected) - `GET /api/events/:id` - Get single event (protected)
@@ -296,18 +317,27 @@ 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)
### Shared Package (packages/shared) ### Shared Package (packages/shared)
The shared package is compiled to `dist/` (CommonJS). All imports must use `@calchat/shared` (NOT `@calchat/shared/src/...`). Server `dev` and `build` scripts automatically build shared first.
``` ```
src/ src/
├── index.ts ├── index.ts
├── 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
@@ -317,12 +347,14 @@ src/
├── index.ts ├── index.ts
├── dateHelpers.ts # getDay() - get date for specific weekday relative to today ├── dateHelpers.ts # getDay() - get date for specific weekday relative to today
├── formatters.ts # formatDate(), formatTime(), formatDateTime(), formatDateWithWeekday() - German locale ├── formatters.ts # formatDate(), formatTime(), formatDateTime(), formatDateWithWeekday() - German locale
└── rruleHelpers.ts # parseRRule() - parse RRULE strings to extract freq, until, count, interval, byDay └── rruleHelpers.ts # parseRRule(), buildRRule(), formatRecurrenceRule() - RRULE parsing, building, and German formatting
``` ```
**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 +409,36 @@ 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`): Events load instantly from DB on focus (`loadEvents`), CalDAV sync runs in background (`syncAndReload`) and reloads events after. Repeats every 10s via `setInterval`
- **Sync button** (`settings.tsx`): Manual trigger in CaldavSettings
**Lazy sync (server-side in ChatService):**
- AI data access callbacks (`fetchEventsInRange`, `searchEvents`, `fetchEventById`) trigger `syncOnce()` before the first DB query
- Uses `CaldavService.sync()` which checks config internally (silent no-op without config)
**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 on `EventService` and `CaldavService` (lazy CalDAV sync on AI data access)
- `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 +522,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,11 +535,24 @@ 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`
### Server Docker Image
```bash
# Build (requires local build context):
docker build -f apps/server/docker/Dockerfile -t calchat-server .
docker run -p 3001:3001 --env-file apps/server/.env calchat-server
```
Multi-stage COPY-based build: copies `package.json` files first for layer caching, then source code. Compiles shared + server, then copies only `dist/` and production dependencies to the runtime stage. Exposes port 3001. In CI, the `plugins/docker` Drone plugin builds and pushes the image automatically.
### Environment Variables ### Environment Variables
Server requires `.env` file in `apps/server/`: Server requires `.env` file in `apps/server/`:
``` ```
JWT_SECRET=your-secret-key
JWT_EXPIRES_IN=1h
MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin
OPENAI_API_KEY=sk-proj-... OPENAI_API_KEY=sk-proj-...
USE_TEST_RESPONSES=false # true = static test responses, false = real GPT AI USE_TEST_RESPONSES=false # true = static test responses, false = real GPT AI
@@ -510,22 +585,26 @@ 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, sync, getConfig, saveConfig, deleteConfig). `sync()` checks config internally and is a silent no-op without config.
- `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`: Uses EventService + CaldavService (lazy sync on AI data access via syncOnce pattern)
- `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):**
- `AuthController`: refresh(), logout()
- `AuthService`: refreshToken()
- 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. `buildRRule()` builds RRULE from RepeatType + interval. `formatRecurrenceRule()` formats RRULE into German description (e.g., "Jede Woche", "Alle 2 Monate"). Exports `REPEAT_TYPE_LABELS` and `RepeatType`.
- `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,9 +612,9 @@ 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, preloads app data (events + CalDAV config) into stores before dismissing spinner, triggers CalDAV sync, shows loading, redirects if unauthenticated. Exports `preloadAppData()` (also called by `login.tsx`)
- Login screen: Supports email OR userName login - Login screen: Supports email OR userName login, uses CustomTextInput with focus border, preloads app data + triggers CalDAV sync after successful login
- Register screen: Email validation, checks for existing email/userName - Register screen: Email validation, checks for existing email/userName, uses CustomTextInput with focus border
- `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)
- `(tabs)/_layout.tsx`: Wraps tabs with AuthGuard for protected access - `(tabs)/_layout.tsx`: Wraps tabs with AuthGuard for protected access
@@ -544,7 +623,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). Save/Sync buttons show independent feedback via `FeedbackRow` component: spinner + loading text during request, then success (green) or error (red) message that auto-clears after 3s. Both feedbacks can be visible simultaneously.
- `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 +633,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 - Events load instantly from local DB on tab focus, CalDAV sync runs non-blocking in background (`syncAndReload`) with 10s interval
- 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
@@ -566,19 +645,23 @@ NODE_ENV=development # development = pretty logs, production = JSON
- Tracks conversationId for message continuity across sessions - Tracks conversationId for message continuity across sessions
- ChatStore with addMessages() for bulk loading, chatMessageToMessageData() helper - ChatStore with addMessages() for bulk loading, chatMessageToMessageData() helper
- KeyboardAvoidingView for proper keyboard handling (iOS padding, Android height) - KeyboardAvoidingView for proper keyboard handling (iOS padding, Android height)
- Auto-scroll to end on new messages and keyboard show - Auto-scroll to end on new messages and keyboard show; initial load uses `onContentSizeChange` with `animated: false` to start at bottom without visible scrolling
- 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 with focus border highlight. Props: `text`, `onValueChange`, `placeholder`, `placeholderTextColor`, `secureTextEntry`, `autoCapitalize`, `keyboardType`, `className`, `multiline`. No default padding — callers must set padding via `className` (e.g., `px-3 py-2` or `p-4`). When not focused, cursor is reset to start (`selection={{ start: 0 }}`) to avoid text appearing scrolled to the end.
- `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. Accepts `recurrenceRule` string (not boolean) and displays German-formatted recurrence via `formatRecurrenceRule()`
- `EventCard`: Uses EventCardBase + edit/delete buttons (TouchableOpacity with delayPressIn for scroll-friendly touch handling) - `EventCard`: Uses EventCardBase + edit/delete buttons (TouchableOpacity with delayPressIn for scroll-friendly touch handling)
- `ProposedEventCard`: Uses EventCardBase + confirm/reject/edit buttons for chat proposals, displays green highlighted text for new changes ("Neue Ausnahme: [date]" for single delete, "Neues Ende: [date]" for UNTIL updates), shows yellow conflict warnings when proposed time overlaps with existing events. Edit button allows modifying proposals before confirming. - `ProposedEventCard`: Uses EventCardBase + confirm/reject/edit buttons for chat proposals, displays green highlighted text for new changes ("Neue Ausnahme: [date]" for single delete, "Neues Ende: [date]" for UNTIL updates), shows yellow conflict warnings when proposed time overlaps with existing events. Edit button allows modifying proposals before confirming.
- `DeleteEventModal`: Delete confirmation modal using ModalBase - shows three options for recurring events (single/future/all), simple confirm for non-recurring - `DeleteEventModal`: Delete confirmation modal using ModalBase - shows three options for recurring events (single/future/all), simple confirm for non-recurring
- `CalendarToolbar` (in calendar.tsx): Toolbar between header and weekdays with Sync button (CalDAV sync with spinner/green checkmark/red X feedback, disabled without config) and Logout button
- `EventOverlay` (in calendar.tsx): Day events overlay using ModalBase - shows EventCards for selected day - `EventOverlay` (in calendar.tsx): Day events overlay using ModalBase - shows EventCards for selected day
- `Themes.tsx`: Theme definitions with THEMES object (defaultLight, defaultDark) including all color tokens (textPrimary, borderPrimary, eventIndicator, secondaryBg, shadowColor, etc.) - `Themes.tsx`: Theme definitions with THEMES object (defaultLight, defaultDark) including all color tokens (textPrimary, borderPrimary, eventIndicator, secondaryBg, shadowColor, etc.)
- `EventsStore`: Zustand store with setEvents(), addEvent(), updateEvent(), deleteEvent() - stores ExpandedEvent[] - `EventsStore`: Zustand store with setEvents(), addEvent(), updateEvent(), deleteEvent() - stores ExpandedEvent[], preloaded by AuthGuard
- `CaldavConfigStore`: Zustand store with config (CaldavConfig | null), setConfig() - cached CalDAV config, preloaded by AuthGuard, used by Settings to avoid API call on mount
- `ChatStore`: Zustand store with addMessage(), addMessages(), updateMessage(), clearMessages(), isWaitingForResponse/setWaitingForResponse() for typing indicator - loads from server on mount and persists across tab switches - `ChatStore`: Zustand store with addMessage(), addMessages(), updateMessage(), clearMessages(), isWaitingForResponse/setWaitingForResponse() for typing indicator - loads from server on mount and persists across tab switches
- `ThemeStore`: Zustand store with theme/setTheme() for reactive theme switching across all components - `ThemeStore`: Zustand store with theme/setTheme() for reactive theme switching across all components
- `ChatBubble`: Reusable chat bubble component with Tailwind styling, used by ChatMessage and TypingIndicator - `ChatBubble`: Reusable chat bubble component with Tailwind styling, used by ChatMessage and TypingIndicator
@@ -613,6 +696,29 @@ This uses the `preview` profile from `eas.json` which builds an APK with:
- Package name: `com.gilmour109.calchat` - Package name: `com.gilmour109.calchat`
- EAS Project ID: `b722dde6-7d89-48ff-9095-e007e7c7da87` - EAS Project ID: `b722dde6-7d89-48ff-9095-e007e7c7da87`
## CI/CD (Drone)
The project uses Drone CI (`.drone.yml`) with five pipelines:
**On push to main:**
1. **`server_build_and_test`**: Builds the server (`npm ci` + `npm run build`) and runs Jest tests (`npm run test`)
2. **`check_for_formatting`**: Checks Prettier formatting across all workspaces (`npm run check_format`)
3. **`deploy_latest`**: Builds Docker image, pushes to Gitea Container Registry (`gitea.gilmour109.de/gilmour109/calchat-server:latest`), then SSHs into VPS (`10.0.0.1`) to pull and restart via `docker compose`. Depends on both pipelines above passing first.
**On tag:**
4. **`upload_tag`**: Builds Docker image tagged with the git tag (`${DRONE_TAG}`), pushes to registry, then deploys to k3s cluster (`192.168.178.201`) via SSH using `envsubst` with a Kubernetes manifest template.
**On promote:**
5. **`upload_commit`**: Builds Docker image tagged with short commit SHA (first 8 chars), pushes to registry, then deploys to k3s cluster (`192.168.178.201`) via SSH using `envsubst` with a Kubernetes manifest template.
## Testing
Server uses Jest with ts-jest for unit testing. Config in `apps/server/jest.config.js` ignores `/node_modules/` and `/dist/`.
**Existing tests:**
- `src/utils/password.test.ts` - Tests for bcrypt hash() and compare()
- `src/utils/recurrenceExpander.test.ts` - Tests for expandRecurringEvents() (non-recurring, weekly/daily/UNTIL recurrence, EXDATE filtering, RRULE: prefix stripping, invalid RRULE fallback, multi-day events, sorting)
## Documentation ## Documentation
Detailed architecture diagrams are in `docs/`: Detailed architecture diagrams are in `docs/`:
+118 -27
View File
@@ -1,50 +1,141 @@
# Welcome to your Expo app 👋 # CalChat
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app). Kalender-App mit KI-Chatbot. Termine lassen sich per Chat in natuerlicher Sprache erstellen, bearbeiten und loeschen.
## Get started ## Tech Stack
1. Install dependencies | Bereich | Technologie |
|---------|-------------|
| Frontend | React Native, Expo, Expo-Router, NativeWind, Zustand |
| Backend | Express.js, MongoDB, Mongoose, OpenAI GPT |
| Shared | TypeScript Monorepo mit npm Workspaces |
| Optional | CalDAV-Sync (z.B. Radicale) |
## Voraussetzungen
- Node.js (>= 20)
- npm
- Docker & Docker Compose (fuer MongoDB)
- OpenAI API Key (fuer KI-Chat)
- Android SDK + Java (nur fuer APK-Build)
## Projekt aufsetzen
### 1. Repository klonen
```bash
git clone <repo-url>
cd calchat
```
### 2. Dependencies installieren
```bash ```bash
npm install npm install
``` ```
2. Start the app Installiert alle Dependencies fuer Client, Server und Shared.
### 3. MongoDB starten
```bash ```bash
npx expo start cd apps/server/docker/mongo
docker compose up -d
``` ```
In the output, you'll find options to open the app in a - MongoDB: `localhost:27017` (root/mongoose)
- Mongo Express UI: `localhost:8083` (admin/admin)
- [development build](https://docs.expo.dev/develop/development-builds/introduction/) ### 4. Server konfigurieren
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
## Get a fresh project
When you're ready, run:
```bash ```bash
npm run reset-project cp apps/server/.env.example apps/server/.env
``` ```
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing. `apps/server/.env` bearbeiten:
## Learn more ```env
MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin
OPENAI_API_KEY=sk-proj-... # Eigenen Key eintragen
USE_TEST_RESPONSES=false # true = statische Testantworten ohne GPT
LOG_LEVEL=debug
NODE_ENV=development
PORT=3000
```
To learn more about developing your project with Expo, look at the following resources: ### 5. Client konfigurieren
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides). ```bash
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web. cp apps/client/.env.example apps/client/.env
```
## Join the community `apps/client/.env` bearbeiten:
Join our community of developers creating universal apps. ```env
# Fuer Emulator/Web:
EXPO_PUBLIC_API_URL=http://localhost:3000/api
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute. # Fuer physisches Geraet im gleichen Netzwerk:
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions. EXPO_PUBLIC_API_URL=http://<DEINE-LOKALE-IP>:3000/api
```
### 6. Server starten
```bash
npm run dev -w @calchat/server
```
Startet den Server auf Port 3000 (mit `tsx watch` - startet bei Dateiänderungen automatisch neu (oder sollte es zumindest)).
### 7. Client starten
```bash
npm run start -w @calchat/client
```
Dann im Expo-Menue die gewuenschte Plattform waehlen:
- `a` - Android Emulator
- `i` - iOS Simulator
- `w` - Web Browser
Oder direkt:
```bash
npm run android -w @calchat/client
npm run ios -w @calchat/client
npm run web -w @calchat/client
```
## CalDAV (optional)
Fuer CalDAV-Synchronisation kann ein Radicale-Server gestartet werden:
```bash
cd apps/server/docker/radicale
docker compose up -d
```
Radicale ist dann unter `localhost:5232` erreichbar. Die CalDAV-Verbindung wird in der App unter Einstellungen konfiguriert.
## Weitere Befehle
```bash
npm run format # Prettier auf alle TS/TSX-Dateien
npm run lint -w @calchat/client # ESLint (Client)
npm run build -w @calchat/server # TypeScript kompilieren (Server)
npm run build:apk -w @calchat/client # APK lokal bauen (EAS)
```
## Projektstruktur
```
calchat/
├── apps/
│ ├── client/ # Expo React Native App
│ └── server/ # Express.js Backend
│ └── docker/
│ ├── mongo/ # MongoDB + Mongo Express
│ └── radicale/ # CalDAV Server
└── packages/
└── shared/ # Geteilte Types und Utilities
```
+8
View File
@@ -0,0 +1,8 @@
# Base URL of the CalChat API server
# Must include the /api path suffix
# Use your local network IP for mobile device testing, or localhost for emulator/web
# Examples:
# http://192.168.178.22:3001/api (local network, for physical device)
# http://localhost:3001/api (emulator or web)
# https://calchat.example.com/api (production)
EXPO_PUBLIC_API_URL=http://localhost:3001/api
+142 -16
View File
@@ -1,4 +1,4 @@
import { Pressable, Text, View } from "react-native"; import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { import {
DAYS, DAYS,
MONTHS, MONTHS,
@@ -11,19 +11,16 @@ import { EventCard } from "../../components/EventCard";
import { DeleteEventModal } from "../../components/DeleteEventModal"; import { DeleteEventModal } from "../../components/DeleteEventModal";
import { ModalBase } from "../../components/ModalBase"; import { ModalBase } from "../../components/ModalBase";
import { ScrollableDropdown } from "../../components/ScrollableDropdown"; import { ScrollableDropdown } from "../../components/ScrollableDropdown";
import React, { import React, { useCallback, useEffect, useMemo, useState } from "react";
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { router, useFocusEffect } from "expo-router"; import { router, useFocusEffect } from "expo-router";
import { Ionicons } from "@expo/vector-icons"; 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 { AuthService, EventService } from "../../services";
import { useEventsStore } from "../../stores"; import { useEventsStore } from "../../stores";
import { useDropdownPosition } from "../../hooks/useDropdownPosition"; import { useDropdownPosition } from "../../hooks/useDropdownPosition";
import { CaldavConfigService } from "../../services/CaldavConfigService";
import { useCaldavConfigStore } from "../../stores/CaldavConfigStore";
// MonthSelector types and helpers // MonthSelector types and helpers
type MonthItem = { type MonthItem = {
@@ -84,7 +81,7 @@ const Calendar = () => {
const { events, setEvents, deleteEvent } = useEventsStore(); const { events, setEvents, deleteEvent } = useEventsStore();
// Function to load events for current view // Load events from local DB (fast, no network sync)
const loadEvents = useCallback(async () => { const loadEvents = useCallback(async () => {
try { try {
// Calculate first visible day (up to 6 days before month start) // Calculate first visible day (up to 6 days before month start)
@@ -112,17 +109,20 @@ const Calendar = () => {
} }
}, [monthIndex, currentYear, setEvents]); }, [monthIndex, currentYear, setEvents]);
// Load events when tab gains focus or month/year changes // Load events from DB on focus
// NOTE: Wrapper needed because loadEvents is async (returns Promise)
// and useFocusEffect expects a sync function (optionally returning cleanup)
// Also re-open overlay if selectedDate exists (for back navigation from editEvent)
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
loadEvents(); loadEvents();
}, [loadEvents]),
);
// Re-open overlay after back navigation from editEvent
useFocusEffect(
useCallback(() => {
if (selectedDate) { if (selectedDate) {
setOverlayVisible(true); setOverlayVisible(true);
} }
}, [loadEvents, selectedDate]), }, [selectedDate]),
); );
// Group events by date (YYYY-MM-DD format) // Group events by date (YYYY-MM-DD format)
@@ -203,7 +203,7 @@ const Calendar = () => {
const occurrenceDate = getDateKey(new Date(event.occurrenceStart)); const occurrenceDate = getDateKey(new Date(event.occurrenceStart));
try { try {
if (event.isRecurring) { if (event.recurrenceRule) {
// Recurring event: use mode and occurrenceDate // Recurring event: use mode and occurrenceDate
await EventService.delete(event.id, mode, occurrenceDate); await EventService.delete(event.id, mode, occurrenceDate);
// Reload events to reflect changes // Reload events to reflect changes
@@ -241,6 +241,7 @@ const Calendar = () => {
setMonthIndex={setMonthIndex} setMonthIndex={setMonthIndex}
setYear={setCurrentYear} setYear={setCurrentYear}
/> />
<CalendarToolbar loadEvents={loadEvents} />
<WeekDaysLine /> <WeekDaysLine />
<CalendarGrid <CalendarGrid
month={MONTHS[monthIndex]} month={MONTHS[monthIndex]}
@@ -260,7 +261,7 @@ const Calendar = () => {
<DeleteEventModal <DeleteEventModal
visible={deleteModalVisible} visible={deleteModalVisible}
eventTitle={eventToDelete?.title || ""} eventTitle={eventToDelete?.title || ""}
isRecurring={eventToDelete?.isRecurring || false} isRecurring={!!eventToDelete?.recurrenceRule}
onConfirm={handleDeleteConfirm} onConfirm={handleDeleteConfirm}
onCancel={handleDeleteCancel} onCancel={handleDeleteCancel}
/> />
@@ -545,6 +546,131 @@ const ChangeMonthButton = (props: ChangeMonthButtonProps) => {
); );
}; };
type CalendarToolbarProps = {
loadEvents: () => Promise<void>;
};
const CalendarToolbar = ({ loadEvents }: CalendarToolbarProps) => {
const { theme } = useThemeStore();
const { config } = useCaldavConfigStore();
const [isSyncing, setIsSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<"success" | "error" | null>(
null,
);
const handleSync = async () => {
if (!config || isSyncing) return;
setSyncResult(null);
setIsSyncing(true);
try {
await CaldavConfigService.sync();
await loadEvents();
setSyncResult("success");
} catch (error) {
console.error("CalDAV sync failed:", error);
setSyncResult("error");
} finally {
setIsSyncing(false);
}
};
useEffect(() => {
if (!syncResult) return;
const timer = setTimeout(() => setSyncResult(null), 3000);
return () => clearTimeout(timer);
}, [syncResult]);
const handleLogout = async () => {
await AuthService.logout();
router.replace("/login");
};
const syncIcon = () => {
if (isSyncing) {
return <ActivityIndicator size="small" color={theme.primeFg} />;
}
if (syncResult === "success") {
return (
<Ionicons
name="checkmark-circle"
size={20}
color={theme.confirmButton}
/>
);
}
if (syncResult === "error") {
return (
<Ionicons name="close-circle" size={20} color={theme.rejectButton} />
);
}
return (
<Ionicons
name="sync-outline"
size={20}
color={config ? theme.primeFg : theme.textMuted}
/>
);
};
const buttonStyle = {
backgroundColor: theme.chatBot,
borderColor: theme.borderPrimary,
borderWidth: 1,
shadowColor: theme.shadowColor,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 4,
};
const className = "flex flex-row items-center gap-2 px-3 py-1 rounded-lg";
return (
<View
className="flex flex-row items-center justify-around py-2"
style={{
backgroundColor: theme.primeBg,
borderBottomWidth: 0,
borderBottomColor: theme.borderPrimary,
}}
>
<Pressable
onPress={handleSync}
disabled={!config || isSyncing}
className={className}
style={{
...buttonStyle,
...(config
? {}
: {
backgroundColor: theme.disabledButton,
borderColor: theme.disabledButton,
}),
}}
>
{syncIcon()}
<Text
style={{ color: config ? theme.textPrimary : theme.textMuted }}
className="font-medium"
>
Sync
</Text>
</Pressable>
<Pressable
onPress={handleLogout}
className={className}
style={buttonStyle}
>
<Ionicons name="log-out-outline" size={20} color={theme.primeFg} />
<Text style={{ color: theme.textPrimary }} className="font-medium">
Logout
</Text>
</Pressable>
</View>
);
};
const WeekDaysLine = () => { const WeekDaysLine = () => {
const { theme } = useThemeStore(); const { theme } = useThemeStore();
return ( return (
+17 -7
View File
@@ -64,11 +64,11 @@ const Chat = () => {
string | undefined string | undefined
>(); >();
const [hasLoadedMessages, setHasLoadedMessages] = useState(false); const [hasLoadedMessages, setHasLoadedMessages] = useState(false);
const needsInitialScroll = useRef(false);
useEffect(() => { useEffect(() => {
const keyboardDidShow = Keyboard.addListener( const keyboardDidShow = Keyboard.addListener("keyboardDidShow", () =>
"keyboardDidShow", scrollToEnd(),
scrollToEnd,
); );
return () => keyboardDidShow.remove(); return () => keyboardDidShow.remove();
}, []); }, []);
@@ -90,7 +90,7 @@ const Chat = () => {
await ChatService.getConversation(conversationId); await ChatService.getConversation(conversationId);
const clientMessages = serverMessages.map(chatMessageToMessageData); const clientMessages = serverMessages.map(chatMessageToMessageData);
addMessages(clientMessages); addMessages(clientMessages);
scrollToEnd(); needsInitialScroll.current = true;
} }
} catch (error) { } catch (error) {
console.error("Failed to load messages:", error); console.error("Failed to load messages:", error);
@@ -102,9 +102,9 @@ const Chat = () => {
}, [isAuthLoading, isAuthenticated, hasLoadedMessages]), }, [isAuthLoading, isAuthenticated, hasLoadedMessages]),
); );
const scrollToEnd = () => { const scrollToEnd = (animated = true) => {
setTimeout(() => { setTimeout(() => {
listRef.current?.scrollToEnd({ animated: true }); listRef.current?.scrollToEnd({ animated });
}, 100); }, 100);
}; };
@@ -175,7 +175,11 @@ const Chat = () => {
params: { params: {
mode: "chat", mode: "chat",
eventData: JSON.stringify(proposal.event), eventData: JSON.stringify(proposal.event),
proposalContext: JSON.stringify({ messageId, proposalId, conversationId }), proposalContext: JSON.stringify({
messageId,
proposalId,
conversationId,
}),
}, },
}); });
}; };
@@ -273,6 +277,12 @@ const Chat = () => {
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
keyboardDismissMode="interactive" keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
onContentSizeChange={() => {
if (needsInitialScroll.current) {
needsInitialScroll.current = false;
listRef.current?.scrollToEnd({ animated: false });
}
}}
ListFooterComponent={ ListFooterComponent={
isWaitingForResponse ? <TypingIndicator /> : null isWaitingForResponse ? <TypingIndicator /> : null
} }
+203 -8
View File
@@ -1,18 +1,212 @@
import { Text, View } from "react-native"; import { ActivityIndicator, 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 { useCallback, useRef, useState } from "react";
import { CaldavConfigService } from "../../services/CaldavConfigService";
import { useCaldavConfigStore } from "../../stores";
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;
secureTextEntry?: boolean;
};
const CaldavTextInput = ({
title,
value,
onValueChange,
secureTextEntry,
}: CaldavTextInputProps) => {
const { theme } = useThemeStore();
return (
<View className="flex flex-row items-center py-1">
<Text className="ml-4 w-24" style={{ color: theme.textPrimary }}>
{title}:
</Text>
<CustomTextInput
className="flex-1 mr-4 px-3 py-2"
text={value}
onValueChange={onValueChange}
secureTextEntry={secureTextEntry}
/>
</View>
);
};
type Feedback = { text: string; isError: boolean; loading: boolean };
const FeedbackRow = ({ feedback }: { feedback: Feedback | null }) => {
const { theme } = useThemeStore();
if (!feedback) return null;
return (
<View className="flex flex-row items-center justify-center mt-2 mx-4 gap-2">
{feedback.loading && (
<ActivityIndicator size="small" color={theme.textMuted} />
)}
<Text
style={{
color: feedback.loading
? theme.textMuted
: feedback.isError
? theme.rejectButton
: theme.confirmButton,
}}
>
{feedback.text}
</Text>
</View>
);
};
const CaldavSettings = () => {
const { theme } = useThemeStore();
const { config, setConfig } = useCaldavConfigStore();
const [serverUrl, setServerUrl] = useState(config?.serverUrl ?? "");
const [username, setUsername] = useState(config?.username ?? "");
const [password, setPassword] = useState(config?.password ?? "");
const [saveFeedback, setSaveFeedback] = useState<Feedback | null>(null);
const [syncFeedback, setSyncFeedback] = useState<Feedback | null>(null);
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const syncTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const showFeedback = useCallback(
(
setter: typeof setSaveFeedback,
timer: typeof saveTimer,
text: string,
isError: boolean,
loading = false,
) => {
if (timer.current) clearTimeout(timer.current);
setter({ text, isError, loading });
if (!loading) {
timer.current = setTimeout(() => setter(null), 3000);
}
},
[],
);
const saveConfig = async () => {
showFeedback(
setSaveFeedback,
saveTimer,
"Speichere Konfiguration...",
false,
true,
);
try {
const saved = await CaldavConfigService.saveConfig(
serverUrl,
username,
password,
);
setConfig(saved);
showFeedback(
setSaveFeedback,
saveTimer,
"Konfiguration wurde gespeichert",
false,
);
} catch {
showFeedback(
setSaveFeedback,
saveTimer,
"Fehler beim Speichern der Konfiguration",
true,
);
}
};
const sync = async () => {
showFeedback(setSyncFeedback, syncTimer, "Synchronisiere...", false, true);
try {
await CaldavConfigService.sync();
showFeedback(
setSyncFeedback,
syncTimer,
"Synchronisierung erfolgreich",
false,
);
} catch {
showFeedback(
setSyncFeedback,
syncTimer,
"Fehler beim Synchronisieren",
true,
);
}
};
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}
secureTextEntry
/>
</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>
<FeedbackRow feedback={saveFeedback} />
<FeedbackRow feedback={syncFeedback} />
</View>
</>
);
};
const Settings = () => { const Settings = () => {
const { theme, setTheme } = useThemeStore(); const { theme, setTheme } = useThemeStore();
@@ -20,10 +214,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 +226,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>
); );
}; };
+14 -29
View File
@@ -19,40 +19,34 @@ import { Ionicons } from "@expo/vector-icons";
import { ScrollableDropdown } from "../components/ScrollableDropdown"; import { ScrollableDropdown } from "../components/ScrollableDropdown";
import { useDropdownPosition } from "../hooks/useDropdownPosition"; import { useDropdownPosition } from "../hooks/useDropdownPosition";
import { EventService, ChatService } from "../services"; import { EventService, ChatService } from "../services";
import { buildRRule, CreateEventDTO } from "@calchat/shared"; import {
buildRRule,
CreateEventDTO,
REPEAT_TYPE_LABELS,
RepeatType,
} 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 px-3 py-2"
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>
); );
@@ -97,15 +91,6 @@ const PickerRow = ({
); );
}; };
type RepeatType = "Tag" | "Woche" | "Monat" | "Jahr";
const REPEAT_TYPE_LABELS: Record<RepeatType, string> = {
Tag: "Tage",
Woche: "Wochen",
Monat: "Monate",
Jahr: "Jahre",
};
type RepeatPressableProps = { type RepeatPressableProps = {
focused: boolean; focused: boolean;
repeatType: RepeatType; repeatType: RepeatType;
+16 -19
View File
@@ -1,9 +1,12 @@
import { useState } from "react"; import { useState } from "react";
import { View, Text, TextInput, Pressable } from "react-native"; import { View, Text, Pressable } from "react-native";
import { Link, router } from "expo-router"; 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 CustomTextInput from "../components/CustomTextInput";
import { AuthService } from "../services"; import { AuthService } from "../services";
import { CaldavConfigService } from "../services/CaldavConfigService";
import { preloadAppData } from "../components/AuthGuard";
import { useThemeStore } from "../stores/ThemeStore"; import { useThemeStore } from "../stores/ThemeStore";
const LoginScreen = () => { const LoginScreen = () => {
@@ -24,6 +27,12 @@ const LoginScreen = () => {
setIsLoading(true); setIsLoading(true);
try { try {
await AuthService.login({ identifier, password }); await AuthService.login({ identifier, password });
await preloadAppData();
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.");
@@ -51,34 +60,22 @@ const LoginScreen = () => {
</Text> </Text>
)} )}
<TextInput <CustomTextInput
placeholder="E-Mail oder Benutzername" placeholder="E-Mail oder Benutzername"
placeholderTextColor={theme.textMuted} placeholderTextColor={theme.textMuted}
value={identifier} text={identifier}
onChangeText={setIdentifier} onValueChange={setIdentifier}
autoCapitalize="none" autoCapitalize="none"
className="w-full rounded-lg p-4 mb-4" className="w-full rounded-lg p-4 mb-4"
style={{
backgroundColor: theme.secondaryBg,
color: theme.textPrimary,
borderWidth: 1,
borderColor: theme.borderPrimary,
}}
/> />
<TextInput <CustomTextInput
placeholder="Passwort" placeholder="Passwort"
placeholderTextColor={theme.textMuted} placeholderTextColor={theme.textMuted}
value={password} text={password}
onChangeText={setPassword} onValueChange={setPassword}
secureTextEntry secureTextEntry
className="w-full rounded-lg p-4 mb-6" className="w-full rounded-lg p-4 mb-6"
style={{
backgroundColor: theme.secondaryBg,
color: theme.textPrimary,
borderWidth: 1,
borderColor: theme.borderPrimary,
}}
/> />
<AuthButton <AuthButton
+11 -28
View File
@@ -1,8 +1,9 @@
import { useState } from "react"; import { useState } from "react";
import { View, Text, TextInput, Pressable } from "react-native"; import { View, Text, Pressable } from "react-native";
import { Link, router } from "expo-router"; 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 CustomTextInput from "../components/CustomTextInput";
import { AuthService } from "../services"; import { AuthService } from "../services";
import { useThemeStore } from "../stores/ThemeStore"; import { useThemeStore } from "../stores/ThemeStore";
@@ -59,50 +60,32 @@ const RegisterScreen = () => {
</Text> </Text>
)} )}
<TextInput <CustomTextInput
placeholder="E-Mail" placeholder="E-Mail"
placeholderTextColor={theme.textMuted} placeholderTextColor={theme.textMuted}
value={email} text={email}
onChangeText={setEmail} onValueChange={setEmail}
autoCapitalize="none" autoCapitalize="none"
keyboardType="email-address" keyboardType="email-address"
className="w-full rounded-lg p-4 mb-4" className="w-full rounded-lg p-4 mb-4"
style={{
backgroundColor: theme.secondaryBg,
color: theme.textPrimary,
borderWidth: 1,
borderColor: theme.borderPrimary,
}}
/> />
<TextInput <CustomTextInput
placeholder="Benutzername" placeholder="Benutzername"
placeholderTextColor={theme.textMuted} placeholderTextColor={theme.textMuted}
value={userName} text={userName}
onChangeText={setUserName} onValueChange={setUserName}
autoCapitalize="none" autoCapitalize="none"
className="w-full rounded-lg p-4 mb-4" className="w-full rounded-lg p-4 mb-4"
style={{
backgroundColor: theme.secondaryBg,
color: theme.textPrimary,
borderWidth: 1,
borderColor: theme.borderPrimary,
}}
/> />
<TextInput <CustomTextInput
placeholder="Passwort" placeholder="Passwort"
placeholderTextColor={theme.textMuted} placeholderTextColor={theme.textMuted}
value={password} text={password}
onChangeText={setPassword} onValueChange={setPassword}
secureTextEntry secureTextEntry
className="w-full rounded-lg p-4 mb-6" className="w-full rounded-lg p-4 mb-6"
style={{
backgroundColor: theme.secondaryBg,
color: theme.textPrimary,
borderWidth: 1,
borderColor: theme.borderPrimary,
}}
/> />
<AuthButton <AuthButton
+46 -3
View File
@@ -1,16 +1,52 @@
import { useEffect, ReactNode } from "react"; import { useEffect, useState, ReactNode } from "react";
import { View, ActivityIndicator } from "react-native"; 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 { useEventsStore } from "../stores/EventsStore";
import { useCaldavConfigStore } from "../stores/CaldavConfigStore";
import { EventService } from "../services";
import { CaldavConfigService } from "../services/CaldavConfigService";
type AuthGuardProps = { type AuthGuardProps = {
children: ReactNode; children: ReactNode;
}; };
/**
* Preloads app data (events + CalDAV config) into stores.
* Called before the loading spinner is dismissed so screens have data immediately.
*/
export const preloadAppData = async () => {
const now = new Date();
const firstOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const dayOfWeek = firstOfMonth.getDay();
const daysFromPrevMonth = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
const startDate = new Date(
now.getFullYear(),
now.getMonth(),
1 - daysFromPrevMonth,
);
const endDate = new Date(startDate);
endDate.setDate(startDate.getDate() + 41);
endDate.setHours(23, 59, 59);
const [eventsResult, configResult] = await Promise.allSettled([
EventService.getByDateRange(startDate, endDate),
CaldavConfigService.getConfig(),
]);
if (eventsResult.status === "fulfilled") {
useEventsStore.getState().setEvents(eventsResult.value);
}
if (configResult.status === "fulfilled") {
useCaldavConfigStore.getState().setConfig(configResult.value);
}
};
/** /**
* Wraps content that requires authentication. * Wraps content that requires authentication.
* - Loads stored user on mount * - Loads stored user on mount
* - Preloads app data (events, CalDAV config) before dismissing spinner
* - Shows loading indicator while checking auth state * - Shows loading indicator while checking auth state
* - Redirects to login if not authenticated * - Redirects to login if not authenticated
* - Renders children if authenticated * - Renders children if authenticated
@@ -18,12 +54,19 @@ type AuthGuardProps = {
export const AuthGuard = ({ children }: AuthGuardProps) => { export const AuthGuard = ({ children }: AuthGuardProps) => {
const { theme } = useThemeStore(); const { theme } = useThemeStore();
const { isAuthenticated, isLoading, loadStoredUser } = useAuthStore(); const { isAuthenticated, isLoading, loadStoredUser } = useAuthStore();
const [dataReady, setDataReady] = useState(false);
useEffect(() => { useEffect(() => {
loadStoredUser(); const init = async () => {
await loadStoredUser();
if (!useAuthStore.getState().isAuthenticated) return;
await preloadAppData();
setDataReady(true);
};
init();
}, [loadStoredUser]); }, [loadStoredUser]);
if (isLoading) { if (isLoading || (isAuthenticated && !dataReady)) {
return ( return (
<View <View
style={{ style={{
+9 -3
View File
@@ -2,17 +2,23 @@ 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,
@@ -0,0 +1,46 @@
import { TextInput, TextInputProps } 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;
placeholder?: string;
placeholderTextColor?: string;
secureTextEntry?: boolean;
autoCapitalize?: TextInputProps["autoCapitalize"];
keyboardType?: TextInputProps["keyboardType"];
};
const CustomTextInput = (props: CustomTextInputProps) => {
const { theme } = useThemeStore();
const [focused, setFocused] = useState(props.focused ?? false);
return (
<TextInput
className={`border border-solid rounded-2xl ${props.className}`}
onChangeText={props.onValueChange}
value={props.text}
multiline={props.multiline}
placeholder={props.placeholder}
placeholderTextColor={props.placeholderTextColor}
secureTextEntry={props.secureTextEntry}
autoCapitalize={props.autoCapitalize}
keyboardType={props.keyboardType}
selection={!focused ? { start: 0 } : undefined}
style={{
backgroundColor: theme.messageBorderBg,
color: theme.textPrimary,
textAlignVertical: "top",
borderColor: focused ? theme.chatBot : theme.borderPrimary,
}}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
/>
);
};
export default CustomTextInput;
@@ -126,11 +126,11 @@ const DateTimePickerButton = ({
// Convenience wrappers for simpler usage // Convenience wrappers for simpler usage
export const DatePickerButton = ( export const DatePickerButton = (
props: Omit<DateTimePickerButtonProps, "mode"> props: Omit<DateTimePickerButtonProps, "mode">,
) => <DateTimePickerButton {...props} mode="date" />; ) => <DateTimePickerButton {...props} mode="date" />;
export const TimePickerButton = ( export const TimePickerButton = (
props: Omit<DateTimePickerButtonProps, "mode"> props: Omit<DateTimePickerButtonProps, "mode">,
) => <DateTimePickerButton {...props} mode="time" />; ) => <DateTimePickerButton {...props} mode="time" />;
export default DateTimePickerButton; export default DateTimePickerButton;
+1 -1
View File
@@ -19,7 +19,7 @@ export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
startTime={event.occurrenceStart} startTime={event.occurrenceStart}
endTime={event.occurrenceEnd} endTime={event.occurrenceEnd}
description={event.description} description={event.description}
isRecurring={event.isRecurring} recurrenceRule={event.recurrenceRule}
> >
{/* Action buttons - TouchableOpacity with delayPressIn allows ScrollView to detect scroll gestures */} {/* Action buttons - TouchableOpacity with delayPressIn allows ScrollView to detect scroll gestures */}
<View className="flex-row justify-end mt-3 gap-3"> <View className="flex-row justify-end mt-3 gap-3">
+7 -4
View File
@@ -8,6 +8,7 @@ import {
formatDateWithWeekday, formatDateWithWeekday,
formatDateWithWeekdayShort, formatDateWithWeekdayShort,
formatTime, formatTime,
formatRecurrenceRule,
} from "@calchat/shared"; } from "@calchat/shared";
type EventCardBaseProps = { type EventCardBaseProps = {
@@ -16,7 +17,7 @@ type EventCardBaseProps = {
startTime: Date; startTime: Date;
endTime: Date; endTime: Date;
description?: string; description?: string;
isRecurring?: boolean; recurrenceRule?: string;
children?: ReactNode; children?: ReactNode;
}; };
@@ -46,7 +47,7 @@ export const EventCardBase = ({
startTime, startTime,
endTime, endTime,
description, description,
isRecurring, recurrenceRule,
children, children,
}: EventCardBaseProps) => { }: EventCardBaseProps) => {
const { theme } = useThemeStore(); const { theme } = useThemeStore();
@@ -95,7 +96,7 @@ export const EventCardBase = ({
</View> </View>
{/* Recurring indicator */} {/* Recurring indicator */}
{isRecurring && ( {recurrenceRule && (
<View className="flex-row items-center mb-1"> <View className="flex-row items-center mb-1">
<Feather <Feather
name="repeat" name="repeat"
@@ -103,7 +104,9 @@ export const EventCardBase = ({
color={theme.textPrimary} color={theme.textPrimary}
style={{ marginRight: 8 }} style={{ marginRight: 8 }}
/> />
<Text style={{ color: theme.textPrimary }}>Wiederkehrend</Text> <Text style={{ color: theme.textPrimary }}>
{formatRecurrenceRule(recurrenceRule)}
</Text>
</View> </View>
)} )}
@@ -113,7 +113,7 @@ export const ProposedEventCard = ({
startTime={event.startTime} startTime={event.startTime}
endTime={event.endTime} endTime={event.endTime}
description={event.description} description={event.description}
isRecurring={!!event.recurrenceRule} recurrenceRule={event.recurrenceRule}
> >
{/* Show new exception date for delete/single actions */} {/* Show new exception date for delete/single actions */}
{newExceptionDate && ( {newExceptionDate && (
@@ -124,8 +124,12 @@ export const ProposedEventCard = ({
color={theme.confirmButton} color={theme.confirmButton}
style={{ marginRight: 8 }} style={{ marginRight: 8 }}
/> />
<Text style={{ color: theme.confirmButton }} className="font-medium"> <Text
Neue Ausnahme: {formatDate(new Date(proposedChange.occurrenceDate!))} style={{ color: theme.confirmButton }}
className="font-medium"
>
Neue Ausnahme:{" "}
{formatDate(new Date(proposedChange.occurrenceDate!))}
</Text> </Text>
</View> </View>
)} )}
@@ -138,7 +142,10 @@ export const ProposedEventCard = ({
color={theme.confirmButton} color={theme.confirmButton}
style={{ marginRight: 8 }} style={{ marginRight: 8 }}
/> />
<Text style={{ color: theme.confirmButton }} className="font-medium"> <Text
style={{ color: theme.confirmButton }}
className="font-medium"
>
Neues Ende: {formatDate(newUntilDate)} Neues Ende: {formatDate(newUntilDate)}
</Text> </Text>
</View> </View>
@@ -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");
},
};
@@ -0,0 +1,14 @@
import { create } from "zustand";
import { CaldavConfig } from "@calchat/shared";
interface CaldavConfigState {
config: CaldavConfig | null;
setConfig: (config: CaldavConfig | null) => void;
}
export const useCaldavConfigStore = create<CaldavConfigState>((set) => ({
config: null,
setConfig: (config: CaldavConfig | null) => {
set({ config });
},
}));
+1
View File
@@ -5,3 +5,4 @@ export {
type MessageData, type MessageData,
} from "./ChatStore"; } from "./ChatStore";
export { useEventsStore } from "./EventsStore"; export { useEventsStore } from "./EventsStore";
export { useCaldavConfigStore } from "./CaldavConfigStore";
+28
View File
@@ -0,0 +1,28 @@
# OpenAI API key for GPT-based chat assistant
# Required for AI chat functionality
OPENAI_API_KEY=sk-proj-your-key-here
# Port the server listens on
# Default: 3000
PORT=3000
# MongoDB connection URI
# Default: mongodb://localhost:27017/calchat
# The Docker Compose setup uses root:mongoose credentials with authSource=admin
MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin
# Use static test responses instead of real GPT calls
# Values: true | false
# Default: false
USE_TEST_RESPONSES=false
# Log level for pino logger
# Values: debug | info | warn | error | fatal
# Default: debug (development), info (production)
LOG_LEVEL=debug
# Node environment
# Values: development | production
# development = pretty-printed logs, production = JSON logs
# Default: development
NODE_ENV=development
+31
View File
@@ -0,0 +1,31 @@
FROM node:alpine AS build
WORKDIR /app
COPY package.json package-lock.json tsconfig.json ./
COPY packages/shared/package.json ./packages/shared/
COPY apps/server/package.json ./apps/server/
RUN npm ci -w @calchat/server -w @calchat/shared --include-workspace-root
COPY packages/shared/ packages/shared/
COPY apps/server/ apps/server/
RUN npm run build -w @calchat/shared && npm run build -w @calchat/server
FROM node:alpine
WORKDIR /app
COPY --from=build /app/package.json /app/package-lock.json ./
COPY --from=build /app/packages/shared/package.json packages/shared/
COPY --from=build /app/apps/server/package.json apps/server/
RUN npm ci --omit=dev -w @calchat/server -w @calchat/shared
COPY --from=build /app/packages/shared/dist/ packages/shared/dist/
COPY --from=build /app/apps/server/dist/ apps/server/dist/
EXPOSE 3001
CMD ["node", "apps/server/dist/app.js"]
@@ -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
+5
View File
@@ -0,0 +1,5 @@
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
testPathIgnorePatterns: ["/node_modules/", "/dist/"],
};
+12 -7
View File
@@ -3,28 +3,33 @@
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "tsx watch src/app.ts", "dev": "npm run build --workspace=@calchat/shared && tsx watch src/app.ts",
"build": "tsc", "build": "npm run build --workspace=@calchat/shared && 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",
"jsonwebtoken": "^9.0.3", "ical.js": "^2.2.1",
"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" "pino-pretty": "^13.1.3",
"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/jsonwebtoken": "^9.0.10", "@types/ical": "^0.8.3",
"@types/jest": "^30.0.0",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"pino-pretty": "^13.1.3", "jest": "^30.2.0",
"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
View File
@@ -1,4 +1,3 @@
export { formatDate, formatTime, formatDateTime } from "./eventFormatter";
export { buildSystemPrompt } from "./systemPrompt"; export { buildSystemPrompt } from "./systemPrompt";
export { export {
TOOL_DEFINITIONS, TOOL_DEFINITIONS,
+2 -2
View File
@@ -6,7 +6,7 @@ import {
RecurringDeleteMode, RecurringDeleteMode,
} from "@calchat/shared"; } from "@calchat/shared";
import { AIContext } from "../../services/interfaces"; import { AIContext } from "../../services/interfaces";
import { formatDate, formatTime, formatDateTime } from "./eventFormatter"; import { formatDate, formatTime, formatDateTime } from "@calchat/shared";
/** /**
* Check if two time ranges overlap. * Check if two time ranges overlap.
@@ -234,7 +234,7 @@ export async function executeToolCall(
const eventsText = events const eventsText = events
.map((e) => { .map((e) => {
const start = new Date(e.startTime); const start = new Date(e.occurrenceStart);
const recurrenceInfo = e.recurrenceRule ? " (wiederkehrend)" : ""; const recurrenceInfo = e.recurrenceRule ? " (wiederkehrend)" : "";
return `- ${e.title} (ID: ${e.id}) am ${formatDate(start)} um ${formatTime(start)} Uhr${recurrenceInfo}`; return `- ${e.title} (ID: ${e.id}) am ${formatDate(start)} um ${formatTime(start)} Uhr${recurrenceInfo}`;
}) })
+10 -8
View File
@@ -17,6 +17,9 @@ import {
} from "./repositories"; } from "./repositories";
import { GPTAdapter } from "./ai"; import { GPTAdapter } from "./ai";
import { logger } from "./logging"; import { logger } from "./logging";
import { MongoCaldavRepository } from "./repositories/mongo/MongoCaldavRepository";
import { CaldavService } from "./services/CaldavService";
import { CaldavController } from "./controllers/CaldavController";
const app = express(); const app = express();
const port = process.env.PORT || 3000; const port = process.env.PORT || 3000;
@@ -51,6 +54,7 @@ if (process.env.NODE_ENV !== "production") {
const userRepo = new MongoUserRepository(); const userRepo = new MongoUserRepository();
const eventRepo = new MongoEventRepository(); const eventRepo = new MongoEventRepository();
const chatRepo = new MongoChatRepository(); const chatRepo = new MongoChatRepository();
const caldavRepo = new MongoCaldavRepository();
// Initialize AI provider // Initialize AI provider
const aiProvider = new GPTAdapter(); const aiProvider = new GPTAdapter();
@@ -58,17 +62,14 @@ const aiProvider = new GPTAdapter();
// Initialize services // Initialize services
const authService = new AuthService(userRepo); const authService = new AuthService(userRepo);
const eventService = new EventService(eventRepo); const eventService = new EventService(eventRepo);
const chatService = new ChatService( const caldavService = new CaldavService(caldavRepo, eventService);
chatRepo, const chatService = new ChatService(chatRepo, eventService, aiProvider);
eventRepo,
eventService,
aiProvider,
);
// 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 +78,7 @@ app.use(
authController, authController,
chatController, chatController,
eventController, eventController,
caldavController,
}), }),
); );
@@ -21,12 +21,4 @@ export class AuthController {
res.status(400).json({ error: (error as Error).message }); res.status(400).json({ error: (error as Error).message });
} }
} }
async refresh(req: Request, res: Response): Promise<void> {
throw new Error("Not implemented");
}
async logout(req: Request, res: Response): Promise<void> {
throw new Error("Not implemented");
}
} }
@@ -0,0 +1,103 @@
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(
{ err: 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(
{ err: 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(
{ err: 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(
{ err: 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(
{ err: 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(
{ err: error, userId: req.user?.userId },
"Error pushing event",
);
res.status(500).json({ error: "Failed to push event" });
}
}
}
+21 -7
View File
@@ -8,13 +8,17 @@ import {
RecurringDeleteMode, RecurringDeleteMode,
} from "@calchat/shared"; } from "@calchat/shared";
import { ChatService } from "../services"; import { ChatService } from "../services";
import { CaldavService } from "../services/CaldavService";
import { createLogger } from "../logging"; import { createLogger } from "../logging";
import { AuthenticatedRequest } from "./AuthMiddleware"; import { AuthenticatedRequest } from "./AuthMiddleware";
const log = createLogger("ChatController"); const log = createLogger("ChatController");
export class ChatController { export class ChatController {
constructor(private chatService: ChatService) {} constructor(
private chatService: ChatService,
private caldavService: CaldavService,
) {}
async sendMessage(req: AuthenticatedRequest, res: Response): Promise<void> { async sendMessage(req: AuthenticatedRequest, res: Response): Promise<void> {
try { try {
@@ -24,7 +28,7 @@ export class ChatController {
res.json(response); res.json(response);
} catch (error) { } catch (error) {
log.error( log.error(
{ error, userId: req.user?.userId }, { err: error, userId: req.user?.userId },
"Error processing message", "Error processing message",
); );
res.status(500).json({ error: "Failed to process message" }); res.status(500).json({ error: "Failed to process message" });
@@ -68,10 +72,20 @@ 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({ err: error, userId }, "CalDAV push after confirm failed");
}
res.json(response); res.json(response);
} catch (error) { } catch (error) {
log.error( log.error(
{ error, conversationId: req.params.conversationId }, { err: error, conversationId: req.params.conversationId },
"Error confirming event", "Error confirming event",
); );
res.status(500).json({ error: "Failed to confirm event" }); res.status(500).json({ error: "Failed to confirm event" });
@@ -92,7 +106,7 @@ export class ChatController {
res.json(response); res.json(response);
} catch (error) { } catch (error) {
log.error( log.error(
{ error, conversationId: req.params.conversationId }, { err: error, conversationId: req.params.conversationId },
"Error rejecting event", "Error rejecting event",
); );
res.status(500).json({ error: "Failed to reject event" }); res.status(500).json({ error: "Failed to reject event" });
@@ -109,7 +123,7 @@ export class ChatController {
res.json(conversations); res.json(conversations);
} catch (error) { } catch (error) {
log.error( log.error(
{ error, userId: req.user?.userId }, { err: error, userId: req.user?.userId },
"Error getting conversations", "Error getting conversations",
); );
res.status(500).json({ error: "Failed to get conversations" }); res.status(500).json({ error: "Failed to get conversations" });
@@ -143,7 +157,7 @@ export class ChatController {
res.status(404).json({ error: "Conversation not found" }); res.status(404).json({ error: "Conversation not found" });
} else { } else {
log.error( log.error(
{ error, conversationId: req.params.id }, { err: error, conversationId: req.params.id },
"Error getting conversation", "Error getting conversation",
); );
res.status(500).json({ error: "Failed to get conversation" }); res.status(500).json({ error: "Failed to get conversation" });
@@ -173,7 +187,7 @@ export class ChatController {
} }
} catch (error) { } catch (error) {
log.error( log.error(
{ error, messageId: req.params.messageId }, { err: error, messageId: req.params.messageId },
"Error updating proposal event", "Error updating proposal event",
); );
res.status(500).json({ error: "Failed to update proposal event" }); res.status(500).json({ error: "Failed to update proposal event" });
+61 -27
View File
@@ -1,20 +1,49 @@
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({ err: 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({ err: 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(
{ err: error, userId: req.user?.userId },
"Error creating event",
);
res.status(500).json({ error: "Failed to create event" }); res.status(500).json({ error: "Failed to create event" });
} }
} }
@@ -31,7 +60,7 @@ export class EventController {
} }
res.json(event); res.json(event);
} catch (error) { } catch (error) {
log.error({ error, eventId: req.params.id }, "Error getting event"); log.error({ err: error, eventId: req.params.id }, "Error getting event");
res.status(500).json({ error: "Failed to get event" }); res.status(500).json({ error: "Failed to get event" });
} }
} }
@@ -41,7 +70,10 @@ export class EventController {
const events = await this.eventService.getAll(req.user!.userId); const events = await this.eventService.getAll(req.user!.userId);
res.json(events); res.json(events);
} catch (error) { } catch (error) {
log.error({ error, userId: req.user?.userId }, "Error getting events"); log.error(
{ err: error, userId: req.user?.userId },
"Error getting events",
);
res.status(500).json({ error: "Failed to get events" }); res.status(500).json({ error: "Failed to get events" });
} }
} }
@@ -74,7 +106,7 @@ export class EventController {
res.json(events); res.json(events);
} catch (error) { } catch (error) {
log.error( log.error(
{ error, start: req.query.start, end: req.query.end }, { err: error, start: req.query.start, end: req.query.end },
"Error getting events by range", "Error getting events by range",
); );
res.status(500).json({ error: "Failed to get events" }); res.status(500).json({ error: "Failed to get events" });
@@ -83,67 +115,69 @@ 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({ err: error, eventId: req.params.id }, "Error updating event");
res.status(500).json({ error: "Failed to update event" }); res.status(500).json({ error: "Failed to update event" });
} }
} }
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({ err: error, eventId: req.params.id }, "Error deleting event");
res.status(500).json({ error: "Failed to delete event" }); res.status(500).json({ error: "Failed to delete event" });
} }
} }
+1
View File
@@ -3,3 +3,4 @@ export * from "./ChatController";
export * from "./EventController"; export * from "./EventController";
export * from "./AuthMiddleware"; export * from "./AuthMiddleware";
export * from "./LoggingMiddleware"; export * from "./LoggingMiddleware";
export * from "./CaldavController";
@@ -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,15 @@ 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,
);
@@ -2,22 +2,16 @@ import mongoose, { Schema, Document, Model } from "mongoose";
import { CalendarEvent } from "@calchat/shared"; import { CalendarEvent } from "@calchat/shared";
import { IdVirtual } from "./types"; import { IdVirtual } from "./types";
interface EventVirtuals extends IdVirtual { export interface EventDocument extends Omit<CalendarEvent, "id">, Document {
isRecurring: boolean;
}
export interface EventDocument
extends Omit<CalendarEvent, "id" | "isRecurring">,
Document {
toJSON(): CalendarEvent; toJSON(): CalendarEvent;
} }
const EventSchema = new Schema< const EventSchema = new Schema<
EventDocument, EventDocument,
Model<EventDocument, {}, {}, EventVirtuals>, Model<EventDocument, {}, {}, IdVirtual>,
{}, {},
{}, {},
EventVirtuals IdVirtual
>( >(
{ {
userId: { userId: {
@@ -25,6 +19,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,
@@ -61,11 +61,6 @@ const EventSchema = new Schema<
return this._id.toString(); return this._id.toString();
}, },
}, },
isRecurring: {
get() {
return !!this.recurrenceRule;
},
},
}, },
toJSON: { toJSON: {
virtuals: true, virtuals: true,
-2
View File
@@ -6,8 +6,6 @@ export function createAuthRoutes(authController: AuthController): Router {
router.post("/login", (req, res) => authController.login(req, res)); router.post("/login", (req, res) => authController.login(req, res));
router.post("/register", (req, res) => authController.register(req, res)); router.post("/register", (req, res) => authController.register(req, res));
router.post("/refresh", (req, res) => authController.refresh(req, res));
router.post("/logout", (req, res) => authController.logout(req, res));
return router; return router;
} }
+22
View File
@@ -0,0 +1,22 @@
import { Router } from "express";
import { authenticate } from "../controllers";
import { CaldavController } from "../controllers/CaldavController";
export function createCaldavRoutes(caldavController: CaldavController): Router {
const router = Router();
router.use(authenticate);
router.put("/config", (req, res) => caldavController.saveConfig(req, res));
router.get("/config", (req, res) => caldavController.loadConfig(req, res));
router.delete("/config", (req, res) =>
caldavController.deleteConfig(req, res),
);
router.post("/pull", (req, res) => caldavController.pullEvents(req, res));
router.post("/pushAll", (req, res) => caldavController.pushEvents(req, res));
router.post("/push/:caldavUUID", (req, res) =>
caldavController.pushEvent(req, res),
);
return router;
}
+4
View File
@@ -6,12 +6,15 @@ import {
AuthController, AuthController,
ChatController, ChatController,
EventController, EventController,
CaldavController,
} from "../controllers"; } from "../controllers";
import { createCaldavRoutes } from "./caldav.routes";
export interface Controllers { export interface Controllers {
authController: AuthController; authController: AuthController;
chatController: ChatController; chatController: ChatController;
eventController: EventController; eventController: EventController;
caldavController: CaldavController;
} }
export function createRoutes(controllers: Controllers): Router { export function createRoutes(controllers: Controllers): Router {
@@ -20,6 +23,7 @@ export function createRoutes(controllers: Controllers): Router {
router.use("/auth", createAuthRoutes(controllers.authController)); router.use("/auth", createAuthRoutes(controllers.authController));
router.use("/chat", createChatRoutes(controllers.chatController)); router.use("/chat", createChatRoutes(controllers.chatController));
router.use("/events", createEventRoutes(controllers.eventController)); router.use("/events", createEventRoutes(controllers.eventController));
router.use("/caldav", createCaldavRoutes(controllers.caldavController));
return router; return router;
} }
+1 -10
View File
@@ -1,6 +1,5 @@
import { User, CreateUserDTO, LoginDTO, AuthResponse } from "@calchat/shared"; import { CreateUserDTO, LoginDTO, AuthResponse } from "@calchat/shared";
import { UserRepository } from "./interfaces"; import { UserRepository } from "./interfaces";
import * as jwt from "../utils/jwt";
import * as password from "../utils/password"; import * as password from "../utils/password";
export class AuthService { export class AuthService {
@@ -45,12 +44,4 @@ export class AuthService {
return { user, accessToken: "" }; return { user, accessToken: "" };
} }
async refreshToken(refreshToken: string): Promise<AuthResponse> {
throw new Error("Not implemented");
}
async logout(userId: string): Promise<void> {
throw new Error("Not implemented");
}
} }
+271
View File
@@ -0,0 +1,271 @@
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,
CaldavConfig,
formatDateKey,
} from "@calchat/shared";
import { EventService } from "./EventService";
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);
}
/**
* Sync with CalDAV server if config exists. Silent no-op if no config.
*/
async sync(userId: string): Promise<void> {
const config = await this.getConfig(userId);
if (!config) return;
await this.pushAll(userId);
await this.pullEvents(userId);
}
}
+33 -27
View File
@@ -12,7 +12,7 @@ import {
RecurringDeleteMode, RecurringDeleteMode,
ConflictingEvent, ConflictingEvent,
} from "@calchat/shared"; } from "@calchat/shared";
import { ChatRepository, EventRepository, AIProvider } from "./interfaces"; import { ChatRepository, AIProvider } from "./interfaces";
import { EventService } from "./EventService"; import { EventService } from "./EventService";
import { getWeeksOverview, getMonthOverview } from "../utils/eventFormatters"; import { getWeeksOverview, getMonthOverview } from "../utils/eventFormatters";
@@ -333,7 +333,7 @@ const staticResponses: TestResponse[] = [
async function getTestResponse( async function getTestResponse(
index: number, index: number,
eventRepo: EventRepository, eventService: EventService,
userId: string, userId: string,
): Promise<TestResponse> { ): Promise<TestResponse> {
const responseIdx = index % staticResponses.length; const responseIdx = index % staticResponses.length;
@@ -341,7 +341,7 @@ async function getTestResponse(
// === SPORT TEST SCENARIO (Dynamic responses) === // === SPORT TEST SCENARIO (Dynamic responses) ===
// Response 1: Add exception to "Sport" (2 weeks later) // Response 1: Add exception to "Sport" (2 weeks later)
if (responseIdx === 1) { if (responseIdx === 1) {
const events = await eventRepo.findByUserId(userId); const events = await eventService.getAll(userId);
const sportEvent = events.find((e) => e.title === "Sport"); const sportEvent = events.find((e) => e.title === "Sport");
if (sportEvent) { if (sportEvent) {
// Calculate date 2 weeks from the first occurrence // Calculate date 2 weeks from the first occurrence
@@ -362,9 +362,7 @@ async function getTestResponse(
event: { event: {
title: sportEvent.title, title: sportEvent.title,
startTime: exceptionDate, startTime: exceptionDate,
endTime: new Date( endTime: new Date(exceptionDate.getTime() + 90 * 60 * 1000), // +90 min
exceptionDate.getTime() + 90 * 60 * 1000,
), // +90 min
description: sportEvent.description, description: sportEvent.description,
recurrenceRule: sportEvent.recurrenceRule, recurrenceRule: sportEvent.recurrenceRule,
exceptionDates: sportEvent.exceptionDates, exceptionDates: sportEvent.exceptionDates,
@@ -374,25 +372,26 @@ async function getTestResponse(
}; };
} }
return { return {
content: "Ich konnte keinen Termin 'Sport' finden. Bitte erstelle ihn zuerst.", content:
"Ich konnte keinen Termin 'Sport' finden. Bitte erstelle ihn zuerst.",
}; };
} }
// 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
const untilDate = new Date(sportEvent.startTime); const untilDate = new Date(sportEvent.startTime);
untilDate.setDate(untilDate.getDate() + 42); // 6 weeks untilDate.setDate(untilDate.getDate() + 42); // 6 weeks
const untilStr = untilDate.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"; const untilStr =
untilDate.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
const newRule = `FREQ=WEEKLY;BYDAY=WE;UNTIL=${untilStr}`; const newRule = `FREQ=WEEKLY;BYDAY=WE;UNTIL=${untilStr}`;
return { return {
content: content: "Alles klar! Ich beende die Sport-Serie nach 6 Wochen:",
"Alles klar! Ich beende die Sport-Serie nach 6 Wochen:",
proposedChanges: [ proposedChanges: [
{ {
id: "sport-until", id: "sport-until",
@@ -412,13 +411,14 @@ async function getTestResponse(
}; };
} }
return { return {
content: "Ich konnte keinen Termin 'Sport' finden. Bitte erstelle ihn zuerst.", content:
"Ich konnte keinen Termin 'Sport' finden. Bitte erstelle ihn zuerst.",
}; };
} }
// 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)
@@ -439,9 +439,7 @@ async function getTestResponse(
event: { event: {
title: sportEvent.title, title: sportEvent.title,
startTime: exceptionDate, startTime: exceptionDate,
endTime: new Date( endTime: new Date(exceptionDate.getTime() + 90 * 60 * 1000), // +90 min
exceptionDate.getTime() + 90 * 60 * 1000,
), // +90 min
description: sportEvent.description, description: sportEvent.description,
recurrenceRule: sportEvent.recurrenceRule, recurrenceRule: sportEvent.recurrenceRule,
exceptionDates: sportEvent.exceptionDates, exceptionDates: sportEvent.exceptionDates,
@@ -451,19 +449,20 @@ async function getTestResponse(
}; };
} }
return { return {
content: "Ich konnte keinen Termin 'Sport' finden. Bitte erstelle ihn zuerst.", content:
"Ich konnte keinen Termin 'Sport' finden. Bitte erstelle ihn zuerst.",
}; };
} }
// 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 +486,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 +526,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 +540,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 +564,11 @@ 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 +584,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 +625,14 @@ 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.";
+14 -3
View File
@@ -24,10 +24,21 @@ 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,
@@ -37,8 +48,8 @@ export class EventService {
const allEvents = await this.eventRepo.findByUserId(userId); const allEvents = await this.eventRepo.findByUserId(userId);
// Separate recurring and non-recurring events // Separate recurring and non-recurring events
const recurringEvents = allEvents.filter((e) => e.isRecurring); const recurringEvents = allEvents.filter((e) => e.recurrenceRule);
const nonRecurringEvents = allEvents.filter((e) => !e.isRecurring); const nonRecurringEvents = allEvents.filter((e) => !e.recurrenceRule);
// Expand all events (recurring get multiple instances, non-recurring stay as-is) // Expand all events (recurring get multiple instances, non-recurring stay as-is)
const expanded = expandRecurringEvents( const expanded = expandRecurringEvents(
@@ -90,7 +101,7 @@ export class EventService {
} }
// For non-recurring events, always delete completely // For non-recurring events, always delete completely
if (!event.isRecurring || !event.recurrenceRule) { if (!event.recurrenceRule) {
await this.eventRepo.delete(id); await this.eventRepo.delete(id);
return null; return null;
} }
@@ -11,7 +11,10 @@ export interface AIContext {
currentDate: Date; currentDate: Date;
// Callback to load events from a specific date range // Callback to load events from a specific date range
// Returns ExpandedEvent[] with occurrenceStart/occurrenceEnd for recurring events // Returns ExpandedEvent[] with occurrenceStart/occurrenceEnd for recurring events
fetchEventsInRange: (startDate: Date, endDate: Date) => Promise<ExpandedEvent[]>; fetchEventsInRange: (
startDate: Date,
endDate: Date,
) => Promise<ExpandedEvent[]>;
// Callback to search events by title // Callback to search events by title
searchEvents: (query: string) => Promise<CalendarEvent[]>; searchEvents: (query: string) => Promise<CalendarEvent[]>;
// Callback to fetch a single event by ID // Callback to fetch a single event by ID
@@ -0,0 +1,7 @@
import { CaldavConfig } from "@calchat/shared";
export interface CaldavRepository {
findByUserId(userId: string): Promise<CaldavConfig | null>;
createOrUpdate(config: CaldavConfig): Promise<CaldavConfig>;
deleteByUserId(userId: string): Promise<boolean>;
}
@@ -8,6 +8,10 @@ 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>;
+5 -5
View File
@@ -6,7 +6,7 @@ import {
MONTH_TO_GERMAN, MONTH_TO_GERMAN,
ExpandedEvent, ExpandedEvent,
} from "@calchat/shared"; } from "@calchat/shared";
import { EventRepository } from "../services/interfaces"; import { EventService } from "../services/EventService";
import { expandRecurringEvents } from "./recurrenceExpander"; import { expandRecurringEvents } from "./recurrenceExpander";
// Private formatting helpers // Private formatting helpers
@@ -107,13 +107,13 @@ function formatMonthText(events: ExpandedEvent[], monthName: string): string {
* Recurring events are expanded to show all occurrences within the range. * Recurring events are expanded to show all occurrences within the range.
*/ */
export async function getWeeksOverview( export async function getWeeksOverview(
eventRepo: EventRepository, eventService: EventService,
userId: string, userId: string,
weeks: number, weeks: number,
): Promise<string> { ): Promise<string> {
const now = new Date(); const now = new Date();
const endDate = new Date(now.getTime() + weeks * 7 * 24 * 60 * 60 * 1000); const endDate = new Date(now.getTime() + weeks * 7 * 24 * 60 * 60 * 1000);
const events = await eventRepo.findByUserId(userId); const events = await eventService.getAll(userId);
const expanded = expandRecurringEvents(events, now, endDate); const expanded = expandRecurringEvents(events, now, endDate);
return formatWeeksText(expanded, weeks); return formatWeeksText(expanded, weeks);
} }
@@ -123,14 +123,14 @@ export async function getWeeksOverview(
* Recurring events are expanded to show all occurrences within the month. * Recurring events are expanded to show all occurrences within the month.
*/ */
export async function getMonthOverview( export async function getMonthOverview(
eventRepo: EventRepository, eventService: EventService,
userId: string, userId: string,
year: number, year: number,
month: number, month: number,
): Promise<string> { ): Promise<string> {
const startOfMonth = new Date(year, month, 1); const startOfMonth = new Date(year, month, 1);
const endOfMonth = new Date(year, month + 1, 0, 23, 59, 59); const endOfMonth = new Date(year, month + 1, 0, 23, 59, 59);
const events = await eventRepo.findByUserId(userId); const events = await eventService.getAll(userId);
const expanded = expandRecurringEvents(events, startOfMonth, endOfMonth); const expanded = expandRecurringEvents(events, startOfMonth, endOfMonth);
const monthName = MONTH_TO_GERMAN[MONTHS[month]]; const monthName = MONTH_TO_GERMAN[MONTHS[month]];
return formatMonthText(expanded, monthName); return formatMonthText(expanded, monthName);
-1
View File
@@ -1,2 +1 @@
export * from "./jwt";
export * from "./password"; export * from "./password";
-26
View File
@@ -1,26 +0,0 @@
import jwt from "jsonwebtoken";
export interface TokenPayload {
userId: string;
email: string;
}
export interface JWTConfig {
secret: string;
expiresIn: string;
}
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "1h";
export function signToken(payload: TokenPayload): string {
throw new Error("Not implemented");
}
export function verifyToken(token: string): TokenPayload {
throw new Error("Not implemented");
}
export function decodeToken(token: string): TokenPayload | null {
throw new Error("Not implemented");
}
+37
View File
@@ -0,0 +1,37 @@
import { hash, compare } from "./password";
describe("password", () => {
describe("hash()", () => {
it("returns a valid bcrypt hash", async () => {
const result = await hash("testpassword");
expect(result).toMatch(/^\$2b\$/);
});
it("produces different hashes for the same password (salt)", async () => {
const hash1 = await hash("samepassword");
const hash2 = await hash("samepassword");
expect(hash1).not.toBe(hash2);
});
});
describe("compare()", () => {
it("returns true for the correct password", async () => {
const hashed = await hash("correct");
const result = await compare("correct", hashed);
expect(result).toBe(true);
});
it("returns false for a wrong password", async () => {
const hashed = await hash("correct");
const result = await compare("wrong", hashed);
expect(result).toBe(false);
});
it("handles special characters and unicode", async () => {
const password = "p@$$w0rd!#%& äöü 🔑";
const hashed = await hash(password);
expect(await compare(password, hashed)).toBe(true);
expect(await compare("other", hashed)).toBe(false);
});
});
});
@@ -0,0 +1,210 @@
import { CalendarEvent } from "@calchat/shared";
import { expandRecurringEvents } from "./recurrenceExpander";
// Helper: create a CalendarEvent with sensible defaults
function makeEvent(
overrides: Partial<CalendarEvent> & { startTime: Date; endTime: Date },
): CalendarEvent {
return {
id: "evt-1",
userId: "user-1",
title: "Test Event",
...overrides,
};
}
// Helper: create a date from "YYYY-MM-DD HH:mm" (local time)
function d(dateStr: string): Date {
const [datePart, timePart] = dateStr.split(" ");
const [y, m, day] = datePart.split("-").map(Number);
if (timePart) {
const [h, min] = timePart.split(":").map(Number);
return new Date(y, m - 1, day, h, min);
}
return new Date(y, m - 1, day);
}
describe("expandRecurringEvents", () => {
// Range: 2025-06-01 to 2025-06-30
const rangeStart = d("2025-06-01 00:00");
const rangeEnd = d("2025-06-30 23:59");
describe("non-recurring events", () => {
it("returns event within the range", () => {
const event = makeEvent({
startTime: d("2025-06-10 09:00"),
endTime: d("2025-06-10 10:00"),
});
const result = expandRecurringEvents([event], rangeStart, rangeEnd);
expect(result).toHaveLength(1);
expect(result[0].occurrenceStart).toEqual(d("2025-06-10 09:00"));
expect(result[0].occurrenceEnd).toEqual(d("2025-06-10 10:00"));
});
it("excludes event outside the range", () => {
const event = makeEvent({
startTime: d("2025-07-05 09:00"),
endTime: d("2025-07-05 10:00"),
});
const result = expandRecurringEvents([event], rangeStart, rangeEnd);
expect(result).toHaveLength(0);
});
it("includes event that starts before range and ends within", () => {
const event = makeEvent({
startTime: d("2025-05-31 22:00"),
endTime: d("2025-06-01 02:00"),
});
const result = expandRecurringEvents([event], rangeStart, rangeEnd);
expect(result).toHaveLength(1);
});
it("includes event that spans the entire range", () => {
const event = makeEvent({
startTime: d("2025-05-01 00:00"),
endTime: d("2025-07-31 23:59"),
});
const result = expandRecurringEvents([event], rangeStart, rangeEnd);
expect(result).toHaveLength(1);
});
it("returns empty array for empty input", () => {
const result = expandRecurringEvents([], rangeStart, rangeEnd);
expect(result).toEqual([]);
});
});
describe("recurring events", () => {
it("expands weekly event to all occurrences in range", () => {
// Weekly on Mondays, starting 2025-06-02 (a Monday)
const event = makeEvent({
startTime: d("2025-06-02 10:00"),
endTime: d("2025-06-02 11:00"),
recurrenceRule: "FREQ=WEEKLY;BYDAY=MO",
});
const result = expandRecurringEvents([event], rangeStart, rangeEnd);
// Mondays in June 2025: 2, 9, 16, 23, 30
expect(result).toHaveLength(5);
expect(result[0].occurrenceStart).toEqual(d("2025-06-02 10:00"));
expect(result[1].occurrenceStart).toEqual(d("2025-06-09 10:00"));
expect(result[2].occurrenceStart).toEqual(d("2025-06-16 10:00"));
expect(result[3].occurrenceStart).toEqual(d("2025-06-23 10:00"));
expect(result[4].occurrenceStart).toEqual(d("2025-06-30 10:00"));
});
it("daily event with UNTIL stops at the right date", () => {
const event = makeEvent({
startTime: d("2025-06-01 08:00"),
endTime: d("2025-06-01 09:00"),
recurrenceRule: "FREQ=DAILY;UNTIL=20250605T235959",
});
const result = expandRecurringEvents([event], rangeStart, rangeEnd);
// June 1, 2, 3, 4, 5
expect(result).toHaveLength(5);
expect(result[4].occurrenceStart).toEqual(d("2025-06-05 08:00"));
});
it("skips occurrences on exception dates (EXDATE)", () => {
const event = makeEvent({
startTime: d("2025-06-02 10:00"),
endTime: d("2025-06-02 11:00"),
recurrenceRule: "FREQ=WEEKLY;BYDAY=MO",
exceptionDates: ["2025-06-09", "2025-06-23"],
});
const result = expandRecurringEvents([event], rangeStart, rangeEnd);
// 5 Mondays minus 2 exceptions = 3
expect(result).toHaveLength(3);
const dates = result.map((r) => r.occurrenceStart.getDate());
expect(dates).toEqual([2, 16, 30]);
});
it("handles RRULE: prefix (strips it)", () => {
const event = makeEvent({
startTime: d("2025-06-01 08:00"),
endTime: d("2025-06-01 09:00"),
recurrenceRule: "RRULE:FREQ=DAILY;COUNT=3",
});
const result = expandRecurringEvents([event], rangeStart, rangeEnd);
expect(result).toHaveLength(3);
});
it("falls back to single occurrence on invalid RRULE", () => {
const consoleSpy = jest.spyOn(console, "error").mockImplementation();
const event = makeEvent({
startTime: d("2025-06-10 09:00"),
endTime: d("2025-06-10 10:00"),
recurrenceRule: "COMPLETELY_INVALID_RULE",
});
const result = expandRecurringEvents([event], rangeStart, rangeEnd);
expect(result).toHaveLength(1);
expect(result[0].occurrenceStart).toEqual(d("2025-06-10 09:00"));
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
describe("multi-day events", () => {
it("finds event starting before range that ends within range", () => {
// 3-day recurring event starting May 15, weekly
const event = makeEvent({
startTime: d("2025-05-15 08:00"),
endTime: d("2025-05-18 08:00"),
recurrenceRule: "FREQ=WEEKLY",
});
// The occurrence starting May 29 ends June 1 → overlaps with range
const result = expandRecurringEvents([event], rangeStart, rangeEnd);
const starts = result.map((r) => r.occurrenceStart.getDate());
// May 29 (ends June 1), June 5, 12, 19, 26
expect(starts).toContain(29); // May 29
});
});
describe("sorting", () => {
it("returns events sorted by occurrenceStart", () => {
const laterEvent = makeEvent({
id: "evt-later",
startTime: d("2025-06-20 14:00"),
endTime: d("2025-06-20 15:00"),
});
const earlierEvent = makeEvent({
id: "evt-earlier",
startTime: d("2025-06-05 09:00"),
endTime: d("2025-06-05 10:00"),
});
// Pass in reverse order
const result = expandRecurringEvents(
[laterEvent, earlierEvent],
rangeStart,
rangeEnd,
);
expect(result).toHaveLength(2);
expect(result[0].id).toBe("evt-earlier");
expect(result[1].id).toBe("evt-later");
});
});
});
+2 -10
View File
@@ -1,5 +1,5 @@
import { RRule, rrulestr } from "rrule"; import { RRule, rrulestr } from "rrule";
import { CalendarEvent, ExpandedEvent } from "@calchat/shared"; import { CalendarEvent, ExpandedEvent, formatDateKey } from "@calchat/shared";
// Convert local time to "fake UTC" for rrule // Convert local time to "fake UTC" for rrule
// rrule interprets all dates as UTC internally, so we need to trick it // rrule interprets all dates as UTC internally, so we need to trick it
@@ -48,7 +48,7 @@ export function expandRecurringEvents(
// to find events that start before rangeStart but extend into the range // to find events that start before rangeStart but extend into the range
const adjustedRangeStart = new Date(rangeStart.getTime() - duration); const adjustedRangeStart = new Date(rangeStart.getTime() - duration);
if (!event.isRecurring || !event.recurrenceRule) { if (!event.recurrenceRule) {
// Non-recurring event: add if it overlaps with the range // Non-recurring event: add if it overlaps with the range
if (endTime >= rangeStart && startTime <= rangeEnd) { if (endTime >= rangeStart && startTime <= rangeEnd) {
expanded.push({ expanded.push({
@@ -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}`;
}
+2 -1
View File
@@ -8,7 +8,8 @@
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"emitDecoratorMetadata": true "emitDecoratorMetadata": true,
"skipLibCheck": true
}, },
"include": ["src"] "include": ["src"]
} }
+2 -4
View File
@@ -11,15 +11,13 @@ Base URL: `/api`
|--------|----------|--------------| |--------|----------|--------------|
| POST | `/auth/login` | User Login | | POST | `/auth/login` | User Login |
| POST | `/auth/register` | User Registrierung | | POST | `/auth/register` | User Registrierung |
| POST | `/auth/refresh` | JWT Token erneuern |
| POST | `/auth/logout` | User Logout |
--- ---
## Events ## Events
### Event Endpoints (`/api/events`) ### Event Endpoints (`/api/events`)
Alle Endpoints erfordern JWT-Authentifizierung. Alle Endpoints erfordern Authentifizierung (X-User-Id Header).
| Method | Endpoint | Beschreibung | | Method | Endpoint | Beschreibung |
|--------|----------|--------------| |--------|----------|--------------|
@@ -35,7 +33,7 @@ Alle Endpoints erfordern JWT-Authentifizierung.
## Chat ## Chat
### Chat Endpoints (`/api/chat`) ### Chat Endpoints (`/api/chat`)
Alle Endpoints erfordern JWT-Authentifizierung. Alle Endpoints erfordern Authentifizierung (X-User-Id Header).
| Method | Endpoint | Beschreibung | | Method | Endpoint | Beschreibung |
|--------|----------|--------------| |--------|----------|--------------|
+50 -11
View File
@@ -30,6 +30,8 @@ package "Controller Layer" #ADD8E6 {
} }
class EventController { class EventController {
' -pushToCaldav()
' -deleteFromCaldav()
' +create() ' +create()
' +getById() ' +getById()
' +getAll() ' +getAll()
@@ -38,6 +40,15 @@ package "Controller Layer" #ADD8E6 {
' +delete() ' +delete()
} }
class CaldavController {
' +saveConfig()
' +loadConfig()
' +deleteConfig()
' +pullEvents()
' +pushEvents()
' +pushEvent()
}
class AuthMiddleware { class AuthMiddleware {
' +authenticate() ' +authenticate()
} }
@@ -59,9 +70,12 @@ package "Service Layer" #90EE90 {
' +findById() ' +findById()
' +findByUserId() ' +findByUserId()
' +findByDateRange() ' +findByDateRange()
' +findByCaldavUUID()
' +searchByTitle()
' +create() ' +create()
' +update() ' +update()
' +delete() ' +delete()
' +addExceptionDate()
} }
interface ChatRepository { interface ChatRepository {
@@ -69,6 +83,14 @@ package "Service Layer" #90EE90 {
' +createConversation() ' +createConversation()
' +getMessages() ' +getMessages()
' +createMessage() ' +createMessage()
' +updateProposalResponse()
' +updateProposalEvent()
}
interface CaldavRepository {
' +findByUserId()
' +createOrUpdate()
' +deleteByUserId()
} }
} }
@@ -80,7 +102,7 @@ package "Service Layer" #90EE90 {
class ChatService { class ChatService {
' -chatRepo: ChatRepository ' -chatRepo: ChatRepository
' -eventRepo: EventRepository ' -eventService: EventService
' -aiProvider: AIProvider ' -aiProvider: AIProvider
' +processMessage() ' +processMessage()
' +confirmEvent() ' +confirmEvent()
@@ -95,13 +117,29 @@ package "Service Layer" #90EE90 {
' +getById() ' +getById()
' +getAll() ' +getAll()
' +getByDateRange() ' +getByDateRange()
' +searchByTitle()
' +findByCaldavUUID()
' +update() ' +update()
' +delete() ' +delete()
' +deleteRecurring()
}
class CaldavService {
' -caldavRepo: CaldavRepository
' -eventService: EventService
' +connect()
' +pullEvents()
' +pushEvent()
' +pushAll()
' +deleteEvent()
' +getConfig()
' +saveConfig()
' +deleteConfig()
} }
} }
package "AI Implementations" #FFA07A { package "AI Implementations" #FFA07A {
class ClaudeAdapter implements AIProvider { class GPTAdapter implements AIProvider {
' -apiKey: string ' -apiKey: string
' +processMessage() ' +processMessage()
} }
@@ -119,6 +157,10 @@ package "Data Access Implementations" #FFD700 {
class MongoChatRepository implements ChatRepository { class MongoChatRepository implements ChatRepository {
' -model: ChatModel ' -model: ChatModel
} }
class MongoCaldavRepository implements CaldavRepository {
' -model: CaldavConfigModel
}
} }
package "Models" #D3D3D3 { package "Models" #D3D3D3 {
@@ -146,11 +188,6 @@ package "Models" #D3D3D3 {
} }
package "Utils" #DDA0DD { package "Utils" #DDA0DD {
class JWT {
' +signToken()
' +verifyToken()
}
class Password { class Password {
' +hash() ' +hash()
' +compare() ' +compare()
@@ -169,18 +206,20 @@ package "Utils" #DDA0DD {
' Controller -> Service ' Controller -> Service
AuthController --> AuthService AuthController --> AuthService
ChatController --> ChatService ChatController --> ChatService
ChatController --> CaldavService
EventController --> EventService EventController --> EventService
AuthMiddleware --> JWT EventController --> CaldavService
CaldavController --> CaldavService
' 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 --> Password AuthService --> Password
' Event/Chat uses Utils ' Event/Chat uses Utils
+27 -7
View File
@@ -16,6 +16,8 @@ package "apps/client (Expo React Native)" as ClientPkg #87CEEB {
[Login/Register] as AuthScreens [Login/Register] as AuthScreens
[Calendar View] as CalendarScreen [Calendar View] as CalendarScreen
[Chat View] as ChatScreen [Chat View] as ChatScreen
[Settings] as SettingsScreen
[Edit Event] as EditEventScreen
[Event Detail] as EventDetail [Event Detail] as EventDetail
[Note Editor] as NoteScreen [Note Editor] as NoteScreen
} }
@@ -25,17 +27,20 @@ package "apps/client (Expo React Native)" as ClientPkg #87CEEB {
[Auth Service] as ClientAuth [Auth Service] as ClientAuth
[Event Service] as ClientEvent [Event Service] as ClientEvent
[Chat Service] as ClientChat [Chat Service] as ClientChat
[Caldav Config Service] as ClientCaldav
} }
package "Components" { package "Components" {
[UI Components] as UIComponents [UI Components] as UIComponents
[Event Cards] as EventCards [Event Cards] as EventCards
[Auth Guard] as AuthGuard
} }
package "Stores" { package "Stores" {
[Auth Store] as AuthStore [Auth Store] as AuthStore
[Events Store] as EventsStore [Events Store] as EventsStore
[Chat Store] as ChatStore [Chat Store] as ChatStore
[Theme Store] as ThemeStore
} }
} }
@@ -59,10 +64,11 @@ package "apps/server (Express.js)" as ServerPkg #98FB98 {
[AuthService] as AuthSvc [AuthService] as AuthSvc
[ChatService] as ChatSvc [ChatService] as ChatSvc
[EventService] as EventSvc [EventService] as EventSvc
[CaldavService] as CaldavSvc
} }
package "AI Implementations" { package "AI Implementations" {
[ClaudeAdapter] as Claude [GPTAdapter] as GPT
} }
package "Data Access Implementations" { package "Data Access Implementations" {
@@ -71,7 +77,6 @@ package "apps/server (Express.js)" as ServerPkg #98FB98 {
} }
package "Utils" { package "Utils" {
[JWT] as JWTUtil
[Password] as PwdUtil [Password] as PwdUtil
[RecurrenceExpander] as RecExpander [RecurrenceExpander] as RecExpander
[EventFormatters] as EvtFormatters [EventFormatters] as EvtFormatters
@@ -80,25 +85,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,20 +136,24 @@ 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
AuthSvc --> JWTUtil
AuthSvc --> PwdUtil AuthSvc --> PwdUtil
Middleware --> JWTUtil
EventSvc --> RecExpander EventSvc --> RecExpander
ChatSvc --> EvtFormatters ChatSvc --> EvtFormatters
@@ -143,6 +162,7 @@ Repos --> Schemas
' Backend -> External ' Backend -> External
Schemas --> MongoDB Schemas --> MongoDB
Claude --> ClaudeAPI GPT --> OpenAIAPI
CaldavSvc --> CaldavServer
@enduml @enduml
+76 -40
View File
@@ -12,58 +12,73 @@ skinparam wrapWidth 100
skinparam nodesep 30 skinparam nodesep 30
skinparam ranksep 30 skinparam ranksep 30
top to bottom direction left to right direction
title Frontend (Expo React Native) title Frontend (Expo React Native)
' ===== COMPONENTS =====
package "Components" #FFA07A {
class AuthGuard
class BaseBackground
class Header
class BaseButton
class CardBase
class ModalBase
class EventCardBase
class EventCard
class ProposedEventCard
class DeleteEventModal
class ChatBubble
class TypingIndicator
}
' ===== SCREENS ===== ' ===== SCREENS =====
package "Screens" #87CEEB { package "Screens" #87CEEB {
class LoginScreen class LoginScreen
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 =====
package "Components" #FFA07A {
class BaseBackground
class Header
class EventCardBase
class EventCard
class ProposedEventCard
class EventConfirmDialog
}
' ===== SERVICES ===== ' ===== SERVICES =====
package "Services" #90EE90 { package "Services" #90EE90 {
class ApiClient { class ApiClient {
+get() ' +get()
+post() ' +post()
+put() ' +put()
+delete() ' +delete()
} }
class AuthService { class AuthService {
+login() ' +login()
+register() ' +register()
+logout() ' +logout()
+refresh() ' +refresh()
} }
class EventService { class EventService {
+getAll() ' +getAll()
+getById() ' +getById()
+getByDateRange() ' +getByDateRange()
+create() ' +create()
+update() ' +update()
+delete() ' +delete()
} }
class ChatService { class ChatService {
+sendMessage() ' +sendMessage()
+confirmEvent() ' +confirmEvent()
+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,31 +117,47 @@ package "Models (shared)" #D3D3D3 {
class User class User
class CalendarEvent class CalendarEvent
class ChatMessage class ChatMessage
class CaldavConfig
} }
' ===== RELATIONSHIPS ===== ' ===== RELATIONSHIPS =====
' Screens -> Services
LoginScreen --> AuthService
CalendarScreen --> EventService
ChatScreen --> ChatService
NoteScreen --> EventService
' 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
' Screens -> Services
LoginScreen --> AuthService
CalendarScreen --> EventService
CalendarScreen --> CaldavConfigService
ChatScreen --> ChatService
NoteScreen --> EventService
EditEventScreen --> EventService
EditEventScreen --> ChatService
SettingsScreen --> CaldavConfigService
' 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
+5 -4
View File
@@ -66,7 +66,7 @@ Backend & Express.js & Web-App Framework \\
& MongoDB & Datenbank \\ & MongoDB & Datenbank \\
& Mongoose & ODM \\ & Mongoose & ODM \\
& Claude (Anthropic) & KI / LLM \\ & Claude (Anthropic) & KI / LLM \\
& JWT & Authentifizierung \\ & X-User-Id Header & Authentifizierung \\
\hline \hline
Geplant & iCalendar & Event-Export \\ Geplant & iCalendar & Event-Export \\
\hline \hline
@@ -112,8 +112,9 @@ Der wichtigste Teil der App ist die KI-Integration über \textbf{Claude}
(Anthropic). Dieses LLM verarbeitet natürlichsprachliche Eingaben der Nutzer und (Anthropic). Dieses LLM verarbeitet natürlichsprachliche Eingaben der Nutzer und
generiert daraus strukturierte Event-Vorschläge. generiert daraus strukturierte Event-Vorschläge.
Die Authentifizierung läuft über \textbf{JSON Web Tokens} (JWT). Der Vorteil: Die Authentifizierung erfolgt über einen \textbf{X-User-Id Header}, der bei
zustandslose Sessions, bei denen der Server keine Session-Daten speichern muss. jedem Request die User-ID mitschickt. Diese einfache Lösung reicht für den
aktuellen Entwicklungsstand aus.
Geplant ist außerdem die Unterstützung des \textbf{iCalendar}-Formats (ICAL) Geplant ist außerdem die Unterstützung des \textbf{iCalendar}-Formats (ICAL)
für den Export von Kalender-Events. für den Export von Kalender-Events.
@@ -157,7 +158,7 @@ Notiz-Feld) und ChatMessage.
Der Controller Layer bildet die Schnittstelle zwischen Frontend und Der Controller Layer bildet die Schnittstelle zwischen Frontend und
Backend-Logik. Die Routes definieren die API-Endpunkte, die Controller Backend-Logik. Die Routes definieren die API-Endpunkte, die Controller
verarbeiten die eingehenden Requests und reichen diese an die Services weiter. verarbeiten die eingehenden Requests und reichen diese an die Services weiter.
Eine Auth Middleware prüft bei geschützten Routen den JWT-Token. Eine Auth Middleware prüft bei geschützten Routen den X-User-Id Header.
\subsubsection{Service Layer} \subsubsection{Service Layer}
+6043 -100
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -7,7 +7,8 @@
"packages/*" "packages/*"
], ],
"scripts": { "scripts": {
"format": "prettier --write \"apps/*/src/**/*.{ts,tsx}\" \"packages/*/src/**/*.ts\"" "format": "prettier --write \"apps/*/src/**/*.{ts,tsx}\" \"packages/*/src/**/*.ts\"",
"check_format": "prettier --check \"apps/*/src/**/*.{ts,tsx}\" \"packages/*/src/**/*.ts\""
}, },
"devDependencies": { "devDependencies": {
"eslint": "^9.25.0", "eslint": "^9.25.0",
+1
View File
@@ -0,0 +1 @@
dist/
+7 -4
View File
@@ -2,11 +2,14 @@
"name": "@calchat/shared", "name": "@calchat/shared",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"main": "./src/index.ts", "main": "./dist/index.js",
"types": "./src/index.ts", "types": "./dist/index.d.ts",
"exports": { "exports": {
".": "./src/index.ts", ".": "./dist/index.js",
"./*": "./src/*" "./*": "./dist/*"
},
"scripts": {
"build": "tsc"
}, },
"dependencies": { "dependencies": {
"rrule": "^2.8.1" "rrule": "^2.8.1"
@@ -0,0 +1,7 @@
export interface CaldavConfig {
userId: string;
serverUrl: string;
username: string;
password: string;
syncIntervalSeconds?: number;
}
+10 -1
View File
@@ -1,19 +1,22 @@
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;
endTime: Date; endTime: Date;
note?: string; note?: string;
isRecurring?: boolean;
recurrenceRule?: string; recurrenceRule?: string;
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;
@@ -21,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;
@@ -28,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;
@@ -38,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 {
+1
View File
@@ -2,3 +2,4 @@ export * from "./User";
export * from "./CalendarEvent"; export * from "./CalendarEvent";
export * from "./ChatMessage"; export * from "./ChatMessage";
export * from "./Constants"; export * from "./Constants";
export * from "./CaldavConfig";
+8
View File
@@ -69,3 +69,11 @@ export function formatDateWithWeekdayShort(date: Date): string {
month: "2-digit", month: "2-digit",
}); });
} }
// Format date as YYYY-MM-DD for exception date comparison
export function formatDateKey(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
+49 -1
View File
@@ -11,6 +11,24 @@ const REPEAT_TYPE_TO_FREQ: Record<RepeatType, string> = {
Jahr: "YEARLY", Jahr: "YEARLY",
}; };
const FREQ_TO_REPEAT_TYPE: Record<string, RepeatType> = Object.fromEntries(
Object.entries(REPEAT_TYPE_TO_FREQ).map(([k, v]) => [v, k as RepeatType]),
);
export const REPEAT_TYPE_LABELS: Record<RepeatType, string> = {
Tag: "Tage",
Woche: "Wochen",
Monat: "Monate",
Jahr: "Jahre",
};
const REPEAT_TYPE_SINGULAR: Record<RepeatType, string> = {
Tag: "Jeden Tag",
Woche: "Jede Woche",
Monat: "Jeden Monat",
Jahr: "Jedes Jahr",
};
/** /**
* Build an RRULE string from repeat count and type. * Build an RRULE string from repeat count and type.
* *
@@ -18,7 +36,10 @@ const REPEAT_TYPE_TO_FREQ: Record<RepeatType, string> = {
* @param interval - The interval between repetitions (default: 1) * @param interval - The interval between repetitions (default: 1)
* @returns RRULE string like "FREQ=WEEKLY;INTERVAL=2" * @returns RRULE string like "FREQ=WEEKLY;INTERVAL=2"
*/ */
export function buildRRule(repeatType: RepeatType, interval: number = 1): string { export function buildRRule(
repeatType: RepeatType,
interval: number = 1,
): string {
const freq = REPEAT_TYPE_TO_FREQ[repeatType]; const freq = REPEAT_TYPE_TO_FREQ[repeatType];
if (interval <= 1) { if (interval <= 1) {
@@ -27,3 +48,30 @@ export function buildRRule(repeatType: RepeatType, interval: number = 1): string
return `FREQ=${freq};INTERVAL=${interval}`; return `FREQ=${freq};INTERVAL=${interval}`;
} }
/**
* Format an RRULE string into a human-readable German description.
*
* @param rrule - RRULE string like "FREQ=WEEKLY;INTERVAL=2"
* @returns German description like "Alle 2 Wochen" or "Wöchentlich"
*/
export function formatRecurrenceRule(rrule: string): string {
const rule = rrule.replace(/^RRULE:/, "");
const parts = Object.fromEntries(
rule.split(";").map((p) => p.split("=") as [string, string]),
);
const freq = parts.FREQ;
const interval = parts.INTERVAL ? parseInt(parts.INTERVAL, 10) : 1;
const repeatType = FREQ_TO_REPEAT_TYPE[freq];
if (!repeatType) {
return "Wiederkehrend";
}
if (interval <= 1) {
return REPEAT_TYPE_SINGULAR[repeatType];
}
return `Alle ${interval} ${REPEAT_TYPE_LABELS[repeatType]}`;
}
+3 -3
View File
@@ -1,10 +1,10 @@
{ {
"extends": "../../tsconfig.json", "extends": "../../tsconfig.json",
"compilerOptions": { "compilerOptions": {
"composite": true,
"declaration": true, "declaration": true,
"declarationMap": true, "outDir": "dist",
"module": "ESNext", "rootDir": "src",
"module": "CommonJS",
"target": "ES2020", "target": "ES2020",
"moduleResolution": "Node", "moduleResolution": "Node",
"esModuleInterop": true, "esModuleInterop": true,