Öffnungszeiten: Einschalten im Wartungs-Abo

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 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 02:03:49 +00:00
1 parent 8d670ca5dc
commit 66793d06d5
9 files changed
+1124 -58

No files matched your search

+116 -47
View File
@@ -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 <E-Mail>" 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) => {
+44
View File
@@ -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 <E-Mail>" 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 })
}
}
+44 -3
View File
@@ -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')
}
+31 -6
View File
@@ -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,
+177
View File
@@ -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:<kunde>:<projekt>` (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:<dokument>`: 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 "<Zeit> <Name>: 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<unknown>)|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<object>} 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 }
}
+13 -2
View File
@@ -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)
})
+4
View File
@@ -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'])
+13
View File
@@ -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'],
+682
View File
@@ -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 <k@example.invalid>')
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')
})