Merge branch 'feature/cardav-contacts'

# Conflicts:
#	package-lock.json
This commit is contained in:
Ulas Kalayci
2026-05-04 19:10:13 +02:00
11 changed files with 5518 additions and 33 deletions
+314
View File
@@ -0,0 +1,314 @@
# CardDAV API Routes Implementation - Fortschritt
**Stand:** 2026-05-04, alle 15 Tasks abgeschlossen ✅
**Plan:** `docs/superpowers/plans/2026-05-04-cardav-api-routes.md`
**Status:** Implementierung vollständig, bereit für Final Review
## Abgeschlossene Tasks
### ✅ Task 1: Multi-Value Array Validators
**Commit:** a715475 + 930800e (fixes)
- Implementiert: `validatePhones()`, `validateEmails()`, `validateAddresses()`
- Location: `server/routes/contacts.js`
- Tests: 33 neue Tests in test-carddav.js
- Validierungen: Arrays, Objekt-Struktur, Pflichtfelder, Max-Längen, Email-Format, isPrimary-Typ, Array-Länge (max 20)
**Review-Findings & Fixes:**
- Spec Compliance: Minor extras (logging) - akzeptabel
- Code Quality Issues behoben:
- Whitespace-Validierung ergänzt
- Null-Guards hinzugefügt
- Email-Format-Check ergänzt
- isPrimary Typ-Validierung
- DoS-Schutz: Array-Länge begrenzt auf 20
### ✅ Task 2: CardDAV Router Setup
**Commits:** cf68bff, 930800e
- Erstellt: `server/routes/cardav.js` mit Express Router
- Implementiert: GET /accounts Endpoint
- Test-Infrastruktur:
- `_setTestDatabase()` / `_resetTestDatabase()` in `server/db.js`
- before()/after() Hooks in test-carddav.js
- Migration 30 wird in Tests angewendet
- Tests: 2 Tests (empty array, populated with shape validation)
**Review-Findings & Fixes:**
- Unused imports entfernt (wurden für Task 3 wieder gebraucht)
- Error-Message leakage behoben (generic "Interner Fehler")
- Test-Cleanup mit after() Hook
- `after` aus node:test importiert
### ✅ Task 3: POST /accounts - Create Account
**Commit:** f7eb73b
- Implementiert: POST /accounts mit Validation
- Validierung: name, cardavUrl, username, password (alle required, mit max lengths)
- Delegiert an: `CardDAVSync.addAccount()`
- Response: 201 mit `{ account, addressbooks }`
- Tests: 2 Tests (success case, validation failure)
**Test-Mocking:**
- `_mockTestConnection()` in cardav-sync.js hinzugefügt
- Mock gibt fake addressbooks zurück für Tests
- Mock wird in before() gesetzt, in after() zurückgesetzt
**Wichtige Änderung:**
- `addAccount()` Return-Wert geändert von `{ accountId, addressbooks }` zu `{ account: { id, name, cardavUrl, username, createdAt, lastSync }, addressbooks }`
### ✅ Task 4: DELETE /accounts/:id - Delete Account
**Commit:** ca92cb2
- Implementiert: DELETE /accounts/:id mit ID-Validierung
- Validierung: ID muss positive Ganzzahl sein
- Delegiert an: `CardDAVSync.deleteAccount(id)`
- Response: 200 mit `{ deleted: true }`
- Tests: 2 Tests (success case mit cascade, invalid ID → 400)
- CASCADE-Verhalten: Foreign Key Constraints löschen addressbooks + contacts automatisch
### ✅ Task 5: POST /accounts/:id/test - Test Connection
**Commit:** dd5ac88
- Implementiert: POST /accounts/:id/test mit ID-Validierung
- Lädt Account aus DB (404 wenn nicht gefunden)
- Delegiert an: `CardDAVSync.testConnection(url, username, password)`
- Response: 200 mit `{ ok, addressbooks }`
- Test: 1 Test (success case mit addressbooks)
- Verwendet gemockten testConnection für konsistente Test-Results
### ✅ Task 6: GET /accounts/:id/addressbooks - List Addressbooks
**Commit:** 12e8edf
- Implementiert: GET /accounts/:id/addressbooks mit ID-Validierung
- Query: carddav_addressbook_selection table, ORDER BY addressbook_name
- Response: 200 mit Array von `{ id, url, name, enabled }`
- Tests: 2 Tests (success case mit shape validation, empty array für non-existent account)
### ✅ Task 7: POST /accounts/:id/addressbooks/refresh - Refresh Addressbooks
**Commit:** c078a48
- Implementiert: POST /accounts/:id/addressbooks/refresh mit ID-Validierung
- Lädt Account aus DB (404 wenn nicht gefunden)
- Delegiert an: `CardDAVSync.discoverAddressbooks(accountId)` für PROPFIND
- Query updated addressbooks nach Discovery
- Response: 200 mit Array von addressbooks
- Test: 1 Test (success case)
### ✅ Task 8: Add bool Validator
**Commit:** 362f711
- Implementiert: `bool(val, field)` Validator in server/middleware/validate.js
- Validiert: type === 'boolean', required by default
- Exportiert: bool in export statement
- Keine Tests (wird in Task 9 verwendet)
### ✅ Task 9: PUT /addressbooks/:id - Toggle Addressbook
**Commit:** 9ec7fda
- Implementiert: PUT /addressbooks/:id Route in server/routes/cardav.js
- Validierung: ID muss positive Ganzzahl sein, enabled muss boolean sein
- Delegiert an: `CardDAVSync.toggleAddressbook(id, enabled)`
- Response: 200 mit `{ updated: true, enabled: boolean }`
- Tests: 2 Tests (success case mit toggle, validation failure für invalid enabled)
- bool Validator verwendet
### ✅ Task 10: POST /accounts/:id/sync - Sync Account
**Commit:** 674fe79
- Implementiert: POST /accounts/:id/sync Route in server/routes/cardav.js
- Validierung: ID muss positive Ganzzahl sein
- Lädt Account aus DB (404 wenn nicht gefunden)
- Delegiert an: `CardDAVSync.syncAccount(accountId)`
- Response: 200 mit `{ synced, contactsAdded, contactsUpdated }`
- Tests: 2 Tests (success case, 404 für non-existent account)
- Mock: `_mockSyncAccount()` für Tests hinzugefügt (Pattern wie `_mockTestConnection`)
### ✅ Task 11: GET /contacts/:id - With Multi-Value Fields
**Commit:** fe8af33
- Implementiert: GET /contacts/:id Route in `server/routes/contacts.js`
- Queries: Separate Abfragen für `contact_phones`, `contact_emails`, `contact_addresses`
- Mapping: is_primary (Integer DB) → isPrimary (Boolean Response), snake_case → camelCase
- Sortierung: ORDER BY is_primary DESC, id ASC (Primary-Einträge zuerst)
- Response-Format: `{ ...contact, phones: [], emails: [], addresses: [] }`
- Tests: 2 neue Tests
- Contact mit allen Multi-Value Fields (phones, emails, addresses)
- Contact ohne Multi-Value Fields (leere Arrays)
- TDD-Workflow eingehalten: RED → GREEN → Commit
### ✅ Task 12: POST /contacts - Create With Multi-Value Fields
**Commit:** 966a6d4
- Implementiert: POST /contacts erweitert um phones, emails, addresses Arrays
- Validierung: `validatePhones()`, `validateEmails()`, `validateAddresses()` vor Insert
- Transaktionen: `db.transaction()` für atomare Contact + Multi-Values Inserts
- Backward Compatible: Optional fields, leere Arrays wenn nicht angegeben
- Response: Contact mit allen Multi-Value Fields inkl. generierte IDs
- Tests: 3 neue Tests
- Contact mit allen Multi-Value Fields erstellen
- Validierung: Fehler bei invaliden Phone-Daten (400)
- Backward Compatibility: Contact ohne Multi-Values (leere Arrays)
- Refactoring: `loadMultiValueFields(contactId)` Helper extrahiert (DRY)
- TDD-Workflow eingehalten: RED → GREEN → REFACTOR → Commit
### ✅ Task 13: PUT /contacts/:id - Update With Multi-Value Fields
**Commit:** 0dc303b
- Implementiert: PUT /contacts/:id erweitert um phones, emails, addresses Arrays
- Validierung: `validatePhones()`, `validateEmails()`, `validateAddresses()` vor Update
- Replacement-Semantik: DELETE alle existierenden Multi-Values, dann INSERT neue
- Transaktionen: `db.transaction()` für atomare Contact + Multi-Values Updates
- Backward Compatible: Multi-Value Fields nur ersetzt wenn im Body vorhanden
- Response: Contact mit allen Multi-Value Fields via `loadMultiValueFields()`
- Tests: 3 neue Tests
- Update mit Multi-Value Fields (Replacement-Semantik verifiziert)
- Validierung: Fehler bei invaliden Phone-Daten (400)
- Backward Compatibility: Update ohne Multi-Values lässt sie unverändert
- TDD-Workflow eingehalten: RED → Verify RED → GREEN → Verify GREEN → Commit
### ✅ Task 14: OpenAPI Documentation
**Commit:** d0eb638
- Dokumentiert: Alle 8 CardDAV Management Routes in `server/openapi.js`
- GET /api/v1/contacts/cardav/accounts
- POST /api/v1/contacts/cardav/accounts
- DELETE /api/v1/contacts/cardav/accounts/{id}
- POST /api/v1/contacts/cardav/accounts/{id}/test
- GET /api/v1/contacts/cardav/accounts/{id}/addressbooks
- POST /api/v1/contacts/cardav/accounts/{id}/addressbooks/refresh
- PUT /api/v1/contacts/cardav/addressbooks/{id}
- POST /api/v1/contacts/cardav/accounts/{id}/sync
- Aktualisiert: Bestehende Contacts-Routes mit Multi-Value-Beschreibungen
- POST /api/v1/contacts: "Create contact with multi-value fields"
- GET /api/v1/contacts/{id}: "Get contact with multi-value fields"
- PUT /api/v1/contacts/{id}: "Update contact with multi-value fields"
- Alle Routes mit korrektem Tag ('Contacts'), CSRF-Header-Params für stateChanging
- OpenAPI Schema baut ohne Fehler
### ✅ Task 15: Mount CardDAV Router
**Commit:** 8891097
- Import hinzugefügt: `import cardavRouter from './routes/cardav.js'`
- Router gemountet: `app.use('/api/v1/contacts/cardav', cardavRouter)`
- Montage-Reihenfolge: CardDAV-Router VOR Contacts-Router (Express route matching order)
- Auth-Middleware: Bereits global angewendet via `requireAuth` in index.js
- CSRF-Middleware: Automatisch über stateChanging-Flag in OpenAPI + CSRF-Middleware
- Integration Tests: Alle 109 Tests bestehen (20 Suites)
- Server startet erfolgreich auf Port 3000
## Wichtige Erkenntnisse
### Test-Infrastruktur
1. **DB-Mocking:** `_setTestDatabase()` / `_resetTestDatabase()` in db.js
2. **CardDAV-Mocking:** `_mockTestConnection()` in cardav-sync.js
3. **before()/after() Pattern:** Setup in before(), Cleanup in after()
4. **Migration 30:** Muss in jedem Test-Setup angewendet werden für CardDAV-Tabellen
### Code-Patterns
1. **Error-Handling:** Immer generic "Interner Fehler", niemals err.message leaken
2. **Validation:** str(), collectErrors(), MAX_TITLE (100), MAX_URL (500)
3. **Response-Format:** `{ data: ... }` für Success, `{ error: ..., code: ... }` für Fehler
4. **Status Codes:** 200 (GET), 201 (POST create), 400 (validation), 500 (server error)
### Konventionen
- Tests nutzen Node built-in test runner (`node:test`)
- Test-DB ist in-memory SQLite (`:memory:`)
- Commits mit Co-Authored-By: Claude Sonnet 4.5
- TDD-Workflow: Test → Run (fail) → Implement → Run (pass) → Commit
## Zusammenfassung
**Alle 15 Tasks vollständig implementiert:**
- ✅ Phase 0: Setup & Validators (Tasks 1-2)
- ✅ Phase 1: Account Management Routes (Tasks 3-4)
- ✅ Phase 2: Connection & Discovery Routes (Tasks 5-7)
- ✅ Phase 3: Toggle & Sync Routes (Tasks 8-10)
- ✅ Phase 4: Extended Contacts Routes (Tasks 11-13)
- ✅ Phase 5: OpenAPI Integration (Task 14)
- ✅ Phase 6: Server Integration (Task 15)
**Nächste Schritte:**
1. Final Code Review (optional)
2. Release Prep via `/release-prep` Skill
3. Merge in main Branch
## Commits-Übersicht
```
a715475 feat(contacts): add multi-value array validators
cf68bff feat(cardav): create cardav router with GET /accounts
930800e fix(cardav): improve router security and test coverage
f7eb73b feat(cardav): implement POST /accounts endpoint
ca92cb2 feat(cardav): implement DELETE /accounts/:id endpoint
38fa84c docs: update PROGRESS.md for completed Task 4
dd5ac88 feat(cardav): implement POST /accounts/:id/test endpoint
2964696 docs: update PROGRESS.md for completed Task 5
12e8edf feat(cardav): implement GET /accounts/:id/addressbooks endpoint
c078a48 feat(cardav): implement POST /accounts/:id/addressbooks/refresh endpoint
362f711 feat(validate): add bool validator
9ec7fda feat(cardav): implement PUT /addressbooks/:id endpoint
674fe79 feat(cardav): implement POST /accounts/:id/sync endpoint
fe8af33 feat(contacts): extend GET /contacts/:id with multi-value fields
966a6d4 feat(contacts): extend POST /contacts with multi-value fields
0dc303b feat(contacts): extend PUT /contacts/:id with multi-value fields
d0eb638 docs(openapi): add CardDAV routes and update contacts routes
8891097 feat(server): mount CardDAV router at /api/v1/contacts/cardav
```
## Test-Status
- **Gesamt:** 109 Tests, alle bestehen
- **Suites:** 20 Suites
- **CardDAV API Routes Suite:** 14 Tests
- **Contacts API - Multi-Value Fields Suite:** 8 Tests
- GET /contacts/:id: 2 Tests
- POST /contacts: 3 Tests
- PUT /contacts/:id: 3 Tests
- Account Management (6 Tests):
- GET /accounts (empty)
- GET /accounts (populated with shape)
- POST /accounts (success)
- POST /accounts (validation failure)
- DELETE /accounts/:id (success with cascade)
- DELETE /accounts/:id (invalid ID → 400)
- Connection & Discovery (4 Tests):
- POST /accounts/:id/test (success)
- GET /accounts/:id/addressbooks (success with addressbooks)
- GET /accounts/:id/addressbooks (empty array)
- POST /accounts/:id/addressbooks/refresh (success)
- Addressbook Management (2 Tests):
- PUT /addressbooks/:id (toggle enabled/disabled)
- PUT /addressbooks/:id (validation failure)
- Sync (2 Tests):
- POST /accounts/:id/sync (success)
- POST /accounts/:id/sync (404 for non-existent account)
## Branch & Remote
- **Branch:** feature/cardav-contacts
- **Worktree:** /home/ulsklyc/Workspace/oikos/.worktrees/feature/cardav-contacts
- **Base:** main (commit 6cc7267)
- **Bereit zum Pushen:** Ja, nach diesem Status-Commit
## Task-Liste Status
```
#1. [completed] Task 1: Multi-Value Array Validators
#2. [completed] Task 2: CardDAV Router Setup
#3. [completed] Task 3: POST /accounts - Create Account
#4. [completed] Task 4: DELETE /accounts/:id - Delete Account
#5. [completed] Task 5: POST /accounts/:id/test - Test Connection
#6. [completed] Task 6: GET /accounts/:id/addressbooks - List Addressbooks
#7. [completed] Task 7: POST /accounts/:id/addressbooks/refresh - Refresh Addressbooks
#8. [completed] Task 8: Add bool validator to validate.js
#9. [completed] Task 9: PUT /addressbooks/:id - Toggle Addressbook
#10. [completed] Task 10: POST /accounts/:id/sync - Sync Account
#11. [completed] Task 11: GET /contacts/:id - With Multi-Values
#12. [completed] Task 12: POST /contacts - Create With Multi-Values
#13. [completed] Task 13: PUT /contacts/:id - Update With Multi-Values
#14. [completed] Task 14: Document All Routes in OpenAPI
#15. [completed] Task 15: Mount CardDAV Router
```
**Alle 15 Tasks abgeschlossen! 🎉**
@@ -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`
+2 -1
View File
@@ -28,7 +28,8 @@
"test:ics-sub": "node --experimental-sqlite test-ics-subscription.js", "test:ics-sub": "node --experimental-sqlite test-ics-subscription.js",
"test:backup-scheduler": "node --experimental-sqlite test-backup-scheduler.js", "test:backup-scheduler": "node --experimental-sqlite test-backup-scheduler.js",
"test:caldav": "node --experimental-sqlite test-caldav-sync.js", "test:caldav": "node --experimental-sqlite test-caldav-sync.js",
"test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser && npm run test:ics-sub && npm run test:setup && npm run test:kitchen-tabs && npm run test:backup-scheduler && npm run test:caldav" "test:carddav": "node --experimental-sqlite test-carddav.js",
"test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser && npm run test:ics-sub && npm run test:setup && npm run test:kitchen-tabs && npm run test:backup-scheduler && npm run test:caldav && npm run test:carddav"
}, },
"dependencies": { "dependencies": {
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
+128 -1
View File
@@ -1074,6 +1074,112 @@ const MIGRATIONS = [
db.exec(`CREATE UNIQUE INDEX idx_calendar_sub_extid ON calendar_events (subscription_id, external_calendar_id)`); db.exec(`CREATE UNIQUE INDEX idx_calendar_sub_extid ON calendar_events (subscription_id, external_calendar_id)`);
}, },
}, },
{
version: 30,
description: 'CardDAV multi-account contacts sync',
up: `
-- ========================================
-- CardDAV Accounts
-- ========================================
CREATE TABLE carddav_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
carddav_url TEXT NOT NULL,
username TEXT NOT NULL,
password TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
last_sync TEXT,
UNIQUE(carddav_url, username)
);
-- ========================================
-- CardDAV Addressbook Selection
-- ========================================
CREATE TABLE carddav_addressbook_selection (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL,
addressbook_url TEXT NOT NULL,
addressbook_name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
UNIQUE(account_id, addressbook_url),
FOREIGN KEY(account_id) REFERENCES carddav_accounts(id) ON DELETE CASCADE
);
CREATE INDEX idx_carddav_addressbook_account
ON carddav_addressbook_selection(account_id, enabled);
-- ========================================
-- Extend Contacts Table for CardDAV
-- ========================================
ALTER TABLE contacts ADD COLUMN organization TEXT;
ALTER TABLE contacts ADD COLUMN job_title TEXT;
ALTER TABLE contacts ADD COLUMN birthday TEXT;
ALTER TABLE contacts ADD COLUMN website TEXT;
ALTER TABLE contacts ADD COLUMN photo TEXT;
ALTER TABLE contacts ADD COLUMN nickname TEXT;
ALTER TABLE contacts ADD COLUMN carddav_account_id INTEGER
REFERENCES carddav_accounts(id) ON DELETE SET NULL;
ALTER TABLE contacts ADD COLUMN carddav_uid TEXT;
ALTER TABLE contacts ADD COLUMN carddav_addressbook_url TEXT;
CREATE INDEX idx_contacts_carddav_uid ON contacts(carddav_uid);
CREATE INDEX idx_contacts_email ON contacts(email);
-- UNIQUE constraint for CardDAV UIDs (prevents duplicates per account+addressbook)
CREATE UNIQUE INDEX idx_contacts_carddav_uid_unique
ON contacts(carddav_account_id, carddav_addressbook_url, carddav_uid)
WHERE carddav_uid IS NOT NULL;
-- ========================================
-- Contact Phones (Multiple per Contact)
-- ========================================
CREATE TABLE contact_phones (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contact_id INTEGER NOT NULL,
label TEXT,
value TEXT NOT NULL,
is_primary INTEGER NOT NULL DEFAULT 0,
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);
-- ========================================
-- Contact Emails (Multiple per Contact)
-- ========================================
CREATE TABLE contact_emails (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contact_id INTEGER NOT NULL,
label TEXT,
value TEXT NOT NULL,
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);
-- ========================================
-- Contact Addresses (Multiple per Contact)
-- ========================================
CREATE TABLE contact_addresses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contact_id INTEGER NOT NULL,
label TEXT,
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);
`,
},
]; ];
/** /**
@@ -1247,6 +1353,27 @@ function transaction(fn) {
return get().transaction(fn)(); return get().transaction(fn)();
} }
let _originalDb = null;
/**
* ONLY FOR TESTING: Override the internal db instance
* @param {import('better-sqlite3').Database} testDb
*/
function _setTestDatabase(testDb) {
if (!_originalDb) _originalDb = db;
db = testDb;
}
/**
* ONLY FOR TESTING: Restore the original db instance
*/
function _resetTestDatabase() {
if (_originalDb) {
db = _originalDb;
_originalDb = null;
}
}
init(); // auto-initialise when module is first imported init(); // auto-initialise when module is first imported
export { init, get, transaction, currentVersion, getPath, backupToFile, restoreFromFile }; export { init, get, transaction, currentVersion, getPath, backupToFile, restoreFromFile, MIGRATIONS, _setTestDatabase, _resetTestDatabase };
+2
View File
@@ -26,6 +26,7 @@ import recipesRouter from './routes/recipes.js';
import calendarRouter from './routes/calendar.js'; import calendarRouter from './routes/calendar.js';
import notesRouter from './routes/notes.js'; import notesRouter from './routes/notes.js';
import contactsRouter from './routes/contacts.js'; import contactsRouter from './routes/contacts.js';
import cardavRouter from './routes/cardav.js';
import birthdaysRouter from './routes/birthdays.js'; import birthdaysRouter from './routes/birthdays.js';
import budgetRouter from './routes/budget.js'; import budgetRouter from './routes/budget.js';
import documentsRouter from './routes/documents.js'; import documentsRouter from './routes/documents.js';
@@ -194,6 +195,7 @@ app.use('/api/v1/meals', mealsRouter);
app.use('/api/v1/recipes', recipesRouter); app.use('/api/v1/recipes', recipesRouter);
app.use('/api/v1/calendar', calendarRouter); app.use('/api/v1/calendar', calendarRouter);
app.use('/api/v1/notes', notesRouter); app.use('/api/v1/notes', notesRouter);
app.use('/api/v1/contacts/cardav', cardavRouter);
app.use('/api/v1/contacts', contactsRouter); app.use('/api/v1/contacts', contactsRouter);
app.use('/api/v1/birthdays', birthdaysRouter); app.use('/api/v1/birthdays', birthdaysRouter);
app.use('/api/v1/budget', budgetRouter); app.use('/api/v1/budget', budgetRouter);
+17 -1
View File
@@ -156,8 +156,24 @@ function id(val, field) {
return { value: n, error: null }; return { value: n, error: null };
} }
/**
* Validiert einen Boolean-Wert.
* @param {any} val
* @param {string} field
* @returns {{ value: boolean|null, error: string|null }}
*/
function bool(val, field) {
if (val === undefined || val === null) {
return { value: null, error: `${field} is required.` };
}
if (typeof val !== 'boolean') {
return { value: null, error: `${field} must be a boolean.` };
}
return { value: val, error: null };
}
export { export {
str, oneOf, date, time, datetime, month, num, color, rrule, id, collectErrors, str, oneOf, date, time, datetime, month, num, color, rrule, id, bool, collectErrors,
MAX_TITLE, MAX_TEXT, MAX_SHORT, MAX_RRULE, MAX_TITLE, MAX_TEXT, MAX_SHORT, MAX_RRULE,
DATE_RE, TIME_RE, DATETIME_RE, COLOR_RE, MONTH_RE, DATE_RE, TIME_RE, DATETIME_RE, COLOR_RE, MONTH_RE,
}; };
+25 -2
View File
@@ -457,11 +457,34 @@ function buildPaths() {
}, },
'/api/v1/contacts': { '/api/v1/contacts': {
get: op({ summary: 'List contacts', tag: 'Contacts' }), get: op({ summary: 'List contacts', tag: 'Contacts' }),
post: op({ summary: 'Create contact', tag: 'Contacts', stateChanging: true, requestBody: jsonBody(null) }), post: op({ summary: 'Create contact with multi-value fields', tag: 'Contacts', stateChanging: true, requestBody: jsonBody(null) }),
}, },
'/api/v1/contacts/meta': { get: op({ summary: 'Get contact metadata', tag: 'Contacts' }) }, '/api/v1/contacts/meta': { get: op({ summary: 'Get contact metadata', tag: 'Contacts' }) },
'/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 }),
},
'/api/v1/contacts/{id}': { '/api/v1/contacts/{id}': {
put: op({ summary: 'Update contact', tag: 'Contacts', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }), 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 }), delete: op({ summary: 'Delete contact', tag: 'Contacts', params: [idParam()], stateChanging: true }),
}, },
'/api/v1/contacts/{id}/vcard': { get: op({ summary: 'Download contact as vCard', tag: 'Contacts', params: [idParam()] }) }, '/api/v1/contacts/{id}/vcard': { get: op({ summary: 'Download contact as vCard', tag: 'Contacts', params: [idParam()] }) },
+205
View File
@@ -0,0 +1,205 @@
/**
* Modul: CardDAV Management
* Zweck: REST-API-Routen für CardDAV Account Management, Addressbook Discovery, Sync
* Abhängigkeiten: express, server/db.js, server/services/cardav-sync.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 } from '../middleware/validate.js';
const log = createLogger('CardDAV');
const MAX_URL = 500;
const router = express.Router();
/**
* GET /api/v1/contacts/cardav/accounts
* Liste aller CardDAV Accounts.
* Response: { data: Account[] }
*/
router.get('/accounts', async (req, res) => {
try {
const accounts = await CardDAVSync.getAllAccounts();
res.json({ data: accounts });
} catch (err) {
log.error('Error fetching accounts:', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* POST /api/v1/contacts/cardav/accounts
* Neuen CardDAV Account erstellen und Addressbooks discovern.
* Body: { name, cardavUrl, username, password }
* Response: { data: { account, addressbooks } }
*/
router.post('/accounts', async (req, res) => {
try {
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 });
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: 'Interner Fehler', code: 500 });
}
});
/**
* DELETE /api/v1/contacts/cardav/accounts/:id
* CardDAV Account löschen (CASCADE löscht addressbooks + contacts).
* Response: { data: { deleted: true } }
*/
router.delete('/accounts/:id', async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (!id || id < 1) return res.status(400).json({ error: 'Invalid ID', code: 400 });
await CardDAVSync.deleteAccount(id);
res.json({ data: { deleted: true } });
} catch (err) {
log.error('Error deleting CardDAV account:', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* POST /api/v1/contacts/cardav/accounts/:id/test
* Connection testen (ohne Account zu speichern).
* Response: { data: { ok, addressbooks } }
*/
router.post('/accounts/:id/test', async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (!id || id < 1) return res.status(400).json({ error: 'Invalid ID', code: 400 });
const account = db.get().prepare('SELECT * FROM carddav_accounts WHERE id = ?').get(id);
if (!account) return res.status(404).json({ error: 'Account nicht gefunden', code: 404 });
const result = await CardDAVSync.testConnection(
account.carddav_url,
account.username,
account.password
);
res.json({ data: result });
} catch (err) {
log.error('Error testing CardDAV connection:', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* GET /api/v1/contacts/cardav/accounts/:id/addressbooks
* Addressbooks für Account auflisten.
* Response: { data: Addressbook[] }
*/
router.get('/accounts/:id/addressbooks', async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (!id || id < 1) 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: 'Interner Fehler', code: 500 });
}
});
/**
* POST /api/v1/contacts/cardav/accounts/:id/addressbooks/refresh
* Addressbooks neu discovern (PROPFIND).
* Response: { data: Addressbook[] }
*/
router.post('/accounts/:id/addressbooks/refresh', async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (!id || id < 1) return res.status(400).json({ error: 'Invalid ID', code: 400 });
const account = db.get().prepare('SELECT * FROM carddav_accounts WHERE id = ?').get(id);
if (!account) return res.status(404).json({ error: 'Account nicht gefunden', code: 404 });
await CardDAVSync.discoverAddressbooks(id);
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 refreshing addressbooks:', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* PUT /api/v1/contacts/cardav/addressbooks/:id
* Toggle Addressbook enabled/disabled.
* Body: { enabled: boolean }
* Response: { data: { updated: true, enabled: boolean } }
*/
router.put('/addressbooks/:id', async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (!id || id < 1) return res.status(400).json({ error: 'Invalid ID', code: 400 });
const vEnabled = bool(req.body.enabled, 'enabled');
const errors = collectErrors([vEnabled]);
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
CardDAVSync.toggleAddressbook(id, vEnabled.value);
res.json({ data: { updated: true, enabled: vEnabled.value } });
} catch (err) {
log.error('Error toggling addressbook:', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* POST /api/v1/contacts/cardav/accounts/:id/sync
* Sync all enabled addressbooks for account.
* Response: { data: { synced: boolean, contactsAdded: number, contactsUpdated: number } }
*/
router.post('/accounts/:id/sync', async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (!id || id < 1) return res.status(400).json({ error: 'Invalid ID', code: 400 });
const account = db.get().prepare('SELECT * FROM carddav_accounts WHERE id = ?').get(id);
if (!account) return res.status(404).json({ error: 'Account nicht gefunden', code: 404 });
const result = await CardDAVSync.syncAccount(id);
res.json({ data: result });
} catch (err) {
log.error('Error syncing account:', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
export default router;
+331 -5
View File
@@ -16,6 +16,140 @@ const router = express.Router();
const VALID_CATEGORIES = ['Arzt', 'Schule/Kita', 'Behörde', 'Versicherung', const VALID_CATEGORIES = ['Arzt', 'Schule/Kita', 'Behörde', 'Versicherung',
'Handwerker', 'Notfall', 'Sonstiges']; 'Handwerker', 'Notfall', 'Sonstiges'];
/**
* Loads multi-value fields (phones, emails, addresses) for a contact.
* @param {number} contactId - Contact ID
* @returns {{ phones: Array, emails: Array, addresses: Array }}
*/
function loadMultiValueFields(contactId) {
const phones = db.get().prepare(`
SELECT id, label, value, is_primary FROM contact_phones
WHERE contact_id = ?
ORDER BY is_primary DESC, id ASC
`).all(contactId).map(p => ({
id: p.id,
label: p.label,
value: p.value,
isPrimary: p.is_primary === 1
}));
const emails = db.get().prepare(`
SELECT id, label, value, is_primary FROM contact_emails
WHERE contact_id = ?
ORDER BY is_primary DESC, id ASC
`).all(contactId).map(e => ({
id: e.id,
label: e.label,
value: e.value,
isPrimary: e.is_primary === 1
}));
const addresses = db.get().prepare(`
SELECT id, label, street, city, state, postal_code, country, is_primary
FROM contact_addresses
WHERE contact_id = ?
ORDER BY is_primary DESC, id ASC
`).all(contactId).map(a => ({
id: a.id,
label: a.label,
street: a.street,
city: a.city,
state: a.state,
postalCode: a.postal_code,
country: a.country,
isPrimary: a.is_primary === 1
}));
return { phones, emails, addresses };
}
/**
* Validates phones array for multi-value contact fields.
* @param {Array} phones - Array of { label, value, isPrimary? }
* @returns {{ valid: boolean, error?: string }}
*/
function validatePhones(phones) {
if (!Array.isArray(phones)) return { valid: false, error: 'Phones must be an array' };
if (phones.length > 20) return { valid: false, error: 'Too many phone entries (max 20)' };
for (const p of phones) {
if (!p || typeof p !== 'object') return { valid: false, error: 'Phone entry must be an object' };
if (!p.label || !p.value) return { valid: false, error: 'Phone requires label and value' };
if (typeof p.label !== 'string' || p.label.trim().length === 0 || p.label.length > 50) {
return { valid: false, error: 'Phone label invalid or too long' };
}
if (typeof p.value !== 'string' || p.value.trim().length === 0 || p.value.length > 50) {
return { valid: false, error: 'Phone value invalid or too long' };
}
if (p.isPrimary !== undefined && typeof p.isPrimary !== 'boolean') {
return { valid: false, error: 'Phone isPrimary must be boolean' };
}
}
return { valid: true };
}
/**
* Validates emails array for multi-value contact fields.
* @param {Array} emails - Array of { label, value, isPrimary? }
* @returns {{ valid: boolean, error?: string }}
*/
function validateEmails(emails) {
if (!Array.isArray(emails)) return { valid: false, error: 'Emails must be an array' };
if (emails.length > 20) return { valid: false, error: 'Too many email entries (max 20)' };
for (const e of emails) {
if (!e || typeof e !== 'object') return { valid: false, error: 'Email entry must be an object' };
if (!e.label || !e.value) return { valid: false, error: 'Email requires label and value' };
if (typeof e.label !== 'string' || e.label.trim().length === 0 || e.label.length > 50) {
return { valid: false, error: 'Email label invalid or too long' };
}
if (typeof e.value !== 'string' || e.value.trim().length === 0 || e.value.length > 255) {
return { valid: false, error: 'Email value invalid or too long' };
}
if (!/^.+@.+$/.test(e.value)) {
return { valid: false, error: 'Email value must be a valid email address' };
}
if (e.isPrimary !== undefined && typeof e.isPrimary !== 'boolean') {
return { valid: false, error: 'Email isPrimary must be boolean' };
}
}
return { valid: true };
}
/**
* Validates addresses array for multi-value contact fields.
* @param {Array} addresses - Array of { label, street?, city?, state?, postalCode?, country?, isPrimary? }
* @returns {{ valid: boolean, error?: string }}
*/
function validateAddresses(addresses) {
if (!Array.isArray(addresses)) return { valid: false, error: 'Addresses must be an array' };
if (addresses.length > 20) return { valid: false, error: 'Too many address entries (max 20)' };
for (const a of addresses) {
if (!a || typeof a !== 'object') return { valid: false, error: 'Address entry must be an object' };
if (!a.label) return { valid: false, error: 'Address requires label' };
if (typeof a.label !== 'string' || a.label.trim().length === 0 || a.label.length > 50) {
return { valid: false, error: 'Address label invalid or too long' };
}
if (a.street !== undefined && (typeof a.street !== 'string' || a.street.length > 255)) {
return { valid: false, error: 'Address street invalid or too long' };
}
if (a.city !== undefined && (typeof a.city !== 'string' || a.city.length > 255)) {
return { valid: false, error: 'Address city invalid or too long' };
}
if (a.state !== undefined && (typeof a.state !== 'string' || a.state.length > 255)) {
return { valid: false, error: 'Address state invalid or too long' };
}
if (a.postalCode !== undefined && (typeof a.postalCode !== 'string' || a.postalCode.length > 255)) {
return { valid: false, error: 'Address postalCode invalid or too long' };
}
if (a.country !== undefined && (typeof a.country !== 'string' || a.country.length > 255)) {
return { valid: false, error: 'Address country invalid or too long' };
}
if (a.isPrimary !== undefined && typeof a.isPrimary !== 'boolean') {
return { valid: false, error: 'Address isPrimary must be boolean' };
}
}
return { valid: true };
}
/** /**
* GET /api/v1/contacts * GET /api/v1/contacts
* Alle Kontakte, optional nach Kategorie gefiltert und nach Name gesucht. * Alle Kontakte, optional nach Kategorie gefiltert und nach Name gesucht.
@@ -53,7 +187,7 @@ router.get('/', (req, res) => {
/** /**
* POST /api/v1/contacts * POST /api/v1/contacts
* Neuen Kontakt anlegen. * Neuen Kontakt anlegen.
* Body: { name, category?, phone?, email?, address?, notes? } * Body: { name, category?, phone?, email?, address?, notes?, phones?, emails?, addresses? }
* Response: { data: Contact } * Response: { data: Contact }
*/ */
router.post('/', (req, res) => { router.post('/', (req, res) => {
@@ -67,14 +201,95 @@ router.post('/', (req, res) => {
const errors = collectErrors([vName, vCat, vPhone, vEmail, vAddress, vNotes]); const errors = collectErrors([vName, vCat, vPhone, vEmail, vAddress, vNotes]);
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
// Validate multi-value fields if provided
if (req.body.phones !== undefined) {
const phonesValidation = validatePhones(req.body.phones);
if (!phonesValidation.valid) {
return res.status(400).json({ error: phonesValidation.error, code: 400 });
}
}
if (req.body.emails !== undefined) {
const emailsValidation = validateEmails(req.body.emails);
if (!emailsValidation.valid) {
return res.status(400).json({ error: emailsValidation.error, code: 400 });
}
}
if (req.body.addresses !== undefined) {
const addressesValidation = validateAddresses(req.body.addresses);
if (!addressesValidation.valid) {
return res.status(400).json({ error: addressesValidation.error, code: 400 });
}
}
// Insert contact and multi-value fields in a transaction
const transaction = db.get().transaction(() => {
const result = db.get().prepare(` const result = db.get().prepare(`
INSERT INTO contacts (name, category, phone, email, address, notes) INSERT INTO contacts (name, category, phone, email, address, notes)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
`).run(vName.value, vCat.value || 'Sonstiges', vPhone.value, vEmail.value, `).run(vName.value, vCat.value || 'Sonstiges', vPhone.value, vEmail.value,
vAddress.value, vNotes.value); vAddress.value, vNotes.value);
const contact = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(result.lastInsertRowid); const contactId = result.lastInsertRowid;
res.status(201).json({ data: contact });
// Insert phones
if (req.body.phones && Array.isArray(req.body.phones)) {
const insertPhone = db.get().prepare(`
INSERT INTO contact_phones (contact_id, label, value, is_primary)
VALUES (?, ?, ?, ?)
`);
for (const phone of req.body.phones) {
insertPhone.run(contactId, phone.label, phone.value, phone.isPrimary ? 1 : 0);
}
}
// Insert emails
if (req.body.emails && Array.isArray(req.body.emails)) {
const insertEmail = db.get().prepare(`
INSERT INTO contact_emails (contact_id, label, value, is_primary)
VALUES (?, ?, ?, ?)
`);
for (const email of req.body.emails) {
insertEmail.run(contactId, email.label, email.value, email.isPrimary ? 1 : 0);
}
}
// Insert addresses
if (req.body.addresses && Array.isArray(req.body.addresses)) {
const insertAddress = db.get().prepare(`
INSERT INTO contact_addresses (contact_id, label, street, city, state, postal_code, country, is_primary)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const address of req.body.addresses) {
insertAddress.run(
contactId,
address.label,
address.street || null,
address.city || null,
address.state || null,
address.postalCode || null,
address.country || null,
address.isPrimary ? 1 : 0
);
}
}
return contactId;
});
const contactId = transaction();
// Query the created contact with multi-value fields
const contact = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(contactId);
const multiValueFields = loadMultiValueFields(contactId);
res.status(201).json({
data: {
...contact,
...multiValueFields
}
});
} catch (err) { } catch (err) {
log.error('', err); log.error('', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 }); res.status(500).json({ error: 'Interner Fehler', code: 500 });
@@ -84,7 +299,7 @@ router.post('/', (req, res) => {
/** /**
* PUT /api/v1/contacts/:id * PUT /api/v1/contacts/:id
* Kontakt bearbeiten. * Kontakt bearbeiten.
* Body: alle Felder optional * Body: alle Felder optional, phones/emails/addresses mit Replacement-Semantik
* Response: { data: Contact } * Response: { data: Contact }
*/ */
router.put('/:id', (req, res) => { router.put('/:id', (req, res) => {
@@ -103,6 +318,31 @@ router.put('/:id', (req, res) => {
const errors = collectErrors(checks); const errors = collectErrors(checks);
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
// Validate multi-value fields if provided
if (req.body.phones !== undefined) {
const phonesValidation = validatePhones(req.body.phones);
if (!phonesValidation.valid) {
return res.status(400).json({ error: phonesValidation.error, code: 400 });
}
}
if (req.body.emails !== undefined) {
const emailsValidation = validateEmails(req.body.emails);
if (!emailsValidation.valid) {
return res.status(400).json({ error: emailsValidation.error, code: 400 });
}
}
if (req.body.addresses !== undefined) {
const addressesValidation = validateAddresses(req.body.addresses);
if (!addressesValidation.valid) {
return res.status(400).json({ error: addressesValidation.error, code: 400 });
}
}
// Update contact and multi-value fields in a transaction
const transaction = db.get().transaction(() => {
// Update scalar fields
db.get().prepare(` db.get().prepare(`
UPDATE contacts UPDATE contacts
SET name = COALESCE(?, name), SET name = COALESCE(?, name),
@@ -122,8 +362,67 @@ router.put('/:id', (req, res) => {
id id
); );
// Replace phones (delete all, insert new)
if (req.body.phones !== undefined && Array.isArray(req.body.phones)) {
db.get().prepare('DELETE FROM contact_phones WHERE contact_id = ?').run(id);
const insertPhone = db.get().prepare(`
INSERT INTO contact_phones (contact_id, label, value, is_primary)
VALUES (?, ?, ?, ?)
`);
for (const phone of req.body.phones) {
insertPhone.run(id, phone.label, phone.value, phone.isPrimary ? 1 : 0);
}
}
// Replace emails (delete all, insert new)
if (req.body.emails !== undefined && Array.isArray(req.body.emails)) {
db.get().prepare('DELETE FROM contact_emails WHERE contact_id = ?').run(id);
const insertEmail = db.get().prepare(`
INSERT INTO contact_emails (contact_id, label, value, is_primary)
VALUES (?, ?, ?, ?)
`);
for (const email of req.body.emails) {
insertEmail.run(id, email.label, email.value, email.isPrimary ? 1 : 0);
}
}
// Replace addresses (delete all, insert new)
if (req.body.addresses !== undefined && Array.isArray(req.body.addresses)) {
db.get().prepare('DELETE FROM contact_addresses WHERE contact_id = ?').run(id);
const insertAddress = db.get().prepare(`
INSERT INTO contact_addresses (contact_id, label, street, city, state, postal_code, country, is_primary)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const address of req.body.addresses) {
insertAddress.run(
id,
address.label,
address.street || null,
address.city || null,
address.state || null,
address.postalCode || null,
address.country || null,
address.isPrimary ? 1 : 0
);
}
}
});
transaction();
// Query the updated contact with multi-value fields
const updated = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(id); const updated = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(id);
res.json({ data: updated }); const multiValueFields = loadMultiValueFields(id);
res.json({
data: {
...updated,
...multiValueFields
}
});
} catch (err) { } catch (err) {
log.error('', err); log.error('', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 }); res.status(500).json({ error: 'Interner Fehler', code: 500 });
@@ -169,6 +468,32 @@ router.get('/meta', (_req, res) => {
} }
}); });
/**
* GET /api/v1/contacts/:id
* Einzelnen Kontakt abrufen mit Multi-Value Fields (phones, emails, addresses).
* Response: { data: Contact }
*/
router.get('/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10);
const contact = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(id);
if (!contact) return res.status(404).json({ error: 'Kontakt nicht gefunden', code: 404 });
// Load multi-value fields
const multiValueFields = loadMultiValueFields(id);
res.json({
data: {
...contact,
...multiValueFields
}
});
} catch (err) {
log.error('', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/** /**
* GET /api/v1/contacts/:id/vcard * GET /api/v1/contacts/:id/vcard
* Kontakt als vCard 3.0 (.vcf) exportieren. * Kontakt als vCard 3.0 (.vcf) exportieren.
@@ -208,3 +533,4 @@ router.get('/:id/vcard', (req, res) => {
}); });
export default router; export default router;
export { validatePhones, validateEmails, validateAddresses };
+916
View File
@@ -0,0 +1,916 @@
/**
* Modul: CardDAV Contacts Sync
* Zweck: Multi-Account CardDAV synchronization with addressbook selection
* Abhängigkeiten: tsdav, server/db.js
*/
import { createLogger } from '../logger.js';
const log = createLogger('CardDAV');
import * as db from '../db.js';
// --------------------------------------------------------
// Helper Functions
// --------------------------------------------------------
/**
* Parse vCard text into structured object
* @param {string} vCardText - Raw vCard data
* @returns {Object} Parsed vCard object
*/
function parseVCard(vCardText) {
const lines = vCardText.split(/\r?\n/).filter(line => line.trim());
const vcard = {
uid: null,
name: null,
phones: [],
emails: [],
addresses: [],
organization: null,
jobTitle: null,
website: null,
birthday: null,
photo: null,
nickname: null,
notes: null,
categories: null,
};
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
// Handle line folding (continuation lines start with space or tab)
while (i + 1 < lines.length && /^[ \t]/.test(lines[i + 1])) {
line += lines[i + 1].substring(1);
i++;
}
const colonIndex = line.indexOf(':');
if (colonIndex === -1) continue;
const fullKey = line.substring(0, colonIndex);
const value = line.substring(colonIndex + 1).trim();
// Parse property and parameters
const [prop, ...params] = fullKey.split(';');
const property = prop.toUpperCase();
switch (property) {
case 'UID':
vcard.uid = value;
break;
case 'FN':
if (!vcard.name) vcard.name = value;
break;
case 'N':
// N is fallback if FN is not present
// Format: Family;Given;Middle;Prefix;Suffix
if (!vcard.name) {
const parts = value.split(';').filter(p => p);
vcard.name = parts.join(' ').trim();
}
break;
case 'TEL':
const phoneType = extractType(params) || 'other';
vcard.phones.push({ label: phoneType, value: value });
break;
case 'EMAIL':
const emailType = extractType(params) || 'other';
vcard.emails.push({ label: emailType, value: value });
break;
case 'ADR':
// Format: POBox;Extended;Street;City;State;Postal;Country
const adrParts = value.split(';');
const adrType = extractType(params) || 'other';
vcard.addresses.push({
label: adrType,
street: adrParts[2] || null,
city: adrParts[3] || null,
state: adrParts[4] || null,
postalCode: adrParts[5] || null,
country: adrParts[6] || null,
});
break;
case 'ORG':
vcard.organization = value;
break;
case 'TITLE':
vcard.jobTitle = value;
break;
case 'URL':
// Take first URL if multiple exist
if (!vcard.website) vcard.website = value;
break;
case 'BDAY':
// Parse birthday to ISO format (YYYY-MM-DD)
vcard.birthday = parseBirthday(value);
break;
case 'PHOTO':
// Handle base64 encoded photos
if (params.some(p => p.toUpperCase().includes('ENCODING=BASE64') || p.toUpperCase().includes('ENCODING=B'))) {
// Photo might span multiple lines in old vCard format
vcard.photo = value;
}
break;
case 'NICKNAME':
vcard.nickname = value;
break;
case 'NOTE':
vcard.notes = value;
break;
case 'CATEGORIES':
vcard.categories = value;
break;
}
}
return vcard;
}
/**
* Extract TYPE parameter from vCard property parameters
* @param {Array<string>} params - Property parameters
* @returns {string|null} Type value
*/
function extractType(params) {
// Priority order: more specific types first
const typeHierarchy = ['CELL', 'MOBILE', 'HOME', 'WORK', 'FAX', 'OTHER', 'VOICE'];
let foundType = null;
for (const param of params) {
const upper = param.toUpperCase();
if (upper.startsWith('TYPE=')) {
return param.substring(5).toLowerCase();
}
// Some vCards use TYPE without =
if (typeHierarchy.includes(upper)) {
// Keep the most specific type (earlier in hierarchy)
const currentIndex = typeHierarchy.indexOf(upper);
const foundIndex = foundType ? typeHierarchy.indexOf(foundType.toUpperCase()) : -1;
if (foundIndex === -1 || currentIndex < foundIndex) {
foundType = upper.toLowerCase();
}
}
}
return foundType;
}
/**
* Parse birthday from various vCard formats to ISO date
* @param {string} value - Birthday value from vCard
* @returns {string|null} ISO date (YYYY-MM-DD) or null
*/
function parseBirthday(value) {
if (!value) return null;
// Remove any non-numeric characters except hyphens
const cleaned = value.replace(/[^\d-]/g, '');
// Try ISO format (YYYY-MM-DD)
if (/^\d{4}-\d{2}-\d{2}$/.test(cleaned)) {
return cleaned;
}
// Try compact format (YYYYMMDD)
if (/^\d{8}$/.test(cleaned)) {
return `${cleaned.slice(0, 4)}-${cleaned.slice(4, 6)}-${cleaned.slice(6, 8)}`;
}
// Try year only
if (/^\d{4}$/.test(cleaned)) {
return `${cleaned}-01-01`;
}
return null;
}
// --------------------------------------------------------
// Account Management
// --------------------------------------------------------
/**
* Test CardDAV connection
* @param {string} cardavUrl - CardDAV server URL
* @param {string} username - Username
* @param {string} password - Password
* @returns {Promise<Object>} { ok: true, addressbooks: [...] }
*/
async function testConnection(cardavUrl, username, password) {
// Use mock if set (for testing)
if (_testConnectionMock) {
return _testConnectionMock(cardavUrl, username, password);
}
try {
const { createDAVClient } = await import('tsdav');
const client = await createDAVClient({
serverUrl: cardavUrl,
credentials: { username, password },
authMethod: 'Basic',
defaultAccountType: 'carddav',
});
const addressbooks = await client.fetchAddressBooks();
if (!addressbooks.length) {
throw new Error('Connected, but no addressbooks found.');
}
return { ok: true, addressbooks };
} catch (err) {
log.error('Connection test failed:', err.message);
throw new Error(`CardDAV connection failed: ${err.message}`);
}
}
/**
* Add new CardDAV account
* @param {string} name - Account display name
* @param {string} cardavUrl - CardDAV server URL
* @param {string} username - Username
* @param {string} password - Password
* @returns {Promise<Object>} { accountId, addressbooks }
*/
async function addAccount(name, cardavUrl, username, password) {
try {
// Validate inputs
if (!name || !cardavUrl || !username || !password) {
throw new Error('All fields required: name, cardavUrl, username, password');
}
// Test connection first
const { addressbooks } = await testConnection(cardavUrl, username, password);
// Check for duplicate
const existing = db.get().prepare(
'SELECT id FROM carddav_accounts WHERE carddav_url = ? AND username = ?'
).get(cardavUrl, username);
if (existing) {
throw new Error('Account with this URL and username already exists.');
}
// Warn if DB_ENCRYPTION_KEY not set
if (!process.env.DB_ENCRYPTION_KEY) {
log.warn('WARNING: DB_ENCRYPTION_KEY is not set - CardDAV credentials will be stored unencrypted.');
}
// Insert account
const result = db.get().prepare(`
INSERT INTO carddav_accounts (name, carddav_url, username, password)
VALUES (?, ?, ?, ?)
`).run(name, cardavUrl, username, password);
const accountId = result.lastInsertRowid;
// Insert addressbook selections (all enabled by default)
const addressbookData = [];
for (const abook of addressbooks) {
const abookName = abook.displayName || 'Unnamed Addressbook';
db.get().prepare(`
INSERT INTO carddav_addressbook_selection (account_id, addressbook_url, addressbook_name, enabled)
VALUES (?, ?, ?, 1)
`).run(accountId, abook.url, abookName);
addressbookData.push({ url: abook.url, name: abookName, enabled: true });
}
log.info(`Added CardDAV account "${name}" with ${addressbooks.length} addressbooks.`);
const account = {
id: accountId,
name,
cardavUrl,
username,
createdAt: new Date().toISOString(),
lastSync: null
};
return { account, addressbooks: addressbookData };
} catch (err) {
log.error('Failed to add account:', err.message);
throw err;
}
}
/**
* Get all CardDAV accounts
* @returns {Array<Object>} Array of account objects (without passwords)
*/
function getAllAccounts() {
try {
const accounts = db.get().prepare(`
SELECT id, name, carddav_url, username, created_at, last_sync
FROM carddav_accounts
ORDER BY created_at DESC
`).all();
return accounts.map(acc => ({
id: acc.id,
name: acc.name,
cardavUrl: acc.carddav_url,
username: acc.username,
createdAt: acc.created_at,
lastSync: acc.last_sync,
}));
} catch (err) {
log.error('Failed to get accounts:', err.message);
throw err;
}
}
/**
* Delete CardDAV account
* @param {number} accountId - Account ID
* @returns {Object} { success: true }
*/
function deleteAccount(accountId) {
try {
const account = db.get().prepare('SELECT * FROM carddav_accounts WHERE id = ?').get(accountId);
if (!account) {
throw new Error(`Account ${accountId} not found.`);
}
// CASCADE will delete carddav_addressbook_selection entries
// Contacts will have carddav_account_id SET NULL (see migration)
db.get().prepare('DELETE FROM carddav_accounts WHERE id = ?').run(accountId);
log.info(`Deleted CardDAV account ${accountId} ("${account.name}").`);
return { success: true };
} catch (err) {
log.error('Failed to delete account:', err.message);
throw err;
}
}
// --------------------------------------------------------
// Addressbook Discovery & Selection
// --------------------------------------------------------
/**
* Discover addressbooks for an account (refresh from server)
* @param {number} accountId - Account ID
* @returns {Promise<Array<Object>>} Array of addressbook objects
*/
async function discoverAddressbooks(accountId) {
try {
const account = db.get().prepare('SELECT * FROM carddav_accounts WHERE id = ?').get(accountId);
if (!account) {
throw new Error(`Account ${accountId} not found.`);
}
const { addressbooks } = await testConnection(account.carddav_url, account.username, account.password);
// UPSERT into carddav_addressbook_selection
const result = [];
for (const abook of addressbooks) {
const abookName = abook.displayName || 'Unnamed Addressbook';
// Check if exists
const existing = db.get().prepare(`
SELECT id, enabled FROM carddav_addressbook_selection
WHERE account_id = ? AND addressbook_url = ?
`).get(accountId, abook.url);
if (existing) {
// Update name only (preserve enabled state)
db.get().prepare(`
UPDATE carddav_addressbook_selection
SET addressbook_name = ?
WHERE id = ?
`).run(abookName, existing.id);
result.push({
id: existing.id,
url: abook.url,
name: abookName,
enabled: existing.enabled === 1
});
} else {
// Insert new (enabled by default)
const insertResult = db.get().prepare(`
INSERT INTO carddav_addressbook_selection (account_id, addressbook_url, addressbook_name, enabled)
VALUES (?, ?, ?, 1)
`).run(accountId, abook.url, abookName);
result.push({
id: insertResult.lastInsertRowid,
url: abook.url,
name: abookName,
enabled: true
});
}
}
log.info(`Discovered ${addressbooks.length} addressbooks for account ${accountId}.`);
return result;
} catch (err) {
log.error('Failed to discover addressbooks:', err.message);
throw err;
}
}
/**
* Toggle addressbook enabled state
* @param {number} addressbookId - Addressbook selection ID
* @param {boolean} enabled - Enable or disable
* @returns {Object} { success: true }
*/
function toggleAddressbook(addressbookId, enabled) {
try {
const enabledValue = enabled ? 1 : 0;
const result = db.get().prepare(`
UPDATE carddav_addressbook_selection
SET enabled = ?
WHERE id = ?
`).run(enabledValue, addressbookId);
if (result.changes === 0) {
throw new Error(`Addressbook ${addressbookId} not found.`);
}
log.info(`Addressbook ${addressbookId} ${enabled ? 'enabled' : 'disabled'}.`);
return { success: true };
} catch (err) {
log.error('Failed to toggle addressbook:', err.message);
throw err;
}
}
// --------------------------------------------------------
// Contact Sync
// --------------------------------------------------------
/**
* Sync all enabled addressbooks for an account
* @param {number} accountId - Account ID
* @returns {Promise<Object>} { synced, errors }
*/
async function syncAccount(accountId) {
// Use mock if set (for testing)
if (_syncAccountMock) {
return _syncAccountMock(accountId);
}
try {
const account = db.get().prepare('SELECT * FROM carddav_accounts WHERE id = ?').get(accountId);
if (!account) {
throw new Error(`Account ${accountId} not found.`);
}
log.info(`Syncing CardDAV account ${accountId} ("${account.name}")...`);
// Create tsdav client
const { createDAVClient } = await import('tsdav');
const client = await createDAVClient({
serverUrl: account.carddav_url,
credentials: { username: account.username, password: account.password },
authMethod: 'Basic',
defaultAccountType: 'carddav',
});
// Get enabled addressbooks for this account
const enabledAddressbooks = db.get().prepare(`
SELECT id, addressbook_url, addressbook_name
FROM carddav_addressbook_selection
WHERE account_id = ? AND enabled = 1
`).all(accountId);
if (enabledAddressbooks.length === 0) {
log.info(`Account ${accountId}: no enabled addressbooks, skipping.`);
return { synced: 0, errors: 0 };
}
let totalSynced = 0;
let totalErrors = 0;
// Fetch all addressbooks from server
const serverAddressbooks = await client.fetchAddressBooks();
for (const selAbook of enabledAddressbooks) {
// Find matching addressbook from server
const serverAbook = serverAddressbooks.find(sa => sa.url === selAbook.addressbook_url);
if (!serverAbook) {
log.warn(`Addressbook ${selAbook.addressbook_url} not found on server, disabling.`);
db.get().prepare(`
UPDATE carddav_addressbook_selection SET enabled = 0
WHERE id = ?
`).run(selAbook.id);
continue;
}
// Sync this addressbook
const { synced, errors } = await syncAddressbook(accountId, selAbook.addressbook_url, client, serverAbook);
totalSynced += synced;
totalErrors += errors;
}
// Update last_sync for account
db.get().prepare(`
UPDATE carddav_accounts SET last_sync = ? WHERE id = ?
`).run(new Date().toISOString(), accountId);
log.info(`Account ${accountId} sync complete: ${totalSynced} contacts synced, ${totalErrors} errors.`);
return { synced: totalSynced, errors: totalErrors };
} catch (err) {
log.error(`Sync failed for account ${accountId}:`, err.message);
throw err;
}
}
/**
* Sync a specific addressbook
* @param {number} accountId - Account ID
* @param {string} addressbookUrl - Addressbook URL
* @param {Object} client - tsdav client instance (optional, will create if not provided)
* @param {Object} serverAddressbook - Server addressbook object (optional)
* @returns {Promise<Object>} { synced, errors }
*/
async function syncAddressbook(accountId, addressbookUrl, client = null, serverAddressbook = null) {
try {
const account = db.get().prepare('SELECT * FROM carddav_accounts WHERE id = ?').get(accountId);
if (!account) {
throw new Error(`Account ${accountId} not found.`);
}
// Create client if not provided
if (!client) {
const { createDAVClient } = await import('tsdav');
client = await createDAVClient({
serverUrl: account.carddav_url,
credentials: { username: account.username, password: account.password },
authMethod: 'Basic',
defaultAccountType: 'carddav',
});
}
// Find addressbook if not provided
if (!serverAddressbook) {
const addressbooks = await client.fetchAddressBooks();
serverAddressbook = addressbooks.find(ab => ab.url === addressbookUrl);
if (!serverAddressbook) {
throw new Error(`Addressbook ${addressbookUrl} not found on server.`);
}
}
// Fetch vCards from addressbook
let vcardObjects;
try {
vcardObjects = await client.fetchVCards({ addressBook: serverAddressbook });
} catch (err) {
log.error(`Failed to fetch vCards from ${addressbookUrl}:`, err.message);
return { synced: 0, errors: 1 };
}
let synced = 0;
let errors = 0;
// Parse and merge each vCard
for (const vcardObj of vcardObjects) {
try {
const vCardText = vcardObj.data || '';
if (!vCardText.trim()) continue;
await parseAndMergeContact(vCardText, accountId, addressbookUrl);
synced++;
} catch (err) {
log.error(`Failed to parse/merge vCard:`, err.message);
errors++;
}
}
log.info(`Addressbook ${addressbookUrl}: ${synced} contacts synced, ${errors} errors.`);
return { synced, errors };
} catch (err) {
log.error(`Failed to sync addressbook ${addressbookUrl}:`, err.message);
throw err;
}
}
/**
* Parse vCard and merge with existing contact using Smart Merge Logic
* @param {string} vCardText - Raw vCard data
* @param {number} accountId - Account ID
* @param {string} addressbookUrl - Addressbook URL
* @returns {Promise<number>} Contact ID
*/
async function parseAndMergeContact(vCardText, accountId, addressbookUrl) {
try {
const vcard = parseVCard(vCardText);
if (!vcard.uid) {
throw new Error('vCard missing UID, skipping.');
}
if (!vcard.name) {
throw new Error('vCard missing name (FN/N), skipping.');
}
// Smart Merge Logic (see design doc)
// Step 1: Check for existing contact by cardav_uid
let contact = db.get().prepare(`
SELECT * FROM contacts
WHERE carddav_account_id = ? AND carddav_addressbook_url = ? AND carddav_uid = ?
`).get(accountId, addressbookUrl, vcard.uid);
if (contact) {
// Update existing contact (only fill NULL fields to preserve manual changes)
updateContact(contact.id, vcard, false);
updateContactMultiValues(contact.id, vcard);
return contact.id;
}
// Step 2: Check for existing contact by email or phone match
contact = findContactByEmailOrPhone(vcard.emails, vcard.phones);
if (contact) {
// Update existing contact and establish CardDAV link
updateContact(contact.id, vcard, true);
// Set CardDAV link
db.get().prepare(`
UPDATE contacts
SET carddav_account_id = ?, carddav_uid = ?, carddav_addressbook_url = ?
WHERE id = ?
`).run(accountId, vcard.uid, addressbookUrl, contact.id);
updateContactMultiValues(contact.id, vcard);
return contact.id;
}
// Step 3: No match - insert new contact
const result = db.get().prepare(`
INSERT INTO contacts (
name, category, organization, job_title, birthday, website,
photo, nickname, notes,
carddav_account_id, carddav_uid, carddav_addressbook_url
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
vcard.name,
vcard.categories || 'Sonstiges',
vcard.organization,
vcard.jobTitle,
vcard.birthday,
vcard.website,
vcard.photo,
vcard.nickname,
vcard.notes,
accountId,
vcard.uid,
addressbookUrl
);
const contactId = result.lastInsertRowid;
// Insert multi-value fields
insertContactMultiValues(contactId, vcard);
return contactId;
} catch (err) {
log.error('Failed to parse and merge contact:', err.message);
throw err;
}
}
/**
* Find existing contact by email or phone match
* @param {Array<Object>} emails - Array of email objects
* @param {Array<Object>} phones - Array of phone objects
* @returns {Object|null} Contact object or null
*/
function findContactByEmailOrPhone(emails, phones) {
// Try email match first
for (const email of emails) {
const contact = db.get().prepare(`
SELECT c.* FROM contacts c
LEFT JOIN contact_emails ce ON c.id = ce.contact_id
WHERE c.email = ? OR ce.value = ?
LIMIT 1
`).get(email.value, email.value);
if (contact) return contact;
}
// Try phone match
for (const phone of phones) {
const contact = db.get().prepare(`
SELECT c.* FROM contacts c
LEFT JOIN contact_phones cp ON c.id = cp.contact_id
WHERE c.phone = ? OR cp.value = ?
LIMIT 1
`).get(phone.value, phone.value);
if (contact) return contact;
}
return null;
}
/**
* Update existing contact with vCard data (only NULL fields)
* @param {number} contactId - Contact ID
* @param {Object} vcard - Parsed vCard object
* @param {boolean} fillAll - If true, update all fields; if false, only update NULL fields
*/
function updateContact(contactId, vcard, fillAll = false) {
const contact = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(contactId);
if (!contact) return;
const updates = [];
const values = [];
// Helper to conditionally update field
const maybeUpdate = (field, dbColumn, vcardValue) => {
if (vcardValue !== null && vcardValue !== undefined) {
if (fillAll || contact[dbColumn] === null) {
updates.push(`${dbColumn} = ?`);
values.push(vcardValue);
}
}
};
maybeUpdate('name', 'name', vcard.name);
maybeUpdate('organization', 'organization', vcard.organization);
maybeUpdate('jobTitle', 'job_title', vcard.jobTitle);
maybeUpdate('birthday', 'birthday', vcard.birthday);
maybeUpdate('website', 'website', vcard.website);
maybeUpdate('photo', 'photo', vcard.photo);
maybeUpdate('nickname', 'nickname', vcard.nickname);
maybeUpdate('notes', 'notes', vcard.notes);
maybeUpdate('categories', 'category', vcard.categories);
if (updates.length === 0) return;
values.push(contactId);
db.get().prepare(`
UPDATE contacts SET ${updates.join(', ')} WHERE id = ?
`).run(...values);
}
/**
* Update contact multi-value fields (phones, emails, addresses)
* Preserves primary entries, replaces non-primary entries
* @param {number} contactId - Contact ID
* @param {Object} vcard - Parsed vCard object
*/
function updateContactMultiValues(contactId, vcard) {
const transaction = db.get().transaction(() => {
// Delete non-primary entries
db.get().prepare('DELETE FROM contact_phones WHERE contact_id = ? AND is_primary = 0').run(contactId);
db.get().prepare('DELETE FROM contact_emails WHERE contact_id = ? AND is_primary = 0').run(contactId);
db.get().prepare('DELETE FROM contact_addresses WHERE contact_id = ? AND is_primary = 0').run(contactId);
// Insert new entries from vCard
insertContactMultiValues(contactId, vcard);
});
transaction();
}
/**
* Insert contact multi-value fields (phones, emails, addresses)
* @param {number} contactId - Contact ID
* @param {Object} vcard - Parsed vCard object
*/
function insertContactMultiValues(contactId, vcard) {
// Check if primary entries exist
const hasPrimaryPhone = db.get().prepare(
'SELECT 1 FROM contact_phones WHERE contact_id = ? AND is_primary = 1'
).get(contactId);
const hasPrimaryEmail = db.get().prepare(
'SELECT 1 FROM contact_emails WHERE contact_id = ? AND is_primary = 1'
).get(contactId);
const hasPrimaryAddress = db.get().prepare(
'SELECT 1 FROM contact_addresses WHERE contact_id = ? AND is_primary = 1'
).get(contactId);
// Batch insert phones
if (vcard.phones && vcard.phones.length > 0) {
const placeholders = vcard.phones.map(() => '(?, ?, ?, ?)').join(', ');
const values = vcard.phones.flatMap((phone, i) => [
contactId,
phone.label || null,
phone.value,
(!hasPrimaryPhone && i === 0) ? 1 : 0
]);
db.get().prepare(`
INSERT INTO contact_phones (contact_id, label, value, is_primary)
VALUES ${placeholders}
`).run(...values);
}
// Batch insert emails
if (vcard.emails && vcard.emails.length > 0) {
const placeholders = vcard.emails.map(() => '(?, ?, ?, ?)').join(', ');
const values = vcard.emails.flatMap((email, i) => [
contactId,
email.label || null,
email.value,
(!hasPrimaryEmail && i === 0) ? 1 : 0
]);
db.get().prepare(`
INSERT INTO contact_emails (contact_id, label, value, is_primary)
VALUES ${placeholders}
`).run(...values);
}
// Batch insert addresses
if (vcard.addresses && vcard.addresses.length > 0) {
const placeholders = vcard.addresses.map(() => '(?, ?, ?, ?, ?, ?, ?, ?)').join(', ');
const values = vcard.addresses.flatMap((addr, i) => [
contactId,
addr.label || null,
addr.street,
addr.city,
addr.state,
addr.postalCode,
addr.country,
(!hasPrimaryAddress && i === 0) ? 1 : 0
]);
db.get().prepare(`
INSERT INTO contact_addresses (contact_id, label, street, city, state, postal_code, country, is_primary)
VALUES ${placeholders}
`).run(...values);
}
}
// --------------------------------------------------------
// Exports
// --------------------------------------------------------
export {
// Account Management
addAccount,
getAllAccounts,
deleteAccount,
testConnection,
// Addressbook Discovery
discoverAddressbooks,
toggleAddressbook,
// Contact Sync
syncAccount,
syncAddressbook,
parseAndMergeContact,
// Helpers (exported for testing)
parseVCard,
_mockTestConnection,
_mockSyncAccount,
};
// --------------------------------------------------------
// Test Mocking Support
// --------------------------------------------------------
let _testConnectionMock = null;
let _syncAccountMock = null;
/**
* ONLY FOR TESTING: Mock testConnection for unit tests
* @param {Function|null} mockFn - Mock function or null to reset
*/
function _mockTestConnection(mockFn) {
_testConnectionMock = mockFn;
}
/**
* ONLY FOR TESTING: Mock syncAccount for unit tests
* @param {Function|null} mockFn - Mock function or null to reset
*/
function _mockSyncAccount(mockFn) {
_syncAccountMock = mockFn;
}
+2645
View File
File diff suppressed because it is too large Load Diff