docs: archive implemented plans, specs, and design documents

Move completed implementation plans (2026-04-20), design specs,
and audit documents to docs/archive/ for historical reference
while keeping the main docs/ directory focused on active
documentation.

Archived:
- 1 implementation plan (superpowers/plans/)
- 2 design specs (superpowers/specs/)
- 3 design documents (designs/)
- 5 audit/proposal documents (root level)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Ulas Kalayci
2026-05-04 20:09:23 +02:00
parent 7349037880
commit dd16078f7a
11 changed files with 0 additions and 0 deletions
@@ -0,0 +1,910 @@
# CardDAV API Routes — Implementation Design
**Date:** 2026-05-04
**Status:** Approved
**Related:** [CardDAV Contacts Design](../../designs/2026-05-04-cardav-contacts-design.md)
## Überblick
Implementierung von 11 API Routes für CardDAV Contacts Integration:
- 8 neue CardDAV Management Routes (Account CRUD, Addressbook Discovery, Sync)
- 3 erweiterte Contacts Routes (Multi-Value-Felder: phones, emails, addresses)
## Entscheidungen
### Route-Organisation
- **CardDAV Management Routes:** Neue Datei `server/routes/cardav.js`
- **Extended Contacts Routes:** Existierende `server/routes/contacts.js` erweitern
- **Rationale:** Klare Trennung (Contact CRUD vs. CardDAV Management), folgt Oikos One-Router-Per-Module Pattern
### Implementierungs-Reihenfolge
**User Flow Approach:**
1. Account Management (POST/GET/DELETE)
2. Connection Test
3. Addressbook Discovery & Toggle
4. Sync Operations
5. Extended Contacts Routes
**Rationale:** Natürliche User Journey, einfacher zu testen
### Architektur
**Route-Level Validation mit Service Delegation:**
- Routes validieren Input mit `validate.js` Middleware
- Routes delegieren Business Logic an `cardav-sync.js`
- **Rationale:** Konsistent mit existierenden Oikos-Routes, bessere User-facing Error Messages
### Error Handling
**Einfaches Fallback:**
```javascript
catch (err) {
log.error('CardDAV error:', err);
res.status(500).json({ error: err.message || 'Interner Fehler', code: 500 });
}
```
**Rationale:** Funktioniert sofort, Error-Klassen können später eingeführt werden
---
## File Structure
### Neue Dateien
- `server/routes/cardav.js` — CardDAV Management Routes
### Geänderte Dateien
- `server/routes/contacts.js` — Extended Contacts Routes (Multi-Values)
- `server/index.js` — Mount cardav.js Router
- `server/openapi.js` — 11 neue Path Definitionen
- `test-carddav.js` — API Route Tests
### Mount Point
```javascript
// server/index.js
import cardavRouter from './routes/cardav.js';
app.use('/api/v1/contacts/cardav', cardavRouter);
```
Alle CardDAV-Routes unter `/api/v1/contacts/cardav/*`, Extended Contacts unter `/api/v1/contacts/*`.
---
## Route Definitions
### CardDAV Management Routes
#### 1. POST /api/v1/contacts/cardav/accounts
**Zweck:** Account erstellen und Addressbooks discovern
**Request:**
```json
{
"name": "iCloud",
"cardavUrl": "https://contacts.icloud.com",
"username": "user@icloud.com",
"password": "app-specific-password"
}
```
**Validation:**
- `name`: str, max MAX_TITLE, required
- `cardavUrl`: str, max MAX_URL, required
- `username`: str, max MAX_TITLE, required
- `password`: str, max MAX_TITLE, required
**Service Call:**
```javascript
const result = await CardDAVSync.addAccount(name, cardavUrl, username, password);
```
**Response:** `201 Created`
```json
{
"data": {
"account": {
"id": 1,
"name": "iCloud",
"cardavUrl": "https://contacts.icloud.com",
"username": "user@icloud.com",
"lastSync": null
},
"addressbooks": [
{ "id": 1, "url": "https://...", "name": "Personal", "enabled": 1 }
]
}
}
```
---
#### 2. GET /api/v1/contacts/cardav/accounts
**Zweck:** Alle Accounts auflisten
**Service Call:**
```javascript
const accounts = await CardDAVSync.getAllAccounts();
```
**Response:** `200 OK`
```json
{
"data": [
{
"id": 1,
"name": "iCloud",
"cardavUrl": "https://contacts.icloud.com",
"username": "user@icloud.com",
"lastSync": "2026-05-04T10:30:00Z"
}
]
}
```
---
#### 3. DELETE /api/v1/contacts/cardav/accounts/:id
**Zweck:** Account löschen (CASCADE löscht addressbooks + contacts)
**Validation:**
- `id`: parseInt, must be > 0
**Service Call:**
```javascript
await CardDAVSync.deleteAccount(id);
```
**Response:** `200 OK`
```json
{
"data": { "deleted": true }
}
```
---
#### 4. POST /api/v1/contacts/cardav/accounts/:id/test
**Zweck:** Connection testen (ohne Account zu erstellen)
**Validation:**
- `id`: parseInt, must be > 0
**Logic:**
1. Account aus DB laden
2. `testConnection(cardavUrl, username, password)` aufrufen
**Response:** `200 OK`
```json
{
"data": {
"ok": true,
"addressbooks": [...]
}
}
```
---
#### 5. GET /api/v1/contacts/cardav/accounts/:id/addressbooks
**Zweck:** Addressbooks für Account auflisten
**Validation:**
- `id`: parseInt, must be > 0
**DB Query:**
```sql
SELECT id, addressbook_url as url, addressbook_name as name, enabled
FROM carddav_addressbook_selection
WHERE account_id = ?
ORDER BY addressbook_name
```
**Response:** `200 OK`
```json
{
"data": [
{ "id": 1, "url": "https://...", "name": "Personal", "enabled": 1 },
{ "id": 2, "url": "https://...", "name": "Work", "enabled": 0 }
]
}
```
---
#### 6. POST /api/v1/contacts/cardav/accounts/:id/addressbooks/refresh
**Zweck:** Addressbooks neu discovern (PROPFIND)
**Validation:**
- `id`: parseInt, must be > 0
**Logic:**
1. Account aus DB laden
2. `discoverAddressbooks(account)` aufrufen
3. Addressbooks aus DB neu laden
**Response:** `200 OK`
```json
{
"data": [
{ "id": 1, "url": "https://...", "name": "Personal", "enabled": 1 }
]
}
```
---
#### 7. PUT /api/v1/contacts/cardav/addressbooks/:id
**Zweck:** Addressbook enable/disable
**Request:**
```json
{
"enabled": true
}
```
**Validation:**
- `id`: parseInt, must be > 0
- `enabled`: bool, required
**Service Call:**
```javascript
await CardDAVSync.toggleAddressbook(id, enabled);
```
**Response:** `200 OK`
```json
{
"data": { "id": 1, "enabled": true }
}
```
---
#### 8. POST /api/v1/contacts/cardav/accounts/:id/sync
**Zweck:** Account syncen (alle enabled addressbooks)
**Validation:**
- `id`: parseInt, must be > 0
**Logic:**
1. Account aus DB laden
2. `syncAccount(account)` aufrufen
**Response:** `200 OK`
```json
{
"data": {
"synced": 15,
"errors": 0
}
}
```
---
### Extended Contacts Routes
#### 9. GET /api/v1/contacts/:id
**Zweck:** Kontakt mit allen Multi-Value-Feldern laden
**Validation:**
- `id`: parseInt, must be > 0
**DB Queries:**
```sql
SELECT * FROM contacts WHERE id = ?;
SELECT * FROM contact_phones WHERE contact_id = ?;
SELECT * FROM contact_emails WHERE contact_id = ?;
SELECT * FROM contact_addresses WHERE contact_id = ?;
```
**Response:** `200 OK`
```json
{
"data": {
"id": 1,
"name": "Alice Smith",
"category": "Sonstiges",
"organization": "Tech Corp",
"jobTitle": "Developer",
"birthday": "1990-01-15",
"website": "https://alice.dev",
"nickname": "Ali",
"notes": "Great developer",
"cardavAccountId": 1,
"cardavUid": "urn:uuid:alice-123",
"phones": [
{ "id": 1, "label": "mobile", "value": "+1234567890", "isPrimary": 1 },
{ "id": 2, "label": "work", "value": "+0987654321", "isPrimary": 0 }
],
"emails": [
{ "id": 1, "label": "home", "value": "alice@home.com", "isPrimary": 1 }
],
"addresses": [
{
"id": 1,
"label": "home",
"street": "123 Main St",
"city": "Springfield",
"state": "IL",
"postalCode": "62701",
"country": "USA",
"isPrimary": 1
}
]
}
}
```
---
#### 10. POST /api/v1/contacts
**Zweck:** Kontakt mit Multi-Values erstellen
**Request:**
```json
{
"name": "Bob Jones",
"category": "Sonstiges",
"phones": [
{ "label": "mobile", "value": "+1111111111", "isPrimary": true }
],
"emails": [
{ "label": "work", "value": "bob@work.com", "isPrimary": true }
],
"addresses": []
}
```
**Validation:**
- `name`: str, max MAX_TITLE, required
- `category`: oneOf(VALID_CATEGORIES), default 'Sonstiges'
- `phones`: validatePhones() (siehe Validation Schema)
- `emails`: validateEmails()
- `addresses`: validateAddresses()
- Alle anderen Felder optional
**Logic (Transaction):**
```javascript
const transaction = db.get().transaction(() => {
// 1. INSERT contact
const result = db.get().prepare(`
INSERT INTO contacts (name, category, ...)
VALUES (?, ?, ...)
`).run(...);
const contactId = result.lastInsertRowid;
// 2. INSERT phones (bulk)
if (phones?.length) {
const placeholders = phones.map(() => '(?, ?, ?, ?)').join(', ');
const values = phones.flatMap(p => [contactId, p.label, p.value, p.isPrimary ? 1 : 0]);
db.get().prepare(`INSERT INTO contact_phones (...) VALUES ${placeholders}`).run(...values);
}
// 3. INSERT emails (bulk)
// 4. INSERT addresses (bulk)
return contactId;
});
const contactId = transaction();
```
**Response:** `201 Created`
```json
{
"data": { /* Contact mit allen Multi-Values */ }
}
```
---
#### 11. PUT /api/v1/contacts/:id
**Zweck:** Kontakt mit Multi-Values updaten
**Request:**
```json
{
"name": "Bob Jones Updated",
"phones": [
{ "label": "mobile", "value": "+2222222222", "isPrimary": true }
]
}
```
**Validation:**
- `id`: parseInt, must be > 0
- Alle Felder optional (nur gesendete werden geupdatet)
- **Multi-Value-Felder (phones/emails/addresses):** REPLACEMENT-Semantik — wenn gesendet, werden ALLE existierenden Werte gelöscht und durch die gesendeten ersetzt. Client muss vollständiges Array schicken, nicht nur Änderungen.
**Logic (Transaction):**
```javascript
const transaction = db.get().transaction(() => {
// 1. UPDATE contacts (nur gesendete Felder)
const updates = [];
const params = [];
if (req.body.name !== undefined) {
updates.push('name = ?');
params.push(req.body.name);
}
// ... andere Felder
params.push(id);
db.get().prepare(`UPDATE contacts SET ${updates.join(', ')} WHERE id = ?`).run(...params);
// 2. Wenn phones gesendet: DELETE + INSERT
if (req.body.phones !== undefined) {
db.get().prepare('DELETE FROM contact_phones WHERE contact_id = ?').run(id);
// ... bulk INSERT wie in POST
}
// 3. Wenn emails gesendet: DELETE + INSERT
// 4. Wenn addresses gesendet: DELETE + INSERT
});
transaction();
```
**Response:** `200 OK`
```json
{
"data": { /* Updated Contact mit allen Multi-Values */ }
}
```
---
## Validation Schema
### CardDAV Routes
```javascript
import { str, bool, collectErrors, MAX_TITLE, MAX_URL } from '../middleware/validate.js';
// POST /accounts
const vName = str(req.body.name, 'Name', { max: MAX_TITLE });
const vUrl = str(req.body.cardavUrl, 'CardDAV URL', { max: MAX_URL });
const vUsername = str(req.body.username, 'Username', { max: MAX_TITLE });
const vPassword = str(req.body.password, 'Password', { max: MAX_TITLE });
const errors = collectErrors([vName, vUrl, vUsername, vPassword]);
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
// PUT /addressbooks/:id
const vEnabled = bool(req.body.enabled, 'Enabled');
if (vEnabled.error) return res.status(400).json({ error: vEnabled.error, code: 400 });
// Alle :id params
const id = parseInt(req.params.id, 10);
if (!id || id < 1) return res.status(400).json({ error: 'Invalid ID', code: 400 });
```
### Extended Contacts Routes
**Multi-Value Array Validators:**
```javascript
// phones: [{ label, value, isPrimary? }]
function validatePhones(phones) {
if (!Array.isArray(phones)) return { valid: false, error: 'Phones must be an array' };
for (let p of phones) {
if (!p.label || !p.value) return { valid: false, error: 'Phone requires label and value' };
if (typeof p.label !== 'string' || p.label.length > 50) {
return { valid: false, error: 'Phone label invalid or too long' };
}
if (typeof p.value !== 'string' || p.value.length > 50) {
return { valid: false, error: 'Phone value invalid or too long' };
}
}
return { valid: true };
}
// emails: [{ label, value, isPrimary? }]
function validateEmails(emails) {
if (!Array.isArray(emails)) return { valid: false, error: 'Emails must be an array' };
for (let e of emails) {
if (!e.label || !e.value) return { valid: false, error: 'Email requires label and value' };
if (typeof e.label !== 'string' || e.label.length > 50) {
return { valid: false, error: 'Email label invalid or too long' };
}
if (typeof e.value !== 'string' || e.value.length > 255) {
return { valid: false, error: 'Email value invalid or too long' };
}
}
return { valid: true };
}
// addresses: [{ label, street?, city?, state?, postalCode?, country?, isPrimary? }]
function validateAddresses(addresses) {
if (!Array.isArray(addresses)) return { valid: false, error: 'Addresses must be an array' };
for (let a of addresses) {
if (!a.label) return { valid: false, error: 'Address requires label' };
if (typeof a.label !== 'string' || a.label.length > 50) {
return { valid: false, error: 'Address label invalid or too long' };
}
// street, city, state, postalCode, country sind optional
// Wenn vorhanden: Type-Check + Max-Length (255 für Text-Felder)
const fields = ['street', 'city', 'state', 'postalCode', 'country'];
for (let field of fields) {
if (a[field] !== undefined && (typeof a[field] !== 'string' || a[field].length > 255)) {
return { valid: false, error: `Address ${field} invalid or too long` };
}
}
}
return { valid: true };
}
```
**Usage in Routes:**
```javascript
// POST/PUT /contacts
if (req.body.phones !== undefined) {
const phoneCheck = validatePhones(req.body.phones);
if (!phoneCheck.valid) return res.status(400).json({ error: phoneCheck.error, code: 400 });
}
if (req.body.emails !== undefined) {
const emailCheck = validateEmails(req.body.emails);
if (!emailCheck.valid) return res.status(400).json({ error: emailCheck.error, code: 400 });
}
if (req.body.addresses !== undefined) {
const addressCheck = validateAddresses(req.body.addresses);
if (!addressCheck.valid) return res.status(400).json({ error: addressCheck.error, code: 400 });
}
```
---
## Service Integration
### Import Pattern
```javascript
// server/routes/cardav.js
import { createLogger } from '../logger.js';
import express from 'express';
import * as db from '../db.js';
import * as CardDAVSync from '../services/cardav-sync.js';
import { str, bool, collectErrors, MAX_TITLE, MAX_URL } from '../middleware/validate.js';
const log = createLogger('CardDAV');
const router = express.Router();
```
### Service Call Examples
**Async/Await Pattern:**
```javascript
router.post('/accounts', async (req, res) => {
try {
// Validation...
const result = await CardDAVSync.addAccount(
vName.value,
vUrl.value,
vUsername.value,
vPassword.value
);
res.status(201).json({ data: result });
} catch (err) {
log.error('Error adding CardDAV account:', err);
res.status(500).json({ error: err.message || 'Interner Fehler', code: 500 });
}
});
```
**DB-Direct Queries** (wo kein Service existiert):
```javascript
router.get('/accounts/:id/addressbooks', async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (!id) return res.status(400).json({ error: 'Invalid ID', code: 400 });
const addressbooks = db.get().prepare(`
SELECT id, addressbook_url as url, addressbook_name as name, enabled
FROM carddav_addressbook_selection
WHERE account_id = ?
ORDER BY addressbook_name
`).all(id);
res.json({ data: addressbooks });
} catch (err) {
log.error('Error fetching addressbooks:', err);
res.status(500).json({ error: err.message, code: 500 });
}
});
```
### Transaction Handling
**Extended Contacts Routes** nutzen Transactions für atomare Multi-Value-Updates:
```javascript
router.post('/', async (req, res) => {
try {
// Validation...
const transaction = db.get().transaction(() => {
// 1. Insert contact
const result = db.get().prepare(`
INSERT INTO contacts (name, category, organization, job_title, birthday, website, nickname, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
vName.value,
vCategory.value || 'Sonstiges',
req.body.organization || null,
req.body.jobTitle || null,
req.body.birthday || null,
req.body.website || null,
req.body.nickname || null,
req.body.notes || null
);
const contactId = result.lastInsertRowid;
// 2. Insert phones (bulk)
if (req.body.phones?.length) {
const phonePlaceholders = req.body.phones.map(() => '(?, ?, ?, ?)').join(', ');
const phoneValues = req.body.phones.flatMap(p => [
contactId,
p.label,
p.value,
p.isPrimary ? 1 : 0
]);
db.get().prepare(`
INSERT INTO contact_phones (contact_id, label, value, is_primary)
VALUES ${phonePlaceholders}
`).run(...phoneValues);
}
// 3. Insert emails (analog)
// 4. Insert addresses (analog)
return contactId;
});
const contactId = transaction();
// Fetch full contact with multi-values
const contact = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(contactId);
const phones = db.get().prepare('SELECT * FROM contact_phones WHERE contact_id = ?').all(contactId);
const emails = db.get().prepare('SELECT * FROM contact_emails WHERE contact_id = ?').all(contactId);
const addresses = db.get().prepare('SELECT * FROM contact_addresses WHERE contact_id = ?').all(contactId);
res.status(201).json({
data: {
...contact,
phones,
emails,
addresses
}
});
} catch (err) {
log.error('Error creating contact:', err);
res.status(500).json({ error: err.message, code: 500 });
}
});
```
---
## OpenAPI Integration
Alle 11 Routes werden in `server/openapi.js` dokumentiert:
```javascript
// CardDAV Management Routes
'/api/v1/contacts/cardav/accounts': {
get: op({ summary: 'List CardDAV accounts', tag: 'Contacts' }),
post: op({ summary: 'Add CardDAV account', tag: 'Contacts', stateChanging: true, requestBody: jsonBody(null) }),
},
'/api/v1/contacts/cardav/accounts/{id}': {
delete: op({ summary: 'Delete CardDAV account', tag: 'Contacts', params: [idParam()], stateChanging: true }),
},
'/api/v1/contacts/cardav/accounts/{id}/test': {
post: op({ summary: 'Test CardDAV connection', tag: 'Contacts', params: [idParam()] }),
},
'/api/v1/contacts/cardav/accounts/{id}/addressbooks': {
get: op({ summary: 'List addressbooks for account', tag: 'Contacts', params: [idParam()] }),
},
'/api/v1/contacts/cardav/accounts/{id}/addressbooks/refresh': {
post: op({ summary: 'Refresh addressbooks for account', tag: 'Contacts', params: [idParam()], stateChanging: true }),
},
'/api/v1/contacts/cardav/addressbooks/{id}': {
put: op({ summary: 'Toggle addressbook enabled state', tag: 'Contacts', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
},
'/api/v1/contacts/cardav/accounts/{id}/sync': {
post: op({ summary: 'Sync CardDAV account', tag: 'Contacts', params: [idParam()], stateChanging: true }),
},
// Extended Contacts Routes (ersetzen existierende Definitionen)
'/api/v1/contacts/{id}': {
get: op({ summary: 'Get contact with multi-value fields', tag: 'Contacts', params: [idParam()] }),
put: op({ summary: 'Update contact with multi-value fields', tag: 'Contacts', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
delete: op({ summary: 'Delete contact', tag: 'Contacts', params: [idParam()], stateChanging: true }),
},
'/api/v1/contacts': {
get: op({ summary: 'List contacts', tag: 'Contacts' }),
post: op({ summary: 'Create contact with multi-value fields', tag: 'Contacts', stateChanging: true, requestBody: jsonBody(null) }),
},
```
**Hinweis:** Alle Routes bleiben unter dem `'Contacts'` Tag für konsistente Swagger-Gruppierung.
---
## Testing Strategy
### Test File Structure
Neue Suite in `test-carddav.js`:
```javascript
describe('CardDAV API Routes', () => {
describe('Account Management', () => {
it('POST /accounts - should create account and discover addressbooks');
it('POST /accounts - should return 400 for missing fields');
it('GET /accounts - should list all accounts');
it('GET /accounts - should return empty array when no accounts');
it('DELETE /accounts/:id - should delete account and cascade addressbooks');
it('DELETE /accounts/:id - should return 400 for invalid ID');
it('POST /accounts/:id/test - should test connection');
});
describe('Addressbook Management', () => {
it('GET /accounts/:id/addressbooks - should list addressbooks');
it('GET /accounts/:id/addressbooks - should return empty array when none');
it('POST /accounts/:id/addressbooks/refresh - should refresh addressbooks');
it('PUT /addressbooks/:id - should enable addressbook');
it('PUT /addressbooks/:id - should disable addressbook');
it('PUT /addressbooks/:id - should return 400 for missing enabled field');
});
describe('Sync Operations', () => {
it('POST /accounts/:id/sync - should return sync result structure');
});
describe('Extended Contacts Routes', () => {
it('POST /contacts - should create contact with phones/emails/addresses');
it('POST /contacts - should create contact without multi-values');
it('POST /contacts - should return 400 for invalid phone array');
it('POST /contacts - should return 400 for invalid email array');
it('GET /contacts/:id - should return contact with all multi-values');
it('GET /contacts/:id - should return 404 for non-existent contact');
it('PUT /contacts/:id - should update contact and replace phones');
it('PUT /contacts/:id - should update contact and keep existing multi-values if not sent');
it('PUT /contacts/:id - should handle transaction rollback on error');
});
});
```
### Testing Approach
**Direct Handler Testing:**
```javascript
// Mock Express req/res
function mockRequest(body = {}, params = {}, query = {}) {
return { body, params, query };
}
function mockResponse() {
const res = {};
res.status = (code) => { res.statusCode = code; return res; };
res.json = (data) => { res.data = data; return res; };
return res;
}
// Example Test
it('POST /accounts - should create account', async () => {
const req = mockRequest({
name: 'Test Account',
cardavUrl: 'https://example.com/carddav',
username: 'user',
password: 'pass'
});
const res = mockResponse();
// Note: Actual handler testing requires importing route handlers
// This is simplified pseudo-code
assert.strictEqual(res.statusCode, 201);
assert.ok(res.data.data.account);
});
```
### Mocking External CardDAV
**Strategie:** Tests fokussieren auf HTTP-Layer (Validation, Response Format, DB-Operations).
Integration Tests für `cardav-sync.js` existieren bereits (Task #2), daher müssen API Route Tests nicht externe CardDAV-Server mocken.
**Für Sync/Discovery Routes:**
- Setup: Account + Addressbooks direkt in DB anlegen
- Test: Response-Struktur validieren
- Skip: Echte PROPFIND/REPORT Requests
### Test Coverage Goals
- ✅ Alle 11 Routes: mindestens 1 Happy-Path-Test
- ✅ Validation Errors (400) für alle POST/PUT Routes
- ✅ Not Found (404) für invalide IDs
- ✅ Multi-Value-Arrays korrekt gespeichert/geladen
- ✅ Transaction Rollback bei Fehlern
- ✅ Error Messages sind user-facing (nicht technische Stack Traces)
---
## Implementation Order
### Phase 1: CardDAV Management (Routes 1-3)
1. POST /accounts — Account erstellen
2. GET /accounts — Accounts auflisten
3. DELETE /accounts/:id — Account löschen
**Tests:** Account CRUD Happy Paths + Validation Errors
---
### Phase 2: Connection & Discovery (Routes 4-6)
4. POST /accounts/:id/test — Connection testen
5. GET /accounts/:id/addressbooks — Addressbooks auflisten
6. POST /accounts/:id/addressbooks/refresh — Addressbooks refreshen
**Tests:** Discovery Flow + Error Handling
---
### Phase 3: Addressbook Toggle & Sync (Routes 7-8)
7. PUT /addressbooks/:id — Addressbook togglen
8. POST /accounts/:id/sync — Sync triggern
**Tests:** Toggle + Sync Response Structure
---
### Phase 4: Extended Contacts (Routes 9-11)
9. GET /contacts/:id — Mit Multi-Values
10. POST /contacts — Mit Multi-Values erstellen
11. PUT /contacts/:id — Mit Multi-Values updaten
**Tests:** Multi-Value CRUD + Transaction Safety
---
## Next Steps
Nach Approval dieses Designs:
1. **Invoke `writing-plans` skill** — Detaillierten Implementation Plan erstellen
2. **TDD Approach** — Tests vor Implementation schreiben
3. **Code Review** nach jeder Phase
---
## Anhang: Service Functions Reference
Aus `server/services/cardav-sync.js`:
**Account Management:**
- `addAccount(name, cardavUrl, username, password)``{ account, addressbooks }`
- `getAllAccounts()``Account[]`
- `deleteAccount(accountId)``void`
- `testConnection(cardavUrl, username, password)``{ ok, addressbooks }`
**Addressbook Discovery:**
- `discoverAddressbooks(account)``Addressbook[]`
- `toggleAddressbook(addressbookId, enabled)``void`
**Contact Sync:**
- `syncAccount(account)``{ synced, errors }`
- `syncAddressbook(account, addressbook)``void`
- `parseAndMergeContact(vCardText, accountId, addressbookUrl)``void`
**Helpers:**
- `parseVCard(vCardText)``ContactData`
@@ -0,0 +1,506 @@
# CardDAV Contacts Sync Design
**Issue:** #10 CardDAV provider for Contacts
**Date:** 2026-05-04
**Status:** Approved
## Overview
Enable multi-account CardDAV synchronization for the Contacts module, allowing family members to sync their phone contacts into Oikos. This implements inbound-only sync (CardDAV → Oikos) with smart merging, multiple values per contact (phones, emails, addresses), and per-account addressbook selection.
## Requirements Summary
Based on Issue #10 and design discussion:
1. **Multi-Account Support** Connect multiple CardDAV servers simultaneously (iCloud, Nextcloud, company servers)
2. **Addressbook Selection** Checkbox-based enable/disable per addressbook (like CalDAV calendar selection)
3. **Inbound-Only Sync** CardDAV → Oikos; no outbound sync (read-only from server perspective)
4. **Smart Merge** Match by email/phone; update existing contacts instead of creating duplicates
5. **Editable with Merge** Synced contacts are editable in Oikos; manual changes preserved (only NULL fields filled on sync)
6. **Hybrid Sync** Auto-sync via cron + manual "Sync Now" button
7. **Visual Source Marking** Icon/badge shows which account synced each contact
8. **Keep on Delete** When account/addressbook deleted, contacts remain (lose CardDAV link, become manual contacts)
9. **Settings Integration** New "Contacts Sync" section in Settings → Calendar tab
10. **Full Field Support** Extended schema for all iOS/Android contact fields (organization, job title, birthday, website, photo, nickname)
11. **Multiple Values** Separate tables for phones/emails/addresses with labels (mobile, work, home)
## Architecture
### Components
- **Service:** `server/services/cardav-sync.js` Account management, addressbook discovery, contact sync
- **API Routes:** `server/routes/contacts.js` extended + new `/cardav/*` endpoints
- **DB Tables:** 6 new/extended tables (Migration 30)
- **UI:** Settings → Calendar tab extended with "Contacts Sync" section
- **Library:** `tsdav` (already present as optionalDependency)
### Data Flow
```
CardDAV Server → tsdav → cardav-sync.js → Smart Merge → contacts + contact_phones/emails/addresses
UI (Settings, Contacts List)
```
## Database Schema (Migration 30)
### New Table: `cardav_accounts`
```sql
CREATE TABLE cardav_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, -- User-defined label ("iCloud", "Nextcloud")
cardav_url TEXT NOT NULL, -- CardDAV server base URL
username TEXT NOT NULL, -- CardDAV username
password TEXT NOT NULL, -- Encrypted if DB_ENCRYPTION_KEY set
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
last_sync TEXT, -- ISO 8601, nullable
UNIQUE(cardav_url, username)
);
```
### New Table: `cardav_addressbook_selection`
```sql
CREATE TABLE cardav_addressbook_selection (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL, -- FK → cardav_accounts
addressbook_url TEXT NOT NULL, -- CardDAV addressbook URL
addressbook_name TEXT NOT NULL, -- Display name from provider
enabled INTEGER NOT NULL DEFAULT 1, -- 0 = disabled, 1 = enabled
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
UNIQUE(account_id, addressbook_url),
FOREIGN KEY(account_id) REFERENCES cardav_accounts(id) ON DELETE CASCADE
);
```
### Extended Table: `contacts`
```sql
ALTER TABLE contacts ADD COLUMN organization TEXT; -- Company/Organization
ALTER TABLE contacts ADD COLUMN job_title TEXT; -- Job title
ALTER TABLE contacts ADD COLUMN birthday TEXT; -- ISO 8601 date (YYYY-MM-DD)
ALTER TABLE contacts ADD COLUMN website TEXT; -- URL
ALTER TABLE contacts ADD COLUMN photo TEXT; -- Base64 data URL
ALTER TABLE contacts ADD COLUMN nickname TEXT;
ALTER TABLE contacts ADD COLUMN cardav_account_id INTEGER; -- FK → cardav_accounts, nullable
ALTER TABLE contacts ADD COLUMN cardav_uid TEXT; -- vCard UID from server, nullable
ALTER TABLE contacts ADD COLUMN cardav_addressbook_url TEXT; -- Source addressbook, nullable
-- Indices for Smart Merge
CREATE INDEX idx_contacts_cardav_uid ON contacts(cardav_uid);
CREATE INDEX idx_contacts_email ON contacts(email);
```
**Note:** Existing `phone`, `email`, `address` columns remain for backward compatibility and as fallback for primary values.
### New Table: `contact_phones`
```sql
CREATE TABLE contact_phones (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contact_id INTEGER NOT NULL,
label TEXT, -- 'mobile', 'work', 'home', 'other', 'iphone', 'main', 'fax'
value TEXT NOT NULL, -- Phone number
is_primary INTEGER NOT NULL DEFAULT 0, -- 1 = primary number
FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE
);
CREATE INDEX idx_contact_phones_contact ON contact_phones(contact_id);
CREATE INDEX idx_contact_phones_value ON contact_phones(value);
```
### New Table: `contact_emails`
```sql
CREATE TABLE contact_emails (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contact_id INTEGER NOT NULL,
label TEXT, -- 'work', 'home', 'other', 'icloud'
value TEXT NOT NULL, -- Email address
is_primary INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE
);
CREATE INDEX idx_contact_emails_contact ON contact_emails(contact_id);
CREATE INDEX idx_contact_emails_value ON contact_emails(value);
```
### New Table: `contact_addresses`
```sql
CREATE TABLE contact_addresses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contact_id INTEGER NOT NULL,
label TEXT, -- 'home', 'work', 'other'
street TEXT,
city TEXT,
state TEXT,
postal_code TEXT,
country TEXT,
is_primary INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE
);
CREATE INDEX idx_contact_addresses_contact ON contact_addresses(contact_id);
```
### Design Decisions
- **`cardav_uid`** stores vCard UID from server for re-sync identification
- **`cardav_account_id`** is NULL for manual contacts, set for synced contacts
- **Account deletion:** Sets `cardav_account_id = NULL` (contacts remain as manual contacts)
- **`is_primary`** flag marks primary phone/email/address for UI display and tel:/mailto: links
- **Backward compatibility:** Existing `phone`, `email`, `address` columns remain; synced contacts also populate these with primary values
## Sync Service (`server/services/cardav-sync.js`)
### Structure
```javascript
// Account Management
addAccount(name, cardavUrl, username, password)
Test connection via tsdav
Store encrypted password
Insert into cardav_accounts
Discover and insert addressbooks
Return { account, addressbooks }
deleteAccount(accountId)
SET cardav_account_id = NULL for all contacts (keep contacts)
DELETE from cardav_accounts (CASCADE deletes addressbook_selection)
testConnection(cardavUrl, username, password)
Use tsdav.createDAVClient() to connect
Fetch addressbooks to verify
Return { ok: true, addressbooks } or throw error
getAllAccounts()
SELECT * FROM cardav_accounts
// Addressbook Discovery
discoverAddressbooks(accountId)
Fetch addressbooks from server via tsdav
UPSERT into cardav_addressbook_selection
Return list with enabled status
// Contact Sync
syncAccount(accountId)
Get all enabled addressbooks for account
For each: syncAddressbook(accountId, addressbookUrl)
Update last_sync timestamp
Return { synced: count, errors: count }
syncAddressbook(accountId, addressbookUrl)
Fetch all vCards from addressbook via tsdav
For each vCard: parseAndMergeContact(vCardText, accountId, addressbookUrl)
parseAndMergeContact(vCardText, accountId, addressbookUrl)
Parse vCard fields (see Field Mapping below)
Apply Smart Merge Logic
Insert/Update contacts + contact_phones/emails/addresses
```
### Smart Merge Logic
```
1. Extract UID from vCard
2. Check: EXISTS contact WHERE cardav_uid = UID?
→ YES:
- UPDATE existing contact (only NULL fields are filled)
- Preserve manual changes in non-NULL fields
- UPDATE cardav_account_id, cardav_addressbook_url
→ NO:
Check: EXISTS contact WHERE email IN vCard.emails OR phone IN vCard.phones?
→ YES:
- UPDATE existing contact (fill NULL fields)
- SET cardav_uid, cardav_account_id (establish link)
→ NO:
- INSERT new contact with all vCard fields
- SET cardav_uid, cardav_account_id
3. Update contact_phones/emails/addresses:
- DELETE existing entries for this contact WHERE is_primary = 0
- INSERT new entries from vCard
- Keep entries WHERE is_primary = 1 (manually marked)
- If no primary exists, mark first entry as primary
```
### Field Mapping (vCard → Oikos)
| vCard Property | Oikos Field(s) | Notes |
|----------------|----------------|-------|
| `FN` | `name` | Formatted name |
| `N` | `name` | Fallback if FN missing |
| `TEL` | `contact_phones` | Multiple entries with labels |
| `EMAIL` | `contact_emails` | Multiple entries with labels |
| `ADR` | `contact_addresses` | Multiple entries with labels |
| `ORG` | `organization` | Company/organization |
| `TITLE` | `job_title` | Job title |
| `URL` | `website` | First URL (if multiple, take first) |
| `BDAY` | `birthday` | ISO 8601 date (YYYY-MM-DD) |
| `PHOTO` | `photo` | Base64 data URL |
| `NICKNAME` | `nickname` | Nickname |
| `NOTE` | `notes` | Notes |
| `CATEGORIES` | `category` | Map to Oikos categories or use 'Sonstiges' |
### Error Handling
- **Connection failures:** Log error, skip sync, return error to UI
- **Invalid vCards:** Log warning, skip contact, continue with next
- **Database errors:** Rollback transaction, return error
- **Auth failures:** Log error, mark account as "needs re-auth" (future enhancement)
## API Routes
### New CardDAV Management Routes
```
POST /api/v1/contacts/cardav/accounts
Body: { name, cardavUrl, username, password }
Response: { data: { account, addressbooks: [...] } }
GET /api/v1/contacts/cardav/accounts
Response: { data: [{ id, name, cardavUrl, username, lastSync }] }
DELETE /api/v1/contacts/cardav/accounts/:id
Response: { data: { deleted: true } }
POST /api/v1/contacts/cardav/accounts/:id/test
Response: { data: { ok: true } }
GET /api/v1/contacts/cardav/accounts/:id/addressbooks
Response: { data: [{ id, url, name, enabled }] }
POST /api/v1/contacts/cardav/accounts/:id/addressbooks/refresh
Response: { data: [{ id, url, name, enabled }] }
PUT /api/v1/contacts/cardav/addressbooks/:id
Body: { enabled: true/false }
Response: { data: { id, enabled } }
POST /api/v1/contacts/cardav/accounts/:id/sync
Response: { data: { synced: 15, errors: 0 } }
```
### Extended Contacts Routes
```
GET /api/v1/contacts/:id
Response: {
data: {
id, name, category, notes, organization, jobTitle, birthday,
website, photo, nickname, cardavAccountId, cardavUid,
phones: [{ id, label, value, isPrimary }],
emails: [{ id, label, value, isPrimary }],
addresses: [{ id, label, street, city, state, postalCode, country, isPrimary }]
}
}
POST /api/v1/contacts
Body: { name, ..., phones: [...], emails: [...], addresses: [...] }
Response: { data: Contact }
PUT /api/v1/contacts/:id
Body: { name, ..., phones: [...], emails: [...], addresses: [...] }
Response: { data: Contact }
```
## UI Integration
### Settings → Calendar Tab (Extended)
Restructure with two sections:
```
Settings → Calendar
[Section 1: Calendar Sync]
- Google Calendar OAuth
- CalDAV Accounts
- ICS Subscriptions
[Section 2: Contacts Sync] ← NEW
- CardDAV Accounts
```
**CardDAV Account Card:**
```html
<div class="sync-account-card">
<div class="account-header">
<strong>iCloud</strong>
<span class="last-sync">Last sync: 2 minutes ago</span>
</div>
<div class="account-actions">
<button class="refresh-addressbooks">Refresh Addressbooks</button>
<button class="sync-now">Sync Now</button>
<button class="delete-account">Delete</button>
</div>
<!-- Addressbook Selection (expandable) -->
<div class="addressbook-list">
<label>
<input type="checkbox" checked data-id="1">
📇 Personal (enabled)
</label>
<label>
<input type="checkbox" data-id="2">
💼 Work (disabled)
</label>
</div>
</div>
```
**Add Account Modal:**
- Fields: Name, CardDAV URL, Username, Password
- Test connection on save
- On success: Show addressbook list immediately
### Contact List (`public/pages/contacts.js`)
**Source Badge:**
```html
<div class="contact-card">
<div class="contact-header">
<strong>Max Mustermann</strong>
<span class="contact-source-badge" v-if="contact.cardavAccountId">
<i data-lucide="cloud"></i> iCloud
</span>
</div>
<div class="contact-phones">
📱 +49 123 456 (mobile) · 🏢 +49 789 (work)
</div>
<div class="contact-emails">
✉️ max@example.com (home) · 💼 max@work.com (work)
</div>
</div>
```
### Contact Modal (Extended)
**New Fields:**
- Organization (text input)
- Job Title (text input)
- Birthday (date picker)
- Website (URL input)
- Nickname (text input)
- Photo (upload button, like Birthdays module)
**Multiple Values UI:**
```html
<div class="form-group">
<label>Phone Numbers</label>
<div id="phones-list">
<div class="multi-value-row">
<select class="phone-label">
<option value="mobile">Mobile</option>
<option value="work">Work</option>
<option value="home">Home</option>
<option value="other">Other</option>
</select>
<input type="tel" class="phone-value" value="+49 123">
<label class="checkbox-inline">
<input type="checkbox" class="is-primary"> Primary
</label>
<button class="btn-remove"></button>
</div>
</div>
<button id="add-phone" class="btn btn--secondary">+ Add Phone</button>
</div>
<!-- Same pattern for Emails and Addresses -->
```
## Testing (`test-cardav.js`)
Uses Node's built-in test runner with in-memory SQLite (like `test-caldav.js`).
### Test Coverage
```javascript
// DB Schema
- should create cardav_accounts table
- should create cardav_addressbook_selection table with FK CASCADE
- should add new columns to contacts table
- should create contact_phones/emails/addresses tables
- should enforce UNIQUE constraint on (cardav_url, username)
// Account Management
- should add account and store encrypted password
- should reject duplicate accounts (same URL + username)
- should delete account and set contacts' cardav_account_id = NULL
- should keep contacts when account is deleted
// Addressbook Selection
- should insert addressbook selection
- should CASCADE delete when account deleted
- should toggle enabled/disabled status
// Smart Merge Logic
- should create new contact when cardav_uid not found
- should update existing contact when cardav_uid matches
- should match by email and link to CardDAV
- should match by phone and link to CardDAV
- should fill only NULL fields on merge (preserve manual changes)
// Multiple Values
- should insert multiple phones/emails/addresses
- should mark is_primary correctly
- should CASCADE delete when contact deleted
// vCard Parsing
- should parse FN, N, TEL, EMAIL, ADR, ORG, TITLE, URL, BDAY, PHOTO, NOTE
- should handle missing optional fields
- should handle multiple TEL/EMAIL/ADR entries with labels
```
### Mock Strategy
- In-memory SQLite (no persistent DB)
- Mock `tsdav` imports with fixture vCard data
- No real CardDAV server calls in tests
## Implementation Notes
### Phase 1: Database & Core Service
1. Migration 30 (all tables)
2. `server/services/cardav-sync.js` (account management, sync logic)
3. Tests for DB schema and sync logic
### Phase 2: API Routes
4. New `/api/v1/contacts/cardav/*` routes
5. Extended `/api/v1/contacts` routes for multiple values
6. Tests for API routes
### Phase 3: UI Integration
7. Settings → Calendar tab extended
8. Contact list with source badges
9. Contact modal extended (new fields, multiple values)
10. Tests for UI interactions
### Phase 4: Cron Integration
11. Add CardDAV sync to existing cron job (like CalDAV)
12. Use same `SYNC_INTERVAL_MINUTES` env var
## Security Considerations
- **Password Encryption:** Use same encryption as CalDAV (DB_ENCRYPTION_KEY)
- **CSRF Protection:** All POST/PUT/DELETE routes use existing CSRF middleware
- **Session Auth:** All routes require authenticated session
- **Input Validation:** Validate all fields (max lengths, URL format, email format)
- **SQL Injection:** Use parameterized queries (better-sqlite3)
- **XSS Prevention:** Use `esc()` for all user-generated content in UI
## Future Enhancements
- **Conflict Resolution UI:** Show conflicts when manual changes differ from server
- **Selective Field Sync:** Choose which fields to sync per addressbook
- **Sync Statistics:** Show detailed sync logs (added, updated, skipped)
- **vCard Export (Multi):** Export all contacts as single .vcf file
- **CardDAV Server Mode:** Oikos as CardDAV server (Issue #10 mentioned this as possible future)
---
**Design Status:** ✅ Approved
**Next Step:** Create implementation plan via `writing-plans` skill
@@ -0,0 +1,439 @@
# Generisches CalDAV Multi-Account Sync
**Datum:** 2026-05-04
**Issue:** #90 - [Feature] CalDav (radicale)
**Status:** Approved Design
## Kontext
Die aktuelle Apple CalDAV-Integration funktioniert bereits mit verschiedenen CalDAV-Servern (iCloud, radicale, Nextcloud, Baikal), ist aber limitiert:
- **Single Account:** Nur ein CalDAV-Account möglich
- **Keine Kalenderauswahl:** Alle Kalender vom Server werden automatisch synchronisiert
- **Apple-Branding:** Name und UI suggerieren iCloud-Exklusivität
Der Benutzer möchte:
- Eigenen gehosteten CalDAV-Server (radicale) verwenden
- Kontrolle darüber haben, welche Kalender synchronisiert werden
- Mehrere CalDAV-Accounts gleichzeitig nutzen können
## Ziel
Transformation der Apple CalDAV-Integration in eine generische, flexible CalDAV-Lösung mit:
1. **Multiple Accounts:** Mehrere CalDAV-Accounts parallel (z.B. iCloud + radicale + Nextcloud)
2. **Kalenderauswahl:** Pro Account können Benutzer wählen, welche Kalender synchronisiert werden (Checkboxen)
3. **Bidirektional mit Account-Auswahl:** Beim Event-Erstellen kann der Ziel-Account/Kalender gewählt werden
4. **Provider-agnostisch:** Funktioniert mit allen CalDAV-kompatiblen Servern
## Ansatz: Kompletter Neuanfang
Neue Implementierung parallel zur bestehenden Apple-Integration, mit sauberer Architektur für Multi-Account-Support und späterer Deprecation von Apple CalDAV.
---
## 1. Architektur
### Komponenten
| Komponente | Beschreibung |
|------------|-------------|
| **Service** | server/services/caldav-sync.js - Neue Datei für Multi-Account-Logik |
| **DB-Tabellen** | caldav_accounts, caldav_calendar_selection |
| **API-Routen** | server/routes/calendar.js erweitert mit /calendar/caldav/* |
| **Frontend** | public/pages/settings.js - Neue CalDAV-Karte (ersetzt Apple-Karte) |
| **Migration** | server/db.js - Migration 22: Neue Tabellen + Apple-Daten migrieren |
| **Tests** | test-caldav-sync.js - Neue Test-Suite |
### Datenfluss
#### Account-Setup
Admin verbindet CalDAV in Settings → POST /caldav/accounts → testConnection via tsdav → INSERT INTO caldav_accounts → fetchCalendars → INSERT INTO caldav_calendar_selection (enabled=1 default) → UI zeigt Kalender-Checkboxen → User wählt aus → PATCH /caldav/accounts/:id/calendars
#### Inbound-Sync (CalDAV → Oikos)
Scheduler ruft caldav-sync.sync() → Für jeden Account: tsdav-Client erstellen → Kalender WHERE enabled=1 → fetchCalendarObjects → parseICS → Upsert in calendar_events mit external_source='caldav' → UPDATE caldav_accounts SET last_sync
#### Outbound-Sync (Oikos → CalDAV)
User erstellt Event → Event-Modal zeigt Dropdown mit CalDAV-Zielen → User wählt Account + Kalender → Speichern mit target_caldav_account_id → Nächster Sync: buildICS → tsdav createCalendarObject → UPDATE external_source='caldav'
---
## 2. Datenbank-Schema
### Neue Tabelle: caldav_accounts
Speichert CalDAV-Account-Credentials.
Spalten:
- id (PK, AUTOINCREMENT)
- name (TEXT, benutzer-definiert: "Mein Radicale", "iCloud")
- caldav_url (TEXT, z.B. https://caldav.icloud.com)
- username (TEXT)
- password (TEXT, Klartext wenn DB_ENCRYPTION_KEY fehlt)
- created_at (TEXT, ISO-8601)
- last_sync (TEXT, ISO-8601)
- UNIQUE(caldav_url, username)
### Neue Tabelle: caldav_calendar_selection
Speichert Kalenderauswahl pro Account.
Spalten:
- id (PK, AUTOINCREMENT)
- account_id (INTEGER, FK zu caldav_accounts ON DELETE CASCADE)
- calendar_url (TEXT, CalDAV calendar.url)
- calendar_name (TEXT, displayName)
- calendar_color (TEXT, #RRGGBB)
- enabled (INTEGER, 1=sync, 0=ignore, default 1)
- created_at (TEXT, ISO-8601)
- UNIQUE(account_id, calendar_url)
Index: idx_caldav_selection_enabled ON (account_id, enabled)
### Änderung an calendar_events
Neue Spalten für Outbound-Target:
- target_caldav_account_id (INTEGER, nullable)
- target_caldav_calendar_url (TEXT, nullable)
NULL = nur lokal, NOT NULL = zu diesem Account synchronisieren
### Änderung an external_calendars
Keine Schema-Änderung. source bekommt neuen Wert 'caldav' (zusätzlich zu 'google', 'apple', 'ics').
---
## 3. Backend-Service (caldav-sync.js)
Neue Datei server/services/caldav-sync.js mit folgenden Funktionen:
### Account-Management
**addAccount(name, caldavUrl, username, password)**
- Validiert via testConnection() (tsdav createDAVClient + fetchCalendars)
- INSERT INTO caldav_accounts
- Fetcht Kalender-Liste
- INSERT INTO caldav_calendar_selection (enabled=1)
- Return: { accountId, calendars }
**updateAccount(accountId, { name, caldavUrl, username, password })**
- UPDATE account
- Bei Credentials-Änderung: testConnection() erneut
- Kalender-Liste neu laden (alte löschen, neue laden)
**deleteAccount(accountId)**
- DELETE FROM caldav_accounts (CASCADE löscht caldav_calendar_selection)
- Events bleiben erhalten (orphaned)
**listAccounts()**
- SELECT * FROM caldav_accounts
- Passwort NICHT zurückgeben
### Kalender-Auswahl
**getCalendars(accountId, { refresh = false })**
- refresh=false: SELECT FROM caldav_calendar_selection
- refresh=true: Frisch via tsdav fetchen
**updateCalendarSelection(accountId, calendarUrl, enabled)**
- UPDATE caldav_calendar_selection SET enabled WHERE account_id AND calendar_url
### Sync
**sync()**
Inbound:
- Für jeden Account: tsdav-Client → enabled Kalender → fetchCalendarObjects → parseICS → Upsert calendar_events (external_source='caldav', external_calendar_id=UID, calendar_ref_id via upsertExternalCalendar)
Outbound:
- SELECT WHERE external_source='local' AND target_caldav_account_id IS NOT NULL → buildICS → tsdav createCalendarObject → UPDATE external_source='caldav'
Error Handling: Fehler pro Account loggen, nicht abbrechen (andere Accounts weiterlaufen lassen)
**getStatus()**
- Anzahl Accounts, letzte Syncs, Fehler pro Account
### Wiederverwendung
Von apple-calendar.js übernehmen: parseICS, buildICS, escapeICS, unescapeICS, normalizeCalColor, upsertExternalCalendar, tsdav-Import
---
## 4. API-Routen
Neue Endpoints in server/routes/calendar.js (alle requireAdmin):
### Account-Management
- POST /calendar/caldav/accounts → addAccount() → { data: { accountId, calendars } }
- GET /calendar/caldav/accounts → listAccounts() → { data: [{ id, name, caldavUrl, username, lastSync }] }
- PUT /calendar/caldav/accounts/:id → updateAccount()
- DELETE /calendar/caldav/accounts/:id → deleteAccount()
### Kalender-Auswahl
- GET /calendar/caldav/accounts/:id/calendars?refresh=true → getCalendars()
- PATCH /calendar/caldav/accounts/:id/calendars → updateCalendarSelection()
### Sync & Status
- POST /calendar/caldav/sync → sync()
- GET /calendar/caldav/status → getStatus()
---
## 5. Frontend-UI
### Settings-Seite (public/pages/settings.js)
Neue CalDAV-Karte ersetzt Apple-Karte:
Struktur:
- Account-Liste mit pro Account:
- Header: Name, URL, Status (Verbunden + letzte Sync)
- Kalender-Liste (expandable details): Checkboxen für jeden Kalender mit Farbe und Name
- Actions: "Jetzt synchronisieren", "Kalender aktualisieren", "Entfernen"
- Button: "CalDAV-Konto hinzufügen"
Modal für Account hinzufügen:
- Name (Textfeld)
- CalDAV-URL (URL-Feld, Placeholder: https://caldav.icloud.com)
- Benutzername (Textfeld)
- Passwort (Password-Feld)
- Hint: Für iCloud App-spezifisches Passwort verwenden
Event-Binding:
- Checkboxen onChange → PATCH /caldav/accounts/:id/calendars
- Sync-Button → POST /caldav/sync
- Refresh-Button → GET /caldav/accounts/:id/calendars?refresh=true
- Delete-Button → Confirmation + DELETE /caldav/accounts/:id
### Event-Modal (public/pages/calendar.js)
Neues Feld im Event-Formular:
Label: "Zu CalDAV synchronisieren (optional)"
Select mit Optionen:
- "Nur lokal speichern" (value="")
- Optgroups pro Account mit Optionen pro enabled Kalender
- Value-Format: "accountId|calendarUrl"
Backend splittet beim Speichern: [accountId, calendarUrl] = value.split('|')
Laden der Optionen: GET /caldav/accounts → für jeden Account GET /caldav/accounts/:id/calendars → nur enabled Kalender
---
## 6. Migration
DB-Migration 22 in server/db.js:
1. CREATE TABLE caldav_accounts
2. CREATE TABLE caldav_calendar_selection
3. CREATE INDEX idx_caldav_selection_enabled
4. Apple-Daten aus sync_config lesen (apple_caldav_url, apple_username, apple_app_password, apple_last_sync)
5. Falls vorhanden: INSERT INTO caldav_accounts mit name='Apple Calendar (migriert)'
6. Alle Apple-Kalender aus external_calendars WHERE source='apple' → INSERT INTO caldav_calendar_selection mit enabled=1
7. UPDATE external_calendars SET source='caldav' WHERE source='apple'
8. UPDATE calendar_events SET external_source='caldav' WHERE external_source='apple'
9. ALTER TABLE calendar_events ADD COLUMN target_caldav_account_id
10. ALTER TABLE calendar_events ADD COLUMN target_caldav_calendar_url
Eigenschaften:
- Idempotent (kann mehrfach laufen)
- Non-destructive (Apple-Daten bleiben in sync_config für Rollback)
- Graceful (überspringt wenn keine Apple-Daten)
---
## 7. Error Handling
### Verbindungsfehler
Beim Account-Hinzufügen: testConnection() wirft bei 401/Network Error → Frontend zeigt Toast mit Fehlermeldung
Beim Sync: Fehler pro Account loggen, nicht abbrechen → Status-API zeigt Fehler pro Account
### Credential-Fehler
401 Unauthorized → Account als nicht verbunden markieren → UI zeigt Warnung "Anmeldedaten ungültig"
Password-Änderung: User muss Account bearbeiten und neues Passwort eingeben
### CalDAV-Protokollfehler
Kalender existiert nicht mehr (404) → UPDATE enabled=0 → UI zeigt "Kalender nicht verfügbar"
ICS-Parse-Fehler → Event überspringen, aber loggen
### Outbound-Fehler
Event kann nicht hochgeladen werden → external_source bleibt 'local' → Retry beim nächsten Sync
Konflikt (UID existiert bereits) → Neuen UID generieren und erneut hochladen
### Logging
Alle Fehler via createLogger('CalDAV') → Console
UI zeigt Fehler-Status pro Account (rote Badges bei Fehlern)
### Graceful Degradation
tsdav nicht installiert → import('tsdav') wirft → Frontend zeigt "CalDAV requires tsdav package"
---
## 8. Testing
Test-Suite: test-caldav-sync.js (--experimental-sqlite, in-memory DB)
Test-Bereiche:
**DB-Schema:**
- caldav_accounts table korrekt erstellt
- caldav_calendar_selection mit FK CASCADE
- calendar_events target-Spalten vorhanden
**Account-Management:**
- addAccount funktioniert (mit Mock tsdav)
- Duplikate werden verhindert (UNIQUE constraint)
- listAccounts gibt keine Passwörter zurück
- updateAccount funktioniert
- deleteAccount mit CASCADE
**Kalender-Auswahl:**
- getCalendars lädt Auswahl
- updateCalendarSelection togglet enabled
- sync() berücksichtigt nur enabled Kalender
**Migration:**
- Apple → caldav_accounts Migration
- external_calendars source apple→caldav
- calendar_events external_source apple→caldav
**Sync (Mock tsdav):**
- Inbound nur von enabled Kalendern
- Outbound zu spezifischem Account/Kalender
- Fehler-Handling (Account 1 fail, Account 2 continue)
**Error Handling:**
- Invalid credentials rejected
- Missing calendars → enabled=0
Mocking: tsdav-Funktionen mocken (createDAVClient, fetchCalendars, fetchCalendarObjects, createCalendarObject)
Alternative: Docker radicale für Integrationstests (später, optional)
package.json:
- "test:caldav": "node --experimental-sqlite test-caldav-sync.js"
- In test script einbinden
---
## 9. i18n Keys
Neue Übersetzungen in public/locales/de.json und en.json:
Settings:
- caldavTitle: "CalDAV Kalender"
- caldavDescription: "Verbinde mehrere CalDAV-Konten..."
- caldavAddAccount: "CalDAV-Konto hinzufügen"
- caldavEmptyState: "Noch keine CalDAV-Konten verbunden..."
- caldavNameLabel, caldavNamePlaceholder
- caldavUrlLabel, caldavUrlHint
- caldavUsernameLabel, caldavPasswordLabel, caldavPasswordHint
- caldavAccountAdded, caldavAccountDeleted
- caldavCalendarsToggle: "Kalender anzeigen/ausblenden"
- caldavRefreshCalendars: "Kalender aktualisieren"
Calendar:
- caldavTargetLabel: "Zu CalDAV synchronisieren"
- caldavTargetLocal: "Nur lokal speichern"
- caldavTargetHint: "Wähle einen CalDAV-Kalender..."
---
## 10. Implementierungsumfang
**Dieses Design beschreibt die vollständige Implementierung aller Features in einem Release.**
Falls gewünscht, könnte die Implementierung theoretisch in Phasen erfolgen:
- Phase 1: Single Account (wie Apple) → Funktionsparität, generisch
- Phase 2: Kalenderauswahl → Löst Issue #90 Hauptproblem
- Phase 3: Multiple Accounts → Vollständig Multi-Account
- Phase 4: Outbound mit Account-Auswahl → Vollständig bidirektional
**Gewählter Ansatz:** Alle Features in einem Release implementieren (einfacher zu testen, keine Zwischenzustände, kohärente Architektur von Anfang an)
---
## 11. Designentscheidungen
**Alte Apple-Integration:**
- Bleibt parallel bestehen (nicht entfernen)
- Später als deprecated markieren (separate Issue)
- Ermöglicht sanfte Migration und Rollback bei Problemen
**Sync-Intervall:**
- Wie bestehende Google/Apple-Integration
- Via SYNC_INTERVAL_MINUTES aus .env (default 15 Minuten)
**Outbound-Standard:**
- Events ohne CalDAV-Target bleiben nur lokal (external_source='local')
- Kein automatischer Upload
- Benutzer muss explizit CalDAV-Ziel wählen
**Multi-User-Support:**
- Nur Admin kann CalDAV-Accounts verwalten (wie Google/Apple)
- Alle User sehen die gleichen synchronisierten Kalender
- Normale User können keine eigenen CalDAV-Accounts hinzufügen
---
## 12. Success Criteria
Funktional:
- Mehrere CalDAV-Accounts parallel
- Kalenderauswahl funktioniert
- Inbound-Sync nur ausgewählte Kalender
- Outbound-Sync mit Account-Auswahl
- Migration ohne Datenverlust
UI/UX:
- Settings zeigt alle Accounts mit Status
- Kalender-Checkboxen intuitiv
- Event-Modal zeigt verfügbare Ziele
- Fehler-Status klar sichtbar
Qualität:
- Alle Tests bestehen
- Error Handling für alle Szenarien
- Migration fehlerfrei
- Kein Datenverlust bei Fehlern
Kompatibilität:
- Funktioniert mit iCloud, radicale, Nextcloud, Baikal
- Google Calendar unberührt
- Apple CalDAV läuft parallel
---
## 13. Risiken & Mitigation
| Risiko | Mitigation |
|--------|------------|
| tsdav Breaking Changes | Optional dependency, Version pinnen |
| Migrations-Fehler | Idempotent, non-destructive |
| CalDAV-Server Inkompatibilität | Tests mit verschiedenen Servern |
| Performance bei vielen Accounts | Index, später Pagination |
| Credential-Sicherheit | DB_ENCRYPTION_KEY empfehlen, Warnung |
---
## Fazit
Diese Spec beschreibt eine vollständige Transformation der Apple CalDAV-Integration in eine generische Multi-Account-Lösung. Der Ansatz "Kompletter Neuanfang" ermöglicht saubere Architektur und einfaches Rollback. Alle Anforderungen aus Issue #90 werden erfüllt.
**Nächster Schritt:** Implementation Plan erstellen (via writing-plans Skill).