Files
Ulas Kalayci 0dc303b81a 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>
2026-05-04 18:31:01 +02:00

537 lines
20 KiB
JavaScript

/**
* Modul: Kontakte (Contacts)
* Zweck: REST-API-Routen für wichtige Familienkontakte
* Abhängigkeiten: express, server/db.js, server/auth.js
*/
import { createLogger } from '../logger.js';
import express from 'express';
import * as db from '../db.js';
import { str, oneOf, collectErrors, MAX_TITLE, MAX_TEXT, MAX_SHORT } from '../middleware/validate.js';
const log = createLogger('Contacts');
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? }
* @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
* Alle Kontakte, optional nach Kategorie gefiltert und nach Name gesucht.
* Query: ?category=<cat>&q=<search>
* Response: { data: Contact[] }
*/
router.get('/', (req, res) => {
try {
let sql = 'SELECT * FROM contacts';
const params = [];
const where = [];
if (req.query.category && VALID_CATEGORIES.includes(req.query.category)) {
where.push('category = ?');
params.push(req.query.category);
}
if (req.query.q) {
where.push('(name LIKE ? OR phone LIKE ? OR email LIKE ?)');
const like = `%${req.query.q}%`;
params.push(like, like, like);
}
if (where.length) sql += ' WHERE ' + where.join(' AND ');
sql += ' ORDER BY category ASC, name ASC';
const contacts = db.get().prepare(sql).all(...params);
res.json({ data: contacts });
} catch (err) {
log.error('', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* POST /api/v1/contacts
* Neuen Kontakt anlegen.
* Body: { name, category?, phone?, email?, address?, notes?, phones?, emails?, addresses? }
* Response: { data: Contact }
*/
router.post('/', (req, res) => {
try {
const vName = str(req.body.name, 'Name', { max: MAX_TITLE });
const vCat = oneOf(req.body.category || 'Sonstiges', VALID_CATEGORIES, 'Kategorie');
const vPhone = str(req.body.phone, 'Telefon', { max: MAX_SHORT, required: false });
const vEmail = str(req.body.email, 'E-Mail', { max: MAX_TITLE, required: false });
const vAddress = str(req.body.address, 'Adresse', { max: MAX_TEXT, required: false });
const vNotes = str(req.body.notes, 'Notizen', { max: MAX_TEXT, required: false });
const errors = collectErrors([vName, vCat, vPhone, vEmail, vAddress, vNotes]);
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(`
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 });
}
});
/**
* PUT /api/v1/contacts/:id
* Kontakt bearbeiten.
* Body: alle Felder optional, phones/emails/addresses mit Replacement-Semantik
* Response: { data: Contact }
*/
router.put('/: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 });
const checks = [];
if (req.body.name !== undefined) checks.push(str(req.body.name, 'Name', { max: MAX_TITLE, required: false }));
if (req.body.category !== undefined) checks.push(oneOf(req.body.category, VALID_CATEGORIES, 'Kategorie'));
if (req.body.phone !== undefined) checks.push(str(req.body.phone, 'Telefon', { max: MAX_SHORT, required: false }));
if (req.body.email !== undefined) checks.push(str(req.body.email, 'E-Mail', { max: MAX_TITLE, required: false }));
if (req.body.address !== undefined) checks.push(str(req.body.address, 'Adresse', { max: MAX_TEXT, required: false }));
if (req.body.notes !== undefined) checks.push(str(req.body.notes, 'Notizen', { max: MAX_TEXT, required: false }));
const errors = collectErrors(checks);
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(`
UPDATE contacts
SET name = COALESCE(?, name),
category = COALESCE(?, category),
phone = ?,
email = ?,
address = ?,
notes = ?
WHERE id = ?
`).run(
req.body.name?.trim() ?? null,
req.body.category ?? null,
req.body.phone !== undefined ? (req.body.phone?.trim() || null) : contact.phone,
req.body.email !== undefined ? (req.body.email?.trim() || null) : contact.email,
req.body.address !== undefined ? (req.body.address?.trim() || null) : contact.address,
req.body.notes !== undefined ? (req.body.notes?.trim() || null) : contact.notes,
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 multiValueFields = loadMultiValueFields(id);
res.json({
data: {
...updated,
...multiValueFields
}
});
} catch (err) {
log.error('', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* DELETE /api/v1/contacts/:id
* Kontakt löschen.
* Response: 204 No Content
*/
router.delete('/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10);
const contact = db.get().prepare('SELECT family_user_id FROM contacts WHERE id = ?').get(id);
if (!contact) return res.status(404).json({ error: 'Kontakt nicht gefunden', code: 404 });
if (contact.family_user_id) {
return res.status(403).json({ error: 'Familienmitglieder können nicht aus der Kontaktliste gelöscht werden.', code: 403 });
}
const result = db.get().prepare('DELETE FROM contacts WHERE id = ?').run(id);
if (result.changes === 0)
return res.status(404).json({ error: 'Kontakt nicht gefunden', code: 404 });
res.status(204).end();
} catch (err) {
log.error('', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* GET /api/v1/contacts/meta
* Kategorien-Liste für Dropdowns.
* Response: { data: { categories } }
*/
router.get('/meta', (_req, res) => {
try {
res.json({ data: { categories: VALID_CATEGORIES } });
} catch (err) {
log.error('', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* 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
* Kontakt als vCard 3.0 (.vcf) exportieren.
* Response: text/vcard Dateidownload
*/
router.get('/:id/vcard', (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 });
const esc = (v) => String(v || '').replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/,/g, '\\,').replace(/;/g, '\\;');
const lines = [
'BEGIN:VCARD',
'VERSION:3.0',
`FN:${esc(contact.name)}`,
`N:${esc(contact.name)};;;;`,
];
if (contact.phone) lines.push(`TEL;TYPE=VOICE:${esc(contact.phone)}`);
if (contact.email) lines.push(`EMAIL:${esc(contact.email)}`);
if (contact.address) lines.push(`ADR;TYPE=HOME:;;${esc(contact.address)};;;;`);
if (contact.notes) lines.push(`NOTE:${esc(contact.notes)}`);
if (contact.category) lines.push(`CATEGORIES:${esc(contact.category)}`);
lines.push('END:VCARD');
const vcf = lines.join('\r\n');
const filename = encodeURIComponent(contact.name.replace(/[^a-zA-Z0-9-_ ]/g, '_')) + '.vcf';
res.setHeader('Content-Type', 'text/vcard; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(vcf);
} catch (err) {
log.error('', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
export default router;
export { validatePhones, validateEmails, validateAddresses };