From 5c21023e282aa497bea26f787e9f94f1a75fbcf4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 15:49:25 +0000 Subject: [PATCH] =?UTF-8?q?Kundenansicht=20P5:=20Kundenart=20beim=20Anlege?= =?UTF-8?q?n=20Pflicht=20(ab=20Schalter=20pilot),=20beim=20=C3=84ndern=20o?= =?UTF-8?q?ptional?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- server/routes/admin/customers.js | 8 +++- server/services/customerAdmin.js | 23 ++++++++++++ test/kundenartAdmin.test.js | 64 ++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 test/kundenartAdmin.test.js diff --git a/server/routes/admin/customers.js b/server/routes/admin/customers.js index a70e119..7ba48de 100644 --- a/server/routes/admin/customers.js +++ b/server/routes/admin/customers.js @@ -11,6 +11,10 @@ import { const router = Router() +// Eigene Fehlercodes (klein, mit Unterstrich, z. B. kundenart_fehlt) gehen mit in die Antwort; Appwrite-Fehler tragen eine +// Zahl und Systemfehler Grossbuchstaben (ECONNREFUSED): die Antworten von heute bleiben dafuer unveraendert +const codeVon = (err) => (typeof err.code === 'string' && /^[a-z][a-z_]*$/.test(err.code) ? { code: err.code } : {}) + router.use(requireAppwriteStaff) router.get('/', async (_req, res) => { @@ -29,7 +33,7 @@ router.post('/', async (req, res) => { return res.status(201).json(result) } catch (err) { const status = err.status || 500 - return res.status(status).json({ error: err.message || 'Kunde konnte nicht angelegt werden' }) + return res.status(status).json({ error: err.message || 'Kunde konnte nicht angelegt werden', ...codeVon(err) }) } }) @@ -39,7 +43,7 @@ router.patch('/:customerId', async (req, res) => { return res.json(result) } catch (err) { const status = err.status || 500 - return res.status(status).json({ error: err.message || 'Kunde konnte nicht aktualisiert werden' }) + return res.status(status).json({ error: err.message || 'Kunde konnte nicht aktualisiert werden', ...codeVon(err) }) } }) diff --git a/server/services/customerAdmin.js b/server/services/customerAdmin.js index d3b549c..de2a44c 100644 --- a/server/services/customerAdmin.js +++ b/server/services/customerAdmin.js @@ -14,6 +14,8 @@ import { Query, } from './appwriteAdmin.js' import { forgetPasswordVersion } from './passwordVersion.js' +import { KUNDENARTEN, kundenartGespeichert } from './kundenart.js' +import { neueAnsichtModus } from './kundenansicht/schalter.js' function readPortalPassword(customer) { if (customer?.portalPassword) return customer.portalPassword @@ -27,6 +29,17 @@ function notesWithoutPortalPassword(notes) { return (notes || '').replace(/^__portalPassword__:[^\n]*\n?/, '').trim() } +/** Fehler fuer eine fehlende oder ungueltige Kundenart (400 kundenart_fehlt); die Route reicht `code` durch. */ +function kundenartFehler() { + const error = new Error('Kundenart ist erforderlich (unternehmen oder privat).') + error.status = 400 + error.code = 'kundenart_fehlt' + return error +} + +/** Als "nicht mitgeschickt" gelten undefined, null und ''. */ +const kundenartFehlt = (wert) => wert === undefined || wert === null || wert === '' + function sanitizeCustomer(customer, { portalPassword } = {}) { const storedPassword = portalPassword || readPortalPassword(customer) return { @@ -41,6 +54,7 @@ function sanitizeCustomer(customer, { portalPassword } = {}) { appwriteUserId: customer.appwriteUserId || '', customerStatus: customer.customerStatus || '', archived: Boolean(customer.archived), + kundenart: kundenartGespeichert(customer), portalPassword: storedPassword || '', } } @@ -73,6 +87,7 @@ export async function createCustomerWithPortalAccess(payload) { email, phone = '', password, + kundenart, } = payload || {} if (!name?.trim()) { @@ -90,6 +105,9 @@ export async function createCustomerWithPortalAccess(payload) { error.status = 400 throw error } + // Kundenart (V4): Pflicht nur ab Schalter pilot (bei off legt das WOMS Kunden wie heute ohne Kundenart an); + // ein mitgeschickter, ungueltiger Wert ist in jedem Modus ein Fehler. Vor jedem Schreibzugriff. + if (kundenartFehlt(kundenart) ? neueAnsichtModus() !== 'off' : !KUNDENARTEN.includes(kundenart)) throw kundenartFehler() const existing = await getCustomerByEmail(email.trim()) if (existing) { @@ -117,6 +135,7 @@ export async function createCustomerWithPortalAccess(payload) { portalAccessEnabled: true, appwriteUserId: appwriteUser.$id, customerStatus: 'active', + ...(kundenartFehlt(kundenart) ? {} : { kundenart }), createdAt: now, updatedAt: now, }) @@ -150,6 +169,7 @@ export async function updateCustomerWithPortalAccess(customerId, payload) { email, phone, password, + kundenart, } = payload || {} const nextEmail = (email ?? customer.email ?? '').trim() @@ -163,6 +183,8 @@ export async function updateCustomerWithPortalAccess(customerId, payload) { error.status = 400 throw error } + // Kundenart optional: nur wenn mitgeschickt, dann gueltig (V4) + if (kundenart !== undefined && !KUNDENARTEN.includes(kundenart)) throw kundenartFehler() const normalizedPassword = typeof password === 'string' && password.trim().length > 0 ? password : undefined @@ -216,6 +238,7 @@ export async function updateCustomerWithPortalAccess(customerId, payload) { ...(location !== undefined ? { location: String(location).trim() } : {}), email: nextEmail, ...(phone !== undefined ? { phone: String(phone).trim() } : {}), + ...(kundenart !== undefined ? { kundenart } : {}), ...(nextPortalPassword ? { portalPassword: nextPortalPassword } : {}), ...(notesChanged ? { notes: cleanedNotes } : {}), // CRM-Stufe (lead/customer/lost) nicht ueberschreiben - nur setzen, wenn leer diff --git a/test/kundenartAdmin.test.js b/test/kundenartAdmin.test.js new file mode 100644 index 0000000..e93733c --- /dev/null +++ b/test/kundenartAdmin.test.js @@ -0,0 +1,64 @@ +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' + +// Kundenart in der Kundenverwaltung (V4): Pflicht beim Anlegen nur ab Schalter pilot, beim Aendern optional +const aw = await startAppwriteMock() +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const { startTestApp, post, send } = await import('./support/testApp.js') +const { default: customersRouter } = await import('../server/routes/admin/customers.js') +const app = await startTestApp({ '/api/admin/customers': customersRouter }) +const urspruenglich = config.kundenansichtNeu.mode +after(() => { + config.kundenansichtNeu.mode = urspruenglich + return Promise.all([app.close(), aw.close(), st.close()]) +}) +const modus = (m) => { config.kundenansichtNeu.mode = m } +beforeEach(() => modus('pilot')) + +const admin = { headers: { Authorization: 'Bearer jwt-admin' } } +const neu = (extra = {}) => ({ name: 'Bäckerei Müller', email: 'mueller@example.invalid', password: 'geheim-1234', ...extra }) + +test('pilot und on: Anlegen ohne oder mit ungültiger Kundenart: 400 kundenart_fehlt, nichts geschrieben', async () => { + for (const mode of ['pilot', 'on']) { + modus(mode) + for (const kundenart of [undefined, '', 'Unternehmen', 'firma', 5, null]) { + const res = await post('/api/admin/customers', neu({ kundenart }), admin) + assert.deepEqual([res.status, res.body], [400, { error: 'Kundenart ist erforderlich (unternehmen oder privat).', code: 'kundenart_fehlt' }], `${mode} ${String(kundenart)}`) + } + } + assert.equal(aw.docs('customers').size, 0) +}) + +test('Anlegen mit unternehmen oder privat; Antwort und Dokument tragen die Kundenart', async () => { + const a = await post('/api/admin/customers', neu({ kundenart: 'unternehmen' }), admin) + assert.deepEqual([a.status, a.body.customer.kundenart], [201, 'unternehmen']) + assert.equal(aw.docs('customers').get(a.body.customer.$id).kundenart, 'unternehmen') + const b = await post('/api/admin/customers', neu({ email: 'frau@example.invalid', kundenart: 'privat' }), admin) + assert.equal(b.body.customer.kundenart, 'privat') +}) + +test('Ändern: ohne Feld bleibt die Kundenart, gültig ändert sie, ungültig ist 400 ohne Änderung', async () => { + const id = [...aw.docs('customers').keys()][0] + assert.equal((await send('PATCH', `/api/admin/customers/${id}`, { body: { name: 'Neu' }, ...admin })).body.customer.kundenart, 'unternehmen') + assert.equal((await send('PATCH', `/api/admin/customers/${id}`, { body: { kundenart: 'privat' }, ...admin })).body.customer.kundenart, 'privat') + const bad = await send('PATCH', `/api/admin/customers/${id}`, { body: { kundenart: 'x' }, ...admin }) + assert.deepEqual([bad.status, bad.body.code], [400, 'kundenart_fehlt']) + assert.equal(aw.docs('customers').get(id).kundenart, 'privat') +}) + +test('off (Schalter aus): Anlegen ohne Kundenart wie heute, kein Feld im Dokument; ungültig mitgeschickt bleibt 400', async () => { + // als letzter Test der Datei: legt Kunden an, die die Tests davor nicht sehen sollen + modus('off') + const a = await post('/api/admin/customers', neu({ email: 'off1@example.invalid' }), admin) + assert.deepEqual([a.status, a.body.customer.kundenart], [201, '']) + assert.equal(Object.hasOwn(aw.docs('customers').get(a.body.customer.$id), 'kundenart'), false) + assert.equal((await post('/api/admin/customers', neu({ email: 'off2@example.invalid', kundenart: '' }), admin)).status, 201) + assert.equal((await post('/api/admin/customers', neu({ email: 'off3@example.invalid', kundenart: 'privat' }), admin)).status, 201) + const bad = await post('/api/admin/customers', neu({ email: 'off4@example.invalid', kundenart: 'firma' }), admin) + assert.deepEqual([bad.status, bad.body.code], [400, 'kundenart_fehlt']) +})