Ö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) => {