Terminplaner: Staff-Aktivierung nur mit Zustimmung in Textform (Modus on), Portal

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-05 04:44:44 +00:00
1 parent 5b5b4b90eb
commit bade4edb74
5 files changed
+381 -7

No files matched your search

+54 -3
View File
@@ -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)
+32 -2
View File
@@ -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 })
}
+12
View File
@@ -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)
}
+252 -1
View File
@@ -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 (-<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))
})
+31 -1
View File
@@ -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)
})