Ö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 }
}