feat(contacts): add POST /contacts with multi-value fields

Implements Task 12: Extend POST /contacts to accept and persist phones,
emails, and addresses arrays. Uses atomic transactions to ensure all
related records are created together or rolled back on error.

- Validation: validatePhones/Emails/Addresses before insert
- Transaction: db.transaction() for atomic Contact + Multi-Values
- Backward compatible: Multi-value fields are optional
- Refactoring: Extracted loadMultiValueFields() helper (DRY)
- Response includes all multi-value fields with generated IDs

Tests: 3 new tests (create with multi-values, validation, backward compat)
TDD workflow: RED → GREEN → REFACTOR → Commit

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Ulas Kalayci
2026-05-04 18:25:18 +02:00
parent 859a205299
commit 966a6d46e3
3 changed files with 310 additions and 61 deletions
+23 -11
View File
@@ -1,8 +1,8 @@
# CardDAV API Routes Implementation - Fortschritt # CardDAV API Routes Implementation - Fortschritt
**Stand:** 2026-05-04, nach Task 11 von 15 (Session 3) **Stand:** 2026-05-04, nach Task 12 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 12 - POST /contacts mit Multi-Value Fields **Nächster Task:** Task 13 - PUT /contacts/:id mit Multi-Value Fields
## Abgeschlossene Tasks ## Abgeschlossene Tasks
@@ -137,12 +137,22 @@
- Contact ohne Multi-Value Fields (leere Arrays) - Contact ohne Multi-Value Fields (leere Arrays)
- TDD-Workflow eingehalten: RED → GREEN → Commit - TDD-Workflow eingehalten: RED → GREEN → Commit
## Offene Tasks (12-15) ### ✅ Task 12: POST /contacts - Create With Multi-Value Fields
**Commit:** [wird gesetzt nach commit]
### 🔄 Task 12: POST /contacts - Implementiert: POST /contacts erweitert um phones, emails, addresses Arrays
- Erstellen mit Multi-Value Fields - Validierung: `validatePhones()`, `validateEmails()`, `validateAddresses()` vor Insert
- Validierung mit `validatePhones()`, `validateEmails()`, `validateAddresses()` - Transaktionen: `db.transaction()` für atomare Contact + Multi-Values Inserts
- Atomare Transaktionen für Contact + Multi-Values - 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
## Offene Tasks (13-15)
### 🔄 Task 13: PUT /contacts/:id ### 🔄 Task 13: PUT /contacts/:id
- Update mit Multi-Value Fields - Update mit Multi-Value Fields
@@ -239,10 +249,12 @@ c078a48 feat(cardav): implement POST /accounts/:id/addressbooks/refresh endpoint
## Test-Status ## Test-Status
- **Gesamt:** 103 Tests, alle bestehen - **Gesamt:** 106 Tests, alle bestehen
- **Suites:** 18 Suites - **Suites:** 19 Suites
- **CardDAV API Routes Suite:** 14 Tests - **CardDAV API Routes Suite:** 14 Tests
- **Contacts API - Multi-Value Fields Suite:** 2 Tests - **Contacts API - Multi-Value Fields Suite:** 5 Tests
- GET /contacts/:id: 2 Tests
- POST /contacts: 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)
@@ -283,7 +295,7 @@ c078a48 feat(cardav): implement POST /accounts/:id/addressbooks/refresh endpoint
#9. [completed] Task 9: PUT /addressbooks/:id - Toggle Addressbook #9. [completed] Task 9: PUT /addressbooks/:id - Toggle Addressbook
#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. [pending] 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. [pending] 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
+134 -45
View File
@@ -16,6 +16,53 @@ 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. * Validates phones array for multi-value contact fields.
* @param {Array} phones - Array of { label, value, isPrimary? } * @param {Array} phones - Array of { label, value, isPrimary? }
@@ -140,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) => {
@@ -154,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 });
@@ -267,52 +395,13 @@ router.get('/:id', (req, res) => {
const contact = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(id); 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 }); if (!contact) return res.status(404).json({ error: 'Kontakt nicht gefunden', code: 404 });
// Query multi-value fields // Load multi-value fields
const phones = db.get().prepare(` const multiValueFields = loadMultiValueFields(id);
SELECT id, label, value, is_primary FROM contact_phones
WHERE contact_id = ?
ORDER BY is_primary DESC, id ASC
`).all(id).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(id).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(id).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
}));
// Combine contact with multi-value fields
res.json({ res.json({
data: { data: {
...contact, ...contact,
phones, ...multiValueFields
emails,
addresses
} }
}); });
} catch (err) { } catch (err) {
+148
View File
@@ -2297,4 +2297,152 @@ describe('Contacts API - Multi-Value Fields', () => {
assert.strictEqual(contact.addresses.length, 0, 'addresses should be empty'); assert.strictEqual(contact.addresses.length, 0, 'addresses should be empty');
}); });
}); });
describe('POST /contacts', () => {
it('should create contact with multi-value fields', async () => {
const contactsRouter = await import('./server/routes/contacts.js');
const req = {
params: {},
query: {},
body: {
name: 'Dr. Schmidt',
category: 'Arzt',
notes: 'Hausarzt',
phones: [
{ label: 'Praxis', value: '+4930123456', isPrimary: true },
{ label: 'Mobil', value: '+491701234567', isPrimary: false }
],
emails: [
{ label: 'Praxis', value: 'praxis@schmidt.de', isPrimary: true }
],
addresses: [
{
label: 'Praxis',
street: 'Hauptstraße 10',
city: 'Berlin',
postalCode: '10115',
country: 'Deutschland',
isPrimary: true
}
]
}
};
const res = {
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;
assert.ok(postHandler, 'POST /contacts handler should exist');
await postHandler(req, res);
// Verify response
assert.strictEqual(res.statusCode, 201);
assert.ok(res.data.data, 'Response should have data field');
const contact = res.data.data;
assert.strictEqual(contact.name, 'Dr. Schmidt');
assert.strictEqual(contact.category, 'Arzt');
// Verify multi-value fields were created
assert.ok(Array.isArray(contact.phones), 'phones should be in response');
assert.strictEqual(contact.phones.length, 2);
const praxisPhone = contact.phones.find(p => p.label === 'Praxis');
assert.ok(praxisPhone, 'Should have Praxis phone');
assert.strictEqual(praxisPhone.value, '+4930123456');
assert.strictEqual(praxisPhone.isPrimary, true);
assert.ok(Array.isArray(contact.emails), 'emails should be in response');
assert.strictEqual(contact.emails.length, 1);
assert.strictEqual(contact.emails[0].value, 'praxis@schmidt.de');
assert.ok(Array.isArray(contact.addresses), 'addresses should be in response');
assert.strictEqual(contact.addresses.length, 1);
assert.strictEqual(contact.addresses[0].street, 'Hauptstraße 10');
assert.strictEqual(contact.addresses[0].city, 'Berlin');
// Verify data persisted in database
const contactId = contact.id;
const dbPhones = contactsApiDb.prepare('SELECT * FROM contact_phones WHERE contact_id = ?').all(contactId);
assert.strictEqual(dbPhones.length, 2, 'Should have 2 phones in DB');
const dbEmails = contactsApiDb.prepare('SELECT * FROM contact_emails WHERE contact_id = ?').all(contactId);
assert.strictEqual(dbEmails.length, 1, 'Should have 1 email in DB');
const dbAddresses = contactsApiDb.prepare('SELECT * FROM contact_addresses WHERE contact_id = ?').all(contactId);
assert.strictEqual(dbAddresses.length, 1, 'Should have 1 address in DB');
});
it('should validate phones array and return 400 on invalid data', async () => {
const contactsRouter = await import('./server/routes/contacts.js');
const req = {
params: {},
query: {},
body: {
name: 'Test Contact',
phones: [
{ label: 'Invalid' } // missing value
]
}
};
const res = {
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(req, res);
assert.strictEqual(res.statusCode, 400);
assert.ok(res.data.error, 'Should have error message');
assert.ok(res.data.error.includes('Phone'), 'Error should mention Phone');
});
it('should create contact without multi-value fields (backwards compatible)', async () => {
const contactsRouter = await import('./server/routes/contacts.js');
const req = {
params: {},
query: {},
body: {
name: 'Simple Contact',
category: 'Sonstiges'
}
};
const res = {
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(req, res);
assert.strictEqual(res.statusCode, 201);
const contact = res.data.data;
assert.strictEqual(contact.name, 'Simple Contact');
// Should have empty arrays
assert.ok(Array.isArray(contact.phones));
assert.strictEqual(contact.phones.length, 0);
assert.ok(Array.isArray(contact.emails));
assert.strictEqual(contact.emails.length, 0);
assert.ok(Array.isArray(contact.addresses));
assert.strictEqual(contact.addresses.length, 0);
});
});
}); });