feat(contacts): add multi-value fields support to PUT /contacts/:id
Extend PUT /contacts/:id route to support updating phones, emails, and addresses arrays with replacement semantics. Uses atomic transactions to DELETE all existing multi-values then INSERT new ones. Validates all multi-value fields before updating. Response includes full contact with multi-value fields via loadMultiValueFields() helper. Backward compatible: multi-value fields only replaced when present in request body. Scalar fields (name, category, etc.) continue to work independently. Tests added: - Update with multi-value fields (replacement semantics verified) - Validation error on invalid phone data (400) - Backward compatibility: update without multi-values preserves them All 109 tests pass. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
+22
-11
@@ -1,8 +1,8 @@
|
|||||||
# CardDAV API Routes Implementation - Fortschritt
|
# CardDAV API Routes Implementation - Fortschritt
|
||||||
|
|
||||||
**Stand:** 2026-05-04, nach Task 12 von 15 (Session 3)
|
**Stand:** 2026-05-04, nach Task 13 von 15 (Session 3)
|
||||||
**Plan:** `docs/superpowers/plans/2026-05-04-cardav-api-routes.md`
|
**Plan:** `docs/superpowers/plans/2026-05-04-cardav-api-routes.md`
|
||||||
**Nächster Task:** Task 13 - PUT /contacts/:id mit Multi-Value Fields
|
**Nächster Task:** Task 14 - OpenAPI Documentation
|
||||||
|
|
||||||
## Abgeschlossene Tasks
|
## Abgeschlossene Tasks
|
||||||
|
|
||||||
@@ -152,12 +152,22 @@
|
|||||||
- Refactoring: `loadMultiValueFields(contactId)` Helper extrahiert (DRY)
|
- Refactoring: `loadMultiValueFields(contactId)` Helper extrahiert (DRY)
|
||||||
- TDD-Workflow eingehalten: RED → GREEN → REFACTOR → Commit
|
- TDD-Workflow eingehalten: RED → GREEN → REFACTOR → Commit
|
||||||
|
|
||||||
## Offene Tasks (13-15)
|
### ✅ Task 13: PUT /contacts/:id - Update With Multi-Value Fields
|
||||||
|
**Commit:** (wird erstellt)
|
||||||
|
|
||||||
### 🔄 Task 13: PUT /contacts/:id
|
- Implementiert: PUT /contacts/:id erweitert um phones, emails, addresses Arrays
|
||||||
- Update mit Multi-Value Fields
|
- Validierung: `validatePhones()`, `validateEmails()`, `validateAddresses()` vor Update
|
||||||
- Replacement-Semantik für Arrays (DELETE + INSERT)
|
- Replacement-Semantik: DELETE alle existierenden Multi-Values, dann INSERT neue
|
||||||
- Atomare Transaktionen
|
- 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
|
||||||
|
|
||||||
|
## Offene Tasks (14-15)
|
||||||
|
|
||||||
### 🔄 Task 14: OpenAPI Documentation
|
### 🔄 Task 14: OpenAPI Documentation
|
||||||
- Alle neuen Routes in `server/openapi.js` dokumentieren
|
- Alle neuen Routes in `server/openapi.js` dokumentieren
|
||||||
@@ -249,12 +259,13 @@ c078a48 feat(cardav): implement POST /accounts/:id/addressbooks/refresh endpoint
|
|||||||
|
|
||||||
## Test-Status
|
## Test-Status
|
||||||
|
|
||||||
- **Gesamt:** 106 Tests, alle bestehen
|
- **Gesamt:** 109 Tests, alle bestehen
|
||||||
- **Suites:** 19 Suites
|
- **Suites:** 20 Suites
|
||||||
- **CardDAV API Routes Suite:** 14 Tests
|
- **CardDAV API Routes Suite:** 14 Tests
|
||||||
- **Contacts API - Multi-Value Fields Suite:** 5 Tests
|
- **Contacts API - Multi-Value Fields Suite:** 8 Tests
|
||||||
- GET /contacts/:id: 2 Tests
|
- GET /contacts/:id: 2 Tests
|
||||||
- POST /contacts: 3 Tests
|
- POST /contacts: 3 Tests
|
||||||
|
- PUT /contacts/:id: 3 Tests
|
||||||
- Account Management (6 Tests):
|
- Account Management (6 Tests):
|
||||||
- GET /accounts (empty)
|
- GET /accounts (empty)
|
||||||
- GET /accounts (populated with shape)
|
- GET /accounts (populated with shape)
|
||||||
@@ -296,7 +307,7 @@ c078a48 feat(cardav): implement POST /accounts/:id/addressbooks/refresh endpoint
|
|||||||
#10. [completed] Task 10: POST /accounts/:id/sync - Sync Account
|
#10. [completed] Task 10: POST /accounts/:id/sync - Sync Account
|
||||||
#11. [completed] Task 11: GET /contacts/:id - With Multi-Values
|
#11. [completed] Task 11: GET /contacts/:id - With Multi-Values
|
||||||
#12. [completed] Task 12: POST /contacts - Create With Multi-Values
|
#12. [completed] Task 12: POST /contacts - Create With Multi-Values
|
||||||
#13. [pending] Task 13: PUT /contacts/:id - Update With Multi-Values
|
#13. [completed] Task 13: PUT /contacts/:id - Update With Multi-Values
|
||||||
#14. [pending] Task 14: Document All Routes in OpenAPI
|
#14. [pending] Task 14: Document All Routes in OpenAPI
|
||||||
#15. [pending] Task 15: Mount CardDAV Router
|
#15. [pending] Task 15: Mount CardDAV Router
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -299,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) => {
|
||||||
@@ -318,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),
|
||||||
@@ -337,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 });
|
||||||
|
|||||||
+197
@@ -2445,4 +2445,201 @@ describe('Contacts API - Multi-Value Fields', () => {
|
|||||||
assert.strictEqual(contact.addresses.length, 0);
|
assert.strictEqual(contact.addresses.length, 0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('PUT /contacts/:id', () => {
|
||||||
|
it('should update contact with multi-value fields (replacement semantics)', async () => {
|
||||||
|
const contactsRouter = await import('./server/routes/contacts.js');
|
||||||
|
|
||||||
|
// First create a contact with multi-value fields
|
||||||
|
const createReq = {
|
||||||
|
params: {},
|
||||||
|
query: {},
|
||||||
|
body: {
|
||||||
|
name: 'Original Contact',
|
||||||
|
category: 'Arzt',
|
||||||
|
phones: [{ label: 'Mobil', value: '+49171111111', isPrimary: true }],
|
||||||
|
emails: [{ label: 'Privat', value: 'original@example.com', isPrimary: true }],
|
||||||
|
addresses: [{ label: 'Privat', street: 'Alte Straße 1', city: 'Berlin', postalCode: '10115', country: 'Deutschland', isPrimary: true }]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const createRes = {
|
||||||
|
statusCode: 200,
|
||||||
|
status(code) { this.statusCode = code; return this; },
|
||||||
|
json(data) { this.data = data; return this; },
|
||||||
|
};
|
||||||
|
|
||||||
|
const postHandler = contactsRouter.default.stack.find(
|
||||||
|
layer => layer.route?.path === '/' && layer.route.methods.post
|
||||||
|
)?.route?.stack[0]?.handle;
|
||||||
|
|
||||||
|
await postHandler(createReq, createRes);
|
||||||
|
const contactId = createRes.data.data.id;
|
||||||
|
|
||||||
|
// Now update it with new multi-value fields (replacement)
|
||||||
|
const updateReq = {
|
||||||
|
params: { id: String(contactId) },
|
||||||
|
query: {},
|
||||||
|
body: {
|
||||||
|
name: 'Updated Contact',
|
||||||
|
phones: [
|
||||||
|
{ label: 'Arbeit', value: '+49302222222', isPrimary: true },
|
||||||
|
{ label: 'Mobil', value: '+49173333333', isPrimary: false }
|
||||||
|
],
|
||||||
|
emails: [
|
||||||
|
{ label: 'Arbeit', value: 'new.work@example.com', isPrimary: true }
|
||||||
|
],
|
||||||
|
addresses: [
|
||||||
|
{ label: 'Arbeit', street: 'Neue Straße 10', city: 'München', postalCode: '80331', country: 'Deutschland', isPrimary: true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const updateRes = {
|
||||||
|
statusCode: 200,
|
||||||
|
status(code) { this.statusCode = code; return this; },
|
||||||
|
json(data) { this.data = data; return this; },
|
||||||
|
};
|
||||||
|
|
||||||
|
const putHandler = contactsRouter.default.stack.find(
|
||||||
|
layer => layer.route?.path === '/:id' && layer.route.methods.put
|
||||||
|
)?.route?.stack[0]?.handle;
|
||||||
|
|
||||||
|
await putHandler(updateReq, updateRes);
|
||||||
|
|
||||||
|
assert.strictEqual(updateRes.statusCode, 200);
|
||||||
|
const updated = updateRes.data.data;
|
||||||
|
|
||||||
|
// Check name was updated
|
||||||
|
assert.strictEqual(updated.name, 'Updated Contact');
|
||||||
|
|
||||||
|
// Check phones were replaced (old deleted, new inserted)
|
||||||
|
assert.strictEqual(updated.phones.length, 2);
|
||||||
|
assert.strictEqual(updated.phones[0].label, 'Arbeit');
|
||||||
|
assert.strictEqual(updated.phones[0].value, '+49302222222');
|
||||||
|
assert.strictEqual(updated.phones[0].isPrimary, true);
|
||||||
|
assert.strictEqual(updated.phones[1].label, 'Mobil');
|
||||||
|
assert.strictEqual(updated.phones[1].value, '+49173333333');
|
||||||
|
assert.strictEqual(updated.phones[1].isPrimary, false);
|
||||||
|
|
||||||
|
// Check emails were replaced
|
||||||
|
assert.strictEqual(updated.emails.length, 1);
|
||||||
|
assert.strictEqual(updated.emails[0].label, 'Arbeit');
|
||||||
|
assert.strictEqual(updated.emails[0].value, 'new.work@example.com');
|
||||||
|
assert.strictEqual(updated.emails[0].isPrimary, true);
|
||||||
|
|
||||||
|
// Check addresses were replaced
|
||||||
|
assert.strictEqual(updated.addresses.length, 1);
|
||||||
|
assert.strictEqual(updated.addresses[0].label, 'Arbeit');
|
||||||
|
assert.strictEqual(updated.addresses[0].street, 'Neue Straße 10');
|
||||||
|
assert.strictEqual(updated.addresses[0].city, 'München');
|
||||||
|
assert.strictEqual(updated.addresses[0].isPrimary, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 for invalid multi-value data', async () => {
|
||||||
|
const contactsRouter = await import('./server/routes/contacts.js');
|
||||||
|
|
||||||
|
// Create a contact first
|
||||||
|
const createReq = {
|
||||||
|
params: {},
|
||||||
|
query: {},
|
||||||
|
body: {
|
||||||
|
name: 'Test Contact',
|
||||||
|
category: 'Sonstiges'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const createRes = {
|
||||||
|
statusCode: 200,
|
||||||
|
status(code) { this.statusCode = code; return this; },
|
||||||
|
json(data) { this.data = data; return this; },
|
||||||
|
};
|
||||||
|
|
||||||
|
const postHandler = contactsRouter.default.stack.find(
|
||||||
|
layer => layer.route?.path === '/' && layer.route.methods.post
|
||||||
|
)?.route?.stack[0]?.handle;
|
||||||
|
|
||||||
|
await postHandler(createReq, createRes);
|
||||||
|
const contactId = createRes.data.data.id;
|
||||||
|
|
||||||
|
// Try to update with invalid phone data
|
||||||
|
const updateReq = {
|
||||||
|
params: { id: String(contactId) },
|
||||||
|
query: {},
|
||||||
|
body: {
|
||||||
|
phones: [{ label: 'Mobil', value: '', isPrimary: 'invalid' }] // empty value + invalid isPrimary
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const updateRes = {
|
||||||
|
statusCode: 200,
|
||||||
|
status(code) { this.statusCode = code; return this; },
|
||||||
|
json(data) { this.data = data; return this; },
|
||||||
|
};
|
||||||
|
|
||||||
|
const putHandler = contactsRouter.default.stack.find(
|
||||||
|
layer => layer.route?.path === '/:id' && layer.route.methods.put
|
||||||
|
)?.route?.stack[0]?.handle;
|
||||||
|
|
||||||
|
await putHandler(updateReq, updateRes);
|
||||||
|
|
||||||
|
assert.strictEqual(updateRes.statusCode, 400);
|
||||||
|
assert.ok(updateRes.data.error, 'Should have error message');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update contact without multi-value fields (backwards compatible)', async () => {
|
||||||
|
const contactsRouter = await import('./server/routes/contacts.js');
|
||||||
|
|
||||||
|
// Create contact with multi-value fields
|
||||||
|
const createReq = {
|
||||||
|
params: {},
|
||||||
|
query: {},
|
||||||
|
body: {
|
||||||
|
name: 'Test Contact',
|
||||||
|
category: 'Sonstiges',
|
||||||
|
phones: [{ label: 'Mobil', value: '+49171111111', isPrimary: true }]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const createRes = {
|
||||||
|
statusCode: 200,
|
||||||
|
status(code) { this.statusCode = code; return this; },
|
||||||
|
json(data) { this.data = data; return this; },
|
||||||
|
};
|
||||||
|
|
||||||
|
const postHandler = contactsRouter.default.stack.find(
|
||||||
|
layer => layer.route?.path === '/' && layer.route.methods.post
|
||||||
|
)?.route?.stack[0]?.handle;
|
||||||
|
|
||||||
|
await postHandler(createReq, createRes);
|
||||||
|
const contactId = createRes.data.data.id;
|
||||||
|
|
||||||
|
// Update without touching multi-value fields (only scalar fields)
|
||||||
|
const updateReq = {
|
||||||
|
params: { id: String(contactId) },
|
||||||
|
query: {},
|
||||||
|
body: {
|
||||||
|
name: 'Updated Name Only',
|
||||||
|
category: 'Arzt'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const updateRes = {
|
||||||
|
statusCode: 200,
|
||||||
|
status(code) { this.statusCode = code; return this; },
|
||||||
|
json(data) { this.data = data; return this; },
|
||||||
|
};
|
||||||
|
|
||||||
|
const putHandler = contactsRouter.default.stack.find(
|
||||||
|
layer => layer.route?.path === '/:id' && layer.route.methods.put
|
||||||
|
)?.route?.stack[0]?.handle;
|
||||||
|
|
||||||
|
await putHandler(updateReq, updateRes);
|
||||||
|
|
||||||
|
assert.strictEqual(updateRes.statusCode, 200);
|
||||||
|
const updated = updateRes.data.data;
|
||||||
|
|
||||||
|
// Scalar fields should be updated
|
||||||
|
assert.strictEqual(updated.name, 'Updated Name Only');
|
||||||
|
assert.strictEqual(updated.category, 'Arzt');
|
||||||
|
|
||||||
|
// Multi-value fields should remain unchanged
|
||||||
|
assert.strictEqual(updated.phones.length, 1);
|
||||||
|
assert.strictEqual(updated.phones[0].value, '+49171111111');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user