Files
oikos/docs/designs/2026-05-04-cardav-api-routes-implementation.md
T
2026-05-04 12:29:29 +02:00

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.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:

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

// 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, required
  • cardavUrl: str, max MAX_URL, required
  • username: str, max MAX_TITLE, required
  • password: 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:

  1. Account aus DB laden
  2. 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:

  1. Account aus DB laden
  2. discoverAddressbooks(account) aufrufen
  3. 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 > 0
  • enabled: 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:

  1. Account aus DB laden
  2. 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, required
  • category: 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)

  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)

  1. POST /accounts/:id/test — Connection testen
  2. GET /accounts/:id/addressbooks — Addressbooks auflisten
  3. POST /accounts/:id/addressbooks/refresh — Addressbooks refreshen

Tests: Discovery Flow + Error Handling


Phase 3: Addressbook Toggle & Sync (Routes 7-8)

  1. PUT /addressbooks/:id — Addressbook togglen
  2. POST /accounts/:id/sync — Sync triggern

Tests: Toggle + Sync Response Structure


Phase 4: Extended Contacts (Routes 9-11)

  1. GET /contacts/:id — Mit Multi-Values
  2. POST /contacts — Mit Multi-Values erstellen
  3. 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