From 66793d06d5bba4ebc66555ba22d976c3a47a3f24 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 02:03:49 +0000 Subject: [PATCH] =?UTF-8?q?=C3=96ffnungszeiten:=20Einschalten=20im=20Wartu?= =?UTF-8?q?ngs-Abo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/funktionen/zeiten/einschalten (Gratisweg, Spec 3.2): Abo frisch prüfen, Zustimmung (consentFrei) und Nachweis ohne Stripe-Sitzung, Zustand setup mit unlockedBy abo, dann Neubau, Einbau-Ticket und K24; ein zweiter Klick holt Fehlendes nach (holeZeitenNach, eigene Sperre). GET /api/funktionen liefert das Angebot (zeiten), features.zeiten mit unlockedBy, aboEndeAt, einbau, loeschungAm sowie zeitenStatus und zeitenLeer je Projekt. Co-Authored-By: Claude Opus 5.5 --- server/routes/funktionen.js | 163 ++++-- server/services/featureConsents.js | 44 ++ server/services/featureOrder.js | 47 +- server/services/siteFeatures.js | 37 +- server/services/zeiten/einschalten.js | 177 +++++++ test/featureConsents.test.js | 15 +- test/funktionenRoutes.test.js | 4 + test/kundenansichtGuard.test.js | 13 + test/zeitenEinschalten.test.js | 682 ++++++++++++++++++++++++++ 9 files changed, 1124 insertions(+), 58 deletions(-) create mode 100644 server/services/zeiten/einschalten.js create mode 100644 test/zeitenEinschalten.test.js diff --git a/server/routes/funktionen.js b/server/routes/funktionen.js index 9c862ff..71757e4 100644 --- a/server/routes/funktionen.js +++ b/server/routes/funktionen.js @@ -8,17 +8,21 @@ import { createFeatureCheckout, retrieveCheckoutSession } from '../services/stri import { assertOwnProject, backfillInvoiceLinks, + featureView, getFeatureDoc, hakenTexte, + listCustomerFeatureDocs, listCustomerFeatures, markOrdered, runActivation, } from '../services/siteFeatures.js' -import { nachweisTexte, speichereZustimmung, zustimmungFuerSitzung, zustimmungsfassungen } from '../services/featureConsents.js' +import { actingPerson, speichereNachweisAusAnfrage, zustimmungFuerSitzung } from '../services/featureConsents.js' import { termineFuerKunde } from '../services/termine/schalter.js' +import { zeitenFuerKunde } from '../services/zeiten/schalter.js' +import { einschaltenDeps, einschaltenZeiten, zeitenProjektAnsicht } from '../services/zeiten/einschalten.js' import { aboMemo } from '../services/aboStatus.js' -import { featureConfig, featureHaken, featureKeys, featureSperre, oeffentlicheAnsicht } from '../services/featureRegistry.js' -import { bestellFehler, istFortsetzen, pruefeAbo, pruefeZustimmung } from '../services/featureOrder.js' +import { featureConfig, featureHaken, featureKeys, featureSperre, freiweg, oeffentlicheAnsicht } from '../services/featureRegistry.js' +import { bestellFehler, istFortsetzen, oeffentlicheConsent, pruefeAbo, pruefeZustimmung } from '../services/featureOrder.js' import { ELIGIBILITY_MESSAGES, @@ -44,12 +48,6 @@ function fail(res, err, fallback) { return res.status(500).json({ error: fallback }) } -// Handelnde Person: bei Admin-Sitzung (adminName/adminEmail, siehe auth.js) der Admin, sonst der Kunde -const actingPerson = (session) => - session.adminName || session.adminEmail - ? { name: session.adminName, email: session.adminEmail } - : { name: session.name, email: session.email } - 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.' @@ -97,13 +95,13 @@ const ABO_GRUENDE = ['kein_abo', 'gekuendigt', 'nicht_aktiv'] * null), preis und einrichtung aus dem Register. Ein Lesefehler im Abo ergibt bestellbar: null und kostet die Liste nichts; im * Log stehen nur Art und Status, nie der Text (Appwrite-Texte koennten Namen oder IDs enthalten). */ -async function termineAngebot(email, customerId) { +async function termineAngebot(email, customerId, abos = aboMemo()) { if (!termineFuerKunde(email)) return { verfuegbar: false } const register = featureConfig('termine') let bestellbar = null let grund = null try { - const abo = await aboMemo().bestellbar(customerId) + const abo = await abos.bestellbar(customerId) bestellbar = abo?.ok === true grund = bestellbar || !ABO_GRUENDE.includes(abo?.grund) ? null : abo.grund } catch (err) { @@ -112,6 +110,51 @@ async function termineAngebot(email, customerId) { return { verfuegbar: true, bestellbar, grund, preis: register?.priceLabel ?? '', einrichtung: register?.setupEstimate ?? '' } } +/** + * Felder der Zeiten je Projekt in GET /api/funktionen: zeitenStatus (Kurzstatus der Website, null wenn nichts live ist oder nichts + * gespeichert wurde) und zeitenLeer (keine Plaene gespeichert). Nicht verfuegbar: null und true, ohne etwas zu lesen. Ein Lesefehler kostet die + * Liste nichts (null und false: ohne Wissen wird kein Hinweis "Zeiten eintragen" erzwungen); im Log nur Art und Status. + */ +async function zeitenListenfelder(docs, projectId, verfuegbar, jetzt) { + if (!verfuegbar) return { zeitenStatus: null, zeitenLeer: true } + const doc = docs.find((d) => d.projectId === projectId && d.featureKey === 'zeiten') + try { + return await zeitenProjektAnsicht(doc, jetzt) + } catch (err) { + console.error(`[funktionen] Zeiten für die Projektliste nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim()) + return { zeitenStatus: null, zeitenLeer: false } + } +} + +/** + * Oeffnungszeiten-Teil der Antwort von GET /api/funktionen (Spec 3.1). Nicht verfuegbar (Schalter, Pilotliste): genau { verfuegbar: false }, + * das Abo wird dann nicht gelesen. Verfuegbar: inklusive (true: laeuft und ist aktiv, die Funktion ist im Wartungs-Abo enthalten und + * einschaltbar; false: sonst, dann sagt grund warum: kein_abo | gekuendigt | nicht_aktiv; null: Abo nicht lesbar), grund (null bei true und + * null), preis (Register) und einschalten mit den Texten des Gratisweges (Zustimmung mit einem Haken, Knopf "Einschalten", Fassung der + * Leistungsbeschreibung). Ein Lesefehler im Abo kostet die Liste nichts; im Log stehen nur Art und Status, nie der Text. + */ +async function zeitenAngebot(email, customerId, abos) { + if (!zeitenFuerKunde(email)) return { verfuegbar: false } + const register = featureConfig('zeiten') + let inklusive = null + let grund = null + try { + const abo = await abos.bestellbar(customerId) + inklusive = abo?.ok === true + grund = inklusive || !ABO_GRUENDE.includes(abo?.grund) ? null : abo.grund + } catch (err) { + console.error(`[funktionen] Abo für Öffnungszeiten live nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim()) + } + const { version, url } = register.leistungsbeschreibung + return { + verfuegbar: true, + inklusive, + grund, + preis: register.priceLabel, + einschalten: { consent: oeffentlicheConsent(freiweg(register).consent), leistungsbeschreibung: { version, url: url ?? '' } }, + } +} + // Zustaende der Funktionen je Projekt; geschlossene Einrichtungs-Tickets dieses Kunden werden vorher freigeschaltet router.get('/', requireSession, async (req, res) => { try { @@ -119,7 +162,11 @@ router.get('/', requireSession, async (req, res) => { // Ein Fehler beim Freischalten (z. B. Ticketsystem) darf die Liste nicht verhindern await runActivation({ customerId }).catch((err) => console.error('[funktionen] Freischalten fehlgeschlagen:', err.message)) await backfillInvoiceLinks(customerId) - const projects = await listCustomerFeatures(customerId) + // Die Funktionsdokumente einmal lesen: die Ansicht je Projekt und der Zeiten-Status brauchen sie beide + const docs = await listCustomerFeatureDocs(customerId) + const projects = await listCustomerFeatures(customerId, { docs }) + const zeitenAn = zeitenFuerKunde(req.session.email) + const jetzt = einschaltenDeps.jetzt() // Ein Fehler bei einem Projekt (z. B. Abo-Pruefung) darf nicht die ganze Liste kosten await Promise.all( projects.map(async (p) => { @@ -127,8 +174,11 @@ router.get('/', requireSession, async (req, res) => { console.error('[funktionen] Analyse-Zustand fehlgeschlagen:', err.message) return { state: 'unknown' } }) + Object.assign(p, await zeitenListenfelder(docs, p.projectId, zeitenAn, jetzt)) }), ) + // Der Abo-Stand wird fuer Terminplaner und Oeffnungszeiten nur einmal gelesen + const abos = aboMemo() res.json({ projects, // Preise und Bestelltexte je Funktion aus dem Register (nur die oeffentliche Ansicht, auch wenn der Terminplaner @@ -140,7 +190,10 @@ router.get('/', requireSession, async (req, res) => { consentText: config.funktionen.consentText, // Terminplaner: Schalter TERMINE_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel, dann auch // mit dem Abo des Kunden (bestellbar, grund) und dem Preis des Registers - termine: await termineAngebot(req.session.email, customerId), + termine: await termineAngebot(req.session.email, customerId, abos), + // Oeffnungszeiten live: Schalter ZEITEN_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel, dann mit + // dem Abo-Stand (inklusive, grund), dem Preis und den Texten des Gratisweges "Einschalten" + zeiten: await zeitenAngebot(req.session.email, customerId, abos), }) } catch (err) { fail(res, err, 'Die Funktionen konnten gerade nicht geladen werden. Bitte lade die Seite gleich noch einmal.') @@ -171,32 +224,14 @@ async function openOrderSession(doc, feature) { const ZUSTIMMUNG_NOETIG = 'Bitte stimme der Erklärung zu, um zu bestellen.' -/** "Name " der handelnden Person (oder was davon da ist); das Speichern kuerzt auf die Feldgroesse. */ -const kontoLabel = (person) => (person?.name && person?.email ? `${person.name} <${person.email}>` : person?.name || person?.email || '') - -/** - * Zustimmungsnachweis zu einer Stripe-Sitzung speichern: die Serverfassungen und -texte der Funktion (nie Texte aus dem - * Client), dazu die Belehrung aus dem Haken und die Zeit des Klicks. Jeder Fehler hier ist ein Serverfehler: die Route - * antwortet mit dem allgemeinen Text (auch bei Pruefungen des Nachweises wie "Texte zu lang"), der Rohtext steht nur im Log. - */ -async function speichereNachweis({ feature, haken, customerId, projectId, sessionId, req, klickAt }) { - const belehrung = (await haken.belehrung?.()) ?? null - const texte = nachweisTexte(feature, { belehrung }) - try { - await speichereZustimmung({ - customerId, - projectId, - featureKey: feature.key, - stripeSessionId: sessionId, - fassungen: zustimmungsfassungen(feature), - texte, - ip: req.ip, - userAgent: req.get('user-agent'), - konto: kontoLabel(actingPerson(req.session)), - at: klickAt, - }) - } catch (err) { - throw Object.assign(new Error(`Zustimmungsnachweis nicht gespeichert: ${err?.message}`), { status: 500, cause: err }) +/** Pruefstufe "Vertragsunterlagen abrufbar" (Haken unterlagenBereit der Funktion): sonst 503 unterlagen_fehlen, vor jeder Stripe-Sitzung und jedem Dokument. */ +async function pruefeUnterlagen(haken) { + if (haken.unterlagenBereit && !(await haken.unterlagenBereit())) { + throw bestellFehler( + 503, + 'Die Vertragsunterlagen sind gerade nicht abrufbar. Bitte versuch es später noch einmal oder schreib uns im Chat.', + 'unterlagen_fehlen' + ) } } @@ -236,13 +271,7 @@ router.post('/:key/checkout', requireSession, async (req, res) => { if (sperre) throw bestellFehler(409, sperre, 'nicht_verfuegbar') // 5b Vertragsunterlagen abrufbar (vor jeder Stripe-Sitzung) const haken = featureHaken(key) - if (haken.unterlagenBereit && !(await haken.unterlagenBereit())) { - throw bestellFehler( - 503, - 'Die Vertragsunterlagen sind gerade nicht abrufbar. Bitte versuch es später noch einmal oder schreib uns im Chat.', - 'unterlagen_fehlen' - ) - } + await pruefeUnterlagen(haken) // 6 Bestellpruefung: offene Punkte des Entwurfs. Ohne abo: den Abo-Punkt (h) wendet der Checkout nicht an, das tut Stufe 7 if (haken.bestellpruefung) { const offen = await haken.bestellpruefung({ customerId, projectId, feature }) @@ -264,7 +293,7 @@ router.post('/:key/checkout', requireSession, async (req, res) => { } if (existing?.state === 'disabled') throw Object.assign(new Error(feature.disabledMessage), { status: 409 }) - const nachweis = (sessionId) => speichereNachweis({ feature, haken, customerId, projectId, sessionId, req, klickAt }) + const nachweis = (sessionId) => speichereNachweisAusAnfrage(req, { feature, haken, customerId, projectId, sessionId, klickAt }) // b eine offene Bestellung richtet sich nach ihrer gespeicherten Stripe-Sitzung: offen -> dieselbe Zahlungsseite const open = existing?.state === 'ordered' ? await openOrderSession(existing, feature) : null @@ -303,6 +332,46 @@ router.post('/:key/checkout', requireSession, async (req, res) => { } }) +const EINSCHALTEN_ERROR = 'Öffnungszeiten live konnte gerade nicht eingeschaltet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.' + +// Gratisweg: Oeffnungszeiten live im Wartungs-Abo einschalten (Spec 3.2). Vorpruefungen wie im Checkout, jede Stufe ohne Seiteneffekt: +// 1 Kundenansicht, 2 Zustimmung (Gratis-Fassung: ein Haken, Knopf "Einschalten"), 3 Projekt (eigen, nicht archiviert), +// 4 Schalter/Pilot, 5 Unterlagen. Das Abo wird erst unter der Sperre frisch geprueft (einschaltenZeiten), dort entstehen auch Nachweis, +// Zustand, Neubau, Ticket und K24; ein zweiter Klick holt Fehlendes nach. +router.post('/zeiten/einschalten', requireSession, async (req, res) => { + const now = new Date() + try { + // 1 Kundenansicht: Mitarbeiter schalten nicht fuer den Kunden ein + if (req.session.viewAs) return res.status(403).json({ error: 'In der Kundenansicht kann nicht eingeschaltet werden.' }) + const feature = featureConfig('zeiten') + const body = req.body || {} + // 2 Zustimmung: Haken und aktuelle Fassung der Gratis-Zustimmung (consentFrei) + pruefeZustimmung(freiweg(feature), body) + // 3 Projekt + const { projectId } = body + if (typeof projectId !== 'string' || !projectId) return res.status(400).json({ error: 'Bitte wähle ein Projekt.' }) + const customerId = getSessionCustomerId(req) + await assertOwnProject(customerId, projectId, { forNewOrder: true }) + // 4 Schalter (ZEITEN_ENABLED und Pilotliste) + const sperre = featureSperre('zeiten', req.session.email) + if (sperre) throw bestellFehler(409, sperre, 'nicht_verfuegbar') + // 5 Vertragsunterlagen abrufbar + await pruefeUnterlagen(featureHaken('zeiten')) + + const { doc } = await einschaltenZeiten({ + customerId, + projectId, + by: 'kunde', + now, + // Nachweis der Gratis-Zustimmung ohne Stripe-Sitzung und ohne Belehrung (es gibt kein Widerrufsverlangen) + nachweis: () => speichereNachweisAusAnfrage(req, { feature: freiweg(feature), customerId, projectId, klickAt: now.toISOString() }), + }) + res.json({ feature: await featureView(doc, 'zeiten'), einbau: doc.einbau || null }) + } catch (err) { + fail(res, err, EINSCHALTEN_ERROR) + } +}) + const analyseGuard = [requireSession, requireAnalyseEnabled] router.post('/analyse/activate', analyseGuard, async (req, res) => { diff --git a/server/services/featureConsents.js b/server/services/featureConsents.js index 875f733..7d87138 100644 --- a/server/services/featureConsents.js +++ b/server/services/featureConsents.js @@ -158,3 +158,47 @@ export function nachweisTexte(feature, { belehrung = null } = {}) { texte.push({ id: 'knopf', text: feature.consent.knopf, haken: false }) return texte } + +// --------------------------------------------------------------------------------------------------------------- +// Nachweis aus einer Anfrage (Checkout und Gratisweg "Einschalten"): Serverfassungen und -texte der Funktion, IP, Browser und Konto +// aus der Anfrage. Hier statt in der Route, damit beide Wege denselben Nachweis schreiben. +// --------------------------------------------------------------------------------------------------------------- + +/** Handelnde Person: bei Admin-Sitzung (adminName/adminEmail, siehe auth.js) der Admin, sonst der Kunde. */ +export const actingPerson = (session) => + session.adminName || session.adminEmail + ? { name: session.adminName, email: session.adminEmail } + : { name: session.name, email: session.email } + +/** "Name " der handelnden Person (oder was davon da ist); das Speichern kuerzt auf die Feldgroesse. */ +const kontoLabel = (person) => (person?.name && person?.email ? `${person.name} <${person.email}>` : person?.name || person?.email || '') + +/** + * Zustimmungsnachweis aus einer Anfrage speichern: die Serverfassungen und -texte der Funktion (nie Texte aus dem Client), dazu + * (falls der Haken `belehrung` der Funktion einen Text liefert) die Belehrung und die Zeit des Klicks. Jeder Fehler hier ist ein + * Serverfehler: die Route antwortet mit dem allgemeinen Text (auch bei Pruefungen des Nachweises wie "Texte zu lang"), der Rohtext + * steht nur im Log. Ohne sessionId (Gratisweg) traegt der Nachweis keine Stripe-Sitzung. + * @param {object} req Express-Anfrage (ip, User-Agent, Sitzung) + * @param {{ feature: object, haken?: object, customerId: string, projectId: string, sessionId?: string, klickAt: string }} p + * feature: Registereintrag, beim Gratisweg freiweg(feature) (Zustimmung consentFrei) + */ +export async function speichereNachweisAusAnfrage(req, { feature, haken = {}, customerId, projectId, sessionId, klickAt }) { + const belehrung = (await haken.belehrung?.()) ?? null + const texte = nachweisTexte(feature, { belehrung }) + try { + await speichereZustimmung({ + customerId, + projectId, + featureKey: feature.key, + stripeSessionId: sessionId, + fassungen: zustimmungsfassungen(feature), + texte, + ip: req.ip, + userAgent: req.get('user-agent'), + konto: kontoLabel(actingPerson(req.session)), + at: klickAt, + }) + } catch (err) { + throw Object.assign(new Error(`Zustimmungsnachweis nicht gespeichert: ${err?.message}`), { status: 500, cause: err }) + } +} diff --git a/server/services/featureOrder.js b/server/services/featureOrder.js index 36f8b9b..fb0c2f6 100644 --- a/server/services/featureOrder.js +++ b/server/services/featureOrder.js @@ -1,6 +1,6 @@ import { config } from '../config.js' -import { aboMemo } from './aboStatus.js' -import { oeffentlicheAnsicht } from './featureRegistry.js' +import { aboMemo, bestellbar } from './aboStatus.js' +import { featureConfig } from './featureRegistry.js' import { istObjekt } from './termine/ablauf.js' import { holeOderNull, ladeEinstellung } from './termine/kontext.js' @@ -31,6 +31,16 @@ const ZUSTIMMUNG_FEHLT = 'Bitte stimme der Erklärung zu, um zu bestellen.' const FASSUNG_VERALTET = 'Die Erklärungen wurden inzwischen geändert. Bitte lies sie noch einmal und stimme neu zu.' const MAX_HAKEN = 50 +/** + * Sicht der Zustimmung fuer den Browser (Bestellseite, Antwort bei veralteter Fassung): Fassung, Knopf und alle Texte; ob ein Punkt + * einen Haken braucht, steht je Punkt. Fuer feature.consent und, beim Gratisweg, freiweg(feature).consent. + */ +export const oeffentlicheConsent = (consent) => ({ + version: consent.version, + knopf: consent.knopf, + items: consent.items.map((item) => ({ id: item.id, text: item.text, haken: Boolean(item.haken) })), +}) + const fehlt = () => bestellFehler(400, ZUSTIMMUNG_FEHLT, 'zustimmung_fehlt') const hatWert = (wert) => wert !== undefined && wert !== null @@ -63,7 +73,8 @@ export function pruefeZustimmung(feature, eingabe) { const { version, haken } = eingabe.zustimmung if (typeof version !== 'string' || !Array.isArray(haken) || haken.length > MAX_HAKEN) throw fehlt() if (version !== feature.consent.version) { - throw bestellFehler(409, FASSUNG_VERALTET, 'fassung_veraltet', { consent: oeffentlicheAnsicht(feature.key).consent }) + // die Fassung des geprueften Eintrags (beim Gratisweg die Gratis-Zustimmung), nicht die des Registers + throw bestellFehler(409, FASSUNG_VERALTET, 'fassung_veraltet', { consent: oeffentlicheConsent(feature.consent) }) } if (!haken.every((id) => typeof id === 'string' && ids.includes(id))) throw fehlt() const gesetzt = new Set(haken) @@ -132,3 +143,33 @@ export async function pruefeAbo(feature, customerId) { if (!Object.hasOwn(ABO_TEXTE, grund) || grund === 'unbekannt') throw new Error('Abo-Prüfung ohne gültiges Ergebnis') throw bestellFehler(409, aboMeldung(feature, grund), grund, { grund }) } + +const GRATIS_ABO_TEXT = { + kein_abo_gekuendigt: (name, preis) => `Ohne laufendes Wartungs-Abo kannst Du ${name} für einmalig ${preis} bestellen.`, + nicht_aktiv: () => 'Einschaltbar, sobald Dein Abo aktiv ist.', +} +const GERADE_NICHT_PRUEFBAR = 'Gerade nicht prüfbar – bitte versuch es gleich noch einmal.' + +/** + * Abo-Pruefung des Gratisweges "Einschalten" von Oeffnungszeiten live (Spec 3.2 Schritt 1): immer frisch gelesen (bestellbar direkt, + * nie aboMemo), gedacht fuer den Aufruf unter der Sperre feature-order. Laeuft das Wartungs-Abo und ist es aktiv, geschieht nichts. + * kein_abo und gekuendigt: 409 (code und body.grund wie der Grund) mit dem Hinweis auf den 30-Euro-Weg; nicht_aktiv: 409 "Einschaltbar, + * sobald Dein Abo aktiv ist." Ein Lesefehler ist 503 (fail-closed, nie stillschweigend erlaubt); im Log stehen nur Art und Status. + */ +export async function pruefeAboFuerEinschalten(customerId) { + const feature = featureConfig('zeiten') + let ergebnis + try { + ergebnis = await bestellbar(customerId) + } catch (err) { + console.error(`[funktionen] Abo für das Einschalten nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim()) + throw Object.assign(new Error(GERADE_NICHT_PRUEFBAR), { status: 503 }) + } + if (ergebnis?.ok === true) return + const grund = ergebnis?.grund + if (grund === 'nicht_aktiv') throw bestellFehler(409, GRATIS_ABO_TEXT.nicht_aktiv(), grund, { grund }) + if (grund === 'kein_abo' || grund === 'gekuendigt') { + throw bestellFehler(409, GRATIS_ABO_TEXT.kein_abo_gekuendigt(feature.label.name, feature.priceLabel), grund, { grund }) + } + throw new Error('Abo-Prüfung ohne gültiges Ergebnis') +} diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index 549160f..9a08688 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -71,7 +71,11 @@ export async function getFeatureDoc(customerId, projectId, key) { } } -export async function assertOwnProject(customerId, projectId) { +/** + * Das eigene Projekt des Kunden (sonst 403). forNewOrder: fuer alles, was eine Funktion neu einschaltet oder bestellt; ein archiviertes + * Projekt ist dann 409 (Spec 11: eine laufende Funktion laeuft weiter, aber Neubestellung ist gesperrt). + */ +export async function assertOwnProject(customerId, projectId, { forNewOrder = false } = {}) { let project = null try { project = await getDocument(config.collections.websiteProjects, projectId) @@ -79,6 +83,7 @@ export async function assertOwnProject(customerId, projectId) { if (err.status !== 404) throw err } if (!project || !customerId || project.customerId !== customerId) throw httpError(403, 'Kein Zugriff auf dieses Projekt') + if (forNewOrder && project.archived === true) throw httpError(409, 'Dieses Projekt ist archiviert. Dafür kann keine Funktion neu eingeschaltet werden.') return project } @@ -109,7 +114,8 @@ export async function ensureFeatureDoc(customerId, projectId, key) { } } -async function ticketWoid(ticketId) { +/** Ticketnummer (woid) zur Ticket-ID; ein unbekanntes Ticket ergibt null. */ +export async function ticketWoid(ticketId) { if (!ticketId) return null try { const ticket = await getDocument(config.collections.workorders, ticketId) @@ -120,16 +126,28 @@ async function ticketWoid(ticketId) { } } +/** + * Wodurch die Funktion freigeschaltet wurde: 'kauf' | 'abo' | 'team'. Altbestand ohne unlockedBy: unlockedByPurchase => 'kauf', sonst + * 'team' (Kulanz, Pilot, Team-Aktivierung). + */ +export const unlockedByVon = (doc) => doc?.unlockedBy || (doc?.unlockedByPurchase ? 'kauf' : 'team') + /** * Ansicht einer Funktion fuer Kunde und Team. Abschaltgrund (disabledReason, ohne Grund = Altbestand => null), Zeitpunkt * (disabledAt) und Berliner Loeschtag (loeschungAm, 30 Tage danach, termine/frist.js) gibt es nur im Zustand `disabled`; * in jedem anderen Zustand sind alle drei null, auch wenn am Dokument noch Reste einer frueheren Abschaltung stehen. * loeschungAm gibt es nur fuer den Terminplaner (R105: der 30-Tage-Loeschlauf in jobs.js loescht nur Terminplaner-Daten, - * bei News verspraeche das Datum eine Loeschung, die nie kommt) und ist null, wenn disabledAt fehlt oder nicht lesbar ist. + * bei News verspraeche das Datum eine Loeschung, die nie kommt) und fuer Oeffnungszeiten live, dort nur beim Abschaltgrund + * abo_ende (bei einer Abschaltung durch das Team gibt es keine Loeschfrist); es ist null, wenn disabledAt fehlt oder nicht lesbar ist. * disabledReason und disabledAt gelten fuer jede Funktion. + * Funktionen mit aboInklusive (Oeffnungszeiten live) tragen zusaetzlich unlockedBy (kauf | abo | team, null solange die Funktion + * aus ist), aboEndeAt (Ende des Wartungs-Abos erkannt) und einbau (angestossen | manuell, Ergebnis des Neubaus). + * key: Funktionsschluessel (auch ohne Dokument); ohne Angabe der des Dokuments. */ -async function featureView(doc) { +export async function featureView(doc, key = doc?.featureKey) { const abgeschaltet = doc?.state === 'disabled' + const mitAbo = featureConfig(key)?.aboInklusive === true + const loeschtag = abgeschaltet && (key === 'termine' || (mitAbo && doc.disabledReason === 'abo_ende')) return { state: doc?.state || 'off', siteKey: doc?.siteKey || null, @@ -139,7 +157,14 @@ async function featureView(doc) { invoiceUrl: doc?.invoiceUrl || null, disabledReason: abgeschaltet ? doc.disabledReason || null : null, disabledAt: abgeschaltet ? doc.disabledAt || null : null, - loeschungAm: abgeschaltet && doc.featureKey === 'termine' ? loeschTag(doc.disabledAt) : null, + loeschungAm: loeschtag ? loeschTag(doc.disabledAt) : null, + ...(mitAbo + ? { + unlockedBy: doc && doc.state !== 'off' ? unlockedByVon(doc) : null, + aboEndeAt: doc?.aboEndeAt || null, + einbau: doc?.einbau || null, + } + : {}), } } @@ -161,7 +186,7 @@ export async function listCustomerFeatures(customerId, { docs: geladen } = {}) { const features = {} for (const key of FEATURE_KEYS) { const doc = docs.find((d) => d.projectId === p.$id && d.featureKey === key) - features[key] = await featureView(doc) + features[key] = await featureView(doc, key) } result.push({ projectId: p.$id, diff --git a/server/services/zeiten/einschalten.js b/server/services/zeiten/einschalten.js new file mode 100644 index 0000000..7381c3e --- /dev/null +++ b/server/services/zeiten/einschalten.js @@ -0,0 +1,177 @@ +import { config } from '../../config.js' +import { getDocument, updateDocument } from '../appwriteAdmin.js' +import { pruefeAboFuerEinschalten } from '../featureOrder.js' +import { featureConfig, ticketTexte } from '../featureRegistry.js' +import { withLocks } from '../keyedLock.js' +import { customerOf, ensureFeatureDoc, getFeatureDoc, stateFields, ticketWoid, unlockedByVon } from '../siteFeatures.js' +import { createFeatureTicket } from '../ticketAdmin.js' +import { k24Mail, sendeZeitenMail } from './mails.js' +import { stosseNeubauAn } from './neubau.js' +import { ladeZeiten } from './speicher.js' +import { hauptplan, statusZu } from './zeitlogik.js' + +/** + * Gratisweg "Einschalten" von Oeffnungszeiten live im Wartungs-Abo (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, 3.2) + * und die Selbstheilung der Schritte danach. Vorbild ist activateAnalyse (analyse.js). + * + * einschaltenZeiten Schritte 1-4 unter der Sperre `feature-order::` (dieselbe wie im Checkout, damit sich + * Bestellen und Einschalten ausschliessen): Abo frisch pruefen, Zustand pruefen (idempotent), Nachweis speichern, + * Dokument auf setup. Danach, ohne diese Sperre: holeZeitenNach (Neubau, Ticket, K24). + * holeZeitenNach Schritte 5 und 6, jeder fuer sich nur, wenn er noch fehlt: Neubau (einbau), Einbau-Ticket, K24, dann k24At. + * Eigene Sperre `zeiten-nachholen:`: ein Doppelklick und der 5-Minuten-Job legen nie zwei Tickets an. + * + * Sperren (keyedLock) sind nicht wiederbetretbar: stosseNeubauAn nimmt seine eigene Sperre `zeiten-neubau`, darum laufen Neubau, Ticket + * und Mail nie innerhalb von `feature-order`. Aufrufer, die `feature-order` oder `zeiten-nachholen` halten, duerfen diese Funktionen + * nicht aufrufen. + */ + +const KEY = 'zeiten' + +/** Einhaengepunkt fuer Tests: die Uhr der Projektliste (GET /api/funktionen), die den Kurzstatus der Website berechnet. */ +export const einschaltenDeps = { jetzt: () => new Date() } + +const NOTIZ_MAX = 300 +const NICHT_FERTIG = 'Öffnungszeiten live ist eingeschaltet, aber das Einrichten ist noch nicht fertig. Bitte versuch es in einer Minute noch einmal.' + +const konflikt = (text) => Object.assign(new Error(text), { status: 409 }) + +/** Notiz am Dokument: die der Route (staff.adminNote) oder " : Eingeschaltet ohne Zahlung" vor der bisherigen Notiz. */ +function notizDesTeams(staff, bisherige, now) { + if (typeof staff?.adminNote === 'string' && staff.adminNote) return staff.adminNote.slice(0, NOTIZ_MAX) + if (!staff?.name) return null + const eintrag = `${now.toISOString()} ${staff.name}: Eingeschaltet ohne Zahlung` + return (bisherige ? `${eintrag} | ${bisherige}` : eintrag).slice(0, NOTIZ_MAX) +} + +/** + * Oeffnungszeiten live fuer ein Projekt einschalten (Spec 3.2, Schritte 1 bis 6). + * @param {{ customerId: string, projectId: string, by?: 'kunde'|'team', staff?: { name?: string, adminNote?: string }|null, + * nachweis?: (() => Promise)|null, now?: Date }} p + * by 'kunde': Abo frisch pruefen (kein_abo/gekuendigt/nicht_aktiv 409, Lesefehler 503), unlockedBy 'abo', K24 geht an den Kunden. + * by 'team': keine Abo-Pruefung, unlockedBy 'team', keine K24; staff ergibt die Notiz am Dokument. + * nachweis: speichert den Zustimmungsnachweis (Schritt 3, nur wenn wirklich eingeschaltet wird); wirft er, bleibt der Zustand unveraendert. + * @returns {Promise<{ doc: object, changed: boolean }>} doc: das Dokument nach dem Nachholen; changed: false bei idempotentem Aufruf + * @throws {Error} 409 (Abo, anderer Zustand), 503 (Abo nicht lesbar; oder Zustand gespeichert, aber Neubau/Ticket nicht fertig: erneut aufrufen) + */ +export async function einschaltenZeiten({ customerId, projectId, by = 'kunde', staff = null, nachweis = null, now = new Date() } = {}) { + if (by !== 'kunde' && by !== 'team') throw new TypeError(`einschaltenZeiten: by muss kunde oder team sein, nicht ${String(by)}`) + const feature = featureConfig(KEY) + + const { doc, changed } = await withLocks([`feature-order:${customerId}:${projectId}`], async () => { + // 1 Abo frisch (nie aus einem Zwischenspeicher); fuer das Team entfaellt es + if (by === 'kunde') await pruefeAboFuerEinschalten(customerId) + + // 2 Zustand: schon eingeschaltet (setup/active) ist idempotent; sonst nur aus off (der Kunde) bzw. off/ordered/disabled (das Team) + const bisher = await getFeatureDoc(customerId, projectId, KEY) + if (bisher?.state === 'setup' || bisher?.state === 'active') { + if (by === 'team' || unlockedByVon(bisher) === 'abo') return { doc: bisher, changed: false } + throw konflikt(feature.orderedConflictMessage) + } + if (by === 'kunde' && bisher?.state === 'ordered') throw konflikt(feature.orderedConflictMessage) + if (by === 'kunde' && bisher?.state === 'disabled') throw konflikt(feature.disabledMessage) + + // 3 Zustimmung speichern (ohne Stripe-Sitzung), vor dem Zustand: scheitert sie, bleibt alles wie es war + if (nachweis) await nachweis() + + // 4 Dokument auf setup. Reste einer frueheren Abschaltung oder eines Abo-Endes gehoeren zum alten Lauf und enden hier; + // einbau beginnt leer, der Neubau (holeZeitenNach) setzt es neu. + const vorhanden = bisher ?? (await ensureFeatureDoc(customerId, projectId, KEY)) + const notiz = by === 'team' ? notizDesTeams(staff, vorhanden.adminNote, now) : null + const gespeichert = await updateDocument(config.collections.portalFeatures, vorhanden.$id, { + ...stateFields('setup'), + featureKey: KEY, + unlockedBy: by === 'kunde' ? 'abo' : 'team', + unlockedByPurchase: false, + einbau: '', + aboEndeAt: null, + behaltenSessionId: '', + disabledAt: null, + disabledReason: null, + aboEndeMailAt: null, + ...(notiz ? { adminNote: notiz } : {}), + }) + return { doc: gespeichert, changed: true } + }) + + // 5 und 6 nach der Sperre: Neubau, Ticket, K24 + try { + return { doc: await holeZeitenNach(doc, { now }), changed } + } catch (err) { + // der Zustand ist gespeichert; ein erneuter Aufruf (oder der 5-Minuten-Job) holt nach. Im Log Art und Meldung, nie beim Kunden. + console.error(`[zeiten] Einschalten für Projekt ${projectId}: Nachholen fehlgeschlagen: ${err?.name ?? 'Fehler'} ${err?.status ?? ''} ${err?.message ?? ''}`.trim()) + throw Object.assign(new Error(NICHT_FERTIG), { status: 503, cause: err }) + } +} + +/** + * Fehlende Schritte des Einschaltens nachholen (Spec 3.2 Schritt 5/6 und Selbstheilung): fuer ein Dokument im Zustand setup, das nicht + * vom Kauf stammt (unlockedBy kauf bringt markPaid in Ordnung): + * 1. einbau leer => Neubau anstossen (stosseNeubauAn), Ergebnis in einbau + * 2. ticketId leer => Einbau-Ticket aus dem Register (Text mit dem frischen einbau), ticketId + * 3. unlockedBy abo und k24At leer => K24 an den Kunden, danach k24At. Gesendet wird erst nach dem Ticket (die Mail nennt es); + * nicht gesendet (Fehler) laesst k24At leer, ein Kunde ohne Adresse oder ein geloeschter Kunde bekommt k24At (es gibt nichts zu senden). + * Jeder Schritt erst nach dem Erfolg des vorigen. Liest das Dokument unter der Sperre frisch; andere Zustaende bleiben unberuehrt. + * Wirft bei Lesefehlern und wenn das Ticket nicht angelegt werden kann (der Aufrufer faengt und versucht es spaeter); eine + * nicht gesendete K24 wirft nicht. + * @param {{ $id: string }} doc portalFeatures-Dokument der Zeiten (nur $id zaehlt) + * @param {{ now?: Date }} [options] + * @returns {Promise} das aktuelle Dokument + */ +export async function holeZeitenNach(doc, { now = new Date() } = {}) { + if (!doc?.$id) return doc + return withLocks([`zeiten-nachholen:${doc.$id}`], async () => { + let frisch = await getDocument(config.collections.portalFeatures, doc.$id) + if (frisch.state !== 'setup' || unlockedByVon(frisch) === 'kauf') return frisch + const mitK24 = unlockedByVon(frisch) === 'abo' + // nichts fehlt: weder Projekt noch Kunde lesen (der 5-Minuten-Job fragt jedes Dokument in setup) + if (frisch.einbau && frisch.ticketId && !(mitK24 && !frisch.k24At)) return frisch + const feature = featureConfig(KEY) + // das Projekt frisch: der Neubau und der Text des Tickets brauchen den Stand nach dem Speichern des Zustands + const project = await getDocument(config.collections.websiteProjects, frisch.projectId) + + if (!frisch.einbau) { + const einbau = await stosseNeubauAn(project) + frisch = await updateDocument(config.collections.portalFeatures, frisch.$id, { einbau }) + } + + const customer = await customerOf(frisch.customerId) + if (!frisch.ticketId) { + const texte = await ticketTexte({ feature, doc: frisch, project, customer }) + const { ticketId } = await createFeatureTicket({ customerId: frisch.customerId, project, featureKey: KEY, siteKey: frisch.siteKey, ...texte }) + frisch = await updateDocument(config.collections.portalFeatures, frisch.$id, { ticketId }) + } + + if (mitK24 && !frisch.k24At) { + const nachricht = k24Mail({ + kunde: customer, + projekt: project?.projectName || project?.subdomain || '', + einbau: frisch.einbau, + wieder: false, + ticketNr: await ticketWoid(frisch.ticketId).catch(() => null), + }) + const { sent, reason } = await sendeZeitenMail(customer, nachricht) + if (sent || reason === 'keine_adresse') { + frisch = await updateDocument(config.collections.portalFeatures, frisch.$id, { k24At: now.toISOString() }) + } + } + return frisch + }) +} + +/** + * Teil der Projektliste (GET /api/funktionen) zu den Zeiten eines Projekts: { zeitenStatus, zeitenLeer }. Eine Abfrage je Projekt mit + * Funktionsdokument (nicht je Kachel im Browser). zeitenLeer: es sind keine Plaene gespeichert (auch ohne Dokument oder im Zustand off). + * zeitenStatus { offen, kurz, text } (Hauptplan, wie ihn die Website zeigt): nur in setup und active und nur mit gespeichertem + * Hauptplan, sonst null. Wirft bei Lesefehlern. + * @param {object|null|undefined} doc portalFeatures-Dokument der Zeiten + * @param {Date} now + */ +export async function zeitenProjektAnsicht(doc, now) { + if (!doc || doc.state === 'off') return { zeitenStatus: null, zeitenLeer: true } + const daten = (await ladeZeiten(doc.$id))?.daten ?? null + const leer = !Array.isArray(daten?.plaene) || daten.plaene.length === 0 + const haupt = !leer && (doc.state === 'setup' || doc.state === 'active') ? hauptplan(daten) : null + if (!haupt) return { zeitenStatus: null, zeitenLeer: leer } + const { offen, kurz, text } = statusZu(daten, haupt, now) + return { zeitenStatus: { offen, kurz, text }, zeitenLeer: false } +} diff --git a/test/featureConsents.test.js b/test/featureConsents.test.js index 543b228..ae11703 100644 --- a/test/featureConsents.test.js +++ b/test/featureConsents.test.js @@ -85,10 +85,21 @@ test('speichert den Zeitpunkt aus einem Date-Objekt als ISO-Text', async () => { assert.equal(doc.acceptedAt, '2026-10-03T12:34:56.789Z') }) -test('append-only: das Modul exportiert genau die sieben Lese- und Anfügefunktionen', () => { +// Dazu seit Aufgabe 16 der Nachweis aus einer Anfrage (speichereNachweisAusAnfrage, Checkout und Gratisweg) und die handelnde Person (actingPerson) +test('append-only: das Modul exportiert genau die Lese- und Anfügefunktionen', () => { assert.deepEqual( Object.keys(consents).sort(), - ['leseZustimmung', 'letzteZustimmung', 'nachweisTexte', 'speichereZustimmung', 'texteHash', 'zustimmungFuerSitzung', 'zustimmungsfassungen'] + [ + 'actingPerson', + 'leseZustimmung', + 'letzteZustimmung', + 'nachweisTexte', + 'speichereNachweisAusAnfrage', + 'speichereZustimmung', + 'texteHash', + 'zustimmungFuerSitzung', + 'zustimmungsfassungen', + ] ) assert.equal(Object.keys(consents).filter((name) => /update|delete|remove|aender|loesch/i.test(name)).length, 0) }) diff --git a/test/funktionenRoutes.test.js b/test/funktionenRoutes.test.js index acbc93f..c3c4e8b 100644 --- a/test/funktionenRoutes.test.js +++ b/test/funktionenRoutes.test.js @@ -67,6 +67,10 @@ test('liste_zustaende', async () => { // Terminplaner: zweite Funktion je Projekt, Schalter TERMINE_ENABLED (Vorgabe off) - Einzelheiten in funktionenTermine.test.js for (const p of body.projects) assert.equal(p.features.termine.state, 'off') assert.deepEqual(body.termine, { verfuegbar: false }) + // Oeffnungszeiten live: Schalter ZEITEN_ENABLED (Vorgabe off): genau { verfuegbar: false } und je Projekt ohne Status, leer + // (Angebot, Ansicht und Status: test/zeitenEinschalten.test.js) + assert.deepEqual(body.zeiten, { verfuegbar: false }) + for (const p of body.projects) assert.deepEqual([p.features.zeiten.state, p.zeitenStatus, p.zeitenLeer], ['off', null, true]) // Register-Ansicht (B12): Preise und Bestelltexte beider Funktionen aus config.features, auch bei termine.verfuegbar: false assert.deepEqual(Object.keys(body.features).sort(), ['news', 'termine', 'zeiten']) diff --git a/test/kundenansichtGuard.test.js b/test/kundenansichtGuard.test.js index 787cf5a..4c948bb 100644 --- a/test/kundenansichtGuard.test.js +++ b/test/kundenansichtGuard.test.js @@ -120,6 +120,19 @@ test('Checkout je Funktion ist in der Kundenansicht gesperrt (Schleife trifft /a } }) +// Gratisweg "Einschalten" der Oeffnungszeiten (POST /api/funktionen/zeiten/einschalten): eine Schreib-Route, die nicht auf der Erlaubnisliste +// steht; Mitarbeiter schalten nicht fuer den Kunden ein. Der Waechter sperrt sie vor dem Router (die Route prueft zusaetzlich selbst viewAs). +test('Einschalten der Öffnungszeiten ist in der Kundenansicht gesperrt (Route gefunden, nicht erlaubt, 403 view_blocked)', async () => { + const routes = writeRoutes(MOUNTS) + assert.ok(routes.some(({ method, path: p }) => method === 'POST' && p === '/api/funktionen/zeiten/einschalten'), 'Route gefunden') + const p = '/api/funktionen/zeiten/einschalten' + assert.ok(!allowed('POST', p), p) + assert.ok(!allowed('POST', `${p}/`), `${p}/`) + const res = await call('POST', p, { ...kv(), body: { projectId: 'p1', zustimmung: { version: '2026-10-04', haken: ['leistung'] } } }) + assert.equal(res.status, 403) + assert.equal(res.body.code, 'view_blocked') +}) + test('Erlaubnisliste: genau News, Terminplaner, Öffnungszeiten, Analyse-Link, Ende, Logout und Login', () => { const ok = [ ['POST', '/api/news'], ['POST', '/api/news/image'], ['PATCH', '/api/news/abc'], ['DELETE', '/api/news/abc'], diff --git a/test/zeitenEinschalten.test.js b/test/zeitenEinschalten.test.js new file mode 100644 index 0000000..4597010 --- /dev/null +++ b/test/zeitenEinschalten.test.js @@ -0,0 +1,682 @@ +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' +import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.js' + +// Gratisweg "Einschalten" von Oeffnungszeiten live im Wartungs-Abo (Aufgabe 16, Spec 3.1 und 3.2): +// POST /api/funktionen/zeiten/einschalten, einschaltenZeiten/holeZeitenNach (zeiten/einschalten.js), pruefeAboFuerEinschalten +// (featureOrder.js) und das Angebot in GET /api/funktionen (zeiten, features.zeiten, zeitenStatus, zeitenLeer). +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + subscriptions: schemaKeys('subscriptions'), + featureConsents: schemaKeys('featureConsents'), + oeffnungszeiten: schemaKeys('oeffnungszeiten'), + zeitenErinnerungen: schemaKeys('zeitenErinnerungen'), + }, + // Datumsfelder wie in Appwrite: Unlesbares oder '' waere dort ein 400 (leeren geht nur mit null) + datetimes: { + ...ZEITEN_DATETIMES, + portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'k24At'], + featureConsents: ['acceptedAt'], + }, + defaultLimit: 25, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const { startTestApp, send, post } = await import('./support/testApp.js') +const { customerCookie, hostingDoc, kundenansichtCookie, seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js') +const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { default: funktionenRoutes } = await import('../server/routes/funktionen.js') +const sf = await import('../server/services/siteFeatures.js') +const { zeitenDeps } = await import('../server/services/zeiten/neubau.js') +const { einschaltenZeiten, holeZeitenNach, einschaltenDeps } = await import('../server/services/zeiten/einschalten.js') +const { pruefeAboFuerEinschalten } = await import('../server/services/featureOrder.js') +const { speichereZeiten } = await import('../server/services/zeiten/speicher.js') +const { statusZu, hauptplan } = await import('../server/services/zeiten/zeitlogik.js') +const { loeschTag } = await import('../server/services/termine/frist.js') +const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js') + +const app = await startTestApp({ '/api/funktionen': funktionenRoutes }) +after(() => Promise.all([app.close(), aw.close(), st.close()])) +after(() => setMailTransportForTests(null)) + +const Z = config.features.zeiten +const MODUS = { mode: config.zeiten.mode, pilotEmails: [...config.zeiten.pilotEmails] } +const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin +const c1 = customerCookie('c1') +const KEY_P1 = 'f_p1_zeiten' +const KLEIN = 'Öffnungszeiten live' +const KEIN_ABO_TEXT = 'Ohne laufendes Wartungs-Abo kannst Du Öffnungszeiten live für einmalig 30 € bestellen.' + +const wartung = (patch = {}) => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, ...patch })) +const projekt = (id, over = {}) => + aw.seed('websiteProjects', { $id: id, customerId: 'c1', projectName: `Projekt ${id}`, subdomain: id, repoFullName: `JUSN/${id}`, previewMode: 'auto', previewDetected: 'static', ...over }) + +const einschalten = (body, cookie = c1) => post('/api/funktionen/zeiten/einschalten', body, { cookie }) +const haken = () => Z.consentFrei.items.filter((item) => item.haken).map((item) => item.id) +const zustimmung = (patch = {}) => ({ version: Z.consentFrei.version, haken: haken(), ...patch }) +const koerper = (extra = {}) => ({ projectId: 'p1', zustimmung: zustimmung(), ...extra }) +const liste = (cookie = c1) => send('GET', '/api/funktionen', { cookie }) + +const featureDocs = () => [...aw.docs('portalFeatures').values()] +const zeitenDoc = (projectId = 'p1') => aw.docs('portalFeatures').get(`f_${projectId}_zeiten`) +const tickets = () => [...aw.docs('workorders').values()] +const nachweise = () => [...aw.docs('featureConsents').values()] +const spuren = () => ({ features: aw.docs('portalFeatures').size, consents: aw.docs('featureConsents').size, tickets: aw.docs('workorders').size }) +const keineSpuren = () => assert.deepEqual({ ...spuren(), mails: sink.sent.length, neubauten: rebuilds.length }, { features: 0, consents: 0, tickets: 0, mails: 0, neubauten: 0 }) + +let sink +let rebuilds +const originalRebuild = zeitenDeps.rebuild +beforeEach(async () => { + for (const col of ['subscriptions', 'portalFeatures', 'featureConsents', 'oeffnungszeiten', 'workorders', 'websiteProjects']) { + for (const id of aw.docs(col).keys()) await deleteDocument(col, id) + } + resetZeitenCacheForTests() + seedFixtures(aw) + wartung() + projekt('p1') + projekt('p3', { customerId: 'c3' }) + aw.seed('customers', { $id: 'c1', appwriteUserId: 'u1', email: 'k@example.invalid', name: 'Kunde Eins', status: 'active', portalAccessEnabled: true }) + config.zeiten.mode = 'pilot' + config.zeiten.pilotEmails = ['*'] + for (const name of ['verfuegbar', 'bestellpruefung', 'unterlagenBereit', 'freischaltbar']) delete Z[name] + sink = mailSink() + setMailTransportForTests(sink) + rebuilds = [] + zeitenDeps.rebuild = async (p) => { + rebuilds.push({ projektId: p.$id, tickets: aw.docs('workorders').size, einbau: zeitenDoc(p.$id)?.einbau, state: zeitenDoc(p.$id)?.state }) + return { queued: true } + } + einschaltenDeps.jetzt = () => JETZT + mock.method(console, 'error', () => {}) + mock.method(console, 'warn', () => {}) + mock.method(console, 'log', () => {}) +}) +afterEach(() => { + mock.restoreAll() + zeitenDeps.rebuild = originalRebuild + einschaltenDeps.jetzt = () => new Date() +}) +after(() => { + config.zeiten.mode = MODUS.mode + config.zeiten.pilotEmails = MODUS.pilotEmails +}) + +// -------------------------------------------------------------------------------------------------------- Erfolg + +test('Einschalten mit laufendem Abo: setup, unlockedBy abo, Nachweis ohne Sitzung', async () => { + const res = await einschalten(koerper()) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.deepEqual(Object.keys(res.body).sort(), ['einbau', 'feature']) + assert.equal(res.body.einbau, 'angestossen') + assert.equal(res.body.feature.state, 'setup') + assert.equal(res.body.feature.unlockedBy, 'abo') + assert.equal(res.body.feature.einbau, 'angestossen') + assert.equal(typeof res.body.feature.ticketWoid, 'string') + + const doc = zeitenDoc() + assert.deepEqual([doc.featureKey, doc.state, doc.enabled, doc.unlockedBy, doc.unlockedByPurchase], ['zeiten', 'setup', false, 'abo', false]) + assert.match(doc.siteKey, /^[a-z2-7]{16}$/) + assert.equal(doc.einbau, 'angestossen') + assert.ok(doc.ticketId) + assert.ok(doc.k24At) + assert.equal(featureDocs().length, 1, 'nur das Dokument der Zeiten') + + // Nachweis: Fassung und Texte der Gratis-Zustimmung, ohne Stripe-Sitzung, mit dem handelnden Konto + const [nachweis] = nachweise() + assert.equal(nachweise().length, 1) + assert.deepEqual([nachweis.customerId, nachweis.projectId, nachweis.featureKey, nachweis.stripeSessionId], ['c1', 'p1', 'zeiten', '']) + assert.equal(nachweis.konto, 'Kunde Eins ') + assert.deepEqual(JSON.parse(nachweis.fassungen), { zustimmung: Z.consentFrei.version, leistungsbeschreibung: Z.leistungsbeschreibung.version }) + assert.deepEqual(JSON.parse(nachweis.texte), [ + { id: 'leistung', text: Z.consentFrei.items[0].text, haken: true }, + { id: 'knopf', text: 'Einschalten', haken: false }, + ]) +}) + +test('Reihenfolge: Neubau vor Ticket, Ticket-Text nennt einbau, dann K24, dann k24At', async () => { + const beimVersand = [] + setMailTransportForTests({ + sent: sink.sent, + async sendMail(mail) { + beimVersand.push({ k24At: zeitenDoc()?.k24At ?? null, tickets: aw.docs('workorders').size }) + return sink.sendMail(mail) + }, + }) + const res = await einschalten(koerper()) + assert.equal(res.status, 200, JSON.stringify(res.body)) + + // Neubau: Zustand schon gespeichert (setup), noch kein Ticket, einbau noch nicht eingetragen + assert.deepEqual(rebuilds, [{ projektId: 'p1', tickets: 0, einbau: '', state: 'setup' }]) + // Ticket: einmal, Text nennt das Ergebnis des Neubaus und den siteKey + assert.equal(tickets().length, 1) + const [ticket] = tickets() + assert.equal(ticket.topic, 'Öffnungszeiten live einbauen – Projekt p1') + assert.match(ticket.details, /Einbau: automatisch eingesetzt \(nach dem nächsten Veröffentlichen prüfen\)/) + assert.ok(ticket.details.includes(`data-site="${zeitenDoc().siteKey}"`)) + assert.equal(zeitenDoc().ticketId, ticket.$id) + // K24: genau eine Mail, nach dem Ticket, k24At erst danach + assert.equal(sink.sent.length, 1) + assert.deepEqual(beimVersand, [{ k24At: null, tickets: 1 }]) + const mail = sink.sent[0] + assert.equal(mail.subject, 'Öffnungszeiten live ist eingeschaltet') + assert.deepEqual(mail.to.map((a) => a.address), ['k@example.invalid']) + assert.match(mail.text, /erscheinen nach dem nächsten Veröffentlichen/) + assert.ok(mail.text.includes(`(Ticket #${ticket.woid})`)) + assert.doesNotMatch(mail.text, /nicht automatisch aktualisieren/) + assert.ok(Date.parse(zeitenDoc().k24At) > 0) +}) + +test('Neubau nicht möglich: einbau manuell, Ticket und K24 sagen „Team baut ein“', async () => { + aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Ohne Repo' }) // kein Repo ⇒ manuell + const res = await einschalten(koerper()) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal(res.body.einbau, 'manuell') + assert.deepEqual(rebuilds, []) + assert.equal(zeitenDoc().einbau, 'manuell') + assert.match(tickets()[0].details, /Einbau: bitte von Hand einsetzen/) + assert.match(sink.sent[0].text, /nicht automatisch aktualisieren/) + assert.doesNotMatch(sink.sent[0].text, /erscheinen nach dem nächsten Veröffentlichen/) +}) + +// ------------------------------------------------------------------------------------------------------ Abo + +test('kein Abo / gekündigt ⇒ 409 mit Hinweis auf 30 €, keine Spur', async () => { + aw.seed('subscriptions', hostingDoc) + const keins = await einschalten(koerper()) + assert.equal(keins.status, 409) + assert.equal(keins.body.error, KEIN_ABO_TEXT) + assert.equal(keins.body.code, 'kein_abo') + assert.equal(keins.body.grund, 'kein_abo') + keineSpuren() + + wartung({ cancelAtPeriodEnd: true }) + const gekuendigt = await einschalten(koerper()) + assert.equal(gekuendigt.status, 409) + assert.equal(gekuendigt.body.error, KEIN_ABO_TEXT) + assert.equal(gekuendigt.body.code, 'gekuendigt') + keineSpuren() + + const ohne = await einschalten({ projectId: 'p3', zustimmung: zustimmung() }, customerCookie('c3')) + assert.equal(ohne.status, 409) + assert.equal(ohne.body.code, 'kein_abo') + keineSpuren() +}) + +test('Abo nicht aktiv ⇒ 409 „sobald Dein Abo aktiv ist“, keine Spur', async () => { + for (const status of ['trialing', 'past_due']) { + wartung({ status }) + const res = await einschalten(koerper()) + assert.equal(res.status, 409, status) + assert.equal(res.body.error, 'Einschaltbar, sobald Dein Abo aktiv ist.', status) + assert.equal(res.body.code, 'nicht_aktiv', status) + keineSpuren() + } +}) + +test('Abo-Lesefehler ⇒ 503, nichts geschrieben, kein Rohtext', async () => { + aw.failNext({ collection: 'subscriptions', status: 500, message: 'Interner Appwrite-Text mit Kunde c1' }) + const res = await einschalten(koerper()) + assert.equal(res.status, 503) + assert.equal(res.body.error, 'Gerade nicht prüfbar – bitte versuch es gleich noch einmal.') + assert.equal(JSON.stringify(res.body).includes('Appwrite'), false) + keineSpuren() +}) + +test('pruefeAboFuerEinschalten liest frisch: ok ⇒ nichts, Lesefehler ⇒ 503 ohne Rohtext im Log', async () => { + await pruefeAboFuerEinschalten('c1') // Wartung aktiv + aw.failNext({ collection: 'subscriptions', status: 500, message: 'Geheimer Text' }) + await assert.rejects(pruefeAboFuerEinschalten('c1'), (err) => err.status === 503 && err.message === 'Gerade nicht prüfbar – bitte versuch es gleich noch einmal.') + const log = console.error.mock.calls.map((c) => c.arguments.join(' ')).join('\n') + assert.doesNotMatch(log, /Geheimer Text/) + // zwei Aufrufe hintereinander lesen zweimal (kein Zwischenspeicher): nach dem Ende des Abos sieht der zweite es sofort + wartung({ status: 'canceled', endedAt: '2026-09-01T00:00:00.000Z' }) + await assert.rejects(pruefeAboFuerEinschalten('c1'), (err) => err.status === 409 && err.code === 'kein_abo') +}) + +// ------------------------------------------------------------------------------------------- Vorpruefungen + +test('Kundenansicht ⇒ 403, keine Spur', async () => { + const res = await einschalten(koerper(), kundenansichtCookie()) + assert.equal(res.status, 403) + assert.equal(res.body.error, 'In der Kundenansicht kann nicht eingeschaltet werden.') + // die Sperre kommt vor jeder anderen Pruefung, auch vor der Zustimmung + assert.equal((await einschalten({}, kundenansichtCookie())).status, 403) + keineSpuren() +}) + +test('ohne Anmeldung 401', async () => { + assert.equal((await post('/api/funktionen/zeiten/einschalten', koerper())).status, 401) + keineSpuren() +}) + +test('Zustimmung fehlt oder veraltet ⇒ 400/409 wie beim Checkout, mit der Gratis-Fassung, keine Spur', async () => { + for (const body of [{ projectId: 'p1' }, koerper({ zustimmung: zustimmung({ haken: [] }) }), koerper({ zustimmung: zustimmung({ haken: ['leistung', 'beginn'] }) }), koerper({ zustimmung: 'ja' })]) { + const res = await einschalten(body) + assert.equal(res.status, 400, JSON.stringify(body)) + assert.equal(res.body.code, 'zustimmung_fehlt') + assert.equal(res.body.error, 'Bitte stimme der Erklärung zu, um zu bestellen.') + } + const alt = await einschalten(koerper({ zustimmung: zustimmung({ version: '2020-01-01' }) })) + assert.equal(alt.status, 409) + assert.equal(alt.body.code, 'fassung_veraltet') + // die neue Fassung ist die des Gratisweges (ein Haken, Knopf „Einschalten“), nicht die der Bestellung + assert.deepEqual(alt.body.consent, { + version: Z.consentFrei.version, + knopf: 'Einschalten', + items: Z.consentFrei.items.map(({ id, text }) => ({ id, text, haken: true })), + }) + keineSpuren() +}) + +test('Projekt: fehlt ⇒ 400, fremd oder unbekannt ⇒ 403, archiviert ⇒ 409', async () => { + assert.equal((await einschalten({ zustimmung: zustimmung() })).status, 400) + assert.equal((await einschalten(koerper({ projectId: 'p3' }))).status, 403) + assert.equal((await einschalten(koerper({ projectId: 'gibtsnicht' }))).status, 403) + keineSpuren() + projekt('p_alt', { archived: true }) + const res = await einschalten(koerper({ projectId: 'p_alt' })) + assert.equal(res.status, 409) + assert.match(res.body.error, /archiviert/) + keineSpuren() +}) + +test('Schalter und Pilotliste: aus oder nicht in der Liste ⇒ 409 nicht verfügbar, keine Spur', async () => { + config.zeiten.mode = 'off' + const aus = await einschalten(koerper()) + assert.equal(aus.status, 409) + assert.equal(aus.body.code, 'nicht_verfuegbar') + assert.equal(aus.body.error, Z.unavailableMessage) + config.zeiten.mode = 'pilot' + config.zeiten.pilotEmails = ['andere@example.test'] + assert.equal((await einschalten(koerper())).status, 409) + keineSpuren() + config.zeiten.pilotEmails = ['k@example.invalid'] + assert.equal((await einschalten(koerper())).status, 200) +}) + +test('Vertragsunterlagen nicht abrufbar ⇒ 503, keine Spur', async () => { + Z.unterlagenBereit = async () => false + const res = await einschalten(koerper()) + assert.equal(res.status, 503) + assert.equal(res.body.code, 'unterlagen_fehlen') + keineSpuren() +}) + +test('Nachweis nicht speicherbar ⇒ 500 ohne Rohtext, Zustand bleibt off, kein Ticket, keine Mail', async () => { + aw.failNext({ collection: 'featureConsents', method: 'POST', status: 500, message: 'Interner Text' }) + const res = await einschalten(koerper()) + assert.equal(res.status, 500) + assert.equal(JSON.stringify(res.body).includes('Interner Text'), false) + keineSpuren() +}) + +// ------------------------------------------------------------------------------------------- Zustaende + +test('zweiter Klick ist idempotent und holt Fehlendes nach: Ticket, dann genau ein K24', async () => { + aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Ticketsystem down' }) + const erster = await einschalten(koerper()) + // Zustand ist gespeichert, das Ticket fehlt: der Kunde bekommt einen Hinweis zum erneuten Versuch, nicht den Rohtext + assert.equal(erster.status, 503) + assert.equal(JSON.stringify(erster.body).includes('Ticketsystem'), false) + assert.equal(zeitenDoc().state, 'setup') + assert.equal(zeitenDoc().unlockedBy, 'abo') + assert.ok(!zeitenDoc().ticketId) + assert.ok(!zeitenDoc().k24At) + assert.equal(tickets().length, 0) + assert.equal(sink.sent.length, 0) + assert.equal(nachweise().length, 1) + assert.equal(rebuilds.length, 1) + + const zweiter = await einschalten(koerper()) + assert.equal(zweiter.status, 200, JSON.stringify(zweiter.body)) + assert.equal(tickets().length, 1) + assert.equal(zeitenDoc().ticketId, tickets()[0].$id) + assert.equal(sink.sent.length, 1) + assert.ok(zeitenDoc().k24At) + assert.equal(nachweise().length, 1, 'kein zweiter Nachweis') + assert.equal(rebuilds.length, 1, 'einbau steht schon, kein zweiter Neubau') + assert.equal(zweiter.body.einbau, 'angestossen') + + // dritter Klick: alles da, nichts passiert + const dritter = await einschalten(koerper()) + assert.equal(dritter.status, 200) + assert.deepEqual([tickets().length, sink.sent.length, nachweise().length, rebuilds.length], [1, 1, 1, 1]) +}) + +test('K24 scheitert: Zustand und Ticket bleiben, k24At leer, der nächste Klick sendet nach (ein Ticket, eine Mail)', async () => { + setMailTransportForTests({ sent: [], async sendMail() { throw new Error('SMTP down') } }) + const erster = await einschalten(koerper()) + assert.equal(erster.status, 200, 'Mail-Fehler kosten die Antwort nichts') + assert.equal(tickets().length, 1) + assert.ok(!zeitenDoc().k24At) + setMailTransportForTests(sink) + assert.equal((await einschalten(koerper())).status, 200) + assert.equal(tickets().length, 1) + assert.equal(sink.sent.length, 1) + assert.ok(zeitenDoc().k24At) + assert.match(sink.sent[0].text, new RegExp(`Ticket #${tickets()[0].woid}`)) +}) + +test('zwei gleichzeitige Klicks: ein Nachweis, ein Neubau, ein Ticket, eine K24', async () => { + const [a, b] = await Promise.all([einschalten(koerper()), einschalten(koerper())]) + assert.deepEqual([a.status, b.status], [200, 200], JSON.stringify([a.body, b.body])) + assert.deepEqual([nachweise().length, rebuilds.length, tickets().length, sink.sent.length], [1, 1, 1, 1]) + assert.equal(zeitenDoc().ticketId, tickets()[0].$id) +}) + +test('Zustand active mit abo: idempotent 200 ohne Schreibzugriff', async () => { + assert.equal((await einschalten(koerper())).status, 200) + await updateDocument('portalFeatures', KEY_P1, { state: 'active', enabled: true }) + const vorher = { ...zeitenDoc() } + const res = await einschalten(koerper()) + assert.equal(res.status, 200) + assert.equal(res.body.feature.state, 'active') + assert.deepEqual(zeitenDoc(), vorher) + assert.deepEqual([nachweise().length, rebuilds.length, tickets().length, sink.sent.length], [1, 1, 1, 1]) +}) + +test('andere Zustände: ordered, setup/active vom Kauf ⇒ 409 Meldung der Bestellung; disabled ⇒ 409 Meldung der Abschaltung', async () => { + const faelle = [ + [{ state: 'ordered' }, Z.orderedConflictMessage], + [{ state: 'setup', unlockedBy: 'kauf', unlockedByPurchase: true }, Z.orderedConflictMessage], + [{ state: 'active', enabled: true, unlockedBy: 'kauf', unlockedByPurchase: true }, Z.orderedConflictMessage], + [{ state: 'disabled', disabledReason: 'team', disabledAt: '2026-10-01T10:00:00.000Z' }, Z.disabledMessage], + [{ state: 'disabled', disabledReason: 'abo_ende', disabledAt: '2026-10-01T10:00:00.000Z', unlockedBy: 'abo' }, Z.disabledMessage], + ] + for (const [felder, text] of faelle) { + for (const id of aw.docs('portalFeatures').keys()) await deleteDocument('portalFeatures', id) + aw.seed('portalFeatures', { $id: KEY_P1, customerId: 'c1', projectId: 'p1', featureKey: 'zeiten', enabled: false, siteKey: 'abcdefghijklmnop', ...felder }) + const vorher = { ...zeitenDoc() } + const res = await einschalten(koerper()) + assert.equal(res.status, 409, felder.state + felder.unlockedBy) + assert.equal(res.body.error, text, felder.state) + assert.deepEqual(zeitenDoc(), vorher) + } + assert.deepEqual([nachweise().length, rebuilds.length, tickets().length, sink.sent.length], [0, 0, 0, 0]) +}) + +test('Abo zuerst: ist es beendet, hilft auch ein vorhandenes setup nicht (409 kein_abo), nichts wird nachgeholt', async () => { + aw.failNext({ collection: 'workorders', method: 'POST', status: 500 }) + assert.equal((await einschalten(koerper())).status, 503) + wartung({ status: 'canceled', endedAt: '2026-09-01T00:00:00.000Z' }) + const res = await einschalten(koerper()) + assert.equal(res.status, 409) + assert.equal(res.body.code, 'kein_abo') + assert.equal(tickets().length, 0) +}) + +test('Dokument im Zustand off mit Resten wird sauber auf setup gestellt', async () => { + aw.seed('portalFeatures', { + $id: KEY_P1, customerId: 'c1', projectId: 'p1', featureKey: 'zeiten', state: 'off', enabled: false, siteKey: 'abcdefghijklmnop', + disabledAt: '2026-08-01T10:00:00.000Z', disabledReason: 'abo_ende', aboEndeAt: '2026-07-01T10:00:00.000Z', behaltenSessionId: 'cs_alt', + }) + assert.equal((await einschalten(koerper())).status, 200) + const doc = zeitenDoc() + assert.deepEqual([doc.state, doc.siteKey, doc.disabledAt, doc.disabledReason, doc.aboEndeAt, doc.behaltenSessionId], ['setup', 'abcdefghijklmnop', null, null, null, '']) +}) + +// --------------------------------------------------------------------------------------------- Team und Nachholen + +test('Team: kein Abo nötig, unlockedBy team, kein Nachweis, Neubau, Ticket, keine K24, Notiz', async () => { + aw.seed('subscriptions', hostingDoc) + const { doc, changed } = await einschaltenZeiten({ customerId: 'c1', projectId: 'p1', by: 'team', staff: { name: 'Andrej' }, now: JETZT }) + assert.equal(changed, true) + assert.deepEqual([doc.state, doc.unlockedBy, doc.unlockedByPurchase, doc.einbau], ['setup', 'team', false, 'angestossen']) + assert.match(doc.adminNote, /Andrej/) + assert.equal(nachweise().length, 0) + assert.equal(tickets().length, 1) + assert.equal(sink.sent.length, 0) + assert.ok(!zeitenDoc().k24At) + assert.equal(rebuilds.length, 1) + // ein zweiter Aufruf aendert nichts + const nochmal = await einschaltenZeiten({ customerId: 'c1', projectId: 'p1', by: 'team', staff: { name: 'Andrej' }, now: JETZT }) + assert.equal(nochmal.changed, false) + assert.deepEqual([tickets().length, rebuilds.length], [1, 1]) +}) + +test('Team aus disabled: zurück auf setup, Neubau erneut, kein neues Ticket', async () => { + aw.seed('portalFeatures', { + $id: KEY_P1, customerId: 'c1', projectId: 'p1', featureKey: 'zeiten', state: 'disabled', enabled: false, siteKey: 'abcdefghijklmnop', + unlockedBy: 'abo', einbau: 'angestossen', ticketId: 'ticket_alt', disabledReason: 'abo_ende', disabledAt: '2026-10-01T10:00:00.000Z', aboEndeAt: '2026-09-01T10:00:00.000Z', + }) + const { doc } = await einschaltenZeiten({ customerId: 'c1', projectId: 'p1', by: 'team', now: JETZT }) + assert.deepEqual([doc.state, doc.unlockedBy, doc.disabledAt, doc.disabledReason, doc.aboEndeAt, doc.ticketId], ['setup', 'team', null, null, null, 'ticket_alt']) + assert.equal(rebuilds.length, 1) + assert.equal(doc.einbau, 'angestossen') + assert.equal(tickets().length, 0) +}) + +test('holeZeitenNach: Ticket fehlt in setup ⇒ anlegen; K24 nur bei unlockedBy abo und leerem k24At, danach Marker', async () => { + aw.seed('portalFeatures', { $id: KEY_P1, customerId: 'c1', projectId: 'p1', featureKey: 'zeiten', state: 'setup', enabled: false, siteKey: 'abcdefghijklmnop', unlockedBy: 'abo', einbau: 'manuell' }) + const doc = await holeZeitenNach(zeitenDoc(), { now: JETZT }) + assert.equal(tickets().length, 1) + assert.equal(doc.ticketId, tickets()[0].$id) + assert.equal(Date.parse(doc.k24At), JETZT.getTime()) + assert.equal(sink.sent.length, 1) + assert.deepEqual(rebuilds, [], 'einbau steht schon: kein Neubau') + // noch einmal: nichts + await holeZeitenNach(zeitenDoc(), { now: JETZT }) + assert.deepEqual([tickets().length, sink.sent.length], [1, 1]) +}) + +test('holeZeitenNach: Neubau nur bei leerem einbau; kauf, active und off bleiben unberührt; team bekommt keine K24', async () => { + const seed = (id, felder) => { + projekt(id) + aw.seed('portalFeatures', { $id: `f_${id}_zeiten`, customerId: 'c1', projectId: id, featureKey: 'zeiten', siteKey: 'abcdefghijklmnop', enabled: false, ...felder }) + return aw.docs('portalFeatures').get(`f_${id}_zeiten`) + } + for (const doc of [ + seed('p_kauf', { state: 'setup', unlockedBy: 'kauf', unlockedByPurchase: true }), + seed('p_aktiv', { state: 'active', enabled: true, unlockedBy: 'abo' }), + seed('p_aus', { state: 'off' }), + seed('p_wartet', { state: 'ordered' }), + ]) { + const vorher = { ...doc } + assert.deepEqual(await holeZeitenNach(doc, { now: JETZT }), vorher) + } + assert.deepEqual([tickets().length, sink.sent.length, rebuilds.length], [0, 0, 0]) + + const team = seed('p_team', { state: 'setup', unlockedBy: 'team' }) + const fertig = await holeZeitenNach(team, { now: JETZT }) + assert.deepEqual([rebuilds.map((r) => r.projektId), tickets().length, sink.sent.length], [['p_team'], 1, 0]) + assert.equal(fertig.einbau, 'angestossen') + assert.ok(!fertig.k24At) + assert.match(tickets()[0].details, /automatisch eingesetzt/, 'das Ticket kennt das frische einbau') +}) + +test('holeZeitenNach: Kunde ohne Adresse oder gelöscht ⇒ k24At wird gesetzt (nichts zu senden), Lesefehler ⇒ wirft und lässt den Marker leer', async () => { + aw.seed('customers', { $id: 'c1', appwriteUserId: 'u1', email: '', name: 'Kunde Eins', status: 'active', portalAccessEnabled: true }) + aw.seed('portalFeatures', { $id: KEY_P1, customerId: 'c1', projectId: 'p1', featureKey: 'zeiten', state: 'setup', enabled: false, siteKey: 'abcdefghijklmnop', unlockedBy: 'abo', einbau: 'manuell', ticketId: 'ticket_da' }) + const doc = await holeZeitenNach(zeitenDoc(), { now: JETZT }) + assert.ok(doc.k24At) + assert.equal(sink.sent.length, 0) + + await updateDocument('portalFeatures', KEY_P1, { k24At: null }) + aw.seed('customers', { $id: 'c1', appwriteUserId: 'u1', email: 'k@example.invalid', name: 'Kunde Eins', status: 'active', portalAccessEnabled: true }) + aw.failNext({ collection: 'customers', status: 500 }) + await assert.rejects(holeZeitenNach(zeitenDoc(), { now: JETZT })) + assert.ok(!zeitenDoc().k24At) +}) + +// ----------------------------------------------------------------------------------------------------- Angebot + +const angebot = async (cookie = c1) => (await liste(cookie)).body.zeiten +const FREI = () => ({ + consent: { version: Z.consentFrei.version, knopf: 'Einschalten', items: Z.consentFrei.items.map(({ id, text }) => ({ id, text, haken: true })) }, + leistungsbeschreibung: { version: Z.leistungsbeschreibung.version, url: Z.leistungsbeschreibung.url }, +}) + +test('Angebot: nicht verfügbar ⇒ genau { verfuegbar: false }, das Abo wird nicht gelesen', async () => { + config.zeiten.mode = 'off' + aw.resetListCalls() + assert.deepEqual(await angebot(), { verfuegbar: false }) + assert.equal(aw.listCalls('subscriptions').length, 0) + config.zeiten.mode = 'pilot' + config.zeiten.pilotEmails = ['andere@example.test'] + assert.deepEqual(await angebot(), { verfuegbar: false }) +}) + +test('Angebot: inklusive true/false/null je nach Abo-Lage, Preis und Texte aus dem Register', async () => { + const erwartet = (inklusive, grund) => ({ verfuegbar: true, inklusive, grund, preis: '30 €', einschalten: FREI() }) + assert.deepEqual(await angebot(), erwartet(true, null)) + assert.deepEqual(Object.keys(await angebot()), ['verfuegbar', 'inklusive', 'grund', 'preis', 'einschalten']) + + aw.seed('subscriptions', hostingDoc) + assert.deepEqual(await angebot(), erwartet(false, 'kein_abo'), 'nur Hosting') + assert.deepEqual(await angebot(customerCookie('c3')), erwartet(false, 'kein_abo'), 'gar kein Abo') + wartung({ cancelAtPeriodEnd: true }) + assert.deepEqual(await angebot(), erwartet(false, 'gekuendigt')) + wartung({ status: 'past_due' }) + assert.deepEqual(await angebot(), erwartet(false, 'nicht_aktiv')) + + wartung() + aw.failNext({ collection: 'subscriptions', status: 500, message: 'Geheim' }) + const lesefehler = await liste() + assert.equal(lesefehler.status, 200, 'ein Abo-Lesefehler kostet die Liste nichts') + assert.deepEqual(lesefehler.body.zeiten, erwartet(null, null)) + assert.doesNotMatch(console.error.mock.calls.map((c) => c.arguments.join(' ')).join('\n'), /Geheim/) +}) + +test('Angebot: die Register-Ansicht der Zeiten bleibt unverändert (keine neuen Schlüssel)', async () => { + const { features } = (await liste()).body + assert.deepEqual(Object.keys(features.zeiten).sort(), ['avv', 'consent', 'key', 'kurz', 'leistungsbeschreibung', 'name', 'priceLabel', 'requiresWartungsAbo', 'returnHash', 'setupEstimate', 'widerruf']) + assert.deepEqual(features.zeiten.consent.items.map((i) => i.id), ['leistung', 'beginn'], 'die bezahlte Zustimmung') +}) + +test('Ansicht features.zeiten: unlockedBy, aboEndeAt, einbau; loeschungAm nur bei disabled mit abo_ende', async () => { + const ansicht = async (felder) => { + for (const id of aw.docs('portalFeatures').keys()) await deleteDocument('portalFeatures', id) + if (felder) aw.seed('portalFeatures', { $id: KEY_P1, customerId: 'c1', projectId: 'p1', featureKey: 'zeiten', enabled: false, siteKey: 'abcdefghijklmnop', ...felder }) + return (await liste()).body.projects.find((p) => p.projectId === 'p1').features.zeiten + } + const neu = await ansicht(null) + assert.deepEqual([neu.state, neu.unlockedBy, neu.aboEndeAt, neu.einbau, neu.loeschungAm], ['off', null, null, null, null]) + + const setup = await ansicht({ state: 'setup', unlockedBy: 'abo', einbau: 'manuell', aboEndeAt: '2026-10-01T10:00:00.000Z' }) + assert.deepEqual([setup.state, setup.unlockedBy, setup.einbau, setup.loeschungAm], ['setup', 'abo', 'manuell', null]) + assert.equal(Date.parse(setup.aboEndeAt), Date.parse('2026-10-01T10:00:00.000Z')) + + const alt = await ansicht({ state: 'active', enabled: true, unlockedByPurchase: true }) + assert.equal(alt.unlockedBy, 'kauf', 'Altbestand ohne unlockedBy: aus unlockedByPurchase abgeleitet') + const teamAlt = await ansicht({ state: 'active', enabled: true }) + assert.equal(teamAlt.unlockedBy, 'team') + + const aboEnde = await ansicht({ state: 'disabled', unlockedBy: 'abo', disabledReason: 'abo_ende', disabledAt: '2026-10-04T23:30:00.000Z' }) + assert.deepEqual([aboEnde.disabledReason, aboEnde.loeschungAm], ['abo_ende', '2026-11-04']) + assert.equal(aboEnde.loeschungAm, loeschTag('2026-10-04T23:30:00.000Z'), 'Berliner Tag') + const team = await ansicht({ state: 'disabled', unlockedBy: 'team', disabledReason: 'team', disabledAt: '2026-10-04T23:30:00.000Z' }) + assert.equal(team.loeschungAm, null) + + // andere Funktionen behalten ihre Ansicht + const news = (await liste()).body.projects[0].features.news + assert.equal('einbau' in news || 'unlockedBy' in news || 'aboEndeAt' in news, false) +}) + +// ----------------------------------------------------------------------------------------------------- Projektliste + +function tage(modus) { + const t = {} + for (let d = 1; d <= 7; d += 1) t[d] = { modus, fenster: modus === 'zeiten' ? [['09:00', '18:00']] : [] } + return t +} +const eingabe = (modus) => ({ + land: 'RP', + plaene: [{ id: 'p_aaaaaaa1', name: 'Standard', haupt: true, tage: tage(modus) }], + feiertage: {}, + ausnahmen: [], + hinweise: [], + kontakt: { an: false, telefon: '', adresse: '', whatsapp: null }, +}) +async function mitZeiten(projectId, state, modus) { + aw.seed('portalFeatures', { $id: `f_${projectId}_zeiten`, customerId: 'c1', projectId, featureKey: 'zeiten', state, enabled: state === 'active', siteKey: 'abcdefghijklmnop', unlockedBy: 'abo' }) + if (modus) await speichereZeiten({ feature: zeitenDoc(projectId), eingabe: eingabe(modus), version: 0, now: JETZT }) +} + +test('Projektliste enthält zeitenStatus und zeitenLeer ohne Zusatzabfrage pro Kachel', async () => { + projekt('p_offen') + projekt('p_zu') + projekt('p_leer') + projekt('p_aus') + projekt('p_bestellt') + projekt('p_abgeschaltet') + await mitZeiten('p_offen', 'active', 'zeiten') + await mitZeiten('p_zu', 'setup', 'zu') + await mitZeiten('p_leer', 'setup', null) + await mitZeiten('p_bestellt', 'ordered', 'zeiten') + await mitZeiten('p_abgeschaltet', 'disabled', 'zeiten') + // p1 und p_aus ohne Dokument + + const abfragen = [] + const echt = globalThis.fetch + globalThis.fetch = async (url, optionen) => { + if (/\/collections\/oeffnungszeiten\/documents/.test(String(url))) abfragen.push(`${optionen?.method ?? 'GET'} ${String(url).replace(/\?.*/, '').split('/documents')[1]}`) + return echt(url, optionen) + } + let res + try { + res = await liste() + } finally { + globalThis.fetch = echt + } + assert.equal(res.status, 200) + const von = (id) => res.body.projects.find((p) => p.projectId === id) + + const daten = eingabe('zeiten') + assert.deepEqual(von('p_offen').zeitenStatus, statusZu(daten, hauptplan(daten), JETZT)) + assert.equal(von('p_offen').zeitenStatus.offen, true) + assert.deepEqual(Object.keys(von('p_offen').zeitenStatus).sort(), ['kurz', 'offen', 'text']) + assert.equal(von('p_offen').zeitenLeer, false) + assert.equal(von('p_zu').zeitenStatus.offen, false) + assert.equal(von('p_zu').zeitenLeer, false) + // keine gespeicherten Zeiten: kein Status, leer + for (const id of ['p_leer', 'p1', 'p_aus']) assert.deepEqual([von(id).zeitenStatus, von(id).zeitenLeer], [null, true], id) + // bestellt oder abgeschaltet: Daten da, aber nicht live + for (const id of ['p_bestellt', 'p_abgeschaltet']) assert.deepEqual([von(id).zeitenStatus, von(id).zeitenLeer], [null, false], id) + // je Projekt mit Dokument eine Abfrage, keine fuer die anderen + assert.equal(abfragen.length, 5, abfragen.join(' | ')) + assert.equal(abfragen.every((a) => a.startsWith('GET /')), true) +}) + +test('Projektliste: nicht verfügbar ⇒ keine Zeiten gelesen; Lesefehler bei einem Projekt kostet die Liste nichts', async () => { + await mitZeiten('p1', 'active', 'zeiten') + config.zeiten.mode = 'off' + const abfragen = [] + const echt = globalThis.fetch + globalThis.fetch = async (url, optionen) => { + if (/\/collections\/oeffnungszeiten\//.test(String(url))) abfragen.push(String(url)) + return echt(url, optionen) + } + try { + const aus = await liste() + assert.deepEqual([aus.body.projects[0].zeitenStatus, aus.body.projects[0].zeitenLeer], [null, true]) + } finally { + globalThis.fetch = echt + } + assert.equal(abfragen.length, 0) + + config.zeiten.mode = 'pilot' + aw.failNext({ collection: 'oeffnungszeiten', status: 500, message: 'Geheim' }) + const res = await liste() + assert.equal(res.status, 200) + const p1 = res.body.projects.find((p) => p.projectId === 'p1') + assert.deepEqual([p1.zeitenStatus, p1.zeitenLeer], [null, false]) + assert.doesNotMatch(console.error.mock.calls.map((c) => c.arguments.join(' ')).join('\n'), /Geheim/) +}) + +// ---------------------------------------------------------------------------------------------------- siteFeatures + +test('assertOwnProject: forNewOrder sperrt archivierte Projekte mit 409, ohne die Option bleibt alles wie vorher', async () => { + projekt('p_alt', { archived: true }) + assert.equal((await sf.assertOwnProject('c1', 'p_alt')).$id, 'p_alt') + await assert.rejects(sf.assertOwnProject('c1', 'p_alt', { forNewOrder: true }), (err) => err.status === 409) + assert.equal((await sf.assertOwnProject('c1', 'p1', { forNewOrder: true })).$id, 'p1') +})