Kundenansicht neu: Pilot-Schalter (off/pilot/on) mit Randfall-Tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f573d49f0a
commit
7c4a4a567d
3 files changed
+157
No files matched your search
@@ -34,6 +34,7 @@ const shopEnabled = (process.env.SHOP_ENABLED || '').trim().toLowerCase()
|
|||||||
const unterkunftEnabled = (process.env.UNTERKUNFT_ENABLED || '').trim().toLowerCase()
|
const unterkunftEnabled = (process.env.UNTERKUNFT_ENABLED || '').trim().toLowerCase()
|
||||||
const zeitenEnabled = (process.env.ZEITEN_ENABLED || '').trim().toLowerCase()
|
const zeitenEnabled = (process.env.ZEITEN_ENABLED || '').trim().toLowerCase()
|
||||||
const auftraegeEnabled = (process.env.AUFTRAEGE_ENABLED || '').trim().toLowerCase()
|
const auftraegeEnabled = (process.env.AUFTRAEGE_ENABLED || '').trim().toLowerCase()
|
||||||
|
const kundenansichtNeuEnabled = (process.env.KUNDENANSICHT_NEU || '').trim().toLowerCase()
|
||||||
|
|
||||||
/** Komma-Liste aus einer Umgebungsvariablen: getrimmt, klein geschrieben, ohne Leereintraege; leer ergibt den Vorgabewert. */
|
/** Komma-Liste aus einer Umgebungsvariablen: getrimmt, klein geschrieben, ohne Leereintraege; leer ergibt den Vorgabewert. */
|
||||||
function commaList(raw, fallback) {
|
function commaList(raw, fallback) {
|
||||||
@@ -1123,6 +1124,15 @@ export const config = {
|
|||||||
keinNeubau: commaList(process.env.ZEITEN_KEIN_NEUBAU, ['emmas-schatzkiste']),
|
keinNeubau: commaList(process.env.ZEITEN_KEIN_NEUBAU, ['emmas-schatzkiste']),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Neue Kundenansicht (Spec 2026-10-15): Schalter fuer die neue Oberflaeche; wird bei jedem Aufruf gelesen, siehe
|
||||||
|
// server/services/kundenansicht/schalter.js. Der Modus off schlaegt jede Liste.
|
||||||
|
kundenansichtNeu: {
|
||||||
|
// off (Vorgabe) | pilot (nur KUNDENANSICHT_NEU_PILOT_EMAILS und die Mitarbeiter-Kundenansicht) | on (alle Kunden)
|
||||||
|
mode: ['pilot', 'on'].includes(kundenansichtNeuEnabled) ? kundenansichtNeuEnabled : 'off',
|
||||||
|
// Kunden-E-Mails fuer den Pilotbetrieb; '*' = alle
|
||||||
|
pilotEmails: commaList(process.env.KUNDENANSICHT_NEU_PILOT_EMAILS, []),
|
||||||
|
},
|
||||||
|
|
||||||
// Aufträge und Rechnungen: Schalter nur für die AB-Funktionen und die Rechnungsarten anzahlung/schluss; wird bei
|
// 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
|
// jedem Aufruf gelesen, siehe server/services/auftraege/schalter.js
|
||||||
auftraege: {
|
auftraege: {
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { config } from '../../config.js'
|
||||||
|
import { parsePortalSession } from '../../middleware/session.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pilot-Schalter der neuen Kundenansicht: KUNDENANSICHT_NEU = off (Vorgabe) | pilot | on, dazu die Komma-Liste
|
||||||
|
* KUNDENANSICHT_NEU_PILOT_EMAILS ('*' = alle). Liest config.kundenansichtNeu bei jedem Aufruf. Der Modus off
|
||||||
|
* schlaegt jede Liste; eine leere Liste im Pilot laesst nur die Mitarbeiter-Kundenansicht durch.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 'off' | 'pilot' | 'on'; alles andere (leer, unbekannt) gilt als off. */
|
||||||
|
export function neueAnsichtModus() {
|
||||||
|
const mode = config.kundenansichtNeu.mode
|
||||||
|
return mode === 'pilot' || mode === 'on' ? mode : 'off'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nicht-leerer Text nach dem Trimmen? */
|
||||||
|
const istText = (wert) => typeof wert === 'string' && wert.trim() !== ''
|
||||||
|
|
||||||
|
/** Listenvergleich im Pilot: '*' oder die getrimmte, klein geschriebene E-Mail steht in der Liste; ein Nicht-Text zaehlt als leer. */
|
||||||
|
function stehtInPilotListe(email) {
|
||||||
|
const erlaubt = config.kundenansichtNeu.pilotEmails
|
||||||
|
if (erlaubt.includes('*')) return true
|
||||||
|
const adresse = typeof email === 'string' ? email.trim().toLowerCase() : ''
|
||||||
|
return adresse !== '' && erlaubt.includes(adresse)
|
||||||
|
}
|
||||||
|
|
||||||
|
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
|
||||||
|
|
||||||
|
/** Mitarbeiter-Kundenansicht ("Als Kunde ansehen") mit Kennung. */
|
||||||
|
const istKundenansicht = (s) => s.viewAs === true && istText(s.viewId)
|
||||||
|
|
||||||
|
/** Angemeldeter Kunde (keine alte Mitarbeiter-Sitzung). */
|
||||||
|
const istKundenSitzung = (s) => istText(s.customerId) && istText(s.appwriteUserId) && s.role !== 'admin'
|
||||||
|
|
||||||
|
/** Sieht diese Sitzung die neue Kundenansicht? Ungueltige Sitzungen: nie. */
|
||||||
|
export function neueAnsichtFuerSession(session) {
|
||||||
|
const modus = neueAnsichtModus()
|
||||||
|
if (modus === 'off') return false
|
||||||
|
if (!istObjekt(session)) return false
|
||||||
|
const kundenansicht = istKundenansicht(session)
|
||||||
|
if (!kundenansicht && !istKundenSitzung(session)) return false
|
||||||
|
if (modus === 'on') return true
|
||||||
|
return kundenansicht || stehtInPilotListe(session.email)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sieht diese Anfrage die neue Kundenansicht? req.session hat Vorrang, sonst das signierte Cookie. */
|
||||||
|
export function neueAnsicht(req) {
|
||||||
|
return neueAnsichtFuerSession(req?.session ?? parsePortalSession(req))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuer Jobs und Dienste ohne Cookie: gleiche Modus- und Listenregeln, aber ohne Sitzungspruefung und ohne
|
||||||
|
* Mitarbeiter-Regel. Ohne nicht-leere customerId ist die Antwort in jedem Modus false.
|
||||||
|
*/
|
||||||
|
export function neueAnsichtFuerKunde(kunde) {
|
||||||
|
const modus = neueAnsichtModus()
|
||||||
|
if (modus === 'off') return false
|
||||||
|
if (!istObjekt(kunde) || !istText(kunde.customerId)) return false
|
||||||
|
if (modus === 'on') return true
|
||||||
|
return stehtInPilotListe(kunde.email)
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { test, beforeEach, afterEach } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { config } from '../server/config.js'
|
||||||
|
import { neueAnsicht, neueAnsichtFuerKunde, neueAnsichtFuerSession, neueAnsichtModus } from '../server/services/kundenansicht/schalter.js'
|
||||||
|
|
||||||
|
let original
|
||||||
|
beforeEach(() => { original = { ...config.kundenansichtNeu } })
|
||||||
|
afterEach(() => { Object.assign(config.kundenansichtNeu, original) })
|
||||||
|
const stelle = (mode, pilotEmails = []) => Object.assign(config.kundenansichtNeu, { mode, pilotEmails })
|
||||||
|
const MAIL = 'k@example.invalid'
|
||||||
|
const kunde = (email = MAIL) => ({ customerId: 'c1', appwriteUserId: 'u1', name: 'K', email })
|
||||||
|
const ansicht = (extra = {}) => ({ role: 'admin', viewAs: true, viewId: 'a'.repeat(32), customerId: 'c1', appwriteUserId: 'u_staff', email: 'andrej@webklar.com', ...extra })
|
||||||
|
const UNGUELTIG = [null, undefined, {}, 'x', 42, [], { role: 'admin', appwriteUserId: 'u', email: 'kenso@webklar.com' }, ansicht({ viewId: undefined }), ansicht({ viewId: '' }), ansicht({ viewAs: false }), { customerId: 'c1' }, { appwriteUserId: 'u1' }]
|
||||||
|
const ENV = ['KUNDENANSICHT_NEU', 'KUNDENANSICHT_NEU_PILOT_EMAILS']
|
||||||
|
let zaehler = 0
|
||||||
|
async function frischeConfig(env = {}) { // neue config.js-Instanz mit genau diesen Variablen (Muster zeitenSchalter.test.js)
|
||||||
|
const alt = Object.fromEntries(ENV.map((k) => [k, process.env[k]]))
|
||||||
|
for (const k of ENV) delete process.env[k]
|
||||||
|
Object.assign(process.env, env)
|
||||||
|
try { zaehler += 1; return (await import(`../server/config.js?kn-schalter=${zaehler}`)).config } finally {
|
||||||
|
for (const k of ENV) { if (alt[k] === undefined) delete process.env[k]; else process.env[k] = alt[k] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Konfiguration: Vorgabe off, Unbekanntes ist off, Werte werden bereinigt', async () => {
|
||||||
|
assert.deepEqual((await frischeConfig()).kundenansichtNeu, { mode: 'off', pilotEmails: [] })
|
||||||
|
for (const wert of ['', ' ', 'banane', 'true', '1']) assert.equal((await frischeConfig({ KUNDENANSICHT_NEU: wert })).kundenansichtNeu.mode, 'off', wert)
|
||||||
|
assert.equal((await frischeConfig({ KUNDENANSICHT_NEU: ' ON ' })).kundenansichtNeu.mode, 'on')
|
||||||
|
const f = await frischeConfig({ KUNDENANSICHT_NEU: ' Pilot ', KUNDENANSICHT_NEU_PILOT_EMAILS: ' Kenso.Gri@Gmail.com, ,justin@webklar.com ' })
|
||||||
|
assert.deepEqual([f.kundenansichtNeu.mode, f.kundenansichtNeu.pilotEmails], ['pilot', ['kenso.gri@gmail.com', 'justin@webklar.com']])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Regeln: [Modus, Liste, Sitzung, erwartet]', () => {
|
||||||
|
const TABELLE = [
|
||||||
|
['off', ['*', MAIL], kunde(), false], ['off', ['*'], ansicht(), false],
|
||||||
|
['on', [], kunde(), true], ['on', [], kunde(''), true], ['on', [], ansicht(), true],
|
||||||
|
['pilot', [MAIL], kunde(), true], ['pilot', [MAIL], kunde(' K@Example.Invalid '), true],
|
||||||
|
['pilot', [MAIL], kunde('k@example.invalid.evil'), false], ['pilot', [MAIL], kunde('anderer@example.invalid'), false], ['pilot', [MAIL], kunde(''), false],
|
||||||
|
['pilot', [MAIL], { ...kunde(), email: undefined }, false], ['pilot', [MAIL], { ...kunde(), email: 42 }, false], ['pilot', [MAIL], { ...kunde(), email: [MAIL] }, false],
|
||||||
|
['pilot', ['*'], kunde(), true], ['pilot', ['*'], ansicht(), true],
|
||||||
|
['pilot', [], kunde(), false], ['pilot', [], ansicht(), true], // leere Liste: nur die Mitarbeiter-Kundenansicht
|
||||||
|
]
|
||||||
|
for (const [modus, liste, sitzung, soll] of TABELLE) { stelle(modus, liste); assert.equal(neueAnsichtFuerSession(sitzung), soll, `${modus} ${liste} ${JSON.stringify(sitzung)}`) }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ungültige Sitzungen sind in jedem Modus und bei jeder Liste false', () => {
|
||||||
|
for (const modus of ['off', 'on', 'pilot']) for (const liste of [[], ['*'], [MAIL, 'kenso@webklar.com']]) { stelle(modus, liste); for (const s of UNGUELTIG) assert.equal(neueAnsichtFuerSession(s), false, `${modus} ${liste} ${JSON.stringify(s)}`) }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('neueAnsicht(req): req.session hat Vorrang, sonst das signierte Cookie; kaputt oder leer ist false', () => {
|
||||||
|
stelle('pilot', [MAIL])
|
||||||
|
const cookie = (s) => ({ signedCookies: { [config.cookieName]: typeof s === 'string' ? s : JSON.stringify(s) } })
|
||||||
|
const fremd = kunde('x@example.invalid')
|
||||||
|
assert.deepEqual([neueAnsicht({ session: kunde() }), neueAnsicht({ session: fremd }), neueAnsicht(cookie(kunde())), neueAnsicht({ session: null, ...cookie(kunde()) })], [true, false, true, true])
|
||||||
|
assert.deepEqual([neueAnsicht({ session: fremd, ...cookie(kunde()) }), neueAnsicht(cookie('{kaputt')), neueAnsicht({})], [false, false, false])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('der Modus wird bei jedem Aufruf gelesen; Unbekanntes gilt als off', () => {
|
||||||
|
stelle('off'); assert.equal(neueAnsichtFuerSession(kunde()), false)
|
||||||
|
stelle('on'); assert.equal(neueAnsichtFuerSession(kunde()), true)
|
||||||
|
stelle('banane'); assert.equal(neueAnsichtModus(), 'off')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('neueAnsichtFuerKunde (Jobs ohne Cookie): [Modus, Liste, Eingabe, erwartet]', () => {
|
||||||
|
const k = (email) => ({ customerId: 'c1', email })
|
||||||
|
const TABELLE = [
|
||||||
|
['off', ['*', MAIL], k(MAIL), false], ['off', ['*'], k(undefined), false],
|
||||||
|
['on', [], k(MAIL), true], ['on', [], k(''), true], ['on', [], { customerId: 'c1' }, true],
|
||||||
|
['pilot', [MAIL], k(MAIL), true], ['pilot', [MAIL], k(' K@Example.Invalid '), true],
|
||||||
|
['pilot', [MAIL], k('k@example.invalid.evil'), false], ['pilot', [MAIL], k('anderer@example.invalid'), false],
|
||||||
|
['pilot', [MAIL], k(''), false], ['pilot', [MAIL], k(undefined), false], ['pilot', [MAIL], k(42), false], ['pilot', [MAIL], k([MAIL]), false],
|
||||||
|
['pilot', ['*'], k(MAIL), true], ['pilot', ['*'], k(undefined), true],
|
||||||
|
['pilot', [], k(MAIL), false], // leere Liste: niemand, denn ohne Sitzung gibt es keine Mitarbeiter-Kundenansicht
|
||||||
|
]
|
||||||
|
for (const [modus, liste, eingabe, soll] of TABELLE) { stelle(modus, liste); assert.equal(neueAnsichtFuerKunde(eingabe), soll, `${modus} ${liste} ${JSON.stringify(eingabe)}`) }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('neueAnsichtFuerKunde: leere customerId und Unbrauchbares sind in jedem Modus false; Listenregeln wie bei Sitzungen', () => {
|
||||||
|
const UNBRAUCHBAR = [undefined, null, 'x', 42, [], {}, { email: MAIL }, { customerId: '', email: MAIL }, { customerId: ' ', email: MAIL }, { customerId: 42, email: MAIL }, { customerId: null, email: MAIL }, { customerId: ['c1'], email: MAIL }]
|
||||||
|
for (const modus of ['off', 'on', 'pilot']) for (const liste of [[], ['*'], [MAIL]]) {
|
||||||
|
stelle(modus, liste)
|
||||||
|
for (const e of UNBRAUCHBAR) assert.equal(neueAnsichtFuerKunde(e), false, `${modus} ${liste} ${JSON.stringify(e)}`)
|
||||||
|
for (const mail of [MAIL, ' K@Example.Invalid ', 'x@example.invalid', '', undefined, 42]) // gleiche Antwort wie für eine gültige Kunden-Sitzung mit derselben E-Mail
|
||||||
|
assert.equal(neueAnsichtFuerKunde({ customerId: 'c1', email: mail }), neueAnsichtFuerSession({ ...kunde(), email: mail }), `${modus} ${liste} ${JSON.stringify(mail)}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in new issue
Block a user