Merge remote-tracking branch 'origin/main' into feature/oeffnungszeiten

Zusammenführung von TP2-Abschluss und Webshop (origin/main cab9c33) mit Öffnungszeiten live. Beide
Funktionen bleiben vollständig: jeder Konflikt ist als Vereinigung gelöst, Reihenfolge der Funktionsschlüssel
überall news, termine, shop, zeiten.

Konfliktdateien (28): public/app.js, dashboard.html, funktionen.js, widerrufen.html; scripts/stripe-products.mjs;
server/config.js, index.js, middleware/kundenansichtGuard.js, routes/abo.js, routes/admin/funktionenAdmin.js,
routes/funktionen.js, services/customerAdmin.js, customerViewLog.js, featureRegistry.js, retention.js,
siteFeatures.js, stripeBilling.js; test/customerViewLog, featureFreischaltung, featureRegistry, funktionenRoutes,
funktionenTermine, kundenansichtGuard, mailTemplates, publicForms, siteFeatures, stripeProductsScript (je
.test.js) und test/support/schema.js.

Entscheidungen: Mailvorlagen G01–G15, K01–K30, T01–T21 lückenlos (K24–K26 Webshop, K27–K30 Öffnungszeiten);
Stripe-Katalog mit shopSetup und zeitenSetup; Widerruf kennt shop und zeiten; ABO_FUNKTIONEN bleibt analyse
und termine (kein zeiten); activate() schreibt die Zeiten-Felder nur über extra; kein Halter von
feature-activate:<id> ruft disable() auf; die Aufbewahrungs-Löschung löscht Zeiten-Daten vor dem Abschalten und
hält den Kunden bei einem Fehler (held); die Admin-Antwort trägt ersatzTicket und warnung; Hash-Routen webshop
und zeiten, Ansichten view-webshop und view-zeiten; assertOwnProject behält die Meldung von main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-06 06:12:23 +00:00
commit e87140e5ba
245 files changed
+38799 -1050

No files matched your search

+207 -3
View File
@@ -4,7 +4,7 @@ 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)
// Register config.features (News, Terminplaner, Webshop) 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()
@@ -121,11 +121,12 @@ 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' },
shop: { name: 'Webshop', keine: 'keinen Webshop' },
zeiten: { name: 'Öffnungszeiten live', keine: 'keine Öffnungszeiten live' },
})
assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine', 'zeiten'])
assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine', 'shop', 'zeiten'])
assert.deepEqual(sf.FEATURE_KEYS, reg.featureKeys())
assert.deepEqual(reg.featureKeys(), ['news', 'termine', 'zeiten'])
assert.deepEqual(reg.featureKeys(), ['news', 'termine', 'shop', 'zeiten'])
})
// ---------------------------------------------------------------------------------- Schlüsselschutz ---
@@ -296,3 +297,206 @@ test('Bestelltexte ohne ENTWURF', async () => {
const belehrung = reg.featureHaken('termine').belehrung
if (belehrung) frei(await belehrung(), 'belehrung')
})
// ---------------------------------------------------------------------------------- Webshop (Block A3) ---
test('Shop: Preis, Struktur und Texte', async () => {
const s = reg.featureConfig('shop')
assert.equal(s, config.features.shop)
assert.equal(s.key, 'shop')
assert.equal(s.product, 'shopSetup')
assert.equal(config.shopSetup.amountCents, 29900)
assert.deepEqual(s.label, { name: 'Webshop', kurz: 'Shop', keine: 'keinen Webshop' })
assert.equal(s.priceLabel, '299 €')
assert.equal(s.setupEstimate, 'meist innerhalb von 2–3 Wochen')
assert.equal(s.consent.knopf, 'Zahlungspflichtig bestellen · 299 €')
assert.deepEqual(
s.consent.items.map((i) => i.id),
['unterlagen', 'verantwortung', 'stripe', 'beginn', 'information']
)
assert.deepEqual(
s.consent.items.map((i) => i.haken),
[true, true, true, true, true]
)
for (const item of s.consent.items) {
assert.equal(typeof item.text, 'string')
assert.ok(item.text.length > 0)
assert.equal(item.url, undefined, 'ohne url')
}
const F = config.shop.fassung
assert.equal(s.consent.version, F)
assert.equal(s.consent.items[0].text, `Ich stimme der Leistungsbeschreibung Webshop und dem Auftragsverarbeitungsvertrag Webshop in der Fassung ${F} zu.`)
assert.match(s.consent.items[3].text, /Einrichtung des Webshops/)
assert.ok(s.consent.items[4].text.includes(`Gesamtpreis 299 € einmalig (${config.ustHinweis})`))
assert.ok(s.consent.items[4].text.includes('meist innerhalb von 2–3 Wochen'))
// Fassung und Adressen folgen config.shop.fassung; die Widerrufsbelehrung ist die der Funktionen
assert.deepEqual(s.leistungsbeschreibung, { version: F, url: `/rechtliches/leistungsbeschreibung-webshop-${F}.html` })
assert.deepEqual(s.avv, { version: F, url: `/rechtliches/avv-webshop-${F}.html` })
assert.deepEqual(s.widerruf, { version: F, url: `/rechtliches/widerrufsbelehrung-funktionen-${F}.html` })
assert.equal(s.returnHash, 'webshop')
assert.equal(s.returnProjekt, true)
assert.equal(s.requiresWartungsAbo, false)
assert.equal(s.ticketFreischaltung, true)
assert.equal(s.disabledMessage, 'Der Webshop ist für diese Website abgeschaltet. Schreib uns im Chat, wenn Du ihn wieder möchtest.')
assert.equal(s.orderedConflictMessage, 'Der Webshop ist für dieses Projekt schon bestellt.')
assert.equal(s.unavailableMessage, 'Der Webshop ist noch nicht verfügbar.')
assert.equal(s.mails.funktion, 'Webshop')
assert.deepEqual(s.mails.formen, { dein: 'Dein Webshop', nom: 'der Webshop', akk: 'den Webshop', gen: 'des Webshops', pron: 'ihn' })
assert.equal(s.mails.knopf, 'Webshop öffnen')
assert.equal(s.mails.anleitung, false)
assert.equal(s.mails.preisHinweis, `einmalig, ${config.ustHinweis}; keine laufende Gebühr von WEBklar, Stripe-Gebühren zahlst Du direkt an Stripe`)
assert.equal(s.mails.danach, 'Danach kannst Du im Portal Artikel anlegen, Dein Stripe-Konto verbinden und den Shop öffnen.')
assert.equal(s.mails.bereit, 'Dein Webshop ist eingebaut. Sobald Stripe-Konto, Artikel und Rechtstexte fertig sind, kannst Du ihn im Portal öffnen.')
assert.equal(s.ticket.topic({ projectName: 'Laden' }), 'Webshop einbauen – Laden')
assert.equal(s.ticket.topic({ subdomain: 'laden' }), 'Webshop einbauen – laden')
assert.equal(typeof s.ticket.details, 'function')
const ansicht = reg.oeffentlicheAnsicht('shop')
assert.equal(ansicht.name, 'Webshop')
assert.equal(ansicht.kurz, 'Shop')
assert.equal(ansicht.priceLabel, '299 €')
assert.equal(ansicht.requiresWartungsAbo, false)
assert.equal(ansicht.returnHash, 'webshop')
assert.deepEqual(ansicht.consent.items.map((i) => i.id), ['unterlagen', 'verantwortung', 'stripe', 'beginn', 'information'])
assert.equal(ansicht.consent.version, F)
assert.deepEqual(ansicht.avv, { version: F, url: `/rechtliches/avv-webshop-${F}.html` })
assert.doesNotMatch(JSON.stringify(ansicht), /ENTWURF/)
// Termine bleibt unberührt
assert.equal(config.features.termine.consent.knopf, 'Zahlungspflichtig bestellen · 149 €')
})
test('Shop: Fassung folgt config.shop.fassung bei jedem Zugriff', () => {
const s = config.features.shop
const vorher = config.shop.fassung
try {
config.shop.fassung = '2099-01-02'
assert.equal(s.consent.version, '2099-01-02')
assert.ok(s.consent.items[0].text.includes('in der Fassung 2099-01-02 zu.'))
assert.deepEqual(s.avv, { version: '2099-01-02', url: '/rechtliches/avv-webshop-2099-01-02.html' })
assert.equal(s.leistungsbeschreibung.url, '/rechtliches/leistungsbeschreibung-webshop-2099-01-02.html')
// null beim Widerruf heißt "keine Unterlage" (Tests setzen so eine Bestellung ohne sie), undefined stellt den Standard her
s.widerruf = null
assert.equal(s.widerruf, null)
s.widerruf = undefined
assert.equal(s.widerruf.version, '2099-01-02')
} finally {
config.shop.fassung = vorher
s.widerruf = undefined
}
assert.equal(s.consent.version, vorher)
assert.ok(s.consent.items[0].text.includes(`in der Fassung ${vorher} zu.`))
})
test('Shop-Haken: verfügbar nach Schalter, Belehrung, kein freischaltbar', async () => {
const haken = reg.featureHaken('shop')
assert.deepEqual(Object.keys(haken).sort(), ['belehrung', 'bestellpruefung', 'unterlagenBereit', 'verfuegbar'])
assert.equal(haken.freischaltbar, undefined)
const vorher = { ...config.shop }
try {
config.shop.mode = 'off'
assert.equal(haken.verfuegbar('a@b.de'), false)
assert.equal(reg.featureSperre('shop', 'a@b.de'), 'Der Webshop ist noch nicht verfügbar.')
config.shop.mode = 'pilot'
config.shop.pilotEmails = ['a@b.de']
assert.equal(haken.verfuegbar('A@B.de'), true)
assert.equal(haken.verfuegbar('x@y.de'), false)
assert.equal(reg.featureSperre('shop', 'a@b.de'), null)
config.shop.mode = 'on'
assert.equal(haken.verfuegbar('x@y.de'), true)
} finally {
Object.assign(config.shop, vorher)
}
// dieselbe Belehrung wie beim Terminplaner (gleiche Quelle)
assert.equal(await haken.belehrung(), await reg.featureHaken('termine').belehrung())
assert.equal(typeof (await haken.belehrung()), 'string')
})
test('Shop-Haken: Bestellprüfung verlangt die angenommene Anfrage', async () => {
const { bestellpruefung } = reg.featureHaken('shop')
const OFFEN = ['Deine Webshop-Anfrage ist noch nicht freigegeben.']
aw.seed('customers', { $id: 'sc1', name: 'Shop Eins' })
aw.seed('websiteProjects', { $id: 'sp1', customerId: 'sc1', projectName: 'Laden' })
// kein portalFeatures-Dokument
assert.deepEqual(await bestellpruefung({ customerId: 'sc1', projectId: 'sp1' }), OFFEN)
aw.seed('portalFeatures', { $id: 'f_sp1_shop', customerId: 'sc1', projectId: 'sp1', featureKey: 'shop', siteKey: SITE_KEY, state: 'off', enabled: false })
// portalFeatures-Dokument, aber kein shopConfig
assert.deepEqual(await bestellpruefung({ customerId: 'sc1', projectId: 'sp1' }), OFFEN)
for (const status of ['', 'offen', 'abgelehnt', 'zurueckgezogen']) {
aw.seed('shopConfig', { $id: 'f_sp1_shop', customerId: 'sc1', projectId: 'sp1', siteKey: SITE_KEY, anfrageStatus: status })
assert.deepEqual(await bestellpruefung({ customerId: 'sc1', projectId: 'sp1' }), OFFEN, `Status "${status}"`)
}
aw.seed('shopConfig', { $id: 'f_sp1_shop', customerId: 'sc1', projectId: 'sp1', siteKey: SITE_KEY, anfrageStatus: 'angenommen' })
assert.deepEqual(await bestellpruefung({ customerId: 'sc1', projectId: 'sp1' }), [])
// ein fremder Kunde sieht die Anfrage nicht
assert.deepEqual(await bestellpruefung({ customerId: 'sc2', projectId: 'sp1' }), OFFEN)
})
test('Shop-Haken: Unterlagen sind bereit, sobald die Dateien da sind (bis Block J: nicht)', async () => {
const { unterlagenBereit } = reg.featureHaken('shop')
const { featureLegalReady, legalPaths } = await import('../server/services/legalDocs.js')
const { existsSync } = await import('node:fs')
const F = config.shop.fassung
const dateien = Object.values(legalPaths('shop', F)).flatMap((p) => [p, p.replace(/\.html$/, '.pdf')])
const alleDa = dateien.every((p) => existsSync(new URL(`../public${p}`, import.meta.url)))
assert.equal(await featureLegalReady('shop'), alleDa)
assert.equal(await unterlagenBereit(), alleDa)
})
test('Shop-Ticket: Details deterministisch, mit Snippet und Schritten', async () => {
aw.seed('shopConfig', {
$id: 'f_sp9_shop',
customerId: 'sc9',
projectId: 'sp9',
siteKey: SITE_KEY,
anfrage: JSON.stringify({ warenart: 'kleidung', beschreibung: 'Streng geheim', artikelzahl: 'bis100', versand: true, abholung: true, nachricht: 'Hallo' }),
anfrageStatus: 'angenommen',
})
const doc = { $id: 'f_sp9_shop', customerId: 'sc9', projectId: 'sp9', siteKey: SITE_KEY, featureKey: 'shop' }
const ctx = { feature: config.features.shop, doc, project: { projectName: 'Laden', liveDomain: 'laden.de' }, customer: { name: 'Frau Muster' } }
const text = await config.features.shop.ticket.details(ctx)
assert.equal(typeof text, 'string')
for (const teil of [
'Funktion: Webshop',
'Projekt: Laden',
'Domain: laden.de',
'Kunde: Frau Muster',
`siteKey: ${SITE_KEY}`,
'Anfrage: Warenart: Kleidung, Lieferung: Versand und Abholung, Artikelzahl: bis 100',
`<div data-webklar-shop="${SITE_KEY}"></div>`,
'<script src="https://project.webklar.com/embed/shop.js" defer></script>',
`https://project.webklar.com/shop/${SITE_KEY}/widerruf`,
`https://project.webklar.com/shop/${SITE_KEY}`,
'docs/funktionen/shop-datenschutz-absatz.md',
'Zeit im Arbeitsblatt',
'Verweis: ',
'/customers/sc9?tab=funktionen&anleitung=sp9',
]) {
assert.ok(text.includes(teil), `fehlt: ${teil}`)
}
// freier Text der Anfrage gehört nicht ins Ticket; deterministisch
assert.ok(!text.includes('Streng geheim'))
assert.equal(await config.features.shop.ticket.details(ctx), text)
assert.ok(text.length <= reg.TICKET_DETAILS_MAX)
// ohne Anfrage oder bei Lesefehler: dieselben Teile, nur ohne Anfrage-Zeile; wirft nie
const ohne = await config.features.shop.ticket.details({ ...ctx, doc: { ...doc, $id: 'f_unbekannt_shop' } })
assert.ok(!ohne.includes('Anfrage:'))
assert.ok(ohne.includes(`data-webklar-shop="${SITE_KEY}"`))
aw.seed('shopConfig', { $id: 'f_sp8_shop', customerId: 'sc9', projectId: 'sp8', siteKey: SITE_KEY, anfrage: '{kaputt' })
const kaputt = await config.features.shop.ticket.details({ ...ctx, doc: { ...doc, $id: 'f_sp8_shop' } })
assert.ok(!kaputt.includes('Anfrage:'))
const notfall = await config.features.shop.ticket.details(null)
assert.equal(typeof notfall, 'string')
assert.ok(notfall.length > 0)
})
test('Shop-Ticket über ticketTexte: Betreff und Details', async () => {
const doc = { $id: 'f_sp7_shop', customerId: 'sc7', projectId: 'sp7', siteKey: SITE_KEY, featureKey: 'shop' }
const { topic, details } = await reg.ticketTexte({ feature: config.features.shop, doc, project: { projectName: 'Laden' }, customer: null })
assert.equal(topic, 'Webshop einbauen – Laden')
assert.ok(details.includes(`data-webklar-shop="${SITE_KEY}"`))
})