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>
25 KiB
CardDAV API Routes — Implementation Design
Date: 2026-05-04
Status: Approved
Related: CardDAV Contacts Design
Ü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.jserweitern - Rationale: Klare Trennung (Contact CRUD vs. CardDAV Management), folgt Oikos One-Router-Per-Module Pattern
Implementierungs-Reihenfolge
User Flow Approach:
- Account Management (POST/GET/DELETE)
- Connection Test
- Addressbook Discovery & Toggle
- Sync Operations
- Extended Contacts Routes
Rationale: Natürliche User Journey, einfacher zu testen
Architektur
Route-Level Validation mit Service Delegation:
- Routes validieren Input mit
validate.jsMiddleware - Routes delegieren Business Logic an
cardav-sync.js - Rationale: Konsistent mit existierenden Oikos-Routes, bessere User-facing Error Messages
Error Handling
Einfaches Fallback:
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 Routerserver/openapi.js— 11 neue Path Definitionentest-carddav.js— API Route Tests
Mount Point
// 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:
{
"name": "iCloud",
"cardavUrl": "https://contacts.icloud.com",
"username": "user@icloud.com",
"password": "app-specific-password"
}
Validation:
name: str, max MAX_TITLE, requiredcardavUrl: str, max MAX_URL, requiredusername: str, max MAX_TITLE, requiredpassword: str, max MAX_TITLE, required
Service Call:
const result = await CardDAVSync.addAccount(name, cardavUrl, username, password);
Response: 201 Created
{
"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:
const accounts = await CardDAVSync.getAllAccounts();
Response: 200 OK
{
"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:
await CardDAVSync.deleteAccount(id);
Response: 200 OK
{
"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:
- Account aus DB laden
testConnection(cardavUrl, username, password)aufrufen
Response: 200 OK
{
"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:
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
{
"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:
- Account aus DB laden
discoverAddressbooks(account)aufrufen- Addressbooks aus DB neu laden
Response: 200 OK
{
"data": [
{ "id": 1, "url": "https://...", "name": "Personal", "enabled": 1 }
]
}
7. PUT /api/v1/contacts/cardav/addressbooks/:id
Zweck: Addressbook enable/disable
Request:
{
"enabled": true
}
Validation:
id: parseInt, must be > 0enabled: bool, required
Service Call:
await CardDAVSync.toggleAddressbook(id, enabled);
Response: 200 OK
{
"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:
- Account aus DB laden
syncAccount(account)aufrufen
Response: 200 OK
{
"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:
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
{
"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:
{
"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, requiredcategory: oneOf(VALID_CATEGORIES), default 'Sonstiges'phones: validatePhones() (siehe Validation Schema)emails: validateEmails()addresses: validateAddresses()- Alle anderen Felder optional
Logic (Transaction):
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
{
"data": { /* Contact mit allen Multi-Values */ }
}
11. PUT /api/v1/contacts/:id
Zweck: Kontakt mit Multi-Values updaten
Request:
{
"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):
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
{
"data": { /* Updated Contact mit allen Multi-Values */ }
}
Validation Schema
CardDAV Routes
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:
// 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:
// 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
// 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:
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):
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:
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:
// 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:
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:
// 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)
- POST /accounts — Account erstellen
- GET /accounts — Accounts auflisten
- DELETE /accounts/:id — Account löschen
Tests: Account CRUD Happy Paths + Validation Errors
Phase 2: Connection & Discovery (Routes 4-6)
- POST /accounts/:id/test — Connection testen
- GET /accounts/:id/addressbooks — Addressbooks auflisten
- POST /accounts/:id/addressbooks/refresh — Addressbooks refreshen
Tests: Discovery Flow + Error Handling
Phase 3: Addressbook Toggle & Sync (Routes 7-8)
- PUT /addressbooks/:id — Addressbook togglen
- POST /accounts/:id/sync — Sync triggern
Tests: Toggle + Sync Response Structure
Phase 4: Extended Contacts (Routes 9-11)
- GET /contacts/:id — Mit Multi-Values
- POST /contacts — Mit Multi-Values erstellen
- PUT /contacts/:id — Mit Multi-Values updaten
Tests: Multi-Value CRUD + Transaction Safety
Next Steps
Nach Approval dieses Designs:
- Invoke
writing-plansskill — Detaillierten Implementation Plan erstellen - TDD Approach — Tests vor Implementation schreiben
- 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)→voidtestConnection(cardavUrl, username, password)→{ ok, addressbooks }
Addressbook Discovery:
discoverAddressbooks(account)→Addressbook[]toggleAddressbook(addressbookId, enabled)→void
Contact Sync:
syncAccount(account)→{ synced, errors }syncAddressbook(account, addressbook)→voidparseAndMergeContact(vCardText, accountId, addressbookUrl)→void
Helpers:
parseVCard(vCardText)→ContactData