Kundenansicht P5: Analyse im Abo-Modell (Hosting, Zielgruppe erzwungen, Nachweis, Tagesprüfung)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-16 16:14:35 +00:00
1 parent 23252df359
commit 57d5db2344
4 files changed
+271 -21

No files matched your search

+36 -11
View File
@@ -26,16 +26,17 @@ import { einschaltenDeps, einschaltenZeiten, zeitenProjektAnsicht } from '../ser
import { starteBehalten } from '../services/zeiten/behalten.js' import { starteBehalten } from '../services/zeiten/behalten.js'
import { aboMemo } from '../services/aboStatus.js' import { aboMemo } from '../services/aboStatus.js'
import { aboProdukt } from '../services/aboFunktionen.js' import { aboProdukt } from '../services/aboFunktionen.js'
import { analyseUnterlagen } from '../services/aboUnterlagen.js'
import { anfrageStatusJeProjekt, katalogFuerKunde } from '../services/aboKatalog.js' import { anfrageStatusJeProjekt, katalogFuerKunde } from '../services/aboKatalog.js'
import { datumDe, einschalten, EINSCHALTEN_TEXTE, pruefeAboDeckung, pruefeEinschaltZustand } from '../services/funktionEinschalten.js' import { datumDe, einschalten, EINSCHALTEN_TEXTE, pruefeAboDeckung, pruefeEinschaltZustand } from '../services/funktionEinschalten.js'
import { kundenartWirksam, NUR_BETRIEB_TEXT } from '../services/kundenart.js' import { kundenartWirksam, NUR_BETRIEB_TEXT } from '../services/kundenart.js'
import { neueAnsicht } from '../services/kundenansicht/schalter.js' import { neueAnsicht } from '../services/kundenansicht/schalter.js'
import { aboweg, consentAbo, consentAboAktiv, featureConfig, featureHaken, featureKeys, featureSperre, freiweg, katalogFelder, oeffentlicheAnsicht } from '../services/featureRegistry.js' import { abowegAnalyse, aboweg, consentAbo, consentAboAktiv, featureConfig, featureHaken, featureKeys, featureSperre, freiweg, katalogFelder, oeffentlicheAnsicht } from '../services/featureRegistry.js'
import { aboMeldung, bestellFehler, istFortsetzen, oeffentlicheConsent, pruefeAbo, pruefeInklusive, pruefeZustimmung } from '../services/featureOrder.js' import { aboMeldung, bestellFehler, istFortsetzen, oeffentlicheConsent, pruefeAbo, pruefeInklusive, pruefeZustimmung } from '../services/featureOrder.js'
import { import {
ELIGIBILITY_MESSAGES,
activateAnalyse, activateAnalyse,
eligibilityMessage,
analyseDeps, analyseDeps,
analyseHosts, analyseHosts,
isDevPreview, isDevPreview,
@@ -60,11 +61,11 @@ function fail(res, err, fallback) {
const ANALYSE_OFF = 'Website-Analyse ist noch nicht freigeschaltet' const ANALYSE_OFF = 'Website-Analyse ist noch nicht freigeschaltet'
const ANALYSE_ERROR = 'Die Website-Analyse konnte gerade nicht geändert werden. Bitte versuch es später noch einmal oder schreib uns im Chat.' const ANALYSE_ERROR = 'Die Website-Analyse konnte gerade nicht geändert werden. Bitte versuch es später noch einmal oder schreib uns im Chat.'
async function analyseView(customerId, projectId) { async function analyseView(customerId, projectId, { aboModell = false } = {}) {
if (!config.analyse.enabled) return { comingSoon: true } if (!config.analyse.enabled) return { comingSoon: true }
const [doc, eligibility, controller] = await Promise.all([ const [doc, eligibility, controller] = await Promise.all([
getFeatureDoc(customerId, projectId, 'analyse'), getFeatureDoc(customerId, projectId, 'analyse'),
analyseEligibility(customerId, projectId), analyseEligibility(customerId, projectId, { aboModell }),
controllerView(customerId), controllerView(customerId),
]) ])
let devMode = false let devMode = false
@@ -78,9 +79,10 @@ async function analyseView(customerId, projectId) {
accentColor: doc?.accentColor || '#111111', accentColor: doc?.accentColor || '#111111',
eligible: eligibility.ok, eligible: eligibility.ok,
reason: eligibility.reason || null, reason: eligibility.reason || null,
reasonText: eligibility.reason ? ELIGIBILITY_MESSAGES[eligibility.reason] : null, reasonText: eligibility.reason ? eligibilityMessage(eligibility.reason, { aboModell }) : null,
avvVersion: config.analyse.avvVersion, // Neue Ansicht: Fassung und Adressen aus derselben Quelle wie der Nachweis (die Oberflaeche zeigt und sendet, was der Server prueft)
legal: legalPaths(config.analyse.avvVersion), avvVersion: aboModell ? analyseUnterlagen().avvVersion : config.analyse.avvVersion,
legal: aboModell ? analyseUnterlagen().legal : legalPaths(config.analyse.avvVersion),
controller, controller,
activatedAt: doc?.state === 'active' ? doc.activatedAt || null : null, activatedAt: doc?.state === 'active' ? doc.activatedAt || null : null,
} }
@@ -215,10 +217,11 @@ router.get('/', requireSession, async (req, res) => {
const projects = await listCustomerFeatures(customerId, { docs }) const projects = await listCustomerFeatures(customerId, { docs })
const zeitenAn = zeitenFuerKunde(req.session.email) const zeitenAn = zeitenFuerKunde(req.session.email)
const jetzt = einschaltenDeps.jetzt() const jetzt = einschaltenDeps.jetzt()
const neu = neueAnsicht(req)
// Ein Fehler bei einem Projekt (z. B. Abo-Pruefung) darf nicht die ganze Liste kosten // Ein Fehler bei einem Projekt (z. B. Abo-Pruefung) darf nicht die ganze Liste kosten
await Promise.all( await Promise.all(
projects.map(async (p) => { projects.map(async (p) => {
p.analyse = await analyseView(customerId, p.projectId).catch((err) => { p.analyse = await analyseView(customerId, p.projectId, { aboModell: neu }).catch((err) => {
console.error('[funktionen] Analyse-Zustand fehlgeschlagen:', err.message) console.error('[funktionen] Analyse-Zustand fehlgeschlagen:', err.message)
return { state: 'unknown' } return { state: 'unknown' }
}) })
@@ -252,7 +255,7 @@ router.get('/', requireSession, async (req, res) => {
} }
// Kundenansicht neu (V5): Abo-Modell mit Kundenart, Abo-Stand, Katalog und Anfragestatus von Webshop und Buchungskalender je Projekt. // Kundenansicht neu (V5): Abo-Modell mit Kundenart, Abo-Stand, Katalog und Anfragestatus von Webshop und Buchungskalender je Projekt.
// Ohne neue Ansicht bleibt die Antwort wie sie war. Ein Lesefehler beim Abo-Stand ist ein Fehler der Liste (nie ein erfundenes "kein Abo"). // Ohne neue Ansicht bleibt die Antwort wie sie war. Ein Lesefehler beim Abo-Stand ist ein Fehler der Liste (nie ein erfundenes "kein Abo").
if (neueAnsicht(req)) { if (neu) {
const [kunde, abo, katalog, anfrageStatus] = await Promise.all([ const [kunde, abo, katalog, anfrageStatus] = await Promise.all([
kundenDokument(customerId), kundenDokument(customerId),
aboProdukt(customerId, { now: jetzt }), aboProdukt(customerId, { now: jetzt }),
@@ -601,14 +604,36 @@ router.post('/analyse/activate', analyseGuard, async (req, res) => {
return res.status(403).json({ error: 'In der Kundenansicht kann nichts geändert werden.' }) return res.status(403).json({ error: 'In der Kundenansicht kann nichts geändert werden.' })
} }
const { projectId, accentColor, consent } = req.body || {} const { projectId, accentColor, consent } = req.body || {}
await assertOwnProject(getSessionCustomerId(req), analyseProjectId(projectId), { forNewOrder: true }) // Neue Ansicht (Abo-Modell): die Zielgruppen-Aussage (keine Website fuer Kinder unter 16) ist serverseitig erzwungen, nicht nur ein
// Haekchen im Browser; vor jedem Schreiben. Die alte Ansicht bleibt unveraendert.
const neu = neueAnsicht(req)
if (neu && req.body?.audienceConfirmed !== true) {
throw bestellFehler(400, 'Bitte bestätige, dass sich deine Website nicht gezielt an Kinder unter 16 Jahren richtet.', 'zielgruppe_fehlt')
}
const customerId = getSessionCustomerId(req)
await assertOwnProject(customerId, analyseProjectId(projectId), { forNewOrder: true })
// Nachweis der Erklaerung (featureConsents) mit der Kundenart des Dokuments, nie aus dem Body; entsteht nur bei einer neuen Aktivierung
const abo = neu
? {
aboModell: true,
nachweis: async () =>
speichereNachweisAusAnfrage(req, {
feature: abowegAnalyse(),
customerId,
projectId,
klickAt: einschaltenDeps.jetzt().toISOString(),
kundenart: kundenartWirksam(await kundenDokument(customerId)),
}),
}
: {}
const { doc } = await activateAnalyse({ const { doc } = await activateAnalyse({
customerId: getSessionCustomerId(req), customerId,
projectId: analyseProjectId(projectId), projectId: analyseProjectId(projectId),
accentColor, accentColor,
consent, consent,
by: 'customer', by: 'customer',
actor: actingPerson(req.session), actor: actingPerson(req.session),
...abo,
}) })
res.json({ ok: true, state: doc.state }) res.json({ ok: true, state: doc.state })
} catch (err) { } catch (err) {
+20
View File
@@ -1,5 +1,8 @@
import { config } from '../config.js'
import { getDocument } from './appwriteAdmin.js'
import { ACTIVE_STATUSES, getCurrentSubscription } from './stripeBilling.js' import { ACTIVE_STATUSES, getCurrentSubscription } from './stripeBilling.js'
import { listContractEvents, openWithdrawals } from './contractEvents.js' import { listContractEvents, openWithdrawals } from './contractEvents.js'
import { neueAnsichtFuerKunde } from './kundenansicht/schalter.js'
/** /**
* Abo-Zuordnung der Funktionen (V5, Spec E3/E8): Welches Abo deckt welche Funktion? Ein Lesepfad (lies) fuer alle * Abo-Zuordnung der Funktionen (V5, Spec E3/E8): Welches Abo deckt welche Funktion? Ein Lesepfad (lies) fuer alle
@@ -87,3 +90,20 @@ export async function aboLaeuftFuer(customerId, key) {
const l = await lies(customerId, new Date()) const l = await lies(customerId, new Date())
return l.laeuft && ENTHALTEN_IN[key].includes(l.produkt) return l.laeuft && ENTHALTEN_IN[key].includes(l.produkt)
} }
/**
* Gehoert dieser Kunde zum Abo-Modell (sieht er die neue Ansicht)? Fuer Jobs und Dienste ohne Cookie (Tagespruefung der Analyse,
* Team-Aktivierung): liest das Kundendokument (kein Dokument: false) und fragt neueAnsichtFuerKunde({ customerId, email }). Nie
* neueAnsichtFuerSession: eine Sitzung braucht dort auch appwriteUserId, die ein Job nicht hat. Lesefehler werden weitergereicht.
*/
export async function aboModellFuerKunde(customerId) {
if (typeof customerId !== 'string' || customerId === '') return false
let kunde
try {
kunde = await getDocument(config.collections.customers, customerId)
} catch (err) {
if (err.status === 404) return false
throw err
}
return neueAnsichtFuerKunde({ customerId, email: kunde.email })
}
+31 -10
View File
@@ -9,6 +9,8 @@ import { sendCustomerMail } from './mailer.js'
import { triggerRebuild } from './previewRebuild.js' import { triggerRebuild } from './previewRebuild.js'
import { ensureFeatureDoc, getFeatureDoc, newSiteKey, stateFields, assertOwnProject } from './siteFeatures.js' import { ensureFeatureDoc, getFeatureDoc, newSiteKey, stateFields, assertOwnProject } from './siteFeatures.js'
import { laeuft } from './aboStatus.js' import { laeuft } from './aboStatus.js'
import { aboDeckt, aboLaeuftFuer, aboModellFuerKunde } from './aboFunktionen.js'
import { consentAboAnalyse } from './featureRegistry.js'
import { createFeatureTicket } from './ticketAdmin.js' import { createFeatureTicket } from './ticketAdmin.js'
import { berlinDateTime } from './berlinTime.js' import { berlinDateTime } from './berlinTime.js'
import { legalPaths, legalPdfAttachments } from './analyseLegal.js' import { legalPaths, legalPdfAttachments } from './analyseLegal.js'
@@ -40,6 +42,10 @@ export const ELIGIBILITY_MESSAGES = {
keine_subdomain: 'Für dieses Projekt ist noch keine Subdomain eingerichtet.', keine_subdomain: 'Für dieses Projekt ist noch keine Subdomain eingerichtet.',
} }
const ELIGIBILITY_STATUS = { nicht_dein_projekt: 403, kein_abo: 403, nicht_oeffentlich: 409, keine_subdomain: 409 } const ELIGIBILITY_STATUS = { nicht_dein_projekt: 403, kein_abo: 403, nicht_oeffentlich: 409, keine_subdomain: 409 }
/** Im Abo-Modell (neue Ansicht) reicht auch ein Hosting-Abo: nur dieser Text weicht ab. */
export const ELIGIBILITY_MESSAGES_ABO = { kein_abo: 'Die Website-Analyse gibt es nur mit einem laufenden Hosting- oder Wartungs-Abo.' }
export const eligibilityMessage = (reason, { aboModell = false } = {}) =>
(aboModell && ELIGIBILITY_MESSAGES_ABO[reason]) || ELIGIBILITY_MESSAGES[reason]
/** Nur Wartungs-Abo (kein Hosting) mit laufendem Status; strenge Regel des Abo-Bausteins: ein Spiegel ohne productKey zaehlt nicht. */ /** Nur Wartungs-Abo (kein Hosting) mit laufendem Status; strenge Regel des Abo-Bausteins: ein Spiegel ohne productKey zaehlt nicht. */
const hasWartungsAbo = (customerId) => laeuft(customerId, { altbestand: false }) const hasWartungsAbo = (customerId) => laeuft(customerId, { altbestand: false })
@@ -53,11 +59,16 @@ async function loadOwnProject(customerId, projectId) {
} }
} }
/** @returns {Promise<{ ok: boolean, reason?: string, project?: object }>} */ /**
export async function analyseEligibility(customerId, projectId) { * @param {{ aboModell?: boolean }} [optionen] aboModell (neue Ansicht): ein laufendes Hosting- oder Wartungs-Abo genuegt (aboDeckt:
* nicht gekuendigt, aktiv; in der Probezeit gilt weiter kein_abo); sonst die strenge Regel "Wartungs-Abo laeuft".
* @returns {Promise<{ ok: boolean, reason?: string, project?: object }>}
*/
export async function analyseEligibility(customerId, projectId, { aboModell = false } = {}) {
const project = await loadOwnProject(customerId, projectId) const project = await loadOwnProject(customerId, projectId)
if (!project) return { ok: false, reason: 'nicht_dein_projekt' } if (!project) return { ok: false, reason: 'nicht_dein_projekt' }
if (!(await hasWartungsAbo(customerId))) return { ok: false, reason: 'kein_abo' } const aboOk = aboModell ? (await aboDeckt(customerId, FEATURE_KEY)).ok === true : await hasWartungsAbo(customerId)
if (!aboOk) return { ok: false, reason: 'kein_abo' }
if (project.isPublic !== true) return { ok: false, reason: 'nicht_oeffentlich' } if (project.isPublic !== true) return { ok: false, reason: 'nicht_oeffentlich' }
if (!project.subdomain) return { ok: false, reason: 'keine_subdomain' } if (!project.subdomain) return { ok: false, reason: 'keine_subdomain' }
return { ok: true, project } return { ok: true, project }
@@ -278,12 +289,18 @@ async function sendK18(doc, project) {
return doc return doc
} }
export async function activateAnalyse({ customerId, projectId, accentColor, consent, by = 'customer', actor, textform, audienceConfirmed, now = new Date() }) { /**
* Analyse einschalten. aboModell (neue Ansicht, nur durch den Kunden): ein Hosting- oder Wartungs-Abo genuegt, die erwartete Fassung
* ist die der Abo-Erklaerung (consentAboAnalyse().avvVersion). nachweis: async () => void, laeuft im selben try wie recordConsent, nach
* dem Dokument und davor; ein Fehler nimmt alles zurueck (kein "on" ohne Nachweis). Beides nur bei einer neuen Aktivierung.
*/
export async function activateAnalyse({ customerId, projectId, accentColor, consent, by = 'customer', actor, textform, audienceConfirmed, now = new Date(), aboModell = false, nachweis = null }) {
const viaCustomer = by === 'customer' const viaCustomer = by === 'customer'
const avvVersion = aboModell ? consentAboAnalyse().avvVersion : config.analyse.avvVersion
let accepted let accepted
let proof = { date: '', channel: '' } let proof = { date: '', channel: '' }
if (viaCustomer) { if (viaCustomer) {
if (!consent || consent.accepted !== true || consent.avvVersion !== config.analyse.avvVersion) { if (!consent || consent.accepted !== true || consent.avvVersion !== avvVersion) {
throw httpError(400, 'Bitte den Auftragsverarbeitungsvertrag bestätigen') throw httpError(400, 'Bitte den Auftragsverarbeitungsvertrag bestätigen')
} }
// Name und E-Mail der handelnden Person (bei Admin-Sitzung der Admin, siehe Route) // Name und E-Mail der handelnden Person (bei Admin-Sitzung der Admin, siehe Route)
@@ -299,8 +316,8 @@ export async function activateAnalyse({ customerId, projectId, accentColor, cons
if (!COLOR_RE.test(color)) throw httpError(400, 'Ungültige Akzentfarbe') if (!COLOR_RE.test(color)) throw httpError(400, 'Ungültige Akzentfarbe')
return withLocks([`analyse:${projectId}`], async () => { return withLocks([`analyse:${projectId}`], async () => {
const eligibility = await analyseEligibility(customerId, projectId) const eligibility = await analyseEligibility(customerId, projectId, { aboModell })
if (!eligibility.ok) throw httpError(ELIGIBILITY_STATUS[eligibility.reason], ELIGIBILITY_MESSAGES[eligibility.reason]) if (!eligibility.ok) throw httpError(ELIGIBILITY_STATUS[eligibility.reason], eligibilityMessage(eligibility.reason, { aboModell }))
const { project } = eligibility const { project } = eligibility
if (!analyseDomains(project).length) throw httpError(409, 'Keine gültige Domain für die Analyse') if (!analyseDomains(project).length) throw httpError(409, 'Keine gültige Domain für die Analyse')
@@ -323,14 +340,14 @@ export async function activateAnalyse({ customerId, projectId, accentColor, cons
projectId, projectId,
siteKey, siteKey,
event: 'on', event: 'on',
avvVersion: config.analyse.avvVersion, avvVersion,
via: viaCustomer ? 'portal' : 'woms', via: viaCustomer ? 'portal' : 'woms',
acceptedAt: at, acceptedAt: at,
acceptedBy: accepted, acceptedBy: accepted,
textformDate: proof.date, textformDate: proof.date,
textformChannel: proof.channel, textformChannel: proof.channel,
audienceConfirmed: true, // Kunden-Haken enthaelt die Zielgruppen-Aussage; Mitarbeiter bestaetigen sie getrennt audienceConfirmed: true, // Kunden-Haken enthaelt die Zielgruppen-Aussage; Mitarbeiter bestaetigen sie getrennt
audienceTextVersion: config.analyse.avvVersion, audienceTextVersion: avvVersion,
} }
let updated = false let updated = false
try { try {
@@ -353,7 +370,9 @@ export async function activateAnalyse({ customerId, projectId, accentColor, cons
publishTicketId: '', publishTicketId: '',
}) })
updated = true updated = true
// Nachweis erst nach dem Dokument: scheitert er, wird alles zurueckgenommen (kein "on" ohne aktives Dokument) // Nachweis der Abo-Erklaerung (featureConsents) und Historie erst nach dem Dokument: scheitert einer, wird alles
// zurueckgenommen (kein "on" ohne Nachweis und ohne aktives Dokument)
await nachweis?.()
await analyseDeps.recordConsent(entry) await analyseDeps.recordConsent(entry)
} catch (err) { } catch (err) {
await analyseDeps.client await analyseDeps.client
@@ -480,6 +499,8 @@ export async function runAboCheck({ now = new Date() } = {}) {
try { try {
// Ein Lesefehler (Stripe/Appwrite) beendet nie eine Analyse // Ein Lesefehler (Stripe/Appwrite) beendet nie eine Analyse
if (await hasWartungsAbo(doc.customerId)) continue if (await hasWartungsAbo(doc.customerId)) continue
// Neue Ansicht (Abo-Modell): auch ein laufendes Hosting-Abo haelt die Analyse (gekuendigt zaehlt bis zum Abo-Ende); alle anderen wie bisher
if ((await aboModellFuerKunde(doc.customerId)) && (await aboLaeuftFuer(doc.customerId, FEATURE_KEY))) continue
const { changed } = await deactivateAnalyse({ customerId: doc.customerId, projectId: doc.projectId, now, by: 'system' }) const { changed } = await deactivateAnalyse({ customerId: doc.customerId, projectId: doc.projectId, now, by: 'system' })
if (!changed) continue if (!changed) continue
deactivated.push(doc.$id) deactivated.push(doc.$id)
+184
View File
@@ -0,0 +1,184 @@
import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { mailSink } from './support/mailSink.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
// Analyse im Abo-Modell (P5 Task 13): Hosting reicht, die Zielgruppen-Aussage ist serverseitig erzwungen, der Nachweis liegt in
// featureConsents, die Tagesprüfung lässt Hosting-Analysen der neuen Ansicht in Ruhe. Die alte Ansicht bleibt unverändert.
const ANALYSE_KEYS = ['accentColor', 'avvVersion', 'avvAcceptedAt', 'avvAcceptedBy', 'activatedAt', 'offSince', 'privacyTicketId', 'activatedMailAt', 'avvConsentDate', 'avvConsentChannel', 'removalTicketId', 'publishTicketId', 'audienceConfirmed', 'audienceTextVersion']
const CONSENT_KEYS = ['customerId', 'projectId', 'siteKey', 'event', 'avvVersion', 'via', 'acceptedAt', 'acceptedBy', 'textformDate', 'textformChannel', 'audienceConfirmed', 'audienceTextVersion']
const aw = await startAppwriteMock({
schema: {
portalFeatures: [...schemaKeys('portalFeatures'), ...ANALYSE_KEYS],
subscriptions: schemaKeys('subscriptions'),
contractEvents: schemaKeys('contractEvents'),
featureConsents: schemaKeys('featureConsents'),
analyseConsents: CONSENT_KEYS,
},
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, get, post } = await import('./support/testApp.js')
const { customerCookie, customers, hostingDoc, seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { default: funktionenRoutes } = await import('../server/routes/funktionen.js')
const an = await import('../server/services/analyse.js')
const { analyseUnterlagen } = await import('../server/services/aboUnterlagen.js')
const { aboModellFuerKunde } = await import('../server/services/aboFunktionen.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const app = await startTestApp({ '/api/funktionen': funktionenRoutes })
setMailTransportForTests(mailSink())
const vorher = {
neu: { mode: config.kundenansichtNeu.mode, pilotEmails: [...config.kundenansichtNeu.pilotEmails] },
analyse: config.analyse.enabled,
}
after(() => {
Object.assign(config.kundenansichtNeu, vorher.neu)
config.analyse.enabled = vorher.analyse
setMailTransportForTests(null)
return Promise.all([app.close(), aw.close(), st.close()])
})
const putSiteAufrufe = []
an.analyseDeps.client = {
putSite: async (siteKey, body) => putSiteAufrufe.push({ siteKey, ...body }),
stopSite: async () => {},
getStats: async () => ({ pv: 5 }),
heatToken: () => 'token',
}
an.analyseDeps.rebuild = async () => {}
config.analyse.enabled = true
const JETZT = new Date('2026-10-17T08:00:00.000Z')
const c1 = customerCookie('c1')
const abo = (doc) => aw.seed('subscriptions', subscriptionDoc('sub_h', doc))
const aktiveAnalyse = (c, p) => aw.seed('portalFeatures', { $id: `f_${p}_analyse`, customerId: c, projectId: p, featureKey: 'analyse', state: 'active', enabled: true, siteKey: 'abcdefghijklmnop' })
const consent = () => ({ accepted: true, avvVersion: analyseUnterlagen().avvVersion })
// aw.docs() liefert eine Kopie: Dokumente werden ueber die API geloescht
const leere = async (...collections) => {
for (const col of collections) for (const id of aw.docs(col).keys()) await deleteDocument(col, id)
}
beforeEach(async () => {
putSiteAufrufe.length = 0
await leere('portalFeatures', 'featureConsents', 'analyseConsents', 'subscriptions', 'websiteProjects', 'contractEvents', 'workorders')
seedFixtures(aw)
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei', subdomain: 'baeckerei', liveDomain: 'baeckerei.de', isPublic: true })
config.kundenansichtNeu.mode = 'pilot'
config.kundenansichtNeu.pilotEmails = ['k@example.invalid']
config.analyse.enabled = true
mock.method(console, 'error', () => {})
mock.method(console, 'warn', () => {})
mock.method(console, 'log', () => {})
})
afterEach(() => mock.restoreAll())
test('Eignung: Hosting zählt nur im Abo-Modell; gekündigt und kein Abo nicht', async () => {
abo(hostingDoc)
assert.deepEqual(await an.analyseEligibility('c1', 'p1'), { ok: false, reason: 'kein_abo' }) // alte Regel
const neu = await an.analyseEligibility('c1', 'p1', { aboModell: true })
assert.deepEqual([neu.ok, neu.project.$id], [true, 'p1'])
abo({ ...hostingDoc, cancelAtPeriodEnd: true })
assert.deepEqual(await an.analyseEligibility('c1', 'p1', { aboModell: true }), { ok: false, reason: 'kein_abo' })
await leere('subscriptions')
assert.equal((await an.analyseEligibility('c1', 'p1', { aboModell: true })).reason, 'kein_abo')
abo({ ...wartungDoc, status: 'trialing', trialEnd: '2099-01-01T05:00:00.000Z' }) // Probezeit: die Analyse-Eignung meldet weiter kein_abo
assert.equal((await an.analyseEligibility('c1', 'p1', { aboModell: true })).reason, 'kein_abo')
assert.equal(an.eligibilityMessage('kein_abo', { aboModell: true }), 'Die Website-Analyse gibt es nur mit einem laufenden Hosting- oder Wartungs-Abo.')
assert.equal(an.eligibilityMessage('kein_abo'), 'Die Website-Analyse gibt es nur mit einem laufenden Wartungs-Abo.')
assert.equal(an.eligibilityMessage('nicht_oeffentlich', { aboModell: true }), an.ELIGIBILITY_MESSAGES.nicht_oeffentlich)
assert.equal(an.ELIGIBILITY_MESSAGES_ABO.kein_abo, 'Die Website-Analyse gibt es nur mit einem laufenden Hosting- oder Wartungs-Abo.')
})
const aktivieren = (extra = {}, cookie = c1) => post('/api/funktionen/analyse/activate', { projectId: 'p1', consent: consent(), audienceConfirmed: true, ...extra }, { cookie })
const keinSchreiben = () => assert.deepEqual([aw.docs('featureConsents').size, putSiteAufrufe.length, aw.docs('portalFeatures').get('f_p1_analyse')?.state ?? 'off'], [0, 0, 'off'])
test('Liste und Aktivieren über die Routen: neue Ansicht mit Hosting ja, alte Ansicht nein', async () => {
abo(hostingDoc)
const b = await get('/api/funktionen', { cookie: c1 })
const a = b.projects.find((p) => p.projectId === 'p1').analyse
assert.deepEqual([a.eligible, a.reason, a.avvVersion, a.legal], [true, null, analyseUnterlagen().avvVersion, analyseUnterlagen().legal]) // Ansicht zeigt die Fassung, die der Server prüft
assert.equal((await aktivieren()).status, 200)
config.kundenansichtNeu.mode = 'off'
const alt = (await get('/api/funktionen', { cookie: c1 })).projects.find((p) => p.projectId === 'p1').analyse
assert.deepEqual([alt.eligible, alt.reasonText], [false, 'Die Website-Analyse gibt es nur mit einem laufenden Wartungs-Abo.'])
})
test('Zielgruppe serverseitig erzwungen: ohne audienceConfirmed true 400 zielgruppe_fehlt, nichts geschrieben', async () => {
abo(hostingDoc)
for (const audienceConfirmed of [undefined, false, 'true', 1, null]) {
const r = await aktivieren({ audienceConfirmed })
assert.deepEqual([r.status, r.body], [400, { error: 'Bitte bestätige, dass sich deine Website nicht gezielt an Kinder unter 16 Jahren richtet.', code: 'zielgruppe_fehlt' }], String(audienceConfirmed))
keinSchreiben()
}
assert.equal((await aktivieren({ consent: { accepted: true, avvVersion: '1999-01-01' } })).status, 400) // falsche Fassung: weiter abgelehnt, nichts geschrieben
keinSchreiben()
})
test('Nachweis der Aktivierung: Linkzeile, Zielgruppen-Zeile, Knopf, Fassungen und die Kundenart des Dokuments', async () => {
abo(hostingDoc)
for (const [kundenart, soll] of [['unternehmen', 'unternehmen'], ['privat', 'privat'], [undefined, 'privat'], ['kaputt', 'privat']]) {
await leere('portalFeatures', 'featureConsents')
aw.seed('customers', { ...customers.c1, ...(kundenart === undefined ? {} : { kundenart }) })
const r = await aktivieren({ kundenart: 'unternehmen', unternehmer: true })
assert.equal(r.status, 200, String(kundenart)) // Body-Werte zur Kundenart zählen nicht
const [n] = [...aw.docs('featureConsents').values()]
assert.equal(aw.docs('featureConsents').size, 1)
assert.deepEqual([n.featureKey, n.projectId, n.customerId, n.kundenart, n.stripeSessionId], ['analyse', 'p1', 'c1', soll, ''], String(kundenart))
assert.deepEqual(JSON.parse(n.texte), [
{ id: 'einschalten-zeile', text: `Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung ${analyseUnterlagen().avvVersion}).`, haken: false },
{ id: 'zielgruppe', text: 'Meine Website richtet sich nicht gezielt an Kinder unter 16 Jahren.', haken: true },
{ id: 'knopf', text: 'Analyse einschalten', haken: false },
])
assert.deepEqual(JSON.parse(n.fassungen), { zustimmung: config.aboFunktionen.fassung, leistungsbeschreibung: analyseUnterlagen().avvVersion, avv: analyseUnterlagen().avvVersion })
assert.equal(aw.docs('portalFeatures').get('f_p1_analyse').audienceConfirmed, true)
}
assert.equal((await aktivieren({ accentColor: '#222222' })).status, 200)
assert.equal(aw.docs('featureConsents').size, 1) // nur die Farbe einer aktiven Analyse: kein zweiter Nachweis
})
test('Nachweis scheitert: alles zurückgenommen (kein on ohne Nachweis), der nächste Versuch klappt', async () => {
abo(hostingDoc)
aw.failNext({ collection: 'featureConsents', method: 'POST', status: 500 })
const r = await aktivieren()
assert.equal(r.status, 500)
assert.deepEqual([aw.docs('featureConsents').size, aw.docs('portalFeatures').get('f_p1_analyse').state, putSiteAufrufe.map((a) => a.active)], [0, 'off', [true, false]]) // Messdienst angemeldet und wieder zurückgenommen
assert.equal((await aktivieren()).status, 200)
assert.equal(aw.docs('featureConsents').size, 1)
})
test('alte Ansicht unverändert: ohne audienceConfirmed 200, kein Eintrag in featureConsents', async () => {
config.kundenansichtNeu.mode = 'off'
abo({ ...wartungDoc })
assert.equal((await aktivieren({ audienceConfirmed: undefined })).status, 200)
assert.equal(aw.docs('featureConsents').size, 0)
})
test('Tagesprüfung: Hosting-Analyse bleibt für Kunden der neuen Ansicht, sonst endet sie wie bisher (K19)', async () => {
const stelle = (mode, pilotEmails = []) => Object.assign(config.kundenansichtNeu, { mode, pilotEmails })
const lauf = async () => {
aktiveAnalyse('c1', 'p1')
return (await an.runAboCheck({ now: JETZT })).deactivated.length
} // Kunde c1: k@example.invalid
abo(hostingDoc)
for (const [mode, liste, deaktiviert] of [['on', [], 0], ['pilot', ['k@example.invalid'], 0], ['pilot', ['*'], 0], ['pilot', ['k2@example.invalid'], 1], ['pilot', [], 1], ['off', ['*'], 1]]) {
stelle(mode, liste)
assert.equal(await lauf(), deaktiviert, `${mode} ${liste}`)
}
aw.seed('customers', { ...customers.c1, email: ' K@Example.Invalid ' })
stelle('pilot', ['k@example.invalid'])
assert.equal(await lauf(), 0, 'E-Mail des Kunden mit Großbuchstaben und Leerzeichen trifft die Liste')
aw.seed('customers', customers.c1)
stelle('on')
abo({ ...wartungDoc, cancelAtPeriodEnd: true })
assert.equal(await lauf(), 0, 'gekündigt, aber noch laufend')
abo({ ...wartungDoc, status: 'canceled' })
assert.equal(await lauf(), 1, 'beendet')
assert.equal(await aboModellFuerKunde('gibtEsNicht'), false) // kein Kundendokument: kein Abo-Modell
assert.equal(await aboModellFuerKunde(''), false)
})