Kundenansicht P5: POST /api/funktionen/:key/einschalten (nur neue Ansicht)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f5b43c55a9
commit
e30318a470
3 files changed
+431
-15
No files matched your search
+110
-2
@@ -25,7 +25,11 @@ import { zeitenFuerKunde } from '../services/zeiten/schalter.js'
|
||||
import { einschaltenDeps, einschaltenZeiten, zeitenProjektAnsicht } from '../services/zeiten/einschalten.js'
|
||||
import { starteBehalten } from '../services/zeiten/behalten.js'
|
||||
import { aboMemo } from '../services/aboStatus.js'
|
||||
import { featureConfig, featureHaken, featureKeys, featureSperre, freiweg, oeffentlicheAnsicht } from '../services/featureRegistry.js'
|
||||
import { aboProdukt } from '../services/aboFunktionen.js'
|
||||
import { datumDe, einschalten, EINSCHALTEN_TEXTE, pruefeAboDeckung, pruefeEinschaltZustand } from '../services/funktionEinschalten.js'
|
||||
import { kundenartWirksam, NUR_BETRIEB_TEXT } from '../services/kundenart.js'
|
||||
import { neueAnsicht } from '../services/kundenansicht/schalter.js'
|
||||
import { aboweg, consentAbo, consentAboAktiv, featureConfig, featureHaken, featureKeys, featureSperre, freiweg, katalogFelder, oeffentlicheAnsicht } from '../services/featureRegistry.js'
|
||||
import { aboMeldung, bestellFehler, istFortsetzen, oeffentlicheConsent, pruefeAbo, pruefeInklusive, pruefeZustimmung } from '../services/featureOrder.js'
|
||||
|
||||
import {
|
||||
@@ -390,7 +394,10 @@ const EINSCHALTEN_ERROR = 'Öffnungszeiten live konnte gerade nicht eingeschalte
|
||||
// 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 K27; ein zweiter Klick holt Fehlendes nach.
|
||||
router.post('/zeiten/einschalten', requireSession, async (req, res) => {
|
||||
// Weiche (Kundenansicht neu): wer die neue Ansicht sieht, geht an POST /:key/einschalten (unten); alle anderen behalten diesen Weg unveraendert
|
||||
const alteAnsichtNur = (req, _res, next) => (neueAnsicht(req) ? next('route') : next())
|
||||
|
||||
router.post('/zeiten/einschalten', requireSession, alteAnsichtNur, async (req, res) => {
|
||||
const now = new Date()
|
||||
try {
|
||||
// 1 Kundenansicht: Mitarbeiter schalten nicht fuer den Kunden ein
|
||||
@@ -426,6 +433,107 @@ router.post('/zeiten/einschalten', requireSession, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
const EINSCHALTEN_NEU_ERROR = 'Die Funktion konnte gerade nicht eingeschaltet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.'
|
||||
const ZUSTIMMUNG_EINSCHALTEN = 'Bitte bestätige die Erklärung, um einzuschalten.'
|
||||
|
||||
/** Kundendokument einmal lesen; ein unbekannter Kunde ergibt {} (Kundenart privat). */
|
||||
async function kundenDokument(customerId) {
|
||||
try {
|
||||
return await getDocument(config.collections.customers, customerId)
|
||||
} catch (err) {
|
||||
if (err.status === 404) return {}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Einschalten im Abo-Modell (V5, nur neue Ansicht; Spec 5.4): ohne Zahlung, mit Einrichtungs-Ticket. Vorpruefungen, jede ohne Seiteneffekt:
|
||||
// 0 neue Ansicht (sonst 404), 1 Kundenansicht (403), 2 Funktionsschluessel, 3 Eingabe (Projekt, Zustimmung), 4 Projekt,
|
||||
// 5 409 in dieser Reihenfolge: nicht_verfuegbar (dann 503 unterlagen_fehlen), abo_fehlt, nur_betrieb, bestellung_offen, beginn_noetig,
|
||||
// bereits_eingeschaltet | bestellung_laeuft | abgeschaltet, fassung_veraltet; 6 einschalten() (Sperre, Abo frisch, Nachweis, Zustand, Ticket).
|
||||
// Die Kundenart kommt nur aus dem Kundendokument, nie aus dem Koerper.
|
||||
router.post('/:key/einschalten', requireSession, async (req, res) => {
|
||||
const jetzt = einschaltenDeps.jetzt()
|
||||
try {
|
||||
if (!neueAnsicht(req)) return res.status(404).json({ error: 'Nicht gefunden' })
|
||||
if (req.session.viewAs) return res.status(403).json({ error: 'In der Kundenansicht kann nicht eingeschaltet werden.' })
|
||||
const key = req.params.key
|
||||
const feature = featureConfig(key)
|
||||
if (!feature) return res.status(404).json({ error: 'Unbekannte Funktion' })
|
||||
const body = req.body || {}
|
||||
const { projectId, zustimmung } = body
|
||||
if (typeof projectId !== 'string' || !projectId) return res.status(400).json({ error: 'Bitte wähle ein Projekt.' })
|
||||
const zustimmungOk =
|
||||
zustimmung !== null && typeof zustimmung === 'object' && !Array.isArray(zustimmung) && typeof zustimmung.version === 'string' &&
|
||||
Array.isArray(zustimmung.haken) && zustimmung.haken.every((id) => id === 'beginn')
|
||||
if (!zustimmungOk) throw bestellFehler(400, ZUSTIMMUNG_EINSCHALTEN, 'zustimmung_fehlt')
|
||||
const customerId = getSessionCustomerId(req)
|
||||
await assertOwnProject(customerId, projectId, { forNewOrder: true })
|
||||
|
||||
// nicht_verfuegbar (Schalter der Funktion), danach die Vertragsunterlagen
|
||||
const sperre = featureSperre(key, req.session.email)
|
||||
if (sperre) throw bestellFehler(409, sperre, 'nicht_verfuegbar')
|
||||
const haken = featureHaken(key)
|
||||
await pruefeUnterlagen(haken)
|
||||
// abo_fehlt: das Abo frisch lesen, Lesefehler sind 503
|
||||
await pruefeAboDeckung(customerId, key, jetzt)
|
||||
// nur_betrieb
|
||||
const kunde = await kundenDokument(customerId)
|
||||
const privat = kundenartWirksam(kunde) === 'privat'
|
||||
if (privat && katalogFelder(key).nurBetrieb) throw bestellFehler(409, NUR_BETRIEB_TEXT, 'nur_betrieb')
|
||||
// bestellung_offen: offene Punkte der Funktion (Fragebogen, Freigabe der Anfrage)
|
||||
if (haken.bestellpruefung) {
|
||||
const offen = await haken.bestellpruefung({ customerId, projectId, feature })
|
||||
if (!Array.isArray(offen)) throw new TypeError('bestellpruefung muss eine Liste liefern')
|
||||
if (offen.length > 0) throw bestellFehler(409, 'Es fehlen noch Angaben, bevor du einschalten kannst.', 'bestellung_offen', { offen })
|
||||
}
|
||||
// beginn_noetig: Privatkunden innerhalb der Widerrufsfrist brauchen das Haekchen fuer den sofortigen Beginn
|
||||
let fristBis = null
|
||||
if (privat) {
|
||||
try {
|
||||
fristBis = (await aboProdukt(customerId, { now: jetzt })).widerrufsfristBis
|
||||
} catch (err) {
|
||||
console.error(`[funktionen] Widerrufsfrist für das Einschalten nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim())
|
||||
throw bestellFehler(503, EINSCHALTEN_TEXTE.nichtPruefbar)
|
||||
}
|
||||
}
|
||||
const beginnNoetig = privat && fristBis !== null && Date.parse(fristBis) >= jetzt.getTime()
|
||||
const gesetzt = privat && zustimmung.haken.includes('beginn')
|
||||
if (beginnNoetig && !gesetzt) {
|
||||
const ab = new Date(Date.parse(fristBis) + 1).toISOString()
|
||||
throw bestellFehler(409, `Für den sofortigen Beginn brauchst du das Häkchen. Ohne Häkchen kannst du ab dem ${datumDe(ab)} einschalten.`, 'beginn_noetig', { ab })
|
||||
}
|
||||
// bereits_eingeschaltet | bestellung_laeuft | abgeschaltet (setup ohne Ticket durch Abo oder Projekt heilt der Dienst)
|
||||
pruefeEinschaltZustand(await getFeatureDoc(customerId, projectId, key))
|
||||
// fassung_veraltet: der Kunde hat einen alten Wortlaut gesehen
|
||||
if (zustimmung.version !== consentAbo(key).version) {
|
||||
throw bestellFehler(409, 'Die Erklärung wurde inzwischen geändert. Bitte lies sie noch einmal und schalte dann ein.', 'fassung_veraltet', {
|
||||
consent: consentAboAktiv(key, { beginn: beginnNoetig }),
|
||||
})
|
||||
}
|
||||
|
||||
const { doc, ticketWoid } = await einschalten({
|
||||
customerId,
|
||||
projectId,
|
||||
key,
|
||||
by: 'kunde',
|
||||
abrechnung: 'abo',
|
||||
now: jetzt,
|
||||
// Nachweis ohne Stripe-Sitzung und ohne Belehrung; ein mitgeschicktes beginn zaehlt nur fuer Privatkunden
|
||||
nachweis: () =>
|
||||
speichereNachweisAusAnfrage(req, {
|
||||
feature: aboweg(feature, { beginn: gesetzt }),
|
||||
customerId,
|
||||
projectId,
|
||||
klickAt: jetzt.toISOString(),
|
||||
kundenart: kundenartWirksam(kunde),
|
||||
}),
|
||||
})
|
||||
res.json({ feature: await featureView(doc, key), ticketWoid })
|
||||
} catch (err) {
|
||||
fail(res, err, EINSCHALTEN_NEU_ERROR)
|
||||
}
|
||||
})
|
||||
|
||||
const BEHALTEN_ERROR = 'Das Behalten konnte gerade nicht gestartet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.'
|
||||
|
||||
// Behalten (Spec 3.3): nach dem Ende des Wartungs-Abos Öffnungszeiten live fuer einmalig 30 € behalten, ohne neuen Einbau. Vorpruefungen wie im
|
||||
|
||||
@@ -34,6 +34,14 @@ export const EINSCHALTEN_TEXTE = Object.freeze({
|
||||
nichtPruefbar: 'Gerade nicht prüfbar – bitte versuch es gleich noch einmal.',
|
||||
})
|
||||
|
||||
/** Berliner Kalendertag eines Zeitpunkts (ISO) als TT.MM.JJJJ; kein Zeitpunkt ist ein TypeError. */
|
||||
export function datumDe(zeitpunkt) {
|
||||
const ms = Date.parse(zeitpunkt)
|
||||
if (Number.isNaN(ms)) throw new TypeError(`datumDe: kein Zeitpunkt: ${String(zeitpunkt)}`)
|
||||
const [jahr, monat, tag] = berlinDay(new Date(ms)).split('-')
|
||||
return `${tag}.${monat}.${jahr}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Kundentext zu einem Abo-Grund. Bei beginnt_spaeter steht der Berliner Tag von `ab` (ISO) als TT.MM.JJJJ im Text,
|
||||
* z. B. "Dein Abo beginnt am 03.11.2026. Ab dann kannst du einschalten."
|
||||
@@ -41,11 +49,7 @@ export const EINSCHALTEN_TEXTE = Object.freeze({
|
||||
export function aboFehlerText(grund, ab = null) {
|
||||
if (!Object.hasOwn(EINSCHALTEN_TEXTE.abo, grund)) throw new TypeError(`aboFehlerText: unbekannter Grund ${String(grund)}`)
|
||||
const text = EINSCHALTEN_TEXTE.abo[grund]
|
||||
if (grund !== 'beginnt_spaeter') return text
|
||||
const ms = Date.parse(ab)
|
||||
if (Number.isNaN(ms)) throw new TypeError('aboFehlerText: beginnt_spaeter braucht ab als Zeitpunkt')
|
||||
const [jahr, monat, tag] = berlinDay(new Date(ms)).split('-')
|
||||
return text.replace('{datum}', `${tag}.${monat}.${jahr}`)
|
||||
return grund === 'beginnt_spaeter' ? text.replace('{datum}', datumDe(ab)) : text
|
||||
}
|
||||
|
||||
/** 409 abo_fehlt: body.grund, body.wechsel (Hosting-Kunde: Wechsel ins Wartungs-Abo anbieten) und bei beginnt_spaeter body.ab. */
|
||||
@@ -54,8 +58,11 @@ const aboFehlt = (grund, ab) =>
|
||||
|
||||
const konflikt = (code, text) => bestellFehler(409, text, code)
|
||||
|
||||
/** Abo frisch lesen und gegen die Funktion pruefen (immer ohne Zwischenspeicher); Lesefehler sind 503, nie stillschweigend erlaubt. */
|
||||
async function pruefeAbo(customerId, key, now) {
|
||||
/**
|
||||
* Abo frisch lesen und gegen die Funktion pruefen (immer ohne Zwischenspeicher): 409 abo_fehlt oder nichts. Lesefehler sind 503,
|
||||
* nie stillschweigend erlaubt. Auch fuer die Vorpruefungen der Route.
|
||||
*/
|
||||
export async function pruefeAboDeckung(customerId, key, now = new Date()) {
|
||||
let ergebnis
|
||||
try {
|
||||
ergebnis = await aboDeckt(customerId, key, { now })
|
||||
@@ -69,8 +76,11 @@ async function pruefeAbo(customerId, key, now) {
|
||||
/** setup ohne Ticket und durch Abo oder Projekt eingeschaltet: der Dienst heilt (nur das Ticket), es entsteht nichts Neues. */
|
||||
const heilbar = (doc) => doc?.state === 'setup' && !doc.ticketId && ['abo', 'projekt'].includes(unlockedByVon(doc))
|
||||
|
||||
/** Zustand des Dokuments pruefen: wirft den Konflikt mit Code; Heilung und ein frisches oder ausgeschaltetes Dokument sind frei. */
|
||||
function pruefeZustand(doc) {
|
||||
/**
|
||||
* Zustand des Dokuments pruefen: wirft den 409 mit Code (bestellung_laeuft, abgeschaltet, bereits_eingeschaltet); ein fehlendes oder
|
||||
* ausgeschaltetes (off) Dokument und die Heilung (setup ohne Ticket, durch Abo oder Projekt) sind frei. Auch fuer die Vorpruefungen der Route.
|
||||
*/
|
||||
export function pruefeEinschaltZustand(doc) {
|
||||
if (!doc || doc.state === 'off') return
|
||||
if (doc.state === 'ordered') throw konflikt('bestellung_laeuft', EINSCHALTEN_TEXTE.laeuft)
|
||||
if (doc.state === 'disabled') throw konflikt('abgeschaltet', EINSCHALTEN_TEXTE.abgeschaltet)
|
||||
@@ -118,8 +128,8 @@ export async function einschalten({ customerId, projectId, key, by, abrechnung,
|
||||
// liefert dieselben Codes wie unten (nur fuer den Kunden, das Team schaltet wie bisher)
|
||||
if (key === 'zeiten') {
|
||||
const vorpruefung = async () => {
|
||||
await pruefeAbo(customerId, key, now)
|
||||
pruefeZustand(await getFeatureDoc(customerId, projectId, key))
|
||||
await pruefeAboDeckung(customerId, key, now)
|
||||
pruefeEinschaltZustand(await getFeatureDoc(customerId, projectId, key))
|
||||
}
|
||||
const { doc } = await einschaltenZeiten({ customerId, projectId, by, staff, nachweis, now, abrechnung, vorpruefung })
|
||||
return { doc, ticketWoid: await ticketWoid(doc.ticketId) }
|
||||
@@ -127,11 +137,11 @@ export async function einschalten({ customerId, projectId, key, by, abrechnung,
|
||||
|
||||
const doc = await withLocks([`feature-order:${customerId}:${projectId}`], async () => {
|
||||
// 1 Abo frisch (nie aus einem Zwischenspeicher); fuer das Team entfaellt es
|
||||
if (by === 'kunde') await pruefeAbo(customerId, key, now)
|
||||
if (by === 'kunde') await pruefeAboDeckung(customerId, key, now)
|
||||
|
||||
// 2 Zustand: Heilung (setup ohne Ticket, durch Abo oder Projekt) legt nur das Ticket an
|
||||
const bisher = await getFeatureDoc(customerId, projectId, key)
|
||||
pruefeZustand(bisher)
|
||||
pruefeEinschaltZustand(bisher)
|
||||
if (heilbar(bisher)) {
|
||||
const gespeichert = Object.hasOwn(ABRECHNUNGS_ZEILEN, bisher.einrichtungAbrechnung) ? bisher.einrichtungAbrechnung : abrechnung
|
||||
return legeTicketAn(bisher, { key, feature, project, customerId, abrechnung: gespeichert })
|
||||
|
||||
Reference in new issue
Block a user