From 966a6d46e310913e6c1e63bb013773d451b84f77 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Mon, 4 May 2026 18:25:18 +0200 Subject: [PATCH] feat(contacts): add POST /contacts with multi-value fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- PROGRESS.md | 34 ++++--- server/routes/contacts.js | 189 ++++++++++++++++++++++++++++---------- test-carddav.js | 148 +++++++++++++++++++++++++++++ 3 files changed, 310 insertions(+), 61 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 60b8c47..a01de8e 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,8 +1,8 @@ # 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` -**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 @@ -137,12 +137,22 @@ - Contact ohne Multi-Value Fields (leere Arrays) - 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 -- Erstellen mit Multi-Value Fields -- Validierung mit `validatePhones()`, `validateEmails()`, `validateAddresses()` -- Atomare Transaktionen für Contact + Multi-Values +- 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 + +## Offene Tasks (13-15) ### 🔄 Task 13: PUT /contacts/:id - Update mit Multi-Value Fields @@ -239,10 +249,12 @@ c078a48 feat(cardav): implement POST /accounts/:id/addressbooks/refresh endpoint ## Test-Status -- **Gesamt:** 103 Tests, alle bestehen -- **Suites:** 18 Suites +- **Gesamt:** 106 Tests, alle bestehen +- **Suites:** 19 Suites - **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): - GET /accounts (empty) - 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 #10. [completed] Task 10: POST /accounts/:id/sync - Sync Account #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 #14. [pending] Task 14: Document All Routes in OpenAPI #15. [pending] Task 15: Mount CardDAV Router diff --git a/server/routes/contacts.js b/server/routes/contacts.js index 58d8257..5d854c3 100644 --- a/server/routes/contacts.js +++ b/server/routes/contacts.js @@ -16,6 +16,53 @@ const router = express.Router(); const VALID_CATEGORIES = ['Arzt', 'Schule/Kita', 'Behörde', 'Versicherung', '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? } @@ -140,7 +187,7 @@ router.get('/', (req, res) => { /** * POST /api/v1/contacts * Neuen Kontakt anlegen. - * Body: { name, category?, phone?, email?, address?, notes? } + * Body: { name, category?, phone?, email?, address?, notes?, phones?, emails?, addresses? } * Response: { data: Contact } */ router.post('/', (req, res) => { @@ -154,14 +201,95 @@ router.post('/', (req, res) => { const errors = collectErrors([vName, vCat, vPhone, vEmail, vAddress, vNotes]); if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); - const result = db.get().prepare(` - INSERT INTO contacts (name, category, phone, email, address, notes) - VALUES (?, ?, ?, ?, ?, ?) - `).run(vName.value, vCat.value || 'Sonstiges', vPhone.value, vEmail.value, - vAddress.value, vNotes.value); + // 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 }); + } + } - const contact = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(result.lastInsertRowid); - res.status(201).json({ data: contact }); + 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(` + INSERT INTO contacts (name, category, phone, email, address, notes) + VALUES (?, ?, ?, ?, ?, ?) + `).run(vName.value, vCat.value || 'Sonstiges', vPhone.value, vEmail.value, + vAddress.value, vNotes.value); + + const contactId = result.lastInsertRowid; + + // 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) { log.error('', err); 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); if (!contact) return res.status(404).json({ error: 'Kontakt nicht gefunden', code: 404 }); - // Query multi-value fields - 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(id).map(p => ({ - id: p.id, - label: p.label, - value: p.value, - isPrimary: p.is_primary === 1 - })); + // Load multi-value fields + const multiValueFields = loadMultiValueFields(id); - 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({ data: { ...contact, - phones, - emails, - addresses + ...multiValueFields } }); } catch (err) { diff --git a/test-carddav.js b/test-carddav.js index b307009..71a6b4a 100644 --- a/test-carddav.js +++ b/test-carddav.js @@ -2297,4 +2297,152 @@ describe('Contacts API - Multi-Value Fields', () => { 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); + }); + }); });