From bade4edb74edb91c9a160cbe15e426eae0f829e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 04:44:44 +0000 Subject: [PATCH] Terminplaner: Staff-Aktivierung nur mit Zustimmung in Textform (Modus on), Portal Co-Authored-By: Claude Sonnet 5.5 --- server/routes/admin/funktionenAdmin.js | 57 +++++- server/services/siteFeatures.js | 34 +++- server/services/termine/schalter.js | 12 ++ test/funktionenTermine.test.js | 253 ++++++++++++++++++++++++- test/termineSchalter.test.js | 32 +++- 5 files changed, 381 insertions(+), 7 deletions(-) diff --git a/server/routes/admin/funktionenAdmin.js b/server/routes/admin/funktionenAdmin.js index 7baa559..745c793 100644 --- a/server/routes/admin/funktionenAdmin.js +++ b/server/routes/admin/funktionenAdmin.js @@ -1,9 +1,11 @@ import { Router } from 'express' import { config } from '../../config.js' import { aboMemo } from '../../services/aboStatus.js' +import { validateTextform } from '../../services/analyse.js' import { getDocument } from '../../services/appwriteAdmin.js' import { featureConfig } from '../../services/featureRegistry.js' import { withLocks } from '../../services/keyedLock.js' +import { featureLegalAttachments, featureLegalPaths } from '../../services/legalDocs.js' import { FEATURE_KEYS, FEATURE_LABELS, @@ -18,8 +20,9 @@ import { ticketNachlegen, } from '../../services/siteFeatures.js' import { driftWarnung, einbauanleitung, ticketStand } from '../../services/termine/einbauanleitung.js' -import { freischaltHindernis } from '../../services/termine/freischaltung.js' +import { freischaltHindernis, meldeFreigeschaltet } from '../../services/termine/freischaltung.js' import { ladeStand } from '../../services/termine/kontext.js' +import { termineTextformNoetig } from '../../services/termine/schalter.js' /** * Funktionen (News-Pinnwand, Terminplaner) je Kundenprojekt im Ticketsystem (Kunden-Reiter "Funktionen"). @@ -66,6 +69,32 @@ async function customerName(customerId) { const ticketExists = async (ticketId) => (await readTicket(ticketId)) !== null +/** + * Zustimmung des Kunden in Textform fuer die Aktivierung des Terminplaners durch das Team (F13a). null, wenn sie entfaellt: andere + * Funktion, Modus off oder pilot, Pilotadresse (termineTextformNoetig) oder ein Dokument, das nichts mehr aktiviert und dem nichts + * fehlt (schon aktiv, oder durch Kauf freigeschaltet, dann liegt die Zustimmung des Checkouts vor). Sonst { date, channel }; fehlt + * oder ist eine Angabe ungueltig (Datum vor der Fassung oder in der Zukunft, kein Weg), 400 - ohne dass etwas angelegt wird. + */ +async function textformFuerAktivierung({ body, customerId, key, doc }) { + if (key !== 'termine') return null + if (doc?.state === 'active' || doc?.unlockedByPurchase === true) return null + const kunde = await customerOf(customerId) + if (!termineTextformNoetig(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: config.termine.avvVersion }) +} + +/** Leistungsbeschreibung und AVV als PDF fuer die K16 der Textform-Aktivierung; nicht lesbar => 503, bevor etwas geaendert wird. */ +async function unterlagenAlsAnhang() { + try { + return await featureLegalAttachments('termine', { ohne: ['widerruf'] }) + } catch (err) { + console.error('[funktionen-admin] Rechtsunterlagen (PDF) fuer die Freischaltung nicht lesbar:', err.message) + throw Object.assign(new Error('Die Rechtsunterlagen (PDF) sind nicht lesbar. Der Terminplaner wurde nicht freigeschaltet. Bitte später erneut versuchen.'), { status: 503 }) + } +} + // Die Funktionsdokumente des Kunden werden einmal gelesen und fuer alle Projekte und Funktionen verwendet. // hindernis (freischaltHindernis, termine/freischaltung.js): was die Freischaltung eines Terminplaners in setup noch hindert // ('kein_abo' | 'keine_einstellung' | 'abo_unbekannt'), null = nichts; ausserhalb von setup und bei News immer null. Das Abo wird @@ -86,9 +115,25 @@ async function adminView(customerId) { return projects } +/** + * Angaben des Terminplaners fuer die Karte im WOMS (F13a): textformNoetig = verlangt die Aktivierung dieses Kunden die Zustimmung in + * Textform (Modus on und Adresse nicht in der Pilotliste), avvVersion = Fassung der Unterlagen, auf die sich die Zustimmung bezieht, + * links = absolute, versionierte Adressen von Leistungsbeschreibung und AVV fuer die Chat-Vorlage (nie Adressen ohne Fassung). + */ +async function termineFeld(customerId) { + const kunde = await customerOf(customerId) + const { lb, avv } = featureLegalPaths('termine') + return { + textformNoetig: termineTextformNoetig(kunde?.email), + avvVersion: config.termine.avvVersion, + links: { lb: `${config.publicBaseUrl}${lb}`, avv: `${config.publicBaseUrl}${avv}` }, + } +} + funktionenAdminRouter.get('/:customerId', async (req, res) => { try { - res.json({ projects: await adminView(req.params.customerId) }) + const projects = await adminView(req.params.customerId) + res.json({ projects, termine: await termineFeld(req.params.customerId) }) } catch (err) { sendError(res, err) } @@ -166,11 +211,15 @@ funktionenAdminRouter.post('/:customerId/:key', async (req, res) => { } await assertOwnProject(customerId, projectId) const by = adminName(req) + let freigeschaltet = null // Terminplaner mit Zustimmung in Textform: K16 mit den Unterlagen nach der Sperre await withLocks([`feature-admin:${projectId}`], async () => { const doc = await getFeatureDoc(customerId, projectId, key) const adminNote = composeAdminNote(doc?.adminNote, { by, action, note }) if (action === 'activate') { - await adminActivate(customerId, projectId, key, { by, adminNote }) + const textform = await textformFuerAktivierung({ body: req.body, customerId, key, doc }) + const attachments = textform ? await unterlagenAlsAnhang() : [] + const aktiviert = await adminActivate(customerId, projectId, key, { by, adminNote, textform }) + if (textform) freigeschaltet = { doc: aktiviert, attachments } } else if (action === 'abnahme') { await freischaltenNachAbnahme({ customerId, projectId, key, by, adminNote }) } else if (action === 'ticket') { @@ -182,6 +231,8 @@ funktionenAdminRouter.post('/:customerId/:key', async (req, res) => { await disable(doc.$id, { by, adminNote }) } }) + // Der Kunde bekommt die Unterlagen auf einem dauerhaften Datentraeger (Anhang); ein Versandfehler wird dort geloggt, nie geworfen + if (freigeschaltet) await meldeFreigeschaltet(freigeschaltet.doc, { attachments: freigeschaltet.attachments }) res.json({ projects: (await adminView(customerId)).filter((p) => p.projectId === projectId) }) } catch (err) { sendError(res, err) diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index 8e99257..ee8cd03 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -5,7 +5,7 @@ import { withLocks } from './keyedLock.js' import { sendAdminNotice, sendCustomerMail } from './mailer.js' import { getStripe, retrieveCheckoutSession } from './stripeBilling.js' import { anleitungsLink, featureConfig, featureHaken, featureKeys, kuerze, ticketFreischaltungKeys, ticketTexte } from './featureRegistry.js' -import { zustimmungFuerSitzung } from './featureConsents.js' +import { speichereZustimmung, zustimmungFuerSitzung } from './featureConsents.js' import { aboLage, laeuft } from './aboStatus.js' import { berlinDateTime } from './berlinTime.js' import { loeschTag } from './termine/frist.js' @@ -511,16 +511,46 @@ async function assertAboLaeuftFuerAktivierung(customerId) { if (!laeuftJetzt) throw httpError(409, KEIN_ABO_AKTIVIERUNG) } +const TEXTFORM_WEG = { chat: 'Chat im Kundenportal', email: 'E-Mail' } + +/** + * Text des Nachweises, wenn der Kunde dem Terminplaner in Textform zugestimmt hat (Aktivierung durch das Team im Modus on, F13a). + * textform = { date: 'JJJJ-MM-TT', channel: 'chat' | 'email' } (validateTextform); fassung = config.termine.avvVersion. + */ +export function textformNachweisText({ date, channel }, fassung) { + const [jahr, monat, tag] = String(date).split('-') + return `Der Kunde hat der Leistungsbeschreibung Terminplaner und dem Auftragsverarbeitungsvertrag Terminplaner (Fassung ${fassung}) am ${tag}.${monat}.${jahr} per ${TEXTFORM_WEG[channel]} in Textform zugestimmt und bestätigt, dass sein Angebot nicht im Gesundheitsbereich liegt.` +} + /** * Freischaltung ohne Zahlung und ohne Ticket (unlockedByPurchase bleibt false). Ausnahme: ein Terminplaner, den ein Kauf * freigeschaltet hat (unlockedByPurchase true), wird nur bei laufendem Wartungs-Abo aktiviert (409 sonst, Lesefehler => Fehler). * News und Dokumente ohne Kauf (Pilot, Kulanz) sind nicht betroffen. + * textform (nur Terminplaner, schon geprueft: { date, channel }): Zustimmung des Kunden in Textform. Der Nachweis (featureConsents, ohne + * Stripe-Sitzung, IP und Browser; Konto = Mitarbeiter) wird VOR der Freischaltung gespeichert: scheitert er, bleibt der Terminplaner aus. + * Der Aufrufer sendet danach K16 mit den Unterlagen (meldeFreigeschaltet). */ -export async function adminActivate(customerId, projectId, key, { by, adminNote } = {}) { +export async function adminActivate(customerId, projectId, key, { by, adminNote, textform = null, now = new Date() } = {}) { assertKey(key) + if (textform && key !== 'termine') throw httpError(400, 'Zustimmung in Textform gibt es nur für den Terminplaner') await assertOwnProject(customerId, projectId) const doc = await ensureFeatureDoc(customerId, projectId, key) if (key === 'termine' && doc.unlockedByPurchase === true) await assertAboLaeuftFuerAktivierung(customerId) + if (textform) { + const fassung = config.termine.avvVersion + await speichereZustimmung({ + customerId, + projectId, + featureKey: key, + stripeSessionId: '', + fassungen: { leistungsbeschreibung: fassung, avv: fassung }, + texte: [{ id: 'textform', text: textformNachweisText(textform, fassung) }], + ip: '', + userAgent: '', + konto: `Mitarbeiter: ${by || 'Mitarbeiter'}`, + at: now, + }) + } return activate(doc.$id, { by, adminNote }) } diff --git a/server/services/termine/schalter.js b/server/services/termine/schalter.js index 753349a..350be00 100644 --- a/server/services/termine/schalter.js +++ b/server/services/termine/schalter.js @@ -28,3 +28,15 @@ export function termineFuerKunde(email) { const adresse = String(email || '').trim().toLowerCase() return adresse !== '' && erlaubt.includes(adresse) } + +/** + * Verlangt die Aktivierung durch das Team (WOMS, ohne Bestellung) die Zustimmung des Kunden in Textform? Nur im Modus on und nur, + * wenn die Adresse des Kunden nicht in TERMINE_PILOT_EMAILS steht. '*' ist keine Pilotadresse und befreit niemanden. In off und + * pilot bleibt die Aktivierung wie in TP1 (Pilotbetrieb, Mitarbeiter und Kunde sprechen direkt miteinander). + */ +export function termineTextformNoetig(email) { + if (termineModus() !== 'on') return false + const adresse = String(email || '').trim().toLowerCase() + if (adresse === '' || adresse === '*') return true + return !config.termine.pilotEmails.includes(adresse) +} diff --git a/test/funktionenTermine.test.js b/test/funktionenTermine.test.js index 852dd32..2c25ae8 100644 --- a/test/funktionenTermine.test.js +++ b/test/funktionenTermine.test.js @@ -8,7 +8,7 @@ import { schemaKeys } from './support/schema.js' // Terminplaner als zweite Funktion (Aufgabe 17): Team-Route /:customerId/:key, GET /api/funktionen mit Schalter, // Kunde loeschen schaltet den Terminplaner ab und entfernt seine Entwuerfe (C6). News verhaelt sich wie bisher (test/funktionenRoutes.test.js). const aw = await startAppwriteMock({ - schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions'), termineConfig: schemaKeys('termineConfig'), termineBookings: schemaKeys('termineBookings') }, + schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions'), termineConfig: schemaKeys('termineConfig'), termineBookings: schemaKeys('termineBookings'), featureConsents: schemaKeys('featureConsents') }, }) const st = await startFakeStripe() useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) @@ -1363,3 +1363,254 @@ test('Teamansicht: hindernis je Terminplaner in setup, null in jedem anderen Zus assert.equal(view.state, 'setup') assert.equal(view.ticketMissing, false) }) + +// ------------------------------------------------------------- Staff-Aktivierung mit Zustimmung in Textform (TP2 F13a) --- +const { zustimmungFuerSitzung, leseZustimmung } = await import('../server/services/featureConsents.js') +const { setLegalDirForTests } = await import('../server/services/legalDocs.js') + +const berlinTagF13 = (ms = Date.now()) => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin' }).format(ms) +const tagMitVersatzF13 = (tag, tage) => new Date(Date.parse(`${tag}T12:00:00Z`) + tage * 86400000).toISOString().slice(0, 10) +const ttmmjjjjF13 = (tag) => tag.split('-').reverse().join('.') +const consentsF13 = () => [...aw.docs('featureConsents').values()] +const consentsDesF13 = (projectId) => consentsF13().filter((c) => c.projectId === projectId) +let f13Nr = 0 +/** Kunde mit eigenem Projekt (ohne Funktionsdokument); E-Mail frei waehlbar, das Wartungs-Abo laeuft. */ +function legeKundeF13({ email, abo = true } = {}) { + f13Nr += 1 + const customerId = `c_f13_${f13Nr}` + const projectId = `p_f13_${f13Nr}` + aw.seed('customers', { $id: customerId, email: email ?? `f13-${f13Nr}@example.invalid`, name: `Kunde F13 ${f13Nr}`, status: 'active', portalAccessEnabled: true }) + aw.seed('websiteProjects', { $id: projectId, customerId, projectName: `Projekt F13 ${f13Nr}` }) + if (abo) aw.seed('subscriptions', subscriptionDoc(`sub_f13_${f13Nr}`, { ...wartungDoc, customerId })) + return { customerId, projectId, email: aw.docs('customers').get(customerId).email } +} +const aktivierenF13 = (k, body = {}, opts = STAFF) => team(k.customerId, 'termine', { projectId: k.projectId, action: 'activate', ...body }, opts) +const feldF13 = (k, over = {}) => ({ textformDate: berlinTagF13(), textformChannel: 'chat', ...over }) +const k16AnF13 = (email) => mails.sent.filter((m) => m.subject === 'Dein Terminplaner ist bereit' && m.to.some((t) => t.address === email)) + +test('textform_pflicht_modus_on: ohne Angaben 400, mit gültigen Angaben 200, Nachweis mit Datum, Weg, Fassung, Mitarbeiter und leerer Sitzung, K16 mit LB und AVV als PDF', async (t) => { + modusMerken(t) + termineMit('on', ['pilot@example.invalid']) + const k = legeKundeF13() + const v = config.termine.avvVersion + const mailsVorher = mails.sent.length + + const ohne = await aktivierenF13(k) + assert.equal(ohne.status, 400) + assert.match(ohne.body.error, /Datum der Zustimmung/) + assert.equal(await sf.getFeatureDoc(k.customerId, k.projectId, 'termine'), null, 'nichts angelegt') + assert.equal(consentsDesF13(k.projectId).length, 0) + assert.equal(mails.sent.length, mailsVorher) + // nur Datum ohne Weg + const nurDatum = await aktivierenF13(k, { textformDate: berlinTagF13() }) + assert.equal(nurDatum.status, 400) + assert.match(nurDatum.body.error, /Weg der Zustimmung/) + + const tag = berlinTagF13() + const ok = await aktivierenF13(k, { textformDate: tag, textformChannel: 'email' }) + assert.equal(ok.status, 200, JSON.stringify(ok.body)) + assert.equal(featuresOf(ok, k.projectId).termine.state, 'active') + const doc = await sf.getFeatureDoc(k.customerId, k.projectId, 'termine') + assert.deepEqual([doc.state, doc.enabled, doc.unlockedByPurchase], ['active', true, false], 'ohne Bestellung freigeschaltet') + + const [nachweis] = consentsDesF13(k.projectId) + assert.equal(consentsDesF13(k.projectId).length, 1) + assert.deepEqual([nachweis.customerId, nachweis.projectId, nachweis.featureKey], [k.customerId, k.projectId, 'termine']) + assert.equal(nachweis.stripeSessionId, '', 'keine Stripe-Sitzung') + assert.equal(nachweis.ip, '') + assert.equal(nachweis.userAgent, '') + assert.equal(nachweis.konto, 'Mitarbeiter: Andrej') + assert.ok(Math.abs(Date.parse(nachweis.acceptedAt) - Date.now()) < 10000, 'Zeitpunkt der Erfassung') + const gelesen = leseZustimmung(nachweis) + assert.deepEqual(gelesen.fassungen, { leistungsbeschreibung: v, avv: v }) + assert.deepEqual(gelesen.texte, [{ + id: 'textform', + text: `Der Kunde hat der Leistungsbeschreibung Terminplaner und dem Auftragsverarbeitungsvertrag Terminplaner (Fassung ${v}) am ${ttmmjjjjF13(tag)} per E-Mail in Textform zugestimmt und bestätigt, dass sein Angebot nicht im Gesundheitsbereich liegt.`, + }]) + assert.match(nachweis.textHash, /^[0-9a-f]{64}$/) + // der Weg Chat heißt „Chat im Kundenportal“ + const chat = legeKundeF13() + assert.equal((await aktivierenF13(chat, { textformDate: tag, textformChannel: 'chat' })).status, 200) + assert.ok(leseZustimmung(consentsDesF13(chat.projectId)[0]).texte[0].text.includes(`am ${ttmmjjjjF13(tag)} per Chat im Kundenportal in Textform`)) + + // K16 an den Kunden mit genau den beiden PDFs (Leistungsbeschreibung, AVV), keine Widerrufsbelehrung + const k16 = k16AnF13(k.email) + assert.equal(k16.length, 1, mails.sent.map((m) => m.subject).join(' / ')) + assert.deepEqual(k16[0].attachments.map((a) => a.filename), [`Leistungsbeschreibung-Terminplaner-${v}.pdf`, `AVV-Terminplaner-${v}.pdf`]) + for (const a of k16[0].attachments) { + assert.equal(a.contentType, 'application/pdf') + assert.equal(Buffer.from(a.content, 'base64').subarray(0, 4).toString(), '%PDF') + } + assert.equal(mails.sent.length, mailsVorher + 2, 'je eine K16 für den Kunden und den Chat-Kunden, sonst nichts') +}) + +test('textform_entfaellt: Modus off und pilot, Modus on mit Pilotadresse, News und Abschalten brauchen keine Angaben und schreiben keinen Nachweis', async (t) => { + modusMerken(t) + const mailsVorher = mails.sent.length + for (const [mode, pilot] of [['off', []], ['pilot', ['*']], ['pilot', []]]) { + termineMit(mode, pilot) + const k = legeKundeF13() + const res = await aktivierenF13(k) + assert.equal(res.status, 200, `${mode}: ${JSON.stringify(res.body)}`) + assert.equal(featuresOf(res, k.projectId).termine.state, 'active') + assert.equal(consentsDesF13(k.projectId).length, 0, mode) + } + // Modus on, aber die Adresse steht in der Pilotliste + const pilotKunde = legeKundeF13({ email: 'Pilot-F13@example.invalid' }) + termineMit('on', ['pilot-f13@example.invalid']) + assert.equal((await aktivierenF13(pilotKunde)).status, 200) + assert.equal(consentsDesF13(pilotKunde.projectId).length, 0) + // Modus on, andere Adresse: Pflicht; News und Abschalten bleiben frei + const k = legeKundeF13() + assert.equal((await aktivierenF13(k)).status, 400) + const news = await team(k.customerId, 'news', { projectId: k.projectId, action: 'activate' }) + assert.equal(news.status, 200) + assert.equal(featuresOf(news, k.projectId).news.state, 'active') + assert.equal((await team(k.customerId, 'news', { projectId: k.projectId, action: 'disable' })).status, 200) + assert.equal(await sf.getFeatureDoc(k.customerId, k.projectId, 'termine'), null) + const aktiv = legeKundeF13() + termineMit('off') + assert.equal((await aktivierenF13(aktiv)).status, 200) + termineMit('on', []) + const aus = await team(aktiv.customerId, 'termine', { projectId: aktiv.projectId, action: 'disable' }) + assert.equal(aus.status, 200, 'Abschalten braucht keine Angaben') + assert.equal(featuresOf(aus, aktiv.projectId).termine.state, 'disabled') + assert.equal(consentsF13().filter((c) => [k.projectId, aktiv.projectId, pilotKunde.projectId].includes(c.projectId)).length, 0) + assert.equal(k16AnF13('k@example.invalid').length, 0) + assert.equal(mails.sent.slice(mailsVorher).filter((m) => m.subject === 'Dein Terminplaner ist bereit').length, 0, 'ohne Textform keine K16 (wie bisher)') +}) + +test('textform_entfaellt: ein bezahlter oder schon aktiver Terminplaner braucht keine neue Zustimmung in Textform (der Nachweis liegt schon vor)', async (t) => { + modusMerken(t) + termineMit('on', []) + // bezahlt (Zustimmung beim Checkout), Zustand setup, Wartungs-Abo läuft + const bezahlt = legeKundeF13() + aw.seed('portalFeatures', { $id: `f_${bezahlt.projectId}_termine`, customerId: bezahlt.customerId, projectId: bezahlt.projectId, featureKey: 'termine', siteKey: 'qrstuvwxyzabcdef', state: 'setup', enabled: false, unlockedByPurchase: true, purchaseStatus: 'paid' }) + const res = await aktivierenF13(bezahlt) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal(featuresOf(res, bezahlt.projectId).termine.state, 'active') + assert.equal(consentsDesF13(bezahlt.projectId).length, 0) + // schon aktiv (Doppelklick im WOMS): kein zweiter Nachweis, keine zweite Mail + const k = legeKundeF13() + termineMit('off') + assert.equal((await aktivierenF13(k)).status, 200) + termineMit('on', []) + const vorher = mails.sent.length + assert.equal((await aktivierenF13(k)).status, 200) + assert.equal(consentsDesF13(k.projectId).length, 0) + assert.equal(mails.sent.length, vorher) +}) + +test('textform_datum: Datum vor der Fassung, in der Zukunft, ungültig oder fehlend und ungültiger Weg ergeben 400 ohne Nachweis, ohne Dokument, ohne Mail', async (t) => { + modusMerken(t) + termineMit('on', []) + const k = legeKundeF13() + const v = config.termine.avvVersion + const heute = berlinTagF13() + const mailsVorher = mails.sent.length + const faelle = [ + [{ textformDate: tagMitVersatzF13(v, -1), textformChannel: 'chat' }, /liegt vor der Fassung/], + [{ textformDate: tagMitVersatzF13(heute, 1), textformChannel: 'chat' }, /in der Zukunft/], + [{ textformDate: '2026-02-30', textformChannel: 'chat' }, /fehlt oder ist ungültig/], + [{ textformDate: '05.10.2026', textformChannel: 'chat' }, /fehlt oder ist ungültig/], + [{ textformDate: 20261005, textformChannel: 'chat' }, /fehlt oder ist ungültig/], + [{ textformDate: heute }, /Weg der Zustimmung/], + [{ textformDate: heute, textformChannel: 'telefon' }, /Weg der Zustimmung/], + [{ textformDate: heute, textformChannel: '' }, /Weg der Zustimmung/], + [{ textformDate: heute, textformChannel: ['chat'] }, /Weg der Zustimmung/], + ] + for (const [body, meldung] of faelle) { + const res = await aktivierenF13(k, body) + assert.equal(res.status, 400, JSON.stringify(body)) + assert.match(res.body.error, meldung, JSON.stringify(body)) + } + assert.equal(await sf.getFeatureDoc(k.customerId, k.projectId, 'termine'), null, 'nichts angelegt') + assert.equal(consentsDesF13(k.projectId).length, 0) + assert.equal(mails.sent.length, mailsVorher) + // genau am Tag der Fassung und heute ist erlaubt + assert.equal((await aktivierenF13(k, { textformDate: v, textformChannel: 'email' })).status, 200) + assert.equal(consentsDesF13(k.projectId).length, 1) +}) + +test('textform_pflicht_modus_on: fremdes Projekt, Kundensitzung und nicht lesbare Rechtsunterlagen ändern nichts (die Berechtigung des Team-Wegs bleibt)', async (t) => { + modusMerken(t) + termineMit('on', []) + const k = legeKundeF13() + const fremd = legeKundeF13() + // Projekt eines anderen Kunden: 403 vor jeder Prüfung der Zustimmung + const res = await team(k.customerId, 'termine', { projectId: fremd.projectId, action: 'activate', ...feldF13(k) }) + assert.equal(res.status, 403) + const alsKunde = await aktivierenF13(k, feldF13(k), { cookie: customerCookie(k.customerId) }) + assert.ok(alsKunde.status === 401 || alsKunde.status === 403) + assert.equal(consentsF13().filter((c) => [k.projectId, fremd.projectId].includes(c.projectId)).length, 0) + assert.equal(await sf.getFeatureDoc(k.customerId, k.projectId, 'termine'), null) + // PDFs nicht lesbar: nichts wird freigeschaltet, nichts gespeichert, keine Mail + const leer = await (await import('node:fs/promises')).mkdtemp((await import('node:path')).join((await import('node:os')).tmpdir(), 'f13-leer-')) + setLegalDirForTests(leer) + t.after(() => setLegalDirForTests(null)) + const mailsVorher = mails.sent.length + const fehler = await aktivierenF13(k, feldF13(k)) + assert.ok(fehler.status >= 500, `Status ${fehler.status}`) + assert.match(fehler.body.error, /Rechtsunterlagen/) + assert.equal(await sf.getFeatureDoc(k.customerId, k.projectId, 'termine'), null) + assert.equal(consentsDesF13(k.projectId).length, 0) + assert.equal(mails.sent.length, mailsVorher) + setLegalDirForTests(null) + assert.equal((await aktivierenF13(k, feldF13(k))).status, 200) +}) + +test('get_admin_termine_feld: GET /api/team/funktionen/:kunde liefert termine { textformNoetig, avvVersion, links } je Modus', async (t) => { + modusMerken(t) + const k = legeKundeF13({ email: 'get-f13@example.invalid' }) + const v = config.termine.avvVersion + const feldVon = async () => (await get(`/api/team/funktionen/${k.customerId}`, STAFF)).termine + termineMit('off') + assert.equal((await feldVon()).textformNoetig, false) + termineMit('pilot', ['*']) + assert.equal((await feldVon()).textformNoetig, false) + termineMit('on', ['get-f13@example.invalid']) + assert.equal((await feldVon()).textformNoetig, false, 'Pilotadresse') + termineMit('on', []) + const f = await feldVon() + assert.deepEqual(Object.keys(f).sort(), ['avvVersion', 'links', 'textformNoetig']) + assert.equal(f.textformNoetig, true) + assert.equal(f.avvVersion, v) + assert.deepEqual(Object.keys(f.links).sort(), ['avv', 'lb']) + // bisherige Antwort bleibt: projects + const ganz = await get(`/api/team/funktionen/${k.customerId}`, STAFF) + assert.ok(Array.isArray(ganz.projects)) + // unbekannter Kunde: Modus on => Textform nötig (ohne Adresse), keine Ausnahme + termineMit('on', ['*']) + assert.equal((await get('/api/team/funktionen/c_gibtsnicht', STAFF)).termine.textformNoetig, true) +}) + +test('chat_vorlage_versioniert: die Adressen für die Chat-Vorlage verweisen nur auf Seiten mit Fassung (-.html), absolut, folgen der aktuellen Fassung', async (t) => { + modusMerken(t) + termineMit('on', []) + const k = legeKundeF13() + const v = config.termine.avvVersion + const { links } = (await get(`/api/team/funktionen/${k.customerId}`, STAFF)).termine + assert.equal(links.lb, `${config.publicBaseUrl}/rechtliches/leistungsbeschreibung-terminplaner-${v}.html`) + assert.equal(links.avv, `${config.publicBaseUrl}/rechtliches/avv-terminplaner-${v}.html`) + for (const adresse of Object.values(links)) { + assert.ok(adresse.endsWith(`-${v}.html`), adresse) + assert.match(adresse, /^https:\/\//) + } + // eine neue Fassung ändert die Adressen mit + const vorher = config.termine.avvVersion + t.after(() => { config.termine.avvVersion = vorher }) + config.termine.avvVersion = '2099-01-02' + const neu = (await get(`/api/team/funktionen/${k.customerId}`, STAFF)).termine + assert.equal(neu.avvVersion, '2099-01-02') + for (const adresse of Object.values(neu.links)) assert.ok(adresse.endsWith('-2099-01-02.html'), adresse) +}) + +test('zustimmungFuerSitzung(""): der Nachweis der Staff-Aktivierung (leere Sitzung) wird über keine Sitzung gefunden', async (t) => { + modusMerken(t) + termineMit('on', []) + const k = legeKundeF13() + assert.equal((await aktivierenF13(k, feldF13(k))).status, 200) + assert.equal(consentsDesF13(k.projectId).length, 1) + assert.equal(consentsDesF13(k.projectId)[0].stripeSessionId, '') + for (const leer of ['', ' ', undefined, null]) assert.equal(await zustimmungFuerSitzung(leer), null, JSON.stringify(leer)) +}) diff --git a/test/termineSchalter.test.js b/test/termineSchalter.test.js index 9f49cee..c562f5a 100644 --- a/test/termineSchalter.test.js +++ b/test/termineSchalter.test.js @@ -1,7 +1,11 @@ import { test, beforeEach, afterEach } from 'node:test' import assert from 'node:assert/strict' import { config } from '../server/config.js' -import { termineAus, termineFuerKunde, termineModus } from '../server/services/termine/schalter.js' +import * as schalter from '../server/services/termine/schalter.js' + +const { termineAus, termineFuerKunde, termineModus } = schalter +// Namensraum für termineTextformNoetig: fehlt der Export, scheitert nur sein Test +const termineTextformNoetig = (email) => schalter.termineTextformNoetig(email) const TERMINE_ENV = [ 'TERMINE_ENABLED', @@ -162,3 +166,29 @@ test('termineFuerKunde: on gilt fuer alle ohne E-Mail-Filter', () => { assert.equal(termineFuerKunde('jemand@example.com'), true) assert.equal(termineFuerKunde(undefined), true) }) + +test('termineTextformNoetig: nur im Modus on und nur außerhalb der Pilotliste; „*“ und fehlende Adresse befreien nicht', () => { + for (const mode of ['off', 'pilot', undefined, '', 'banane']) { + config.termine.mode = mode + config.termine.pilotEmails = [] + assert.equal(termineTextformNoetig('jemand@example.com'), false, `mode=${JSON.stringify(mode)}`) + config.termine.pilotEmails = ['*'] + assert.equal(termineTextformNoetig('jemand@example.com'), false, `mode=${JSON.stringify(mode)} mit Stern`) + } + config.termine.mode = 'on' + config.termine.pilotEmails = [] + assert.equal(termineTextformNoetig('jemand@example.com'), true) + config.termine.pilotEmails = ['kenso.gri@gmail.com'] + assert.equal(termineTextformNoetig('jemand@example.com'), true) + assert.equal(termineTextformNoetig('KENSO.gri@gmail.com '), false, 'Pilotadresse, Schreibweise egal') + assert.equal(termineTextformNoetig('kenso.gri@gmail.com.evil.de'), true) + config.termine.pilotEmails = ['*'] + assert.equal(termineTextformNoetig('jemand@example.com'), true, 'der Stern zählt in on nicht als Pilotadresse') + assert.equal(termineTextformNoetig('*'), true) + config.termine.pilotEmails = ['*', 'kenso.gri@gmail.com'] + assert.equal(termineTextformNoetig('kenso.gri@gmail.com'), false) + for (const email of [undefined, null, '', ' ']) assert.equal(termineTextformNoetig(email), true, `ohne Adresse: ${JSON.stringify(email)}`) + // wirkt sofort wie die übrigen Schalter + config.termine.mode = 'pilot' + assert.equal(termineTextformNoetig('jemand@example.com'), false) +})