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:
@@ -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).
|
||||
Reference in New Issue
Block a user