From 1b7aa9ebe39cc4fd4a862669a85db55f9b1c4ee7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:44:11 +0000 Subject: [PATCH] Shop Review B I-8: Aktivierung des Webshops durch das Team nur mit Zustimmung in Textform AVV Webshop, Vorbemerkung Abs. 2: Das Team darf den Webshop ohne Online-Bestellung nur einschalten, wenn der Kunde vorher in Textform zugestimmt hat. Die Textform-Aktivierung gilt jetzt wie beim Terminplaner auch fuer 'shop' (Modus on, Pilotadressen ausgenommen, shopTextformNoetig): Nachweis in featureConsents mit Fassung (config.shop.fassung), Datum, Weg, Mitarbeiter und dem Wortlaut der Erklaerungen der Bestelltexte, K16 mit Leistungsbeschreibung und AVV als PDF. GET /api/team/funktionen/:kunde liefert zusaetzlich shop { textformNoetig, avvVersion, links }. Co-Authored-By: Claude Opus 5.5 --- server/routes/admin/funktionenAdmin.js | 47 ++-- server/services/shop/schalter.js | 13 + server/services/siteFeatures.js | 37 ++- test/funktionenShopTextform.test.js | 315 +++++++++++++++++++++++++ test/termineVertragsunterlagen.test.js | 2 +- 5 files changed, 390 insertions(+), 24 deletions(-) create mode 100644 test/funktionenShopTextform.test.js diff --git a/server/routes/admin/funktionenAdmin.js b/server/routes/admin/funktionenAdmin.js index 176de00..012ee54 100644 --- a/server/routes/admin/funktionenAdmin.js +++ b/server/routes/admin/funktionenAdmin.js @@ -9,6 +9,7 @@ import { featureLegalAttachments, featureLegalPaths } from '../../services/legal import { FEATURE_KEYS, FEATURE_LABELS, + TEXTFORM_KEYS, adminActivate, assertOwnProject, customerOf, @@ -17,11 +18,13 @@ import { getFeatureDoc, listCustomerFeatureDocs, listCustomerFeatures, + textformFassung, ticketNachlegen, } from '../../services/siteFeatures.js' import { driftWarnung, einbauanleitung, ticketStand } from '../../services/termine/einbauanleitung.js' import { freischaltHindernis, meldeFreigeschaltet } from '../../services/termine/freischaltung.js' import { ladeStand } from '../../services/termine/kontext.js' +import { shopTextformNoetig } from '../../services/shop/schalter.js' import { termineTextformNoetig } from '../../services/termine/schalter.js' /** @@ -89,29 +92,34 @@ async function notiereK16Fehlt(projectId, doc) { const ticketExists = async (ticketId) => (await readTicket(ticketId)) !== null +/** Verlangt die Aktivierung dieser Funktion fuer diese Adresse die Zustimmung in Textform? Nur Terminplaner und Webshop, je nach Modus und Pilotliste der Funktion. */ +const textformVerlangt = { termine: termineTextformNoetig, shop: shopTextformNoetig } + /** - * 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. + * Zustimmung des Kunden in Textform fuer die Aktivierung des Terminplaners oder des Webshops durch das Team (F13a; AVV Webshop, + * Vorbemerkung Abs. 2). null, wenn sie entfaellt: andere Funktion, Modus off oder pilot, Pilotadresse (termineTextformNoetig, + * shopTextformNoetig) 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 (!TEXTFORM_KEYS.includes(key)) return null if (doc?.state === 'active' || doc?.unlockedByPurchase === true) return null const kunde = await customerOf(customerId) - if (!termineTextformNoetig(kunde?.email)) return null + 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: config.termine.avvVersion }) + return validateTextform({ date: text(body?.textformDate), channel: text(body?.textformChannel) }, { minDate: textformFassung(key) }) } -/** Leistungsbeschreibung und AVV als PDF fuer die K16 der Textform-Aktivierung; nicht lesbar => 503, bevor etwas geaendert wird. */ -async function unterlagenAlsAnhang() { +/** Leistungsbeschreibung und AVV der Funktion als PDF fuer die K16 der Textform-Aktivierung; nicht lesbar => 503, bevor etwas geaendert wird. */ +async function unterlagenAlsAnhang(key) { try { - return await featureLegalAttachments('termine', { ohne: ['widerruf'] }) + return await featureLegalAttachments(key, { 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 }) + const funktion = key === 'shop' ? 'Der Webshop' : 'Der Terminplaner' + throw Object.assign(new Error(`Die Rechtsunterlagen (PDF) sind nicht lesbar. ${funktion} wurde nicht freigeschaltet. Bitte später erneut versuchen.`), { status: 503 }) } } @@ -150,10 +158,21 @@ async function termineFeld(customerId) { } } +/** Dasselbe fuer den Webshop (AVV Webshop, Vorbemerkung Abs. 2): Fassung = config.shop.fassung, Adressen der versionierten Unterlagen des Webshops. */ +async function shopFeld(customerId) { + const kunde = await customerOf(customerId) + const { lb, avv } = featureLegalPaths('shop') + return { + textformNoetig: shopTextformNoetig(kunde?.email), + avvVersion: config.shop.fassung, + links: { lb: `${config.publicBaseUrl}${lb}`, avv: `${config.publicBaseUrl}${avv}` }, + } +} + funktionenAdminRouter.get('/:customerId', async (req, res) => { try { const projects = await adminView(req.params.customerId) - res.json({ projects, termine: await termineFeld(req.params.customerId) }) + res.json({ projects, termine: await termineFeld(req.params.customerId), shop: await shopFeld(req.params.customerId) }) } catch (err) { sendError(res, err) } @@ -231,13 +250,13 @@ 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 + let freigeschaltet = null // Terminplaner oder Webshop 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') { const textform = await textformFuerAktivierung({ body: req.body, customerId, key, doc }) - const attachments = textform ? await unterlagenAlsAnhang() : [] + const attachments = textform ? await unterlagenAlsAnhang(key) : [] const aktiviert = await adminActivate(customerId, projectId, key, { by, adminNote, textform }) if (textform) freigeschaltet = { doc: aktiviert, attachments } } else if (action === 'abnahme') { diff --git a/server/services/shop/schalter.js b/server/services/shop/schalter.js index c154a9f..d4ea080 100644 --- a/server/services/shop/schalter.js +++ b/server/services/shop/schalter.js @@ -24,3 +24,16 @@ export function shopFuerKunde(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 (AVV Webshop, + * Vorbemerkung Abs. 2)? Wie beim Terminplaner (termineTextformNoetig): nur im Modus on und nur, wenn die Adresse des Kunden nicht + * in SHOP_PILOT_EMAILS steht. '*' ist keine Pilotadresse und befreit niemanden. In off und pilot sprechen Mitarbeiter und Kunde + * direkt miteinander (Pilotbetrieb). + */ +export function shopTextformNoetig(email) { + if (shopModus() !== 'on') return false + const adresse = String(email || '').trim().toLowerCase() + if (adresse === '' || adresse === '*') return true + return !config.shop.pilotEmails.includes(adresse) +} diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index 7b15d97..ddc83b1 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -543,38 +543,57 @@ async function assertAboLaeuftFuerAktivierung(customerId) { const TEXTFORM_WEG = { chat: 'Chat im Kundenportal', email: 'E-Mail' } +/** 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'] +export const textformFassung = (key) => (key === 'shop' ? config.shop.fassung : config.termine.avvVersion) + /** - * 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. + * 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). */ -export function textformNachweisText({ date, channel }, fassung) { +export function textformNachweisText({ date, channel }, fassung, key = 'termine') { const [jahr, monat, tag] = String(date).split('-') + if (key === 'shop') { + return `Der Kunde hat am ${tag}.${monat}.${jahr} per ${TEXTFORM_WEG[channel]} in Textform erklärt, dass er der Leistungsbeschreibung Webshop und dem Auftragsverarbeitungsvertrag Webshop (Fassung ${fassung}) zustimmt und die folgenden Erklärungen der Bestelltexte der Fassung ${fassung} abgibt.` + } 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.` } +// Erklaerungen der Webshop-Bestelltexte, die auch bei der Aktivierung in Textform gelten (ohne Widerrufsverlangen und Pflichtinformationen: +// die gehoeren zur kostenpflichtigen Online-Bestellung) +const SHOP_TEXTFORM_ERKLAERUNGEN = ['unterlagen', 'verantwortung', 'stripe'] + +/** Texte des Nachweises in Speicherreihenfolge: Termine = ein Satz; Webshop = Satz mit Datum und Weg, danach der Wortlaut der Erklaerungen der Fassung. */ +function textformNachweisTexte(textform, fassung, key) { + const satz = { id: 'textform', text: textformNachweisText(textform, fassung, key) } + if (key !== 'shop') return [satz] + const items = featureConfig('shop').consent.items.filter((i) => SHOP_TEXTFORM_ERKLAERUNGEN.includes(i.id)) + return [satz, ...items.map(({ id, text }) => ({ id, text }))] +} + /** * 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). + * textform (nur Terminplaner und Webshop, schon geprueft: { date, channel }): Zustimmung des Kunden in Textform. Der Nachweis + * (featureConsents, ohne Stripe-Sitzung, IP und Browser; Konto = Mitarbeiter; Fassung = textformFassung(key)) wird VOR der Freischaltung + * gespeichert: scheitert er, bleibt die Funktion aus. Der Aufrufer sendet danach K16 mit den Unterlagen (meldeFreigeschaltet). */ 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') + if (textform && !TEXTFORM_KEYS.includes(key)) throw httpError(400, 'Zustimmung in Textform gibt es nur für den Terminplaner und den Webshop') 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 + const fassung = textformFassung(key) await speichereZustimmung({ customerId, projectId, featureKey: key, stripeSessionId: '', fassungen: { leistungsbeschreibung: fassung, avv: fassung }, - texte: [{ id: 'textform', text: textformNachweisText(textform, fassung) }], + texte: textformNachweisTexte(textform, fassung, key), ip: '', userAgent: '', konto: `Mitarbeiter: ${by || 'Mitarbeiter'}`, diff --git a/test/funktionenShopTextform.test.js b/test/funktionenShopTextform.test.js new file mode 100644 index 0000000..1a2d94d --- /dev/null +++ b/test/funktionenShopTextform.test.js @@ -0,0 +1,315 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' + +// Webshop: Aktivierung durch das Team nur mit Zustimmung des Kunden in Textform (AVV Webshop, Vorbemerkung Abs. 2; wie der Terminplaner, +// siehe test/funktionenTermine.test.js "Staff-Aktivierung mit Zustimmung in Textform"). Schalter: shopTextformNoetig (services/shop/schalter.js). +const aw = await startAppwriteMock({ + schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions'), featureConsents: schemaKeys('featureConsents') }, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const { startTestApp, get, post } = await import('./support/testApp.js') +const { customerCookie, seedFixtures } = await import('./support/fixtures.js') +const { default: teamRoutes } = await import('../server/routes/team/index.js') +const sf = await import('../server/services/siteFeatures.js') +const { leseZustimmung } = await import('../server/services/featureConsents.js') +const { setLegalDirForTests, legalPdfFiles } = await import('../server/services/legalDocs.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { mailSink } = await import('./support/mailSink.js') +const { shopTextformNoetig } = await import('../server/services/shop/schalter.js') + +const app = await startTestApp({ '/api/team': teamRoutes }) +const mails = mailSink() +setMailTransportForTests(mails) +const MODUS = { mode: config.shop.mode, pilotEmails: [...config.shop.pilotEmails] } +after(() => { + config.shop.mode = MODUS.mode + config.shop.pilotEmails = MODUS.pilotEmails + setLegalDirForTests(null) + setMailTransportForTests(null) + return Promise.all([app.close(), aw.close(), st.close()]) +}) +seedFixtures(aw) +aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' }) + +const STAFF = { headers: { Authorization: 'Bearer jwt-staff' } } +const shopMit = (mode, pilotEmails = []) => { + config.shop.mode = mode + config.shop.pilotEmails = pilotEmails +} +const team = (customerId, key, body, opts = STAFF) => post(`/api/team/funktionen/${customerId}/${key}`, body, opts) +const featuresOf = (res, projectId) => res.body.projects.find((p) => p.projectId === projectId).features +const berlinTag = (ms = Date.now()) => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin' }).format(ms) +const tagMitVersatz = (tag, tage) => new Date(Date.parse(`${tag}T12:00:00Z`) + tage * 86400000).toISOString().slice(0, 10) +const ttmmjjjj = (tag) => tag.split('-').reverse().join('.') +const consents = (projectId) => [...aw.docs('featureConsents').values()].filter((c) => c.projectId === projectId) +const k16An = (email) => mails.sent.filter((m) => m.subject === 'Dein Webshop ist bereit' && m.to.some((t) => t.address === email)) + +let nr = 0 +/** Kunde mit eigenem Projekt (ohne Funktionsdokument); E-Mail frei waehlbar. */ +function legeKunde({ email } = {}) { + nr += 1 + const customerId = `c_shoptf_${nr}` + const projectId = `p_shoptf_${nr}` + aw.seed('customers', { $id: customerId, email: email ?? `shoptf-${nr}@example.invalid`, name: `Kunde Shop ${nr}`, status: 'active', portalAccessEnabled: true }) + aw.seed('websiteProjects', { $id: projectId, customerId, projectName: `Shop-Projekt ${nr}` }) + return { customerId, projectId, email: aw.docs('customers').get(customerId).email } +} +const aktivieren = (k, body = {}, opts = STAFF) => team(k.customerId, 'shop', { projectId: k.projectId, action: 'activate', ...body }, opts) +const feld = (over = {}) => ({ textformDate: berlinTag(), textformChannel: 'chat', ...over }) + +test('shopTextformNoetig: nur im Modus on und nur außerhalb der Pilotliste; „*“ und fehlende Adresse befreien nicht', (t) => { + t.after(() => shopMit(MODUS.mode, MODUS.pilotEmails)) + shopMit('off', ['a@example.invalid']) + assert.equal(shopTextformNoetig('jemand@example.com'), false) + shopMit('pilot', []) + assert.equal(shopTextformNoetig('jemand@example.com'), false) + shopMit('on', ['kenso.gri@gmail.com']) + assert.equal(shopTextformNoetig('jemand@example.com'), true) + assert.equal(shopTextformNoetig(' KENSO.gri@gmail.com '), false, 'Pilotadresse, Schreibweise egal') + assert.equal(shopTextformNoetig('kenso.gri@gmail.com.evil.de'), true) + shopMit('on', ['*']) + assert.equal(shopTextformNoetig('jemand@example.com'), true, 'der Stern zählt in on nicht als Pilotadresse') + assert.equal(shopTextformNoetig('*'), true) + for (const email of [undefined, null, '', ' ']) assert.equal(shopTextformNoetig(email), true, `ohne Adresse: ${JSON.stringify(email)}`) +}) + +test('shop textform: ohne Angaben 400, mit gültigen Angaben 200; Nachweis mit Fassung, Datum, Weg, Erklärungen, Mitarbeiter; K16 mit LB und AVV als PDF', async (t) => { + t.after(() => shopMit(MODUS.mode, MODUS.pilotEmails)) + shopMit('on', ['pilot@example.invalid']) + const k = legeKunde() + const v = config.shop.fassung + const mailsVorher = mails.sent.length + + const ohne = await aktivieren(k) + assert.equal(ohne.status, 400) + assert.match(ohne.body.error, /Datum der Zustimmung/) + assert.equal(await sf.getFeatureDoc(k.customerId, k.projectId, 'shop'), null, 'nichts angelegt') + assert.equal(consents(k.projectId).length, 0) + assert.equal(mails.sent.length, mailsVorher) + const nurDatum = await aktivieren(k, { textformDate: berlinTag() }) + assert.equal(nurDatum.status, 400) + assert.match(nurDatum.body.error, /Weg der Zustimmung/) + + const tag = berlinTag() + const ok = await aktivieren(k, { textformDate: tag, textformChannel: 'email' }) + assert.equal(ok.status, 200, JSON.stringify(ok.body)) + assert.equal(featuresOf(ok, k.projectId).shop.state, 'active') + const doc = await sf.getFeatureDoc(k.customerId, k.projectId, 'shop') + assert.deepEqual([doc.state, doc.enabled, doc.unlockedByPurchase], ['active', true, false], 'ohne Bestellung freigeschaltet') + assert.match(doc.adminNote, /Andrej: activate/, 'Name des Mitarbeiters in der Notiz') + + assert.equal(consents(k.projectId).length, 1) + const [nachweis] = consents(k.projectId) + assert.deepEqual([nachweis.customerId, nachweis.projectId, nachweis.featureKey], [k.customerId, k.projectId, 'shop']) + 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 des Einschaltens') + const gelesen = leseZustimmung(nachweis) + assert.deepEqual(gelesen.fassungen, { leistungsbeschreibung: v, avv: v }) + assert.deepEqual(gelesen.texte.map((x) => x.id), ['textform', 'unterlagen', 'verantwortung', 'stripe']) + assert.equal( + gelesen.texte[0].text, + `Der Kunde hat am ${ttmmjjjj(tag)} per E-Mail in Textform erklärt, dass er der Leistungsbeschreibung Webshop und dem Auftragsverarbeitungsvertrag Webshop (Fassung ${v}) zustimmt und die folgenden Erklärungen der Bestelltexte der Fassung ${v} abgibt.` + ) + // Wortlaut der Erklärungen = die Bestelltexte der Fassung (ohne Widerrufsverlangen und Pflichtinformationen) + const { featureConfig } = await import('../server/services/featureRegistry.js') + const items = featureConfig('shop').consent.items + for (const id of ['unterlagen', 'verantwortung', 'stripe']) { + assert.equal(gelesen.texte.find((x) => x.id === id).text, items.find((i) => i.id === id).text) + } + assert.ok(gelesen.texte[1].text.includes(`Fassung ${v}`)) + assert.match(nachweis.textHash, /^[0-9a-f]{64}$/) + // Weg Chat heißt „Chat im Kundenportal“ + const chat = legeKunde() + assert.equal((await aktivieren(chat, { textformDate: tag, textformChannel: 'chat' })).status, 200) + assert.ok(leseZustimmung(consents(chat.projectId)[0]).texte[0].text.includes(`am ${ttmmjjjj(tag)} per Chat im Kundenportal in Textform`)) + + // K16 an den Kunden mit genau den beiden PDFs (Leistungsbeschreibung, AVV), keine Widerrufsbelehrung + const k16 = k16An(k.email) + assert.equal(k16.length, 1, mails.sent.map((m) => m.subject).join(' / ')) + assert.deepEqual(k16[0].attachments.map((a) => a.filename), [`Leistungsbeschreibung-Webshop-${v}.pdf`, `AVV-Webshop-${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('shop textform: scheitert der Versand von K16, bleibt der Webshop freigeschaltet, adminNote und warnung:true sagen es', async (t) => { + t.after(() => shopMit(MODUS.mode, MODUS.pilotEmails)) + t.mock.method(console, 'error', () => {}) + shopMit('on', []) + const gut = legeKunde() + const ok = await aktivieren(gut, feld()) + assert.equal(ok.status, 200, JSON.stringify(ok.body)) + assert.equal(ok.body.warnung, undefined) + + const k = legeKunde() + setMailTransportForTests({ sendMail: async () => { throw new Error('SMTP weg') } }) + t.after(() => setMailTransportForTests(mails)) + const res = await aktivieren(k, feld()) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal(res.body.warnung, true) + const doc = await sf.getFeatureDoc(k.customerId, k.projectId, 'shop') + assert.equal(doc.state, 'active', 'die Freischaltung bleibt') + assert.equal(consents(k.projectId).length, 1, 'der Nachweis bleibt') + assert.ok(doc.adminNote.includes('K16 mit Unterlagen NICHT gesendet – von Hand nachsenden'), doc.adminNote) +}) + +test('shop textform entfällt: Modus off und pilot, Modus on mit Pilotadresse, schon aktiv, Abschalten und News brauchen keine Angaben und schreiben keinen Nachweis', async (t) => { + t.after(() => shopMit(MODUS.mode, MODUS.pilotEmails)) + const mailsVorher = mails.sent.length + for (const [mode, pilot] of [['off', []], ['pilot', ['*']], ['pilot', []]]) { + shopMit(mode, pilot) + const k = legeKunde() + const res = await aktivieren(k) + assert.equal(res.status, 200, `${mode}: ${JSON.stringify(res.body)}`) + assert.equal(featuresOf(res, k.projectId).shop.state, 'active') + assert.equal(consents(k.projectId).length, 0, mode) + } + const pilotKunde = legeKunde({ email: 'Pilot-Shop@example.invalid' }) + shopMit('on', ['pilot-shop@example.invalid']) + assert.equal((await aktivieren(pilotKunde)).status, 200) + assert.equal(consents(pilotKunde.projectId).length, 0) + // Modus on, andere Adresse: Pflicht; Abschalten und News bleiben frei; ein schon aktiver Webshop braucht nichts mehr + const k = legeKunde() + assert.equal((await aktivieren(k)).status, 400) + const news = await team(k.customerId, 'news', { projectId: k.projectId, action: 'activate' }) + assert.equal(news.status, 200) + const aktiv = legeKunde() + shopMit('off') + assert.equal((await aktivieren(aktiv)).status, 200) + shopMit('on', []) + assert.equal((await aktivieren(aktiv)).status, 200, 'schon aktiv: keine neue Zustimmung') + assert.equal(consents(aktiv.projectId).length, 0) + const aus = await team(aktiv.customerId, 'shop', { projectId: aktiv.projectId, action: 'disable' }) + assert.equal(aus.status, 200, 'Abschalten braucht keine Angaben') + assert.equal(featuresOf(aus, aktiv.projectId).shop.state, 'disabled') + assert.equal(await sf.getFeatureDoc(k.customerId, k.projectId, 'shop'), null) + assert.equal(mails.sent.slice(mailsVorher).filter((m) => m.subject === 'Dein Webshop ist bereit').length, 0, 'ohne Textform keine K16') + // ein bezahlter Webshop (Zustimmung des Checkouts liegt vor) braucht keine Zustimmung in Textform + const bezahlt = legeKunde() + aw.seed('portalFeatures', { $id: `f_${bezahlt.projectId}_shop`, customerId: bezahlt.customerId, projectId: bezahlt.projectId, featureKey: 'shop', siteKey: 'abcdefghijklmnop', state: 'setup', enabled: false, unlockedByPurchase: true, purchaseStatus: 'paid' }) + const res = await aktivieren(bezahlt) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal(featuresOf(res, bezahlt.projectId).shop.state, 'active') + assert.equal(consents(bezahlt.projectId).length, 0) +}) + +test('shop textform: 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) => { + t.after(() => shopMit(MODUS.mode, MODUS.pilotEmails)) + shopMit('on', []) + const k = legeKunde() + const v = config.shop.fassung + const heute = berlinTag() + const mailsVorher = mails.sent.length + for (const [body, muster] of [ + [{ textformDate: tagMitVersatz(v, -1), textformChannel: 'chat' }, /liegt vor der Fassung/], + [{ textformDate: tagMitVersatz(heute, 1), textformChannel: 'chat' }, /in der Zukunft/], + [{ textformDate: '2026-02-30', 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: ['chat'] }, /Weg der Zustimmung/], + ]) { + const res = await aktivieren(k, body) + assert.equal(res.status, 400, JSON.stringify(body)) + assert.match(res.body.error, muster) + } + assert.equal(await sf.getFeatureDoc(k.customerId, k.projectId, 'shop'), null) + assert.equal(consents(k.projectId).length, 0) + assert.equal(mails.sent.length, mailsVorher) + // die Fassung selbst als Datum gilt (nicht davor) + assert.equal((await aktivieren(k, { textformDate: v, textformChannel: 'email' })).status, 200) +}) + +test('shop textform: fremdes Projekt und Kundensitzung ändern nichts; nicht lesbare oder leere PDFs ergeben 503 vor jeder Änderung', async (t) => { + t.after(() => { + shopMit(MODUS.mode, MODUS.pilotEmails) + setLegalDirForTests(null) + }) + shopMit('on', []) + const k = legeKunde() + const fremd = legeKunde() + assert.equal((await team(k.customerId, 'shop', { projectId: fremd.projectId, action: 'activate', ...feld() })).status, 403) + const alsKunde = await aktivieren(k, feld(), { cookie: customerCookie(k.customerId) }) + assert.ok(alsKunde.status === 401 || alsKunde.status === 403) + assert.equal(consents(k.projectId).length + consents(fremd.projectId).length, 0) + + const echt = legalPdfFiles('shop', config.shop.fassung) + const kopien = fs.mkdtempSync(path.join(os.tmpdir(), 'shoptf-pdf-')) + fs.mkdirSync(path.join(kopien, 'rechtliches')) + t.after(() => fs.rmSync(kopien, { recursive: true, force: true })) + const kopiere = () => { + for (const f of Object.values(echt)) fs.copyFileSync(f.path, path.join(kopien, 'rechtliches', path.basename(f.path))) + } + setLegalDirForTests(kopien) + const mailsVorher = mails.sent.length + // gar keine Dateien + let res = await aktivieren(k, feld()) + assert.equal(res.status, 503) + assert.match(res.body.error, /Rechtsunterlagen/) + assert.match(res.body.error, /Webshop/) + for (const [doc, inhalt] of [['lb', ''], ['avv', 'kein PDF']]) { + kopiere() + fs.writeFileSync(path.join(kopien, 'rechtliches', path.basename(echt[doc].path)), inhalt) + res = await aktivieren(k, feld()) + assert.equal(res.status, 503, `${doc}: ${JSON.stringify(inhalt)}`) + assert.equal(await sf.getFeatureDoc(k.customerId, k.projectId, 'shop'), null, 'nichts angelegt') + assert.equal(consents(k.projectId).length, 0, 'kein Nachweis') + assert.equal(mails.sent.length, mailsVorher) + } + kopiere() + res = await aktivieren(k, feld()) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal(consents(k.projectId).length, 1) + assert.equal(k16An(k.email).length, 1) +}) + +test('shop textform: adminActivate verweigert Textform für andere Funktionen (news), der Terminplaner-Nachweis bleibt wie bisher', async () => { + const k = legeKunde() + await assert.rejects( + sf.adminActivate(k.customerId, k.projectId, 'news', { by: 'Andrej', adminNote: 'x', textform: { date: berlinTag(), channel: 'chat' } }), + (err) => err.status === 400 && /nur für den Terminplaner und den Webshop/.test(err.message) + ) + assert.equal(sf.textformFassung('shop'), config.shop.fassung) + assert.equal(sf.textformFassung('termine'), config.termine.avvVersion) + assert.match(sf.textformNachweisText({ date: '2026-10-05', channel: 'chat' }, '2026-10-05'), /^Der Kunde hat der Leistungsbeschreibung Terminplaner/) +}) + +test('GET /api/team/funktionen/:kunde liefert shop { textformNoetig, avvVersion, links } je Modus; termine bleibt', async (t) => { + t.after(() => shopMit(MODUS.mode, MODUS.pilotEmails)) + const k = legeKunde({ email: 'get-shoptf@example.invalid' }) + const v = config.shop.fassung + const feldVon = async () => (await get(`/api/team/funktionen/${k.customerId}`, STAFF)).shop + shopMit('off') + assert.equal((await feldVon()).textformNoetig, false) + shopMit('pilot', ['*']) + assert.equal((await feldVon()).textformNoetig, false) + shopMit('on', ['get-shoptf@example.invalid']) + assert.equal((await feldVon()).textformNoetig, false, 'Pilotadresse') + shopMit('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']) + assert.equal(f.links.lb, `${config.publicBaseUrl}/rechtliches/leistungsbeschreibung-webshop-${v}.html`) + assert.equal(f.links.avv, `${config.publicBaseUrl}/rechtliches/avv-webshop-${v}.html`) + const ganz = await get(`/api/team/funktionen/${k.customerId}`, STAFF) + assert.ok(Array.isArray(ganz.projects)) + assert.deepEqual(Object.keys(ganz.termine).sort(), ['avvVersion', 'links', 'textformNoetig']) + shopMit('on', ['*']) + assert.equal((await get('/api/team/funktionen/c_gibtsnicht', STAFF)).shop.textformNoetig, true) +}) diff --git a/test/termineVertragsunterlagen.test.js b/test/termineVertragsunterlagen.test.js index b9dae41..82365ac 100644 --- a/test/termineVertragsunterlagen.test.js +++ b/test/termineVertragsunterlagen.test.js @@ -628,7 +628,7 @@ test('avv_inhalt: Abschluss, Zwecke ohne Eigennutz, Datenarten, Weisungen, Daten // T1: In der Textform-Aktivierung trägt K16 nur Leistungsbeschreibung und AVV als PDF (ohne Widerrufsbelehrung, ohne Links): der Text verspricht nicht die E-Mail nach Absatz 1 assert.ok(vorbemerkung.includes('Der Kunde erhält anschließend eine E-Mail mit diesen beiden Texten als PDF.')) assert.ok(!vorbemerkung.includes('E-Mail nach Absatz 1 anschließend'), 'der Satz verwies auf die Mail mit Belehrung und Links') - assert.match(lies('server/routes/admin/funktionenAdmin.js'), /featureLegalAttachments\('termine', \{ ohne: \['widerruf'\] \}\)/, 'K16 der Textform-Aktivierung: nur Leistungsbeschreibung und AVV') + assert.match(lies('server/routes/admin/funktionenAdmin.js'), /featureLegalAttachments\(key, \{ ohne: \['widerruf'\] \}\)/, 'K16 der Textform-Aktivierung (Terminplaner und Webshop): nur Leistungsbeschreibung und AVV') assert.ok(vorbemerkung.includes('Für den Terminplaner geht er Anlage 1 vor')) assert.ok(vorbemerkung.includes('Anlage 1 der Abo-Bedingungen'))