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:
1 parent
e2f50407a0
commit
c584d220c8
4 files changed
+512
-13
No files matched your search
+136
-5
@@ -130,6 +130,131 @@ export function resolveEnableBankingApiBase(value) {
|
|||||||
return ENABLE_BANKING_API_BASE
|
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 = {
|
export const config = {
|
||||||
port: Number(process.env.PORT) || 3000,
|
port: Number(process.env.PORT) || 3000,
|
||||||
sessionSecret: process.env.SESSION_SECRET || '',
|
sessionSecret: process.env.SESSION_SECRET || '',
|
||||||
@@ -188,12 +313,18 @@ export const config = {
|
|||||||
products,
|
products,
|
||||||
mailStorage,
|
mailStorage,
|
||||||
newsSetup,
|
newsSetup,
|
||||||
// Funktionen-Tab (News-Pinnwand): Zeiteinschaetzung auf der Buchungsseite und Wortlaut der Zustimmung,
|
ustHinweis,
|
||||||
// der mit Zeitstempel am Feature-Dokument gespeichert wird (Widerruf, § 356 Abs. 4 BGB)
|
// 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: {
|
funktionen: {
|
||||||
setupEstimate: 'meist innerhalb von 1–2 Wochen',
|
get setupEstimate() {
|
||||||
consentText:
|
return config.features.news.setupEstimate
|
||||||
'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 consentText() {
|
||||||
|
return config.features.news.consent.items[0].text
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// Wartungs-Abo (Stripe Billing). Preis/Produkt legt der Server per lookup_key selbst an.
|
// Wartungs-Abo (Stripe Billing). Preis/Produkt legt der Server per lookup_key selbst an.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,20 +4,22 @@ import { createDocument, getDocument, listDocuments, updateDocument, Query } fro
|
|||||||
import { withLocks } from './keyedLock.js'
|
import { withLocks } from './keyedLock.js'
|
||||||
import { sendAdminNotice, sendCustomerMail } from './mailer.js'
|
import { sendAdminNotice, sendCustomerMail } from './mailer.js'
|
||||||
import { getStripe, retrieveCheckoutSession } from './stripeBilling.js'
|
import { getStripe, retrieveCheckoutSession } from './stripeBilling.js'
|
||||||
|
import { featureConfig, featureKeys } from './featureRegistry.js'
|
||||||
import { createFeatureTicket } from './ticketAdmin.js'
|
import { createFeatureTicket } from './ticketAdmin.js'
|
||||||
|
|
||||||
export const FEATURE_KEYS = ['news', 'termine']
|
export const FEATURE_KEYS = featureKeys()
|
||||||
/** Bezeichnung (name) und Verneinung im Satz "Für dieses Projekt gibt es noch {keine}" (keine) je Funktion. */
|
/** Bezeichnung (name) und Verneinung im Satz "Für dieses Projekt gibt es noch {keine}" (keine) je Funktion; aus dem Register. */
|
||||||
export const FEATURE_LABELS = {
|
export const FEATURE_LABELS = Object.fromEntries(
|
||||||
news: { name: 'News-Pinnwand', keine: 'keine News-Pinnwand' },
|
FEATURE_KEYS.map((key) => {
|
||||||
termine: { name: 'Terminplaner', keine: 'keinen Terminplaner' },
|
const { name, keine } = featureConfig(key).label
|
||||||
}
|
return [key, { name, keine }]
|
||||||
|
})
|
||||||
|
)
|
||||||
export const STATES = ['off', 'ordered', 'setup', 'active', 'disabled']
|
export const STATES = ['off', 'ordered', 'setup', 'active', 'disabled']
|
||||||
/** Warum eine Funktion abgeschaltet ist (portalFeatures.disabledReason); 'team' ist der Vorgabewert (Abschalten im WOMS, Aufbewahrungsfrist). */
|
/** 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_REASONS = ['team', 'abo_ende', 'abo_ende_vor_einrichtung', 'kunde_geloescht']
|
||||||
|
|
||||||
export const DISABLED_MESSAGE =
|
export const DISABLED_MESSAGE = featureConfig('news').disabledMessage
|
||||||
'Die News-Pinnwand wurde für diese Website abgeschaltet. Schreib uns im Chat, wenn du sie wieder möchtest.'
|
|
||||||
|
|
||||||
const BASE32 = 'abcdefghijklmnopqrstuvwxyz234567'
|
const BASE32 = 'abcdefghijklmnopqrstuvwxyz234567'
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
Reference in new issue
Block a user