In der neuen Ansicht trägt jede Funktion unter projects[].features.<key> zusätzlich kurzinfo (Text oder null). Nur aktive Funktionen bekommen einen Text: Terminplaner "3 Termine heute · nächster 10:30", Webshop "2 neue Bestellungen", News-Pinnwand "4 Beiträge sichtbar", Buchungskalender "1 offene Anfrage" bzw. "Nächste Anreise 24.10.", Öffnungszeiten live der Status der Website (ohne eigene Abfrage). Berliner Zeit, je Funktion höchstens zwei schmale Abfragen (limit, select, Index-Felder), alles gleichzeitig; jede Funktion einzeln begrenzt (1,5 s) und fehlertolerant: Fehler oder Zeitüberschreitung ergibt null. Ohne neue Ansicht bleibt die Antwort wie sie war und es wird nichts gelesen. Die Analyse hat keine Kurzinfo (Zahlen kommen aus dem Auswertungsdienst). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
718 lines
38 KiB
JavaScript
718 lines
38 KiB
JavaScript
import { Router } from 'express'
|
|
import { legalPaths } from '../services/analyseLegal.js'
|
|
import { config } from '../config.js'
|
|
import { getDocument } from '../services/appwriteAdmin.js'
|
|
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
|
import { withLocks } from '../services/keyedLock.js'
|
|
import { createFeatureCheckout, retrieveCheckoutSession } from '../services/stripeBilling.js'
|
|
import {
|
|
assertOwnProject,
|
|
backfillInvoiceLinks,
|
|
featureView,
|
|
getFeatureDoc,
|
|
hakenTexte,
|
|
listCustomerFeatureDocs,
|
|
listCustomerFeatures,
|
|
markOrdered,
|
|
runActivation,
|
|
unterlagenAdressen,
|
|
} from '../services/siteFeatures.js'
|
|
import { actingPerson, speichereNachweisAusAnfrage, zustimmungFuerSitzung } from '../services/featureConsents.js'
|
|
import { termineFuerKunde } from '../services/termine/schalter.js'
|
|
import { shopFuerKunde } from '../services/shop/schalter.js'
|
|
import { unterkunftFuerKunde } from '../services/unterkunft/schalter.js'
|
|
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 { aboProdukt } from '../services/aboFunktionen.js'
|
|
import { analyseUnterlagen } from '../services/aboUnterlagen.js'
|
|
import { anfrageStatusJeProjekt, katalogFuerKunde } from '../services/aboKatalog.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 { ergaenzeKurzinfos } from '../services/kundenansicht/kurzinfo.js'
|
|
import { abowegAnalyse, 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 {
|
|
activateAnalyse,
|
|
eligibilityMessage,
|
|
analyseDeps,
|
|
analyseHosts,
|
|
isDevPreview,
|
|
analyseEligibility,
|
|
controllerView,
|
|
deactivateAnalyse,
|
|
} from '../services/analyse.js'
|
|
|
|
const router = Router()
|
|
const DAY_MS = 24 * 60 * 60 * 1000
|
|
|
|
// Eigene Fehler (httpError) tragen einen Text fuer den Kunden. Fehler von Appwrite (err.type) oder
|
|
// unerwartete Fehler werden nur geloggt; der Kunde sieht einen verstaendlichen Hinweis statt der Rohmeldung.
|
|
// err.body (Checkout-Vorpruefungen: code, consent, offen, grund) geht mit in die Antwort, error bleibt der Text.
|
|
function fail(res, err, fallback) {
|
|
const own = ((err.status >= 400 && err.status < 500) || err.status === 503) && !err.type
|
|
if (own) return res.status(err.status).json({ ...err.body, error: err.message })
|
|
console.error('[funktionen]', err.status || '', err.type || '', err.message)
|
|
return res.status(500).json({ error: fallback })
|
|
}
|
|
|
|
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.'
|
|
|
|
async function analyseView(customerId, projectId, { aboModell = false } = {}) {
|
|
if (!config.analyse.enabled) return { comingSoon: true }
|
|
const [doc, eligibility, controller] = await Promise.all([
|
|
getFeatureDoc(customerId, projectId, 'analyse'),
|
|
analyseEligibility(customerId, projectId, { aboModell }),
|
|
controllerView(customerId),
|
|
])
|
|
let devMode = false
|
|
if (doc?.state === 'active') {
|
|
const project = eligibility.project || (await getDocument(config.collections.websiteProjects, projectId).catch(() => null))
|
|
devMode = isDevPreview(project)
|
|
}
|
|
return {
|
|
state: doc?.state || 'off',
|
|
devMode,
|
|
accentColor: doc?.accentColor || '#111111',
|
|
eligible: eligibility.ok,
|
|
reason: eligibility.reason || null,
|
|
reasonText: eligibility.reason ? eligibilityMessage(eligibility.reason, { aboModell }) : null,
|
|
// Neue Ansicht: Fassung und Adressen aus derselben Quelle wie der Nachweis (die Oberflaeche zeigt und sendet, was der Server prueft)
|
|
avvVersion: aboModell ? analyseUnterlagen().avvVersion : config.analyse.avvVersion,
|
|
legal: aboModell ? analyseUnterlagen().legal : legalPaths(config.analyse.avvVersion),
|
|
controller,
|
|
activatedAt: doc?.state === 'active' ? doc.activatedAt || null : null,
|
|
}
|
|
}
|
|
|
|
// Schalter aus: alle Analyse-Routen 409
|
|
function requireAnalyseEnabled(_req, res, next) {
|
|
if (!config.analyse.enabled) return res.status(409).json({ error: ANALYSE_OFF })
|
|
next()
|
|
}
|
|
|
|
function analyseProjectId(value) {
|
|
if (typeof value !== 'string' || !value) throw Object.assign(new Error('Bitte wähle ein Projekt.'), { status: 400 })
|
|
return value
|
|
}
|
|
|
|
const ABO_GRUENDE = ['kein_abo', 'gekuendigt', 'nicht_aktiv']
|
|
|
|
/**
|
|
* Terminplaner-Teil der Antwort von GET /api/funktionen. Nicht verfuegbar (Schalter, Pilotliste): genau { verfuegbar: false },
|
|
* das Abo wird dann nicht gelesen. Verfuegbar: dazu bestellbar (true | false | null), grund (kein_abo | gekuendigt | nicht_aktiv |
|
|
* null), preis und einrichtung aus dem Register sowie hinweis, der Abo-Text fuer den Kunden (aboMeldung, einzige Quelle der
|
|
* Abo-Hinweise; der Browser zeigt ihn nur an). hinweis ist null, solange bestellbar true ist. 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, abos = aboMemo()) {
|
|
if (!termineFuerKunde(email)) return { verfuegbar: false }
|
|
const register = featureConfig('termine')
|
|
let bestellbar = null
|
|
let grund = null
|
|
try {
|
|
const abo = await abos.bestellbar(customerId)
|
|
bestellbar = abo?.ok === true
|
|
grund = bestellbar || !ABO_GRUENDE.includes(abo?.grund) ? null : abo.grund
|
|
} catch (err) {
|
|
console.error(`[funktionen] Abo für den Terminplaner nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim())
|
|
}
|
|
const hinweis = bestellbar === true ? null : aboMeldung(register, grund ?? 'unbekannt')
|
|
return { verfuegbar: true, bestellbar, grund, preis: register?.priceLabel ?? '', einrichtung: register?.setupEstimate ?? '', hinweis }
|
|
}
|
|
|
|
/**
|
|
* Vertragsunterlagen je Funktion fuer die Liste (GET /api/funktionen, Feld legal): Funktionen mit Rechtsdokumenten (Terminplaner)
|
|
* { version, links: { lb, avv, widerruf } (absolut, versioniert), belehrung } mit der Widerrufsbelehrung samt Muster-Formular aus
|
|
* dem Haken belehrung (derselbe Text wie auf der Bestellseite und im Nachweis), alle anderen (News) null. Kann der Haken die
|
|
* Belehrung nicht liefern, steht belehrung null: die Liste faellt dafuer nicht aus.
|
|
*/
|
|
async function rechtlichesJeFunktion() {
|
|
const legal = {}
|
|
for (const key of featureKeys()) {
|
|
const unterlagen = unterlagenAdressen(featureConfig(key))
|
|
if (!unterlagen) {
|
|
legal[key] = null
|
|
continue
|
|
}
|
|
let belehrung = null
|
|
try {
|
|
belehrung = (await featureHaken(key).belehrung?.()) ?? null
|
|
} catch (err) {
|
|
console.error(`[funktionen] Widerrufsbelehrung (${key}) nicht lesbar: ${err?.name ?? 'Fehler'}`)
|
|
}
|
|
legal[key] = { version: unterlagen.fassung, links: { lb: unterlagen.lb, avv: unterlagen.avv, widerruf: unterlagen.widerruf }, belehrung }
|
|
}
|
|
return legal
|
|
}
|
|
|
|
/**
|
|
* 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), einschalten mit den Texten des Gratisweges (Zustimmung mit einem Haken, Knopf "Einschalten", Fassung der
|
|
* Leistungsbeschreibung) und belehrung: die Widerrufsbelehrung samt Muster-Formular, die die Bestellseite des bezahlten Weges (30 €, auch
|
|
* "Behalten") vor dem Knopf zeigt (Haken belehrung der Funktion, derselbe Text wie im Zustimmungsnachweis, Ruling R32; null, wenn der Haken
|
|
* sie nicht liefern kann: die Seite bestellt dann nicht). Die Zustimmung des bezahlten Weges steht in features.zeiten (Register-Ansicht).
|
|
* 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())
|
|
}
|
|
let belehrung = null
|
|
try {
|
|
belehrung = (await featureHaken('zeiten').belehrung?.()) ?? null
|
|
} catch (err) {
|
|
console.error(`[funktionen] Widerrufsbelehrung (zeiten) nicht lesbar: ${err?.name ?? 'Fehler'}`)
|
|
}
|
|
const { version, url } = register.leistungsbeschreibung
|
|
return {
|
|
verfuegbar: true,
|
|
inklusive,
|
|
grund,
|
|
preis: register.priceLabel,
|
|
einschalten: { consent: oeffentlicheConsent(freiweg(register).consent), leistungsbeschreibung: { version, url: url ?? '' } },
|
|
belehrung,
|
|
}
|
|
}
|
|
|
|
// Zustaende der Funktionen je Projekt; geschlossene Einrichtungs-Tickets dieses Kunden werden vorher freigeschaltet
|
|
router.get('/', requireSession, async (req, res) => {
|
|
try {
|
|
const customerId = getSessionCustomerId(req)
|
|
// 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)
|
|
// 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()
|
|
const neu = neueAnsicht(req)
|
|
// Ein Fehler bei einem Projekt (z. B. Abo-Pruefung) darf nicht die ganze Liste kosten
|
|
await Promise.all(
|
|
projects.map(async (p) => {
|
|
p.analyse = await analyseView(customerId, p.projectId, { aboModell: neu }).catch((err) => {
|
|
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()
|
|
const antwort = {
|
|
projects,
|
|
// Preise und Bestelltexte je Funktion aus dem Register (nur die oeffentliche Ansicht, auch wenn der Terminplaner
|
|
// dem Kunden noch nicht freigegeben ist); setupEstimate und consentText bleiben als News-Alias fuer aeltere Clients
|
|
features: Object.fromEntries(featureKeys().map((key) => [key, oeffentlicheAnsicht(key)])),
|
|
// Preishinweis fuer alle Bestellseiten: die einzige Stelle, an der er in die Oberflaeche gelangt
|
|
ustHinweis: config.ustHinweis,
|
|
// Vertragsunterlagen und Widerrufsbelehrung je Funktion (Terminplaner: Fassung, versionierte Links, Belehrung; News: null)
|
|
legal: await rechtlichesJeFunktion(),
|
|
setupEstimate: config.funktionen.setupEstimate,
|
|
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, abos),
|
|
// Webshop: Schalter SHOP_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel; Anfrage und Zustand je Projekt
|
|
// kommen aus projects[].features.shop und GET /api/shop/status
|
|
shop: { verfuegbar: shopFuerKunde(req.session.email) },
|
|
// Buchungskalender: Schalter UNTERKUNFT_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel
|
|
unterkunft: { verfuegbar: unterkunftFuerKunde(req.session.email) },
|
|
// 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),
|
|
}
|
|
// Kundenansicht neu (V5): Abo-Modell mit Kundenart, Abo-Stand, Katalog und Anfragestatus von Webshop und Buchungskalender je Projekt,
|
|
// dazu je Projekt und Funktion die Kurzinfo (Live-Detail der Zeile in "Meine", Text oder null; jede Abfrage begrenzt und fehlertolerant).
|
|
// Ohne neue Ansicht bleibt die Antwort wie sie war. Ein Lesefehler beim Abo-Stand ist ein Fehler der Liste (nie ein erfundenes "kein Abo").
|
|
if (neu) {
|
|
const [kunde, abo, katalog, anfrageStatus] = await Promise.all([
|
|
kundenDokument(customerId),
|
|
aboProdukt(customerId, { now: jetzt }),
|
|
katalogFuerKunde({ email: req.session.email }),
|
|
anfrageStatusJeProjekt({ docs, projects }),
|
|
ergaenzeKurzinfos({ projects, docs, jetzt }).catch((err) => console.error(`[funktionen] Kurzinfo nicht lesbar: ${err?.name ?? 'Fehler'}`)),
|
|
])
|
|
for (const p of projects) {
|
|
p.features.shop.anfrageStatus = anfrageStatus.get(p.projectId)?.shop ?? ''
|
|
p.features.unterkunft.anfrageStatus = anfrageStatus.get(p.projectId)?.unterkunft ?? ''
|
|
}
|
|
Object.assign(antwort, { modell: 'abo', kundenart: kundenartWirksam(kunde), abo, katalog })
|
|
}
|
|
res.json(antwort)
|
|
} catch (err) {
|
|
fail(res, err, 'Die Funktionen konnten gerade nicht geladen werden. Bitte lade die Seite gleich noch einmal.')
|
|
}
|
|
})
|
|
|
|
/**
|
|
* Offene Bestellung: die gespeicherte Stripe-Sitzung entscheidet. open -> dieselbe Zahlungsseite nochmal
|
|
* (z. B. nach "Zurueck" im Checkout), expired -> null (neue Sitzung), complete -> 409 (Zahlung laeuft
|
|
* oder ist da, der Webhook richtet ein). Ist Stripe nicht erreichbar, gilt wie frueher: frisch (< 24 h) -> 409.
|
|
* Die Meldung bei 409 steht im Register der Funktion (orderedConflictMessage).
|
|
*/
|
|
async function openOrderSession(doc, feature) {
|
|
const conflict = () => Object.assign(new Error(feature.orderedConflictMessage), { status: 409 })
|
|
if (!doc.stripeSessionId) return null
|
|
let session
|
|
try {
|
|
session = await retrieveCheckoutSession(doc.stripeSessionId)
|
|
} catch (err) {
|
|
console.warn('[funktionen] Sitzung nicht abrufbar:', err.message)
|
|
if (Date.now() - new Date(doc.orderedAt || 0).getTime() < DAY_MS) throw conflict()
|
|
return null
|
|
}
|
|
if (session.status === 'open' && session.url) return { id: session.id, url: session.url }
|
|
if (session.status === 'expired') return null
|
|
throw conflict()
|
|
}
|
|
|
|
const ZUSTIMMUNG_NOETIG = 'Bitte stimme der Erklärung zu, um zu bestellen.'
|
|
|
|
/** 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'
|
|
)
|
|
}
|
|
}
|
|
|
|
// Checkout je Funktion (Register config.features); /news/checkout ist derselbe Pfad mit key = news.
|
|
// Vorpruefungen in der Reihenfolge der Spezifikation, jede Stufe ohne Seiteneffekt (keine Sperre, keine Stripe-Sitzung,
|
|
// kein Dokument, kein Nachweis): 1 Kundenansicht, 2 Funktionsschluessel, 3 Zustimmung, 4 Projekt, 5 Schalter,
|
|
// 5b Unterlagen, 6 Bestellpruefung, 7 Wartungs-Abo, 7b inklusive (Abo laeuft: einschalten statt bezahlen).
|
|
router.post('/:key/checkout', requireSession, async (req, res) => {
|
|
// Zeit des Klicks: Eingang der Anfrage, vor jeder Sperre und jedem Stripe-Aufruf. Sie ist der eine Zeitpunkt von Nachweis,
|
|
// Stripe-Metadaten (consentAt) und Dokument und damit der Anker der Widerrufsfrist (UTC mit Z)
|
|
const klickAt = new Date().toISOString()
|
|
try {
|
|
// 1 Kundenansicht
|
|
if (req.session.viewAs) {
|
|
return res.status(403).json({ error: 'In der Kundenansicht kann nicht bestellt werden.' })
|
|
}
|
|
// 2 Funktionsschluessel (analyse hat einen eigenen Weg und steht nicht im Register)
|
|
const key = req.params.key
|
|
const feature = featureConfig(key)
|
|
if (!feature) return res.status(404).json({ error: 'Unbekannte Funktion' })
|
|
const body = req.body || {}
|
|
// 3 Zustimmung: alle Pflicht-Haken und die aktuelle Fassung; bei "Zahlung fortsetzen" entfaellt sie (Stufe 4 prueft dafuer ordered)
|
|
const fortsetzen = istFortsetzen(body)
|
|
if (!fortsetzen) pruefeZustimmung(feature, body)
|
|
// 4 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 })
|
|
if (fortsetzen && (await getFeatureDoc(customerId, projectId, key))?.state !== 'ordered') {
|
|
throw bestellFehler(400, ZUSTIMMUNG_NOETIG, 'zustimmung_fehlt')
|
|
}
|
|
// 5 Schalter (Terminplaner: TERMINE_ENABLED und Pilotliste)
|
|
const sperre = featureSperre(key, req.session.email)
|
|
if (sperre) throw bestellFehler(409, sperre, 'nicht_verfuegbar')
|
|
// 5b Vertragsunterlagen abrufbar (vor jeder Stripe-Sitzung)
|
|
const haken = featureHaken(key)
|
|
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 })
|
|
if (!Array.isArray(offen)) throw new TypeError('bestellpruefung muss eine Liste liefern')
|
|
if (offen.length > 0) {
|
|
throw bestellFehler(409, 'Die Bestellung ist noch nicht vollständig. Bitte erledige zuerst die offenen Punkte.', 'bestellung_offen', { offen })
|
|
}
|
|
}
|
|
// 7 Wartungs-Abo (nur Funktionen mit requiresWartungsAbo); die einzige Quelle von kein_abo | gekuendigt | nicht_aktiv
|
|
await pruefeAbo(feature, customerId)
|
|
// 7b Funktionen mit aboInklusive (Oeffnungszeiten live): im laufenden Wartungs-Abo gibt es nichts zu bezahlen (409 inklusive)
|
|
await pruefeInklusive(feature, customerId)
|
|
|
|
// Zustand, Sitzung, Nachweis und Eintrag als Ganzes je (Kunde, Projekt): zwei schnelle Klicks duerfen keine zwei Sitzungen
|
|
// erzeugen, sonst ueberschreibt die zweite die ID der ersten und deren Zahlung findet kein Dokument
|
|
const session = await withLocks([`feature-order:${customerId}:${projectId}`], async () => {
|
|
// a Zustand vor jedem Stripe-Aufruf: bezahlt/aktiv und abgeschaltet sind 409 (Meldungen aus dem Register)
|
|
const existing = await getFeatureDoc(customerId, projectId, key)
|
|
if (existing?.state === 'setup' || existing?.state === 'active') {
|
|
throw Object.assign(new Error(feature.orderedConflictMessage), { status: 409 })
|
|
}
|
|
if (existing?.state === 'disabled') throw Object.assign(new Error(feature.disabledMessage), { status: 409 })
|
|
|
|
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
|
|
if (open) {
|
|
// d Liegt zur Sitzung noch kein Nachweis, wird er vor der URL nachgetragen. "Zahlung fortsetzen" (ohne Zustimmung)
|
|
// gibt die URL nur her, wenn er schon liegt, sonst stimmt der Kunde im Formular neu zu
|
|
if (!(await zustimmungFuerSitzung(open.id))) {
|
|
if (fortsetzen) throw bestellFehler(409, ZUSTIMMUNG_NOETIG, 'zustimmung_fehlt')
|
|
await nachweis(open.id)
|
|
}
|
|
return open
|
|
}
|
|
// keine offene Sitzung (abgelaufen, nie angelegt): "Zahlung fortsetzen" legt nie eine neue an, der Kunde stimmt im Formular neu zu
|
|
if (fortsetzen) throw bestellFehler(409, ZUSTIMMUNG_NOETIG, 'zustimmung_fehlt')
|
|
|
|
// c neue Sitzung: die Stripe-Sitzung entsteht zuerst, weil der Nachweis ihre ID traegt
|
|
const customer = await getDocument(config.collections.customers, customerId)
|
|
const created = await createFeatureCheckout(customer, { projectId, key, consentAt: klickAt })
|
|
// d Nachweis vor allem anderen: scheitert er, gibt es keine URL und kein ordered (die unerreichbare Stripe-Sitzung laeuft
|
|
// nach 24 h ab, ein Storno ist nicht noetig)
|
|
await nachweis(created.id)
|
|
// e erst nach dem Nachweis wird die Bestellung eingetragen
|
|
await markOrdered({
|
|
customerId,
|
|
projectId,
|
|
key,
|
|
sessionId: created.id,
|
|
consent: { text: hakenTexte(feature), at: klickAt },
|
|
})
|
|
return created
|
|
})
|
|
// f die URL geht erst jetzt an den Browser
|
|
res.json({ url: session.url })
|
|
} catch (err) {
|
|
fail(res, err, 'Die Bestellung konnte gerade nicht gestartet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.')
|
|
}
|
|
})
|
|
|
|
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 K27; ein zweiter Klick holt Fehlendes nach.
|
|
// 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
|
|
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)
|
|
// nur { zustimmung }: die Altform { consent: true } (News, ohne Fassungspruefung) gilt bei dieser neuen Route nicht, sonst bestaetigte ein
|
|
// alter Tab nach einer Textaenderung den neuen Wortlaut, ohne ihn gesehen zu haben
|
|
pruefeZustimmung(freiweg(feature), { zustimmung: body.zustimmung })
|
|
// 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 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
|
|
// Checkout, jede Stufe ohne Seiteneffekt: 1 Kundenansicht, 2 Zustimmung (die der Bestellung, beide Haken), 3 Projekt (eigen; eine laufende Funktion
|
|
// zu behalten ist keine Neubestellung, ein archiviertes Projekt bleibt erlaubt), 4 Schalter/Pilot, 5 Unterlagen. Ob behalten werden darf, entscheidet
|
|
// das Dokument unter der Sperre (starteBehalten: aboEndeAt, sonst 409); den Zustand aendert die Route nicht, erst die bezahlte Sitzung (Webhook).
|
|
// Die Route steht nicht auf der Erlaubnisliste der Kundenansicht (Mitarbeiter bestellen nicht fuer den Kunden); die Pruefung hier ist die zweite Sicherung.
|
|
router.post('/zeiten/behalten', requireSession, async (req, res) => {
|
|
// Zeit des Klicks: Eingang der Anfrage, Anker des Nachweises und der Metadaten (consentAt) und damit der Widerrufsfrist
|
|
const now = new Date()
|
|
try {
|
|
// 1 Kundenansicht
|
|
if (req.session.viewAs) return res.status(403).json({ error: 'In der Kundenansicht kann nicht bestellt werden.' })
|
|
const feature = featureConfig('zeiten')
|
|
const body = req.body || {}
|
|
// 2 Zustimmung: alle Pflicht-Haken der Bestellung und die aktuelle Fassung
|
|
pruefeZustimmung(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)
|
|
// 4 Schalter (ZEITEN_ENABLED und Pilotliste)
|
|
const sperre = featureSperre('zeiten', req.session.email)
|
|
if (sperre) throw bestellFehler(409, sperre, 'nicht_verfuegbar')
|
|
// 5 Vertragsunterlagen abrufbar
|
|
const haken = featureHaken('zeiten')
|
|
await pruefeUnterlagen(haken)
|
|
|
|
const customer = await getDocument(config.collections.customers, customerId)
|
|
const { url } = await starteBehalten({
|
|
customer,
|
|
projectId,
|
|
now,
|
|
nachweis: (sessionId) => speichereNachweisAusAnfrage(req, { feature, haken, customerId, projectId, sessionId, klickAt: now.toISOString() }),
|
|
})
|
|
res.json({ url })
|
|
} catch (err) {
|
|
fail(res, err, BEHALTEN_ERROR)
|
|
}
|
|
})
|
|
|
|
const analyseGuard = [requireSession, requireAnalyseEnabled]
|
|
|
|
router.post('/analyse/activate', analyseGuard, async (req, res) => {
|
|
try {
|
|
if (req.session.viewAs) {
|
|
return res.status(403).json({ error: 'In der Kundenansicht kann nichts geändert werden.' })
|
|
}
|
|
const { projectId, accentColor, consent } = req.body || {}
|
|
// Neue Ansicht (Abo-Modell): die Zielgruppen-Aussage (keine Website fuer Kinder unter 16) ist serverseitig erzwungen, nicht nur ein
|
|
// Haekchen im Browser; vor jedem Schreiben. Die alte Ansicht bleibt unveraendert.
|
|
const neu = neueAnsicht(req)
|
|
if (neu && req.body?.audienceConfirmed !== true) {
|
|
throw bestellFehler(400, 'Bitte bestätige, dass sich deine Website nicht gezielt an Kinder unter 16 Jahren richtet.', 'zielgruppe_fehlt')
|
|
}
|
|
const customerId = getSessionCustomerId(req)
|
|
await assertOwnProject(customerId, analyseProjectId(projectId), { forNewOrder: true })
|
|
// Nachweis der Erklaerung (featureConsents) mit der Kundenart des Dokuments, nie aus dem Body; entsteht nur bei einer neuen Aktivierung.
|
|
// abrechnung 'abo': dasselbe Marker-Paar am Dokument wie beim Team-Weg (admin/analyse.js), unabhaengig vom Body
|
|
const abo = neu
|
|
? {
|
|
aboModell: true,
|
|
abrechnung: 'abo',
|
|
nachweis: async () =>
|
|
speichereNachweisAusAnfrage(req, {
|
|
feature: abowegAnalyse(),
|
|
customerId,
|
|
projectId,
|
|
klickAt: einschaltenDeps.jetzt().toISOString(),
|
|
kundenart: kundenartWirksam(await kundenDokument(customerId)),
|
|
}),
|
|
}
|
|
: {}
|
|
const { doc } = await activateAnalyse({
|
|
customerId,
|
|
projectId: analyseProjectId(projectId),
|
|
accentColor,
|
|
consent,
|
|
by: 'customer',
|
|
actor: actingPerson(req.session),
|
|
...abo,
|
|
})
|
|
res.json({ ok: true, state: doc.state })
|
|
} catch (err) {
|
|
fail(res, err, ANALYSE_ERROR)
|
|
}
|
|
})
|
|
|
|
router.post('/analyse/deactivate', analyseGuard, async (req, res) => {
|
|
try {
|
|
if (req.session.viewAs) {
|
|
return res.status(403).json({ error: 'In der Kundenansicht kann nichts geändert werden.' })
|
|
}
|
|
const { doc } = await deactivateAnalyse({
|
|
customerId: getSessionCustomerId(req),
|
|
projectId: analyseProjectId(req.body?.projectId),
|
|
by: 'customer',
|
|
actor: actingPerson(req.session),
|
|
})
|
|
res.json({ ok: true, state: doc?.state || 'off' })
|
|
} catch (err) {
|
|
fail(res, err, ANALYSE_ERROR)
|
|
}
|
|
})
|
|
|
|
// Eigenes Projekt und Analyse-Dokument mit siteKey (aktiv oder aus: Daten liegen noch bis zu 60 Tage)
|
|
async function ownAnalyseDoc(req, projectId) {
|
|
const customerId = getSessionCustomerId(req)
|
|
const project = await assertOwnProject(customerId, analyseProjectId(projectId))
|
|
const doc = await getFeatureDoc(customerId, projectId, 'analyse')
|
|
return { project, doc }
|
|
}
|
|
|
|
router.get('/analyse/stats', analyseGuard, async (req, res) => {
|
|
try {
|
|
const range = String(req.query.range || '30')
|
|
if (!['7', '30', '90'].includes(range)) return res.status(400).json({ error: 'Ungültiger Zeitraum' })
|
|
const { doc } = await ownAnalyseDoc(req, req.query.projectId)
|
|
if (!doc?.siteKey || !['active', 'off'].includes(doc.state)) {
|
|
return res.status(409).json({ error: 'Für dieses Projekt gibt es keine Auswertung.' })
|
|
}
|
|
let stats
|
|
try {
|
|
stats = await analyseDeps.client.getStats(doc.siteKey, Number(range))
|
|
} catch (err) {
|
|
console.error('[funktionen] Auswertung:', err.status || '', err.message)
|
|
return res.status(503).json({ error: 'Auswertung gerade nicht erreichbar' })
|
|
}
|
|
res.json(stats)
|
|
} catch (err) {
|
|
fail(res, err, 'Die Auswertung konnte gerade nicht geladen werden.')
|
|
}
|
|
})
|
|
|
|
router.post('/analyse/heatmap-link', analyseGuard, async (req, res) => {
|
|
try {
|
|
const { projectId, path, vp, embed } = req.body || {}
|
|
if (typeof path !== 'string' || !path.startsWith('/') || path.length > 300 || /[?#\\\s\u0000-\u001f\u007f]/.test(path)) {
|
|
return res.status(400).json({ error: 'Ungültiger Pfad' })
|
|
}
|
|
if (!['m', 't', 'd'].includes(vp)) return res.status(400).json({ error: 'Ungültige Ansicht' })
|
|
const { project, doc } = await ownAnalyseDoc(req, projectId)
|
|
if (!doc?.siteKey || doc.state !== 'active') {
|
|
return res.status(409).json({ error: 'Die Website-Analyse ist für dieses Projekt nicht aktiv.' })
|
|
}
|
|
const domains = analyseHosts(project)
|
|
if (!domains.length) return res.status(409).json({ error: 'Keine gültige Domain' })
|
|
const host = project.liveDomain ? domains[domains.length - 1] : domains[0]
|
|
const token = analyseDeps.client.heatToken({ siteKey: doc.siteKey, path, vp })
|
|
// embed: Ansicht im Geraeterahmen des Portals (h.js ohne Leiste, Links gesperrt)
|
|
res.json({ url: `https://${host}${path}?__wk_heat=${encodeURIComponent(token)}${embed === true ? '&__wk_embed=1' : ''}` })
|
|
} catch (err) {
|
|
fail(res, err, 'Der Heatmap-Link konnte gerade nicht erstellt werden.')
|
|
}
|
|
})
|
|
|
|
export default router
|