Funktionen: Register config.features mit News- und Termine-Eintrag

config.features (news, termine) als reine Daten in config.js, config.ustHinweis,
config.funktionen als Alias mit Gettern auf features.news. Neu featureRegistry.js
(featureConfig, featureKeys, featureHaken mit Ueberschreib-Regel, oeffentlicheAnsicht);
FEATURE_KEYS, FEATURE_LABELS und DISABLED_MESSAGE in siteFeatures.js kommen aus dem
Register, Werte unveraendert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 11:48:06 +00:00
1 parent e2f50407a0
commit c584d220c8
4 files changed
+512 -13

No files matched your search

+136 -5
View File
@@ -130,6 +130,131 @@ export function resolveEnableBankingApiBase(value) {
return ENABLE_BANKING_API_BASE
}
// Pflichthinweis zum Preis (Kleinunternehmer); die Funktionen-Register-Texte und die Preisangaben nutzen ihn
const ustHinweis = 'ohne Umsatzsteuer gemäß § 19 UStG'
const ticketProjektName = (project) => project?.projectName || project?.subdomain || ''
/**
* Register der kaufbaren Funktionen (News-Pinnwand, Terminplaner): Preis, Zustimmungen, Rechtsunterlagen,
* Ticket- und Mail-Texte je Funktion; reine Daten, config.js importiert keine Dienste. Die Helfer (featureConfig,
* featureHaken, oeffentlicheAnsicht) stehen in server/services/featureRegistry.js. Haken (verfuegbar,
* bestellpruefung, unterlagenBereit, belehrung, freischaltbar) gehoeren nicht hierher: wer sie in Tests auf dem
* Eintrag setzt, ueberschreibt den Standard aus featureRegistry.js.
* ticket.topic(project) liefert den Ticket-Betreff, ticket.details(ctx) den Text des Einrichtungs-Tickets
* (ctx = { feature, doc, project, customer }: Register-Eintrag, portalFeatures-, websiteProjects- und Kunden-Dokument).
*/
const features = {
news: {
key: 'news',
product: 'newsSetup',
label: { name: 'News-Pinnwand', kurz: 'News', keine: 'keine News-Pinnwand' },
priceLabel: '30 €',
setupEstimate: 'meist innerhalb von 1–2 Wochen',
// Wortlaut der Zustimmung; wird mit Zeitstempel am Feature-Dokument gespeichert (Widerruf, § 356 Abs. 4 BGB)
consent: {
version: '2026-09-30',
knopf: 'Zahlungspflichtig bestellen · 30 €',
items: [
{
id: 'beginn',
haken: true,
text: 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht erlischt, sobald der Einbau vollständig erbracht ist (§ 356 Abs. 4 BGB).',
},
],
},
leistungsbeschreibung: { version: '2026-09-30', url: '/vertragsunterlagen/funktionen-leistungsbeschreibung.html' },
avv: null,
widerruf: null,
returnHash: 'news',
returnProjekt: false,
ticket: {
topic: (project) => `News-Pinnwand einbauen – ${ticketProjektName(project)}`.trim(),
details: async (ctx) => {
const { feature, doc, project } = ctx || {}
const siteKey = doc?.siteKey || ''
const domain = project?.liveDomain || project?.previewUrl || project?.subdomain || ''
return [
`Funktion: ${feature?.key || 'news'}`,
`Projekt: ${ticketProjektName(project)}`,
`Domain: ${domain}`,
`siteKey: ${siteKey}`,
`Einbau: <div data-webklar-news="${siteKey}"></div> + https://project.webklar.com/embed/news.js`,
'Anleitung: docs/funktionen/news-einbau.md (Repo Webklar-Kundenbereich)',
].join('\n')
},
},
mails: {
funktion: 'News-Pinnwand',
formen: { dein: 'Deine News-Pinnwand', nom: 'die News-Pinnwand', akk: 'die News-Pinnwand', gen: 'der News-Pinnwand', pron: 'sie' },
knopf: 'Beitrag schreiben',
danach: 'Danach kannst Du Beiträge im Portal schreiben.',
bereit: 'Du kannst ab sofort Beiträge schreiben, sie erscheinen direkt bei Dir auf der Seite.',
preisHinweis: 'brutto, einmalig; die Nutzung ist kostenlos, es läuft kein Abo',
anleitung: false,
},
requiresWartungsAbo: false,
ticketFreischaltung: true,
disabledMessage:
'Die News-Pinnwand wurde für diese Website abgeschaltet. Schreib uns im Chat, wenn du sie wieder möchtest.',
orderedConflictMessage: 'Die News-Pinnwand ist für dieses Projekt schon bestellt.',
},
termine: {
key: 'termine',
product: 'termineSetup',
label: { name: 'Terminplaner', kurz: 'Termine', keine: 'keinen Terminplaner' },
priceLabel: '149 €',
setupEstimate: 'meist innerhalb von 1–2 Wochen',
// Platzhalter bis Block F (Rechtsrunde): Struktur und IDs stehen, Texte und Fassung ersetzt Block F
consent: {
version: 'ENTWURF-2026-10-03',
knopf: 'Zahlungspflichtig bestellen · 149 €',
items: [
{ id: 'unterlagen', haken: true, text: 'ENTWURF (Block F): Zustimmung zu Leistungsbeschreibung und Auftragsverarbeitungsvertrag (AVV) Terminplaner.' },
{ id: 'gesundheit', haken: true, text: 'ENTWURF (Block F): Bestätigung, dass der Terminplaner nicht im Gesundheitsbereich eingesetzt wird.' },
{
id: 'beginn',
haken: true,
text: 'ENTWURF (Block F): Ausdrückliches Verlangen, dass vor Ablauf der Widerrufsfrist mit dem Einbau begonnen wird, mit Hinweis auf das Erlöschen des Widerrufsrechts und den Wertersatz.',
},
{
id: 'information',
haken: true,
text: 'ENTWURF (Block F): Pflichtinformationen zu Leistung, Gesamtpreis, Ende mit dem Wartungs-Abo und Einrichtungsdauer.',
},
],
},
leistungsbeschreibung: { version: 'ENTWURF', url: '' },
avv: { version: 'ENTWURF', url: '' },
widerruf: null,
returnHash: 'termine',
returnProjekt: true,
ticket: {
topic: (project) => `Terminplaner einbauen – ${ticketProjektName(project)}`,
// Dienst erst beim Aufruf laden: config.js importiert keine Dienste statisch
details: async (ctx) => {
const { termineTicketDetails } = await import('./services/termine/ticketDetails.js')
return termineTicketDetails(ctx)
},
},
mails: {
funktion: 'Terminplaner',
formen: { dein: 'Dein Terminplaner', nom: 'der Terminplaner', akk: 'den Terminplaner', gen: 'des Terminplaners', pron: 'ihn' },
knopf: 'Termine öffnen',
// Platzhalter: den Wortlaut schreibt der Inhaber in seiner Mailrunde
danach: 'ENTWURF (Mailrunde): Danach kannst Du im Portal Termine und Einstellungen verwalten.',
bereit: 'ENTWURF (Mailrunde): Du kannst ab sofort Termine online buchen lassen.',
preisHinweis: `einmalig, ${ustHinweis}; der Terminplaner läuft, solange Dein Wartungs-Abo läuft`,
anleitung: true,
},
requiresWartungsAbo: true,
ticketFreischaltung: true,
disabledMessage: 'Der Terminplaner ist für diese Website abgeschaltet. Schreib uns im Chat, wenn Du ihn wieder möchtest.',
orderedConflictMessage: 'Der Terminplaner ist für dieses Projekt schon bestellt.',
unavailableMessage: 'Der Terminplaner ist noch nicht verfügbar.',
},
}
export const config = {
port: Number(process.env.PORT) || 3000,
sessionSecret: process.env.SESSION_SECRET || '',
@@ -188,12 +313,18 @@ export const config = {
products,
mailStorage,
newsSetup,
// Funktionen-Tab (News-Pinnwand): Zeiteinschaetzung auf der Buchungsseite und Wortlaut der Zustimmung,
// der mit Zeitstempel am Feature-Dokument gespeichert wird (Widerruf, § 356 Abs. 4 BGB)
ustHinweis,
// Register der kaufbaren Funktionen (siehe oben)
features,
// Funktionen-Tab (News-Pinnwand): Alias auf den News-Eintrag des Registers, Zeiteinschaetzung auf der
// Buchungsseite und Wortlaut der Zustimmung (die Getter folgen config.features.news zur Laufzeit)
funktionen: {
setupEstimate: 'meist innerhalb von 1–2 Wochen',
consentText:
'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht erlischt, sobald der Einbau vollständig erbracht ist (§ 356 Abs. 4 BGB).',
get setupEstimate() {
return config.features.news.setupEstimate
},
get consentText() {
return config.features.news.consent.items[0].text
},
},
// Wartungs-Abo (Stripe Billing). Preis/Produkt legt der Server per lookup_key selbst an.
+72
View File
@@ -0,0 +1,72 @@
import { config } from '../config.js'
// Helfer fuer das Funktionen-Register config.features (News-Pinnwand, Terminplaner; Daten in server/config.js).
// Diese Datei wird von siteFeatures.js importiert und importiert selbst keine Dienste statisch: Standard-Haken
// laden ihre Dienste per dynamischem import() innerhalb der Funktion (sonst entstuenden Importzyklen).
/** Namen der Haken, die ein Registereintrag hat (optional): Bestell-, Freischalt- und Unterlagenpruefungen. */
const HAKEN_NAMEN = ['verfuegbar', 'bestellpruefung', 'unterlagenBereit', 'belehrung', 'freischaltbar']
/**
* Standard-Haken je Funktion (Funktionen, nicht Daten). Leer in Task B1; es fuellen sie B6 (verfuegbar,
* bestellpruefung, unterlagenBereit), B7 (belehrung) und B11 (freischaltbar). News hat keine Standard-Haken.
*/
const STANDARD_HAKEN = {
news: {},
termine: {},
}
/** Registereintrag oder null; nur eigene Schluessel des Registers ('analyse', '__proto__', 'toString' => null). */
export function featureConfig(key) {
if (typeof key !== 'string' || !Object.hasOwn(config.features, key)) return null
return config.features[key]
}
/** Schluessel der Funktionen im Register, in Registerreihenfolge. */
export function featureKeys() {
return Object.keys(config.features)
}
/**
* Haken einer Funktion: { verfuegbar?, bestellpruefung?, unterlagenBereit?, belehrung?, freischaltbar? }.
* Wer config.features.<key>.<haken> als Funktion setzt (Tests), ueberschreibt den Standard.
*/
export function featureHaken(key) {
const eintrag = featureConfig(key)
if (!eintrag) return {}
const standard = Object.hasOwn(STANDARD_HAKEN, key) ? STANDARD_HAKEN[key] : {}
const haken = {}
for (const name of HAKEN_NAMEN) {
const fn = typeof eintrag[name] === 'function' ? eintrag[name] : standard[name]
if (typeof fn === 'function') haken[name] = fn
}
return haken
}
const fassung = (f) => (f ? { version: f.version, url: f.url ?? '' } : null)
/**
* Sicht fuer den Browser (Bestellseite): JSON-sicher, ohne Funktionen und ohne ticket/mails, eine Kopie
* (Aenderungen daran aendern das Register nicht). Unbekannter Schluessel => null.
*/
export function oeffentlicheAnsicht(key) {
const e = featureConfig(key)
if (!e) return null
return {
key: e.key,
name: e.label.name,
kurz: e.label.kurz,
priceLabel: e.priceLabel,
setupEstimate: e.setupEstimate,
consent: {
version: e.consent.version,
knopf: e.consent.knopf,
items: e.consent.items.map((item) => ({ id: item.id, text: item.text, haken: Boolean(item.haken) })),
},
leistungsbeschreibung: fassung(e.leistungsbeschreibung),
avv: fassung(e.avv),
widerruf: fassung(e.widerruf),
returnHash: e.returnHash,
requiresWartungsAbo: e.requiresWartungsAbo === true,
}
}
+10 -8
View File
@@ -4,20 +4,22 @@ import { createDocument, getDocument, listDocuments, updateDocument, Query } fro
import { withLocks } from './keyedLock.js'
import { sendAdminNotice, sendCustomerMail } from './mailer.js'
import { getStripe, retrieveCheckoutSession } from './stripeBilling.js'
import { featureConfig, featureKeys } from './featureRegistry.js'
import { createFeatureTicket } from './ticketAdmin.js'
export const FEATURE_KEYS = ['news', 'termine']
/** Bezeichnung (name) und Verneinung im Satz "Für dieses Projekt gibt es noch {keine}" (keine) je Funktion. */
export const FEATURE_LABELS = {
news: { name: 'News-Pinnwand', keine: 'keine News-Pinnwand' },
termine: { name: 'Terminplaner', keine: 'keinen Terminplaner' },
}
export const FEATURE_KEYS = featureKeys()
/** Bezeichnung (name) und Verneinung im Satz "Für dieses Projekt gibt es noch {keine}" (keine) je Funktion; aus dem Register. */
export const FEATURE_LABELS = Object.fromEntries(
FEATURE_KEYS.map((key) => {
const { name, keine } = featureConfig(key).label
return [key, { name, keine }]
})
)
export const STATES = ['off', 'ordered', 'setup', 'active', 'disabled']
/** Warum eine Funktion abgeschaltet ist (portalFeatures.disabledReason); 'team' ist der Vorgabewert (Abschalten im WOMS, Aufbewahrungsfrist). */
export const DISABLED_REASONS = ['team', 'abo_ende', 'abo_ende_vor_einrichtung', 'kunde_geloescht']
export const DISABLED_MESSAGE =
'Die News-Pinnwand wurde für diese Website abgeschaltet. Schreib uns im Chat, wenn du sie wieder möchtest.'
export const DISABLED_MESSAGE = featureConfig('news').disabledMessage
const BASE32 = 'abcdefghijklmnopqrstuvwxyz234567'
+294
View File
@@ -0,0 +1,294 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
// Register config.features (News + Terminplaner) und featureRegistry.js. Dienste (siteFeatures, ticketAdmin)
// brauchen die Test-Umgebung vor dem ersten Import von server/config.js.
const aw = await startAppwriteMock()
const st = await startFakeStripe()
after(() => Promise.all([aw.close(), st.close()]))
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const reg = await import('../server/services/featureRegistry.js')
const sf = await import('../server/services/siteFeatures.js')
const { createFeatureTicket } = await import('../server/services/ticketAdmin.js')
// Wortlaut-Pin des News-Satzes und der Fassung (der einzige Literal-Pin auf Texte, die Block F2 ändert)
const NEWS_SATZ =
'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht erlischt, sobald der Einbau vollständig erbracht ist (§ 356 Abs. 4 BGB).'
const NEWS_FASSUNG = '2026-09-30'
const SITE_KEY = 'abcdefghijklmnop'
// ---------------------------------------------------------------------------------- News (Festwerte) ---
test('News-Eintrag entspricht den bisherigen Festwerten', async () => {
const news = reg.featureConfig('news')
assert.equal(news.key, 'news')
assert.equal(news.product, 'newsSetup')
assert.deepEqual(news.label, { name: 'News-Pinnwand', kurz: 'News', keine: 'keine News-Pinnwand' })
assert.equal(news.priceLabel, '30 €')
assert.equal(news.setupEstimate, 'meist innerhalb von 1–2 Wochen')
assert.equal(news.consent.version, NEWS_FASSUNG)
assert.equal(news.consent.knopf, 'Zahlungspflichtig bestellen · 30 €')
assert.deepEqual(news.consent.items, [{ id: 'beginn', haken: true, text: NEWS_SATZ }])
assert.deepEqual(news.leistungsbeschreibung, { version: NEWS_FASSUNG, url: '/vertragsunterlagen/funktionen-leistungsbeschreibung.html' })
assert.equal(news.avv, null)
assert.equal(news.widerruf, null)
assert.equal(news.returnHash, 'news')
assert.equal(news.returnProjekt, false)
assert.equal(news.requiresWartungsAbo, false)
assert.equal(news.ticketFreischaltung, true)
assert.equal(
news.disabledMessage,
'Die News-Pinnwand wurde für diese Website abgeschaltet. Schreib uns im Chat, wenn du sie wieder möchtest.'
)
assert.equal(news.disabledMessage, sf.DISABLED_MESSAGE)
assert.equal(news.orderedConflictMessage, 'Die News-Pinnwand ist für dieses Projekt schon bestellt.')
assert.equal(news.unavailableMessage, undefined)
assert.equal(news.ticket.topic({ projectName: 'Bäckerei' }), 'News-Pinnwand einbauen – Bäckerei')
assert.equal(news.ticket.topic({ subdomain: 'cafe' }), 'News-Pinnwand einbauen – cafe')
assert.equal(news.ticket.topic(null), 'News-Pinnwand einbauen –')
assert.deepEqual(news.mails, {
funktion: 'News-Pinnwand',
formen: { dein: 'Deine News-Pinnwand', nom: 'die News-Pinnwand', akk: 'die News-Pinnwand', gen: 'der News-Pinnwand', pron: 'sie' },
knopf: 'Beitrag schreiben',
danach: 'Danach kannst Du Beiträge im Portal schreiben.',
bereit: 'Du kannst ab sofort Beiträge schreiben, sie erscheinen direkt bei Dir auf der Seite.',
preisHinweis: news.mails.preisHinweis,
anleitung: false,
})
assert.match(news.mails.preisHinweis, /einmalig/)
})
test('News-Ticketdetails sind die sechs Zeilen des bisherigen Standards', async () => {
const news = reg.featureConfig('news')
const project = { $id: 'p1', projectName: 'Bäckerei', liveDomain: 'baeckerei.de', previewUrl: 'https://p.example', subdomain: 'baeckerei' }
const ctx = { feature: news, doc: { siteKey: SITE_KEY, featureKey: 'news' }, project, customer: { name: 'Kunde' } }
const expected = [
'Funktion: news',
'Projekt: Bäckerei',
'Domain: baeckerei.de',
`siteKey: ${SITE_KEY}`,
`Einbau: <div data-webklar-news="${SITE_KEY}"></div> + https://project.webklar.com/embed/news.js`,
'Anleitung: docs/funktionen/news-einbau.md (Repo Webklar-Kundenbereich)',
].join('\n')
assert.equal(await news.ticket.details(ctx), expected)
// Domain: liveDomain || previewUrl || subdomain; Projektname: projectName || subdomain
const ohneLive = await news.ticket.details({ ...ctx, project: { projectName: 'X', previewUrl: 'https://p.example', subdomain: 's' } })
assert.match(ohneLive, /^Domain: https:\/\/p\.example$/m)
const nurSub = await news.ticket.details({ ...ctx, project: { subdomain: 's' } })
assert.match(nurSub, /^Projekt: s$/m)
assert.match(nurSub, /^Domain: s$/m)
// Parität zu createFeatureTicket (Standardtexte): dasselbe Dokument, Wort für Wort
aw.seed('customers', { $id: 'c1', name: 'Kunde Eins', location: 'Berlin' })
const { ticketId } = await createFeatureTicket({ customerId: 'c1', project, featureKey: 'news', siteKey: SITE_KEY })
const ticket = aw.docs('workorders').get(ticketId)
assert.equal(ticket.details, expected)
assert.equal(ticket.topic, news.ticket.topic(project))
})
// ---------------------------------------------------------------------------------- Alias funktionen ---
test('Alias bleibt: config.funktionen folgt config.features.news zur Laufzeit', () => {
const news = config.features.news
assert.equal(config.funktionen.setupEstimate, news.setupEstimate)
assert.equal(config.funktionen.consentText, news.consent.items[0].text)
assert.equal(config.funktionen.consentText, NEWS_SATZ)
const vorherEstimate = news.setupEstimate
const vorherText = news.consent.items[0].text
try {
news.setupEstimate = 'in 3 Tagen'
news.consent.items[0].text = 'Neuer Satz'
assert.equal(config.funktionen.setupEstimate, 'in 3 Tagen')
assert.equal(config.funktionen.consentText, 'Neuer Satz')
} finally {
news.setupEstimate = vorherEstimate
news.consent.items[0].text = vorherText
}
assert.equal(config.funktionen.setupEstimate, vorherEstimate)
assert.equal(config.funktionen.consentText, vorherText)
})
// ---------------------------------------------------------------------------------- siteFeatures ---
test('FEATURE_LABELS und FEATURE_KEYS unverändert, aus dem Register abgeleitet', () => {
assert.deepEqual(sf.FEATURE_LABELS, {
news: { name: 'News-Pinnwand', keine: 'keine News-Pinnwand' },
termine: { name: 'Terminplaner', keine: 'keinen Terminplaner' },
})
assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine'])
assert.deepEqual(sf.FEATURE_KEYS, reg.featureKeys())
assert.deepEqual(reg.featureKeys(), ['news', 'termine'])
})
// ---------------------------------------------------------------------------------- Schlüsselschutz ---
test('Schlüsselschutz: nur eigene Schlüssel des Registers', () => {
for (const key of ['analyse', '__proto__', 'constructor', 'toString', 'hasOwnProperty', undefined, null, '', 'News']) {
assert.equal(reg.featureConfig(key), null, `featureConfig(${String(key)})`)
assert.equal(reg.oeffentlicheAnsicht(key), null, `oeffentlicheAnsicht(${String(key)})`)
assert.deepEqual(reg.featureHaken(key), {}, `featureHaken(${String(key)})`)
}
assert.equal(reg.featureConfig('news'), config.features.news)
assert.equal(reg.featureConfig('termine'), config.features.termine)
})
// ---------------------------------------------------------------------------------- Haken ---
test('Haken überschreibbar: config.features.<key>.<haken> gewinnt gegen den Standard', () => {
const termine = config.features.termine
const vorherTermine = reg.featureHaken('termine')
const vorherNews = reg.featureHaken('news')
const fn = () => []
try {
termine.bestellpruefung = fn
const haken = reg.featureHaken('termine')
assert.equal(haken.bestellpruefung, fn)
// die anderen Haken bleiben beim Standard
for (const name of ['verfuegbar', 'unterlagenBereit', 'belehrung', 'freischaltbar']) assert.equal(haken[name], vorherTermine[name])
} finally {
delete termine.bestellpruefung
}
assert.deepEqual(reg.featureHaken('termine'), vorherTermine)
// jeder der fünf Haken lässt sich setzen; Nicht-Funktionen und fremde Namen zählen nicht
const namen = ['verfuegbar', 'bestellpruefung', 'unterlagenBereit', 'belehrung', 'freischaltbar']
const news = config.features.news
try {
for (const name of namen) news[name] = () => name
news.unbekannt = () => 'x'
const haken = reg.featureHaken('news')
assert.deepEqual(Object.keys(haken).sort(), [...namen].sort())
for (const name of namen) assert.equal(haken[name](), name)
news.verfuegbar = 'kein Haken'
assert.equal(reg.featureHaken('news').verfuegbar, vorherNews.verfuegbar)
} finally {
for (const name of [...namen, 'unbekannt']) delete news[name]
}
assert.deepEqual(reg.featureHaken('news'), vorherNews)
})
// ---------------------------------------------------------------------------------- öffentliche Ansicht ---
test('Ansicht ohne Funktionen: JSON-sicher, ohne ticket und mails', () => {
for (const key of reg.featureKeys()) {
const ansicht = reg.oeffentlicheAnsicht(key)
assert.deepEqual(JSON.parse(JSON.stringify(ansicht)), ansicht)
assert.deepEqual(Object.keys(ansicht).sort(), [
'avv',
'consent',
'key',
'kurz',
'leistungsbeschreibung',
'name',
'priceLabel',
'requiresWartungsAbo',
'returnHash',
'setupEstimate',
'widerruf',
])
assert.equal(ansicht.ticket, undefined)
assert.equal(ansicht.mails, undefined)
assert.deepEqual(Object.keys(ansicht.consent).sort(), ['items', 'knopf', 'version'])
for (const item of ansicht.consent.items) assert.deepEqual(Object.keys(item).sort(), ['haken', 'id', 'text'])
}
const news = reg.oeffentlicheAnsicht('news')
assert.equal(news.name, 'News-Pinnwand')
assert.equal(news.kurz, 'News')
assert.equal(news.priceLabel, '30 €')
assert.equal(news.consent.knopf, 'Zahlungspflichtig bestellen · 30 €')
assert.deepEqual(news.consent.items, [{ id: 'beginn', text: NEWS_SATZ, haken: true }])
assert.equal(news.returnHash, 'news')
assert.equal(news.requiresWartungsAbo, false)
assert.equal(news.avv, null)
// eine Kopie: Änderungen an der Ansicht ändern das Register nicht
news.consent.items[0].text = 'verändert'
news.leistungsbeschreibung.version = 'verändert'
assert.equal(config.features.news.consent.items[0].text, NEWS_SATZ)
assert.equal(config.features.news.leistungsbeschreibung.version, NEWS_FASSUNG)
})
// ---------------------------------------------------------------------------------- Termine ---
test('Termine: Preis und Struktur', async () => {
const t = reg.featureConfig('termine')
assert.equal(t.key, 'termine')
assert.equal(t.product, 'termineSetup')
assert.deepEqual(t.label, { name: 'Terminplaner', kurz: 'Termine', keine: 'keinen Terminplaner' })
assert.equal(t.priceLabel, '149 €')
assert.equal(t.setupEstimate, 'meist innerhalb von 1–2 Wochen')
assert.equal(t.consent.knopf, 'Zahlungspflichtig bestellen · 149 €')
assert.deepEqual(
t.consent.items.map((i) => i.id),
['unterlagen', 'gesundheit', 'beginn', 'information']
)
assert.deepEqual(
t.consent.items.map((i) => i.haken),
[true, true, true, true]
)
for (const item of t.consent.items) {
assert.equal(typeof item.text, 'string')
assert.ok(item.text.length > 0)
assert.equal(item.url, undefined, 'ohne url')
}
assert.equal(t.widerruf, null)
assert.equal(t.returnHash, 'termine')
assert.equal(t.returnProjekt, true)
assert.equal(t.requiresWartungsAbo, true)
assert.equal(t.ticketFreischaltung, true)
assert.equal(t.disabledMessage, 'Der Terminplaner ist für diese Website abgeschaltet. Schreib uns im Chat, wenn Du ihn wieder möchtest.')
assert.equal(t.orderedConflictMessage, 'Der Terminplaner ist für dieses Projekt schon bestellt.')
assert.equal(t.unavailableMessage, 'Der Terminplaner ist noch nicht verfügbar.')
assert.equal(config.ustHinweis, 'ohne Umsatzsteuer gemäß § 19 UStG')
assert.equal(t.mails.preisHinweis, `einmalig, ${config.ustHinweis}; der Terminplaner läuft, solange Dein Wartungs-Abo läuft`)
assert.ok(t.mails.preisHinweis.includes(config.ustHinweis))
assert.equal(t.mails.funktion, 'Terminplaner')
assert.deepEqual(t.mails.formen, {
dein: 'Dein Terminplaner',
nom: 'der Terminplaner',
akk: 'den Terminplaner',
gen: 'des Terminplaners',
pron: 'ihn',
})
assert.equal(t.mails.knopf, 'Termine öffnen')
assert.equal(t.mails.anleitung, true)
assert.equal(typeof t.mails.danach, 'string')
assert.equal(typeof t.mails.bereit, 'string')
assert.equal(t.ticket.topic({ projectName: 'Salon' }), 'Terminplaner einbauen – Salon')
assert.equal(t.ticket.topic({ subdomain: 'salon' }), 'Terminplaner einbauen – salon')
assert.equal(typeof t.ticket.details, 'function')
const ansicht = reg.oeffentlicheAnsicht('termine')
assert.equal(ansicht.name, 'Terminplaner')
assert.equal(ansicht.kurz, 'Termine')
assert.equal(ansicht.priceLabel, '149 €')
assert.equal(ansicht.requiresWartungsAbo, true)
assert.equal(ansicht.returnHash, 'termine')
})
test('Termine-Ticketdetails werden erst beim Aufruf geladen', async () => {
// das Ziel termine/ticketDetails.js entsteht in Task B8; config.js importiert keine Dienste statisch
const { readFileSync } = await import('node:fs')
const src = readFileSync(new URL('../server/config.js', import.meta.url), 'utf8')
assert.doesNotMatch(src, /^import .* from '\.\/services\//m)
assert.match(src, /import\('\.\/services\/termine\/ticketDetails\.js'\)/)
})
// ---------------------------------------------------------------------------------- Block F ---
test('Bestelltexte ohne ENTWURF', { todo: 'Block F ersetzt die Platzhaltertexte' }, async () => {
const t = config.features.termine
const frei = (wert, was) => assert.doesNotMatch(JSON.stringify(wert), /ENTWURF/, `${was} enthält ENTWURF`)
frei(t.consent.version, 'consent.version')
frei(t.consent.items, 'consent.items')
frei(t.leistungsbeschreibung, 'leistungsbeschreibung')
frei(t.avv, 'avv')
frei(t.widerruf, 'widerruf')
// mails.danach und mails.bereit schreibt der Inhaber in seiner Mailrunde
// ab B7 gibt es den Haken belehrung (Platzhaltertext bis F7)
const belehrung = reg.featureHaken('termine').belehrung
if (belehrung) frei(await belehrung(), 'belehrung')
})