From 6d35674c1e32406fd0b69bba8434e74234ece03f Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Wed, 7 Oct 2026 15:27:50 +0000 Subject: [PATCH] =?UTF-8?q?Buchungskalender:=20Abschluss=20=E2=80=93=20L1?= =?UTF-8?q?=20Textform-Zustimmung=20fuer=20die=20Aktivierung=20ohne=20Zahl?= =?UTF-8?q?ung=20(Server,=20GET-Feld=20unterkunft)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- server/routes/admin/funktionenAdmin.js | 19 ++- server/services/siteFeatures.js | 11 +- server/services/unterkunft/schalter.js | 9 ++ test/funktionenShopTextform.test.js | 2 +- test/funktionenUnterkunftTextform.test.js | 147 ++++++++++++++++++++++ 5 files changed, 179 insertions(+), 9 deletions(-) create mode 100644 test/funktionenUnterkunftTextform.test.js diff --git a/server/routes/admin/funktionenAdmin.js b/server/routes/admin/funktionenAdmin.js index d3c8747..7907118 100644 --- a/server/routes/admin/funktionenAdmin.js +++ b/server/routes/admin/funktionenAdmin.js @@ -31,6 +31,7 @@ import { driftWarnung, einbauanleitung, ticketStand } from '../../services/termi import { freischaltHindernis, meldeFreigeschaltet } from '../../services/termine/freischaltung.js' import { ladeStand } from '../../services/termine/kontext.js' import { shopTextformNoetig } from '../../services/shop/schalter.js' +import { unterkunftTextformNoetig } from '../../services/unterkunft/schalter.js' import { termineTextformNoetig } from '../../services/termine/schalter.js' /** @@ -121,8 +122,8 @@ 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 } +/** Verlangt die Aktivierung dieser Funktion fuer diese Adresse die Zustimmung in Textform? Nur Terminplaner, Webshop und Buchungskalender, je nach Modus und Pilotliste der Funktion. */ +const textformVerlangt = { termine: termineTextformNoetig, shop: shopTextformNoetig, unterkunft: unterkunftTextformNoetig } /** * Zustimmung des Kunden in Textform fuer die Aktivierung des Terminplaners oder des Webshops durch das Team (F13a; AVV Webshop, @@ -147,7 +148,7 @@ async function unterlagenAlsAnhang(key) { return await featureLegalAttachments(key, { ohne: ['widerruf'] }) } catch (err) { console.error('[funktionen-admin] Rechtsunterlagen (PDF) fuer die Freischaltung nicht lesbar:', err.message) - const funktion = key === 'shop' ? 'Der Webshop' : 'Der Terminplaner' + const funktion = key === 'shop' ? 'Der Webshop' : key === 'unterkunft' ? 'Der Buchungskalender' : 'Der Terminplaner' throw Object.assign(new Error(`Die Rechtsunterlagen (PDF) sind nicht lesbar. ${funktion} wurde nicht freigeschaltet. Bitte später erneut versuchen.`), { status: 503 }) } } @@ -199,10 +200,20 @@ async function shopFeld(customerId) { } } +/** Dasselbe fuer den Buchungskalender (AVV Buchungskalender, Vorbemerkung Abs. 2): Fassung = config.unterkunft.fassung. */ +async function unterkunftFeld() { + const { lb, avv } = featureLegalPaths('unterkunft') + return { + textformNoetig: unterkunftTextformNoetig(), + avvVersion: config.unterkunft.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), shop: await shopFeld(req.params.customerId) }) + res.json({ projects, termine: await termineFeld(req.params.customerId), shop: await shopFeld(req.params.customerId), unterkunft: await unterkunftFeld() }) } catch (err) { sendError(res, err) } diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index f49f7f6..a9084cf 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -617,8 +617,8 @@ 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) +export const TEXTFORM_KEYS = ['termine', 'shop', 'unterkunft'] +export const textformFassung = (key) => (key === 'shop' ? config.shop.fassung : key === 'unterkunft' ? config.unterkunft.fassung : config.termine.avvVersion) /** * Text des Nachweises, wenn der Kunde dem Terminplaner oder dem Webshop in Textform zugestimmt hat (Aktivierung durch das Team im @@ -629,6 +629,9 @@ export function textformNachweisText({ date, channel }, fassung, key = 'termine' 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.` } + if (key === 'unterkunft') { + return `Der Kunde hat am ${tag}.${monat}.${jahr} per ${TEXTFORM_WEG[channel]} in Textform erklärt, dass er der Leistungsbeschreibung Buchungskalender und dem Auftragsverarbeitungsvertrag Buchungskalender (Fassung ${fassung}) zustimmt.` + } 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.` } @@ -648,13 +651,13 @@ function textformNachweisTexte(textform, fassung, key) { * 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 und Webshop, schon geprueft: { date, channel }): Zustimmung des Kunden in Textform. Der Nachweis + * textform (nur Terminplaner, Webshop und Buchungskalender, 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 && !TEXTFORM_KEYS.includes(key)) throw httpError(400, 'Zustimmung in Textform gibt es nur für den Terminplaner und den Webshop') + if (textform && !TEXTFORM_KEYS.includes(key)) throw httpError(400, 'Zustimmung in Textform gibt es nur für den Terminplaner, den Webshop und den Buchungskalender') await assertOwnProject(customerId, projectId) const doc = await ensureFeatureDoc(customerId, projectId, key) if (key === 'termine' && doc.unlockedByPurchase === true) await assertAboLaeuftFuerAktivierung(customerId) diff --git a/server/services/unterkunft/schalter.js b/server/services/unterkunft/schalter.js index 3fac8d0..b8af54a 100644 --- a/server/services/unterkunft/schalter.js +++ b/server/services/unterkunft/schalter.js @@ -24,3 +24,12 @@ export function unterkunftFuerKunde(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 Buchungskalender, + * Vorbemerkung Abs. 2)? Anders als beim Webshop auch im Pilot: der Buchungskalender wird zuerst fuer einen echten Kunden (Pilotadresse) + * ohne Bestellung freigeschaltet, dessen Zustimmung muss vorliegen. Nur bei off (Funktion ohnehin gesperrt) entfaellt sie. + */ +export function unterkunftTextformNoetig() { + return unterkunftModus() !== 'off' +} diff --git a/test/funktionenShopTextform.test.js b/test/funktionenShopTextform.test.js index 1a2d94d..c25de39 100644 --- a/test/funktionenShopTextform.test.js +++ b/test/funktionenShopTextform.test.js @@ -281,7 +281,7 @@ test('shop textform: adminActivate verweigert Textform für andere Funktionen (n 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) + (err) => err.status === 400 && /nur für den Terminplaner, den Webshop und den Buchungskalender/.test(err.message) ) assert.equal(sf.textformFassung('shop'), config.shop.fassung) assert.equal(sf.textformFassung('termine'), config.termine.avvVersion) diff --git a/test/funktionenUnterkunftTextform.test.js b/test/funktionenUnterkunftTextform.test.js new file mode 100644 index 0000000..8bb1587 --- /dev/null +++ b/test/funktionenUnterkunftTextform.test.js @@ -0,0 +1,147 @@ +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' + +// Buchungskalender: Aktivierung durch das Team (ohne Zahlung) nur mit Zustimmung des Kunden in Textform (AVV Buchungskalender, Vorbemerkung Abs. 2; +// wie der Webshop, siehe funktionenShopTextform.test.js; anders: auch im Pilot). Schalter: unterkunftTextformNoetig (services/unterkunft/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 { unterkunftTextformNoetig } = await import('../server/services/unterkunft/schalter.js') + +const app = await startTestApp({ '/api/team': teamRoutes }) +const mails = mailSink() +setMailTransportForTests(mails) +const MODUS = { mode: config.unterkunft.mode, pilotEmails: [...config.unterkunft.pilotEmails] } +after(() => { + config.unterkunft.mode = MODUS.mode + config.unterkunft.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 ukMit = (mode, pilotEmails = []) => { + config.unterkunft.mode = mode + config.unterkunft.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) => /ist bereit$/.test(m.subject) && m.to.some((t) => t.address === email)) + +let nr = 0 +function legeKunde({ email } = {}) { + nr += 1 + const customerId = `c_uktf_${nr}` + const projectId = `p_uktf_${nr}` + aw.seed('customers', { $id: customerId, email: email ?? `uktf-${nr}@example.invalid`, name: `Kunde UK ${nr}`, status: 'active', portalAccessEnabled: true }) + aw.seed('websiteProjects', { $id: projectId, customerId, projectName: `UK-Projekt ${nr}` }) + return { customerId, projectId, email: aw.docs('customers').get(customerId).email } +} +const aktivieren = (k, body = {}, opts = STAFF) => team(k.customerId, 'unterkunft', { projectId: k.projectId, action: 'activate', ...body }, opts) + +test('unterkunftTextformNoetig: nur bei off nicht (auch im Pilot und für Pilotadressen)', (t) => { + t.after(() => ukMit(MODUS.mode, MODUS.pilotEmails)) + ukMit('off') + assert.equal(unterkunftTextformNoetig('a@example.com'), false) + ukMit('pilot', ['mimi@example.com']) + assert.equal(unterkunftTextformNoetig('mimi@example.com'), true) + ukMit('on', []) + assert.equal(unterkunftTextformNoetig('a@example.com'), true) +}) + +test('TEXTFORM_KEYS und Fassung: unterkunft ist aufgenommen, Fassung aus config.unterkunft', () => { + assert.ok(sf.TEXTFORM_KEYS.includes('unterkunft')) + assert.equal(sf.textformFassung('unterkunft'), config.unterkunft.fassung) +}) + +test('GET /funktionen/:customerId liefert unterkunft { textformNoetig, avvVersion, links } wie der Webshop', async (t) => { + t.after(() => ukMit(MODUS.mode, MODUS.pilotEmails)) + const k = legeKunde() + ukMit('pilot', ['x@example.com']) + const res = { body: await get(`/api/team/funktionen/${k.customerId}`, STAFF) } + const v = config.unterkunft.fassung + assert.equal(res.body.unterkunft.textformNoetig, true) + assert.equal(res.body.unterkunft.avvVersion, v) + assert.deepEqual(res.body.unterkunft.links, { + lb: `${config.publicBaseUrl}/rechtliches/leistungsbeschreibung-unterkunft-${v}.html`, + avv: `${config.publicBaseUrl}/rechtliches/avv-unterkunft-${v}.html`, + }) + assert.ok(res.body.shop && res.body.termine, 'die übrigen Felder bleiben') + ukMit('off') + assert.equal((await get(`/api/team/funktionen/${k.customerId}`, STAFF)).unterkunft.textformNoetig, false) +}) + +test('unterkunft textform: ohne Angaben 400 ohne Nachweis, mit Angaben 200; Nachweis mit Fassung, Datum, Weg, Mitarbeiter; K16 mit LB und AVV als PDF', async (t) => { + t.after(() => ukMit(MODUS.mode, MODUS.pilotEmails)) + ukMit('pilot', ['mimi@example.invalid']) + const k = legeKunde({ email: 'mimi@example.invalid' }) + const v = config.unterkunft.fassung + 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, 'unterkunft'), null, 'nichts angelegt') + assert.equal(consents(k.projectId).length, 0) + assert.equal((await aktivieren(k, { textformDate: berlinTag() })).status, 400) + + 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).unterkunft.state, 'active') + const doc = await sf.getFeatureDoc(k.customerId, k.projectId, 'unterkunft') + assert.deepEqual([doc.state, doc.enabled, doc.unlockedByPurchase], ['active', true, false]) + const [nachweis] = consents(k.projectId) + assert.deepEqual([nachweis.featureKey, nachweis.stripeSessionId, nachweis.ip, nachweis.userAgent, nachweis.konto], ['unterkunft', '', '', '', 'Mitarbeiter: Andrej']) + const gelesen = leseZustimmung(nachweis) + assert.deepEqual(gelesen.fassungen, { leistungsbeschreibung: v, avv: v }) + assert.deepEqual(gelesen.texte.map((x) => x.id), ['textform']) + assert.equal( + gelesen.texte[0].text, + `Der Kunde hat am ${ttmmjjjj(tag)} per E-Mail in Textform erklärt, dass er der Leistungsbeschreibung Buchungskalender und dem Auftragsverarbeitungsvertrag Buchungskalender (Fassung ${v}) zustimmt.` + ) + 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-Buchungskalender-${v}.pdf`, `AVV-Buchungskalender-${v}.pdf`]) + for (const a of k16[0].attachments) assert.equal(Buffer.from(a.content, 'base64').subarray(0, 4).toString(), '%PDF') +}) + +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') + const k = legeKunde() + assert.equal((await aktivieren(k)).status, 200) + assert.equal(consents(k.projectId).length, 0) + ukMit('on', []) + assert.equal((await aktivieren(k)).status, 200, 'schon aktiv: keine neue Zustimmung') + assert.equal((await team(k.customerId, 'unterkunft', { projectId: k.projectId, action: 'disable' })).status, 200) + const k2 = legeKunde() + const v = config.unterkunft.fassung + for (const body of [{ textformDate: tagMitVersatz(v, -1), textformChannel: 'chat' }, { textformDate: tagMitVersatz(berlinTag(), 1), textformChannel: 'chat' }]) { + assert.equal((await aktivieren(k2, body)).status, 400) + } + assert.equal(consents(k2.projectId).length, 0) +})