From 02a3732e4a3954d03f5f173210e96841e10ef61d Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Sun, 11 Oct 2026 15:02:49 +0000 Subject: [PATCH] Funktionen: Zustimmung in Textform auch per WhatsApp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Terminplaner, Webshop und Buchungskalender nehmen beim Aktivieren durch das Team den Weg whatsapp an (Nachweis: „per WhatsApp in Textform“). Die Analyse bleibt bei Chat oder E-Mail. Co-Authored-By: Claude Opus 5.5 --- server/routes/admin/funktionenAdmin.js | 4 ++-- server/services/analyse.js | 8 +++++--- server/services/siteFeatures.js | 4 ++-- test/analyse.test.js | 4 ++++ test/funktionenUnterkunftTextform.test.js | 18 ++++++++++++++++++ 5 files changed, 31 insertions(+), 7 deletions(-) diff --git a/server/routes/admin/funktionenAdmin.js b/server/routes/admin/funktionenAdmin.js index 7907118..0e4f604 100644 --- a/server/routes/admin/funktionenAdmin.js +++ b/server/routes/admin/funktionenAdmin.js @@ -1,7 +1,7 @@ import { Router } from 'express' import { config } from '../../config.js' import { aboMemo } from '../../services/aboStatus.js' -import { validateTextform } from '../../services/analyse.js' +import { FUNKTION_CHANNELS, validateTextform } from '../../services/analyse.js' import { getDocument, updateDocument } from '../../services/appwriteAdmin.js' import { featureConfig, featureHaken } from '../../services/featureRegistry.js' import { withLocks } from '../../services/keyedLock.js' @@ -139,7 +139,7 @@ async function textformFuerAktivierung({ body, customerId, key, doc }) { if (!textformVerlangt[key](kunde?.email)) return null // nur Texte: validateTextform macht aus jedem Wert einen Text (aus ['chat'] wuerde 'chat') const text = (wert) => (typeof wert === 'string' ? wert : '') - return validateTextform({ date: text(body?.textformDate), channel: text(body?.textformChannel) }, { minDate: textformFassung(key) }) + return validateTextform({ date: text(body?.textformDate), channel: text(body?.textformChannel) }, { minDate: textformFassung(key), channels: FUNKTION_CHANNELS }) } /** Leistungsbeschreibung und AVV der Funktion als PDF fuer die K16 der Textform-Aktivierung; nicht lesbar => 503, bevor etwas geaendert wird. */ diff --git a/server/services/analyse.js b/server/services/analyse.js index 63969d9..0cdb8c1 100644 --- a/server/services/analyse.js +++ b/server/services/analyse.js @@ -19,6 +19,8 @@ const SITE_KEY_RE = /^[a-z0-9]{16,40}$/ const COLOR_RE = /^#[0-9a-f]{6}$/ const DEFAULT_COLOR = '#111111' const CHANNELS = new Set(['chat', 'email']) +/** Wege der Zustimmung bei Funktionen (Terminplaner, Webshop, Buchungskalender): zusaetzlich WhatsApp (eine Nachricht dort ist Textform). */ +export const FUNKTION_CHANNELS = new Set(['chat', 'email', 'whatsapp']) export const MISSING_PRIVACY_MESSAGE = 'Für die Zusatzseite fehlen in Deinen Kundendaten Firma oder Name und eine gültige E-Mail-Adresse. Schreib uns im Chat, wir ergänzen sie.' @@ -109,9 +111,9 @@ const berlinToday = (now) => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europ /** * Zustimmung des Kunden in Textform (Mitarbeiter-Weg): Datum JJJJ-MM-TT (echt, nicht nach heute in Berlin, - * nicht vor der Fassung) und Weg chat/email. Wirft 400, sonst { date, channel }. + * nicht vor der Fassung) und Weg chat/email (channels: erlaubte Wege, Funktionen auch whatsapp). Wirft 400, sonst { date, channel }. */ -export function validateTextform(textform, { today = berlinToday(new Date()), minDate = config.analyse.avvVersion } = {}) { +export function validateTextform(textform, { today = berlinToday(new Date()), minDate = config.analyse.avvVersion, channels = CHANNELS } = {}) { const date = String(textform?.date ?? '') const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date) const real = m && new Date(Date.UTC(+m[1], +m[2] - 1, +m[3])).toISOString().slice(0, 10) === date @@ -119,7 +121,7 @@ export function validateTextform(textform, { today = berlinToday(new Date()), mi if (date > today) throw httpError(400, 'Das Datum der Zustimmung liegt in der Zukunft') if (date < minDate) throw httpError(400, `Das Datum der Zustimmung liegt vor der Fassung ${minDate}`) const channel = String(textform?.channel ?? '') - if (!CHANNELS.has(channel)) throw httpError(400, 'Weg der Zustimmung fehlt: Chat oder E-Mail') + if (!channels.has(channel)) throw httpError(400, channels.has('whatsapp') ? 'Weg der Zustimmung fehlt: Chat, E-Mail oder WhatsApp' : 'Weg der Zustimmung fehlt: Chat oder E-Mail') return { date, channel } } diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index a9084cf..b359544 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -614,7 +614,7 @@ async function assertAboLaeuftFuerAktivierung(customerId) { if (!laeuftJetzt) throw httpError(409, KEIN_ABO_AKTIVIERUNG) } -const TEXTFORM_WEG = { chat: 'Chat im Kundenportal', email: 'E-Mail' } +const TEXTFORM_WEG = { chat: 'Chat im Kundenportal', email: 'E-Mail', whatsapp: 'WhatsApp' } /** Funktionen, die das Team mit Zustimmung des Kunden in Textform aktivieren kann (Modus on), und die Fassung der Unterlagen, auf die sie sich bezieht. */ export const TEXTFORM_KEYS = ['termine', 'shop', 'unterkunft'] @@ -622,7 +622,7 @@ export const textformFassung = (key) => (key === 'shop' ? config.shop.fassung : /** * Text des Nachweises, wenn der Kunde dem Terminplaner oder dem Webshop in Textform zugestimmt hat (Aktivierung durch das Team im - * Modus on, F13a). textform = { date: 'JJJJ-MM-TT', channel: 'chat' | 'email' } (validateTextform); fassung = textformFassung(key). + * Modus on, F13a). textform = { date: 'JJJJ-MM-TT', channel: 'chat' | 'email' | 'whatsapp' } (validateTextform); fassung = textformFassung(key). */ export function textformNachweisText({ date, channel }, fassung, key = 'termine') { const [jahr, monat, tag] = String(date).split('-') diff --git a/test/analyse.test.js b/test/analyse.test.js index c3dad35..a4c0f83 100644 --- a/test/analyse.test.js +++ b/test/analyse.test.js @@ -326,6 +326,10 @@ test('validateTextform: Datum, Zukunft, Fassung, Weg', () => { bad(undefined) assert.deepEqual(an.validateTextform({ date: '2026-10-02', channel: 'email' }, ctx), { date: '2026-10-02', channel: 'email' }) assert.deepEqual(an.validateTextform({ date: '2026-10-05', channel: 'chat' }, ctx), { date: '2026-10-05', channel: 'chat' }) + // WhatsApp nur, wenn der Aufrufer es erlaubt (Funktionen), nicht bei der Analyse + bad({ date: '2026-10-02', channel: 'whatsapp' }) + assert.deepEqual(an.validateTextform({ date: '2026-10-02', channel: 'whatsapp' }, { ...ctx, channels: an.FUNKTION_CHANNELS }), { date: '2026-10-02', channel: 'whatsapp' }) + assert.throws(() => an.validateTextform({ date: '2026-10-02', channel: 'telefon' }, { ...ctx, channels: an.FUNKTION_CHANNELS }), { status: 400 }) }) test('Einschalten ohne Firma/Name oder mit ungültiger E-Mail: 409, nichts passiert', async () => { diff --git a/test/funktionenUnterkunftTextform.test.js b/test/funktionenUnterkunftTextform.test.js index 8bb1587..d2593df 100644 --- a/test/funktionenUnterkunftTextform.test.js +++ b/test/funktionenUnterkunftTextform.test.js @@ -129,6 +129,24 @@ test('unterkunft textform: ohne Angaben 400 ohne Nachweis, mit Angaben 200; Nach for (const a of k16[0].attachments) assert.equal(Buffer.from(a.content, 'base64').subarray(0, 4).toString(), '%PDF') }) +test('unterkunft textform: Weg WhatsApp wird angenommen und im Nachweis genannt; unbekannter Weg 400', async (t) => { + t.after(() => ukMit(MODUS.mode, MODUS.pilotEmails)) + ukMit('pilot', ['whatsapp@example.invalid']) + const k = legeKunde({ email: 'whatsapp@example.invalid' }) + const v = config.unterkunft.fassung + const telefon = await aktivieren(k, { textformDate: berlinTag(), textformChannel: 'telefon' }) + assert.equal(telefon.status, 400) + assert.match(telefon.body.error, /Chat, E-Mail oder WhatsApp/) + assert.equal(consents(k.projectId).length, 0) + const tag = berlinTag() + const ok = await aktivieren(k, { textformDate: tag, textformChannel: 'whatsapp' }) + assert.equal(ok.status, 200, JSON.stringify(ok.body)) + assert.equal( + leseZustimmung(consents(k.projectId)[0]).texte[0].text, + `Der Kunde hat am ${ttmmjjjj(tag)} per WhatsApp in Textform erklärt, dass er der Leistungsbeschreibung Buchungskalender und dem Auftragsverarbeitungsvertrag Buchungskalender (Fassung ${v}) zustimmt.` + ) +}) + test('unterkunft textform entfällt: Modus off, schon aktiv, Abschalten; ungültige Datumsangaben 400', async (t) => { t.after(() => ukMit(MODUS.mode, MODUS.pilotEmails)) ukMit('off')