Funktionen neu: Einschalt-Logik (Vorprüfung, Fristen, 409-Codes) und Testdaten

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-16 18:01:27 +00:00
1 parent 1cd16ff293
commit 437947cdf6
4 files changed
+441

No files matched your search

+198
View File
@@ -0,0 +1,198 @@
/* Kundenansicht neu (P3): Einschalten einer Funktion im Abo-Modell.
Dieser Teil ist rein (ohne DOM, ohne Netz) und läuft unter Node: Wege nach der Vorprüfung, Fristen, Körper des Absendens, Fehlerfolgen.
Die Vorprüfung spiegelt aboDeckt (server/services/aboFunktionen.js) nur, um den richtigen Ort zu wählen; entschieden wird immer vom Server
(POST /api/funktionen/<key>/einschalten, 409 mit Code). Kundentexte sind Festwerte (TEXTE), Fremdtext kommt nie als HTML in die Seite. */
/** Etage 2 je Funktion (Schlüssel der Hülle in FUNKTIONS_MODULE, Hash `#<etage>?projekt=<id>`). */
export const ETAGE_FUER = Object.freeze({ news: 'news', termine: 'termine', shop: 'webshop', unterkunft: 'buchungen', zeiten: 'zeiten', analyse: 'analyse' })
/** Festtexte der Oberfläche (du-Form, kein Preis). */
export const TEXTE = Object.freeze({
stundenkonto: 'Die Einrichtung geht von deinem Stundenkonto ab, auch wenn das Guthaben noch nicht reicht. Extrakosten entstehen keine.',
nichtVerfuegbar: 'Diese Funktion ist für dich gerade nicht verfügbar.',
gekuendigt: 'Dein Abo ist gekündigt. Solange die Kündigung läuft, kannst du nichts Neues einschalten.',
nichtAktiv: 'Dein Abo ist noch nicht aktiv. Sobald die erste Zahlung da ist, kannst du einschalten.',
falschesAbo: 'Dein Abo enthält diese Funktion nicht.',
beginntSpaeter: (datum) => `Dein Abo beginnt am ${datum}. Ab dann kannst du einschalten.`,
fassungVeraltet: 'Die Bedingungen wurden aktualisiert. Bitte lies die Zeile noch einmal.',
allgemein: 'Das Einschalten hat gerade nicht geklappt. Versuch es gleich noch einmal oder schreib uns im Chat.',
nurBetrieb: (name) => `${name} gibt es für Betriebe. Stimmt das nicht? Schreib uns im Chat.`,
registerFehlt: 'Einschalten ist gerade nicht möglich. Schreib uns im Chat.',
kundenansicht: 'In der Kundenansicht kann nichts eingeschaltet werden.',
})
/** Die Erklärung der Funktion aus dem Katalog der Antwort (einzige Stelle für das Register); fehlt sie, null. */
export function consentVon(daten, key) {
const eintrag = Array.isArray(daten?.katalog) ? daten.katalog.find((e) => e?.key === key) : null
const consent = eintrag?.consent
return consent && typeof consent === 'object' && Array.isArray(consent.items) ? consent : null
}
/** Anfragestatus von Webshop oder Buchungskalender an einer Website (N18, einzige Stelle); sonst ''. */
export function anfrageStatusVon(projekt, key) {
const status = projekt?.features?.[key]?.anfrageStatus
return typeof status === 'string' ? status : ''
}
/**
* Wohin führt „Einschalten“? Prüfreihenfolge wie der Server (V5): verfügbar, gekündigt, kein Abo, nicht aktiv, Hosting bei einer
* Wartungs-Funktion, nur Betrieb; danach Etage (Analyse, Fragebogen, Anfrage ohne Zusage) oder die Bestätigungsseite.
* Alles außer 'unternehmen' gilt als privat (E4).
*/
export function einschaltWeg({ eintrag, projekt, abo, kundenart }) {
if (eintrag?.verfuegbar !== true) return { art: 'meldung', text: TEXTE.nichtVerfuegbar }
if (abo?.gekuendigt === true) return { art: 'meldung', text: TEXTE.gekuendigt }
if (!abo || abo.produkt === null || abo.produkt === undefined) return { art: 'bestellen', produkt: eintrag.enthaltenIn }
if (abo.laeuft !== true) return { art: 'meldung', text: TEXTE.nichtAktiv }
if (eintrag.enthaltenIn === 'wartung' && abo.produkt === 'hosting') return { art: 'wechsel' }
if (eintrag.nurBetrieb === true && kundenart !== 'unternehmen') return { art: 'meldung', text: TEXTE.nurBetrieb(eintrag.name) }
if (eintrag.key === 'analyse') return { art: 'etage', etage: ETAGE_FUER.analyse }
if (eintrag.ablauf === 'fragebogen') return { art: 'etage', etage: ETAGE_FUER[eintrag.key] }
if (eintrag.ablauf === 'anfrage' && anfrageStatusVon(projekt, eintrag.key) !== 'angenommen') return { art: 'etage', etage: ETAGE_FUER[eintrag.key] }
return { art: 'seite' }
}
const TAG = /^\d{4}-\d{2}-\d{2}$/
/** Berliner Kalendertag 'JJJJ-MM-TT' eines Zeitpunkts (ISO-Text, Date) oder eines Tagestextes; unlesbar ergibt ''. */
function berlinTag(wert) {
if (typeof wert === 'string' && TAG.test(wert)) return wert
const ms = wert instanceof Date ? wert.getTime() : (typeof wert === 'string' && wert !== '' ? Date.parse(wert) : Number.NaN)
if (Number.isNaN(ms)) return ''
return new Date(ms).toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
}
/** Der Berliner Tag nach dem Fristende (Zeitpunkt) als 'JJJJ-MM-TT'; unlesbar ergibt ''. */
export function tagNachFrist(wert) {
const tag = berlinTag(wert)
if (!tag) return ''
const [j, m, t] = tag.split('-').map(Number)
return new Date(Date.UTC(j, m - 1, t + 1)).toISOString().slice(0, 10)
}
/** 'JJJJ-MM-TT' → 'TT.MM.'; sonst ''. */
export function datumKurz(tag) {
if (typeof tag !== 'string' || !TAG.test(tag)) return ''
const [, m, t] = tag.split('-')
return `${t}.${m}.`
}
/** 'JJJJ-MM-TT' → 'TT.MM.JJJJ' wie datumDe des Servers; sonst ''. */
function datumLang(tag) {
if (typeof tag !== 'string' || !TAG.test(tag)) return ''
const [j, m, t] = tag.split('-')
return `${t}.${m}.${j}`
}
/**
* Braucht der Kunde das Häkchen „sofortiger Beginn“, und ab wann ginge es ohne? Nur Privatkunden (leer zählt als privat) und nur bis
* zum Fristende (am Ende selbst noch Frist). Eine Antwort des Servers (serverAb: ISO oder Tag) gewinnt.
* @returns {{ noetig: boolean, ab: string }} ab = 'JJJJ-MM-TT' oder ''
*/
export function beginnLage({ kundenart, abo, jetzt, serverAb = '' }) {
const vomServer = berlinTag(serverAb)
if (vomServer) return { noetig: true, ab: vomServer }
const bis = abo?.widerrufsfristBis
const ende = typeof bis === 'string' ? Date.parse(bis) : Number.NaN
if (kundenart !== 'unternehmen' && ende >= jetzt.getTime()) return { noetig: true, ab: tagNachFrist(bis) }
return { noetig: false, ab: '' }
}
/** Körper des Absendens: Fassung aus dem Register, Haken nur 'beginn'; die Kundenart sendet der Client nie. */
export function einschaltKoerper({ projectId, consent, beginnGesetzt }) {
return { projectId, zustimmung: { version: consent.version, haken: beginnGesetzt ? ['beginn'] : [] } }
}
/** Nur https ohne Zugangsdaten oder Pfade unter /rechtliches/ und /vertragsunterlagen/; sonst ''. */
export function sichereAdresse(url) {
if (typeof url !== 'string' || url === '') return ''
if (/[\s\u0000-\u001f\u007f\\]/.test(url) || url.includes('..') || /%(2e|2f|5c)/i.test(url)) return ''
if (url.startsWith('/rechtliches/') || url.startsWith('/vertragsunterlagen/')) return url
if (!url.startsWith('https://')) return ''
try {
const u = new URL(url)
return u.protocol === 'https:' && u.hostname !== '' && u.username === '' && u.password === '' ? url : ''
} catch {
return ''
}
}
const UNTERLAGEN_WOERTER = [['Leistungsbeschreibung', 'leistungsbeschreibung'], ['Auftragsverarbeitung', 'avv']]
/**
* Die Zeile aus dem Register als Stücke [{ text, href? }]: das erste Vorkommen von „Leistungsbeschreibung“ und „Auftragsverarbeitung“
* wird zum Link, wenn die Unterlage eine sichere Adresse hat. Fehlt das Wort bei einer sicheren Unterlage, hängt ihr Link hinten an
* (zwei Links mit „ · “ dazwischen). Benachbarte Textstücke sind zusammengefasst.
*/
export function linkzeile(text, unterlagen) {
const basis = typeof text === 'string' ? text : ''
const treffer = []
const anhang = []
for (const [wort, schluessel] of UNTERLAGEN_WOERTER) {
const href = sichereAdresse(unterlagen?.[schluessel]?.url)
if (!href) continue
const pos = basis.indexOf(wort)
if (pos >= 0) treffer.push({ pos, wort, href })
else anhang.push({ wort, href })
}
treffer.sort((a, b) => a.pos - b.pos)
const stuecke = []
const textStueck = (t) => {
if (t === '') return
const letztes = stuecke.at(-1)
if (letztes && letztes.href === undefined) letztes.text += t
else stuecke.push({ text: t })
}
let ab = 0
for (const t of treffer) {
textStueck(basis.slice(ab, t.pos))
stuecke.push({ text: t.wort, href: t.href })
ab = t.pos + t.wort.length
}
textStueck(basis.slice(ab))
anhang.forEach((a, i) => {
textStueck(i === 0 ? ' ' : ' · ')
stuecke.push({ text: a.wort, href: a.href })
})
return stuecke
}
/**
* Was folgt aus einem Fehler des Absendens? fehler = ApiFehler ({ status, code?, message, body }).
* wechsel/bestellen/etage führen weg, beginn und spaeter sperren den Knopf bis zum Datum (beginn: Häkchen möglich), neu-laden lädt die
* Liste neu, schon heißt: ist schon eingeschaltet, meldung zeigt den Text. Alles ohne eigene Regel zeigt den Text des Servers.
*/
export function fehlerAktion(fehler, { eintrag }) {
const body = fehler?.body && typeof fehler.body === 'object' ? fehler.body : {}
const code = fehler?.code || body.code || ''
const servertext = typeof fehler?.message === 'string' && fehler.message !== '' ? fehler.message : TEXTE.allgemein
const meldung = (text) => ({ art: 'meldung', text })
switch (code) {
case 'nicht_verfuegbar':
return meldung(TEXTE.nichtVerfuegbar)
case 'abo_fehlt': {
if (body.wechsel === true) return { art: 'wechsel' }
if (body.grund === 'kein_abo') return { art: 'bestellen', produkt: eintrag.enthaltenIn }
if (body.grund === 'gekuendigt') return meldung(TEXTE.gekuendigt)
if (body.grund === 'nicht_aktiv') return meldung(TEXTE.nichtAktiv)
if (body.grund === 'falsches_abo') return meldung(TEXTE.falschesAbo)
if (body.grund === 'beginnt_spaeter') {
const tag = berlinTag(body.ab)
return tag ? { art: 'spaeter', ab: body.ab, text: TEXTE.beginntSpaeter(datumLang(tag)) } : meldung(servertext)
}
return meldung(servertext)
}
case 'nur_betrieb':
return meldung(TEXTE.nurBetrieb(eintrag.name))
case 'bestellung_offen':
return { art: 'etage', etage: ETAGE_FUER[eintrag.key] }
case 'beginn_noetig':
return { art: 'beginn', ab: body.ab }
case 'bereits_eingeschaltet':
return { art: 'schon' }
case 'fassung_veraltet':
return { art: 'neu-laden', text: TEXTE.fassungVeraltet }
default:
return meldung(servertext)
}
}