Ö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:
1 parent
8d670ca5dc
commit
66793d06d5
9 files changed
+1124
-58
No files matched your search
+116
-47
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user