From ddffc7a92db13bde09b165197f25db419d9d70c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:04:48 +0000 Subject: [PATCH] =?UTF-8?q?Auftr=C3=A4ge:=20Konfiguration=20und=20Pilot-Sc?= =?UTF-8?q?halter=20AUFTRAEGE=5FENABLED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- server/config.js | 14 ++++ server/services/auftraege/schalter.js | 21 ++++++ test/auftraegeSchalter.test.js | 100 ++++++++++++++++++++++++++ 3 files changed, 135 insertions(+) create mode 100644 server/services/auftraege/schalter.js create mode 100644 test/auftraegeSchalter.test.js diff --git a/server/config.js b/server/config.js index f300977..b0ffbad 100644 --- a/server/config.js +++ b/server/config.js @@ -32,6 +32,7 @@ function nonNegativeNumber(raw, fallback) { const termineEnabled = (process.env.TERMINE_ENABLED || '').trim().toLowerCase() const shopEnabled = (process.env.SHOP_ENABLED || '').trim().toLowerCase() const zeitenEnabled = (process.env.ZEITEN_ENABLED || '').trim().toLowerCase() +const auftraegeEnabled = (process.env.AUFTRAEGE_ENABLED || '').trim().toLowerCase() /** Komma-Liste aus einer Umgebungsvariablen: getrimmt, klein geschrieben, ohne Leereintraege; leer ergibt den Vorgabewert. */ function commaList(raw, fallback) { @@ -634,6 +635,9 @@ export const config = { // Oeffnungszeiten live (Einstellungen, Erinnerungsmerker; scripts/zeiten-setup.mjs) oeffnungszeiten: process.env.APPWRITE_COLLECTION_OEFFNUNGSZEITEN || 'oeffnungszeiten', zeitenErinnerungen: process.env.APPWRITE_COLLECTION_ZEITEN_ERINNERUNGEN || 'zeitenErinnerungen', + // Aufträge und Rechnungen (Auftragsbestätigungen, Projektrechnungen; scripts/auftraege-setup.mjs) + auftraege: process.env.APPWRITE_COLLECTION_AUFTRAEGE || 'auftraege', + projektRechnungen: process.env.APPWRITE_COLLECTION_PROJEKT_RECHNUNGEN || 'projektRechnungen', }, publicBaseUrl: (process.env.PORTAL_PUBLIC_URL || 'https://project.webklar.com').replace(/\/$/, ''), @@ -927,6 +931,16 @@ export const config = { keinNeubau: commaList(process.env.ZEITEN_KEIN_NEUBAU, ['emmas-schatzkiste']), }, + // Aufträge und Rechnungen: Schalter nur für die AB-Funktionen und die Rechnungsarten anzahlung/schluss; wird bei + // jedem Aufruf gelesen, siehe server/services/auftraege/schalter.js + auftraege: { + // off (Vorgabe) | pilot (nur AUFTRAEGE_PILOT_EMAILS, '*' = alle) | on + mode: ['pilot', 'on'].includes(auftraegeEnabled) ? auftraegeEnabled : 'off', + pilotEmails: commaList(process.env.AUFTRAEGE_PILOT_EMAILS, []), + // Gesendete AB-PDFs: //.pdf + uploadDir: process.env.AUFTRAEGE_UPLOAD_DIR || path.join(process.cwd(), 'upload-data', 'auftraege'), + }, + invoiceNinja: { url: (process.env.INVOICE_NINJA_URL || 'https://invoice.webklar.com').replace(/\/$/, ''), apiToken: process.env.INVOICE_NINJA_API_TOKEN || '', diff --git a/server/services/auftraege/schalter.js b/server/services/auftraege/schalter.js new file mode 100644 index 0000000..64d52e0 --- /dev/null +++ b/server/services/auftraege/schalter.js @@ -0,0 +1,21 @@ +import { config } from '../../config.js' + +/** + * Pilot-Schalter der Aufträge (AUFTRAEGE_ENABLED = off (Vorgabe) | pilot | on). Betrifft nur die AB-Funktionen und + * die Rechnungsarten anzahlung/schluss; Projektkonto und die Arten frei, festbetrag, aufwand laufen immer. + * Liest config.auftraege bei jedem Aufruf. + */ + +/** + * Darf dieser Kunde Auftragsbestätigungen nutzen? `kunde`: Kundendokument oder { email }. on = alle; pilot = Liste + * enthält '*' oder die (klein geschriebene, getrimmte) E-Mail-Adresse steht in AUFTRAEGE_PILOT_EMAILS (eine leere + * Liste lässt niemanden durch); alles andere, auch ein unbekannter Modus, gilt als off. + */ +export function auftraegeAktiv(kunde) { + const { mode, pilotEmails } = config.auftraege + if (mode === 'on') return true + if (mode !== 'pilot') return false + if (pilotEmails.includes('*')) return true + const adresse = String(kunde?.email || '').trim().toLowerCase() + return adresse !== '' && pilotEmails.includes(adresse) +} diff --git a/test/auftraegeSchalter.test.js b/test/auftraegeSchalter.test.js new file mode 100644 index 0000000..edd9d47 --- /dev/null +++ b/test/auftraegeSchalter.test.js @@ -0,0 +1,100 @@ +// test/auftraegeSchalter.test.js +import { test, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import path from 'node:path' +import { config } from '../server/config.js' +import { auftraegeAktiv } from '../server/services/auftraege/schalter.js' + +const ENV = ['AUFTRAEGE_ENABLED', 'AUFTRAEGE_PILOT_EMAILS', 'AUFTRAEGE_UPLOAD_DIR', 'APPWRITE_COLLECTION_AUFTRAEGE', 'APPWRITE_COLLECTION_PROJEKT_RECHNUNGEN'] + +let original +beforeEach(() => { + original = { ...config.auftraege } +}) +afterEach(() => { + Object.assign(config.auftraege, original) +}) + +/** Frische config.js-Instanz mit genau diesen Umgebungsvariablen (alle anderen entfernt). */ +let freshCounter = 0 +async function freshConfig(env = {}) { + const saved = Object.fromEntries(ENV.map((key) => [key, process.env[key]])) + for (const key of ENV) delete process.env[key] + Object.assign(process.env, env) + try { + freshCounter += 1 + return (await import(`../server/config.js?auftraege-test=${freshCounter}`)).config + } finally { + for (const key of ENV) { + if (saved[key] === undefined) delete process.env[key] + else process.env[key] = saved[key] + } + } +} + +test('Vorgabe: aus, keine Pilotadressen, Ablage unter upload-data/auftraege, Collection-IDs wie die Namen', async () => { + const fresh = await freshConfig() + assert.deepEqual(fresh.auftraege, { mode: 'off', pilotEmails: [], uploadDir: path.join(process.cwd(), 'upload-data', 'auftraege') }) + assert.equal(fresh.collections.auftraege, 'auftraege') + assert.equal(fresh.collections.projektRechnungen, 'projektRechnungen') +}) + +test('AUFTRAEGE_ENABLED: leer und Unbekanntes sind off, " ON " ist on, " Pilot " ist pilot mit bereinigter Liste', async () => { + for (const value of ['', ' ', 'banane', 'true', '1']) { + assert.equal((await freshConfig({ AUFTRAEGE_ENABLED: value })).auftraege.mode, 'off', JSON.stringify(value)) + } + assert.equal((await freshConfig({ AUFTRAEGE_ENABLED: ' ON ' })).auftraege.mode, 'on') + const fresh = await freshConfig({ AUFTRAEGE_ENABLED: ' Pilot ', AUFTRAEGE_PILOT_EMAILS: ' Kenso.Gri@Gmail.com, ,justin@webklar.com ' }) + assert.equal(fresh.auftraege.mode, 'pilot') + assert.deepEqual(fresh.auftraege.pilotEmails, ['kenso.gri@gmail.com', 'justin@webklar.com']) +}) + +test('Ablage und Collection-IDs lassen sich per Umgebung ändern', async () => { + const fresh = await freshConfig({ + AUFTRAEGE_UPLOAD_DIR: '/tmp/ab-test', + APPWRITE_COLLECTION_AUFTRAEGE: 'abX', + APPWRITE_COLLECTION_PROJEKT_RECHNUNGEN: 'prX', + }) + assert.equal(fresh.auftraege.uploadDir, '/tmp/ab-test') + assert.deepEqual([fresh.collections.auftraege, fresh.collections.projektRechnungen], ['abX', 'prX']) +}) + +test('off sperrt alle, auch mit Stern in der Liste; ein unbekannter Modus gilt als off', () => { + config.auftraege.mode = 'off' + config.auftraege.pilotEmails = ['*', 'kenso.gri@gmail.com'] + assert.equal(auftraegeAktiv({ email: 'kenso.gri@gmail.com' }), false) + assert.equal(auftraegeAktiv({ email: 'jemand@example.com' }), false) + for (const value of [undefined, null, '', 'banane', 'PILOT']) { + config.auftraege.mode = value + assert.equal(auftraegeAktiv({ email: 'kenso.gri@gmail.com' }), false, String(value)) + } +}) + +test('pilot: nur Adressen der Liste, ohne Beachtung von Groß- und Kleinschreibung und Leerraum', () => { + config.auftraege.mode = 'pilot' + config.auftraege.pilotEmails = ['a@b.de'] + assert.equal(auftraegeAktiv({ email: 'A@b.de' }), true) + assert.equal(auftraegeAktiv({ email: ' a@b.de ' }), true) + assert.equal(auftraegeAktiv({ email: 'x@y.de' }), false) + assert.equal(auftraegeAktiv({ email: 'a@b.de.evil.de' }), false) + for (const kunde of [undefined, null, {}, { email: '' }, { email: ' ' }]) assert.equal(auftraegeAktiv(kunde), false) +}) + +test('pilot mit Stern lässt alle durch, auch ohne Adresse; eine leere Liste niemanden', () => { + config.auftraege.mode = 'pilot' + config.auftraege.pilotEmails = ['*'] + assert.equal(auftraegeAktiv({ email: 'x@y.de' }), true) + assert.equal(auftraegeAktiv(null), true) + config.auftraege.pilotEmails = [] + assert.equal(auftraegeAktiv({ email: 'a@b.de' }), false) +}) + +test('on gilt für alle; ein Kundendokument zählt wie { email }', () => { + config.auftraege.mode = 'on' + config.auftraege.pilotEmails = [] + assert.equal(auftraegeAktiv({ email: 'x@y.de' }), true) + assert.equal(auftraegeAktiv(undefined), true) + config.auftraege.mode = 'pilot' + config.auftraege.pilotEmails = ['k@example.invalid'] + assert.equal(auftraegeAktiv({ $id: 'c1', email: 'k@example.invalid', name: 'Kunde Eins' }), true) +})