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

+46 -4
View File
@@ -30,11 +30,26 @@ test('ohne_dokument_ist_off', async () => {
assert.equal(list.find((p) => p.projectId === 'p1').liveUrl, 'https://baeckerei.de')
})
test('C1: eine neue Bestellung leert IP und Browser aus einer frueheren Bestellung desselben Dokuments', async () => {
const first = await sf.markOrdered({ customerId: 'c2', projectId: 'p3', key: 'news', sessionId: 'cs_c1_a', consent })
// Altbestand: eine frueher gespeicherte IP am Dokument (vor dieser Aenderung wurde sie geschrieben)
const { updateDocument } = await import('../server/services/appwriteAdmin.js')
await updateDocument('portalFeatures', first.$id, { consentIp: '9.9.9.9', consentUserAgent: 'alt', state: 'off', enabled: false })
const again = await sf.markOrdered({ customerId: 'c2', projectId: 'p3', key: 'news', sessionId: 'cs_c1_b', consent })
assert.deepEqual([again.consentIp, again.consentUserAgent], ['', ''])
assert.equal(aw.docs('portalFeatures').get(first.$id).consentIp, '')
aw.docs('portalFeatures').delete(first.$id) // die uebrigen Faelle dieser Datei zaehlen Dokumente
})
test('bezahlt_legt_ticket_an', async () => {
const ordered = await sf.markOrdered({ customerId: 'c1', projectId: 'p1', key: 'news', sessionId: 'cs_1', consent })
assert.equal(ordered.state, 'ordered')
assert.equal(ordered.enabled, false)
assert.equal(ordered.consentIp, '1.2.3.4')
// C1: IP und Browser der Bestellung stehen nur im Nachweis (featureConsents), nie am Funktionsdokument, auch wenn der Aufrufer sie mitgibt
assert.equal(ordered.consentIp, '')
assert.equal(ordered.consentUserAgent, '')
assert.equal(ordered.consentText, 'Ich stimme zu')
assert.equal(ordered.consentAt, '2026-09-30T10:00:00.000Z')
assert.match(ordered.siteKey, /^[a-z2-7]{16}$/)
const { doc, created } = await sf.markPaid({ sessionId: 'cs_1', invoiceId: 'in_1', invoiceUrl: 'https://x/inv' })
assert.equal(created, true)
@@ -170,10 +185,11 @@ test('termine_ist_zweite_funktion: Schluessel und Bezeichnungen', () => {
aw.seed('websiteProjects', { $id: 'pt1', customerId: 'c1', projectName: 'Salon' })
aw.seed('websiteProjects', { $id: 'pt2', customerId: 'c1', projectName: 'Studio' })
aw.seed('websiteProjects', { $id: 'pt3', customerId: 'c2', projectName: 'Fremder Salon' })
assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine', 'zeiten'])
assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine', 'shop', 'zeiten'])
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' },
})
})
@@ -181,7 +197,7 @@ test('termine_ist_zweite_funktion: Schluessel und Bezeichnungen', () => {
test('liste_zeigt_termine_je_projekt_ohne_dokument_als_off', async () => {
const list = await sf.listCustomerFeatures('c1')
const p = list.find((x) => x.projectId === 'pt1')
assert.deepEqual(Object.keys(p.features), ['news', 'termine', 'zeiten'])
assert.deepEqual(Object.keys(p.features), ['news', 'termine', 'shop', 'zeiten'])
assert.equal(p.features.termine.state, 'off')
assert.equal(p.features.termine.siteKey, null)
assert.equal(p.features.termine.ticketWoid, null)
@@ -247,7 +263,7 @@ test('activateClosedTickets: Funktionen mit ticketFreischaltung; News verhaelt s
const ids = ['f_setup_termine', 'f_setup_news', 'f_setup_analyse']
const reg = await import('../server/services/featureRegistry.js')
try {
assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'termine', 'zeiten'])
assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'termine', 'shop', 'zeiten'])
// News wie bisher; Termine wartet auf Einstellung und Abo (Haken freischaltbar); Analyse hat keine ticketFreischaltung
const done = await sf.activateClosedTickets({ customerId: 'c1' })
assert.deepEqual(done.map((d) => d.featureKey), ['news'])
@@ -721,3 +737,29 @@ test('featureView: nach disable() steht der Grund in der Ansicht und die Frist i
const danach = (await sf.listCustomerFeatures('c_e2')).find((p) => p.projectId === 'p_e2_live').features.termine
assert.deepEqual([danach.disabledReason, danach.disabledAt, danach.loeschungAm], [null, null, null], 'Wiedereinschalten leert die Anzeige')
})
// ------------------------------------------------ Shop: Kontext-Cache nach dem Abschalten (Review A, M10) ---
test('M10: disable und disableCustomerFeatures leeren den Kontext-Cache des Shops (kein Annehmen von Bestellungen bis zu 30 s nach dem Abschalten)', async () => {
const K = await import('../server/services/shop/kontext.js')
const feature = (id, siteKey, projectId) => ({ $id: id, customerId: 'c1', projectId, featureKey: 'shop', siteKey, state: 'active', enabled: true })
const shopDoc = (id, siteKey, projectId) => ({ $id: id, customerId: 'c1', projectId, siteKey, anfrageStatus: 'angenommen', geoeffnet: true, version: 1 })
aw.seed('portalFeatures', feature('f_m10_a', 'abcdefghijklmnop', 'p1'))
aw.seed('shopConfig', shopDoc('f_m10_a', 'abcdefghijklmnop', 'p1'))
aw.seed('portalFeatures', feature('f_m10_b', 'bcdefghijklmnopq', 'p2'))
aw.seed('shopConfig', shopDoc('f_m10_b', 'bcdefghijklmnopq', 'p2'))
K.leereCache()
assert.equal((await K.shopPerSiteKey('abcdefghijklmnop')).doc.state, 'active')
await sf.disable('f_m10_a', { by: 'kenso' })
assert.equal((await K.shopPerSiteKey('abcdefghijklmnop')).doc.state, 'disabled')
assert.equal((await K.shopPerSiteKey('bcdefghijklmnopq')).doc.state, 'active')
await sf.disableCustomerFeatures('c1', 'shop', { adminNote: 'Kunde gelöscht' })
assert.equal((await K.shopPerSiteKey('bcdefghijklmnopq')).doc.state, 'disabled')
for (const id of ['f_m10_a', 'f_m10_b']) {
aw.docs('portalFeatures').delete(id)
aw.docs('shopConfig').delete(id)
}
K.leereCache()
})