Merge feature/terminplaner-tp2 (a629b25) in feature/oeffnungszeiten
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
1d5a565e9f
95 files changed
+12879
-546
No files matched your search
+33
-16
@@ -19,6 +19,7 @@ import {
|
||||
switchToWartung,
|
||||
} from '../services/planSwitch.js'
|
||||
import { handleFeatureCheckoutEvent, isFeatureCheckoutEvent, paidFeatureOrders } from '../services/siteFeatures.js'
|
||||
import { featureConfig } from '../services/featureRegistry.js'
|
||||
import { PRODUCT_KEYS, contractAttachmentFor, describeItems, isTermsV4OrNewer, productLabel, productView, termsDocFor } from '../services/products.js'
|
||||
import {
|
||||
ACTIVE_STATUSES,
|
||||
@@ -705,13 +706,15 @@ export function publicRateLimitState() {
|
||||
|
||||
// Auswahl im Formular: Vertrag (Kuendigung) und Bestellung (Widerruf), Standard jeweils der erste Wert
|
||||
const CONTRACTS = ['abo', 'zusatzspeicher']
|
||||
const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion']
|
||||
const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'termine']
|
||||
// Bestellung einer Funktion -> Funktionsschluessel im Register ('funktion' meint die News-Pinnwand; jede Funktion hat ihre eigene Frist)
|
||||
const FEATURE_OF_ORDER = { funktion: 'news', termine: 'termine' }
|
||||
// Bestellung -> Ereignistyp mit Widerrufsfrist (F03); beim Zusatzspeicher zaehlt nur ein Plus (openWithdrawals)
|
||||
const ORDER_EVENT_TYPE = { abschluss: 'abschluss', wechsel: 'wechsel', zusatzspeicher: 'speicher' }
|
||||
|
||||
/**
|
||||
* Erklaerung aus dem Formular: null ohne Name oder gueltige E-Mail; { error } bei einer unzulaessigen Paketzahl.
|
||||
* contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion'; packages (nur beim
|
||||
* contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion' | 'termine'; packages (nur beim
|
||||
* Zusatzspeicher) ganze Zahl von 1 bis maxUnits, null heisst alle gebuchten (Rechtsrunde 28.09.2026).
|
||||
*/
|
||||
function validDeclaration(body) {
|
||||
@@ -784,6 +787,7 @@ function orderLabel(order, sub, { customer = false } = {}) {
|
||||
if (order === 'wechsel') return 'Wechsel ins Wartungs-Abo'
|
||||
if (order === 'zusatzspeicher') return 'E-Mail-Zusatzspeicher'
|
||||
if (order === 'funktion') return 'Funktion (z. B. News-Pinnwand)'
|
||||
if (order === 'termine') return 'Terminplaner (Einrichtung)'
|
||||
const abo = productNameOf(sub)
|
||||
return `${customer ? 'Abschluss Ihres Abos' : 'Abschluss des Abos'}${abo ? ` (${abo})` : ''}`
|
||||
}
|
||||
@@ -1092,19 +1096,29 @@ async function withdrawOrder(sub, d, receivedAt) {
|
||||
const FEATURE_STATE_LABEL = { setup: 'in Einrichtung', active: 'aktiv', disabled: 'abgeschaltet' }
|
||||
|
||||
/**
|
||||
* Widerruf einer Funktion (z. B. News-Pinnwand, 30 € einmalig): gehoert zu keinem Abo und hat kein Vertrags-
|
||||
* ereignis, darum aendert sich nichts automatisch. Gesucht wird der Kunde ueber die E-Mail-Adresse; das Team
|
||||
* bekommt in T09 die bezahlten Funktionen mit Fristende und erstattet/schaltet ab von Hand.
|
||||
* Widerruf einer Funktion (News-Pinnwand 30 € oder Terminplaner-Einrichtung 149 €, je einmalig): gehoert zu keinem Abo
|
||||
* und hat kein Vertragsereignis, darum aendert sich nichts automatisch. Gesucht wird der Kunde ueber die E-Mail-Adresse;
|
||||
* das Team bekommt in T09 die bezahlten Bestellungen der gewaehlten Funktion mit Fristende und erstattet/schaltet ab von
|
||||
* Hand. Jede Funktion hat ihre eigene Widerrufsfrist: gefiltert wird auf featureKey, der Anker ist deren vertragAt
|
||||
* (Zeit des Klicks der bezahlten Sitzung, ohne Nachweis orderedAt); das spaeteste Fristende entscheidet ueber "rechtzeitig".
|
||||
* Dokumente ohne featureKey stammen aus der Zeit, als es nur die News gab.
|
||||
*/
|
||||
async function withdrawFeature(d, receivedAt) {
|
||||
async function withdrawFeature(d, receivedAt, featureKey) {
|
||||
const customer =
|
||||
(await getCustomerByEmail(d.email).catch(() => null)) || (await getCustomerByEmail(d.email.toLowerCase()).catch(() => null))
|
||||
const orders = customer ? await paidFeatureOrders(customer.$id) : []
|
||||
// Anker der Frist ist die Zeit des Klicks der bezahlten Sitzung (vertragAt), ohne Nachweis orderedAt
|
||||
const ends = orders.filter((o) => o.vertragAt || o.orderedAt).map((o) => withdrawalDeadline(o.vertragAt || o.orderedAt).endsAt)
|
||||
const deadline = ends.length ? new Date(Math.max(...ends.map((e) => new Date(e).getTime()))) : null
|
||||
const alle = customer ? await paidFeatureOrders(customer.$id) : []
|
||||
const orders = alle.filter((o) => (o.featureKey || 'news') === featureKey)
|
||||
const endOf = (o) => {
|
||||
const anker = o.vertragAt || o.orderedAt
|
||||
return anker ? new Date(withdrawalDeadline(anker).endsAt) : null
|
||||
}
|
||||
const ends = orders.map(endOf).filter(Boolean)
|
||||
const deadline = ends.length ? new Date(Math.max(...ends.map((e) => e.getTime()))) : null
|
||||
const lines = orders
|
||||
.map((o) => `${o.projekt || 'Projekt'}: ${FEATURE_STATE_LABEL[o.state] || o.state}, bestellt am ${fmtDate(o.vertragAt || o.orderedAt)}${o.ticketWoid ? `, Ticket #${o.ticketWoid}` : ''}`)
|
||||
.map((o) => {
|
||||
const ende = endOf(o)
|
||||
return `${o.projekt || 'Projekt'}: ${FEATURE_STATE_LABEL[o.state] || o.state}, bestellt am ${fmtDate(o.vertragAt || o.orderedAt)}${ende ? `, Fristende ${fmtDate(ende)}` : ''}${o.ticketWoid ? `, Ticket #${o.ticketWoid}` : ''}`
|
||||
})
|
||||
.join('\n')
|
||||
return { customer, orders, deadline, lines, inTime: Boolean(deadline) && receivedAt <= deadline }
|
||||
}
|
||||
@@ -1127,12 +1141,14 @@ router.post('/public/withdraw', async (req, res) => {
|
||||
|
||||
let sub = null
|
||||
let result = { inTime: false, deadline: null, withdrawn: [], canceled: false, cancelFailed: false, event: null, unreadable: false, startMissing: false, alreadyWithdrawn: false, late: false, openSwitchWithdrawn: false }
|
||||
// Funktion: kein Abo, keine Vertragsereignisse; nur Nachschlagen fuer das Team (withdrawFeature)
|
||||
const feature = d.order === 'funktion'
|
||||
// Funktion (News oder Terminplaner): kein Abo, keine Vertragsereignisse; nur Nachschlagen fuer das Team (withdrawFeature)
|
||||
const featureKey = FEATURE_OF_ORDER[d.order] ?? null
|
||||
const feature = featureKey !== null
|
||||
const funktion = feature ? featureConfig(featureKey) : null
|
||||
let fn = { customer: null, orders: [], deadline: null, lines: '', inTime: false }
|
||||
try {
|
||||
if (feature) {
|
||||
fn = await withdrawFeature(d, receivedAt)
|
||||
fn = await withdrawFeature(d, receivedAt, featureKey)
|
||||
result = { ...result, deadline: fn.deadline, inTime: fn.inTime, late: !fn.inTime && Boolean(fn.deadline) }
|
||||
} else {
|
||||
sub = await findActiveSubscriptionByEmail(d.email)
|
||||
@@ -1153,7 +1169,7 @@ router.post('/public/withdraw', async (req, res) => {
|
||||
values: {
|
||||
name: d.name,
|
||||
bestellung: orderLabel(d.order, sub, { customer: true }),
|
||||
produkt: feature ? 'News-Pinnwand' : productLabelOf(sub),
|
||||
produkt: feature ? funktion.label.name : productLabelOf(sub),
|
||||
eingang_zeitpunkt: fmtDateTime(receivedAt),
|
||||
email: d.email,
|
||||
kundennummer: d.customerNumber,
|
||||
@@ -1176,7 +1192,8 @@ router.post('/public/withdraw', async (req, res) => {
|
||||
name: d.name,
|
||||
kundennummer: d.customerNumber,
|
||||
bestellung: orderLabel(d.order, sub),
|
||||
produkt: feature ? 'News-Pinnwand' : productLabelOf(sub),
|
||||
produkt: feature ? funktion.label.name : productLabelOf(sub),
|
||||
preis: feature ? funktion.priceLabel : '',
|
||||
stripe_abo_id: sub?.stripeSubscriptionId || '',
|
||||
abo_beginn: fmtDate(sub?.startedAt),
|
||||
fristende: result.deadline ? `${fmtDate(result.deadline)}, 24 Uhr` : 'keine Frist gefunden',
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
import { Router } from 'express'
|
||||
import { config } from '../../config.js'
|
||||
import { aboMemo } from '../../services/aboStatus.js'
|
||||
import { getDocument } from '../../services/appwriteAdmin.js'
|
||||
import { featureConfig, featureHaken } from '../../services/featureRegistry.js'
|
||||
import { withLocks } from '../../services/keyedLock.js'
|
||||
import {
|
||||
FEATURE_KEYS,
|
||||
FEATURE_LABELS,
|
||||
adminActivate,
|
||||
assertOwnProject,
|
||||
customerOf,
|
||||
disable,
|
||||
freischaltenNachAbnahme,
|
||||
getFeatureDoc,
|
||||
listCustomerFeatureDocs,
|
||||
listCustomerFeatures,
|
||||
ticketNachlegen,
|
||||
} from '../../services/siteFeatures.js'
|
||||
import { featureHaken } from '../../services/featureRegistry.js'
|
||||
import { nachAbschaltung } from '../../services/zeiten/abschalten.js'
|
||||
import { einschaltenZeiten } from '../../services/zeiten/einschalten.js'
|
||||
import { ersatzHtml } from '../../services/zeiten/ersatz.js'
|
||||
import { normalisiereTeam } from '../../services/zeiten/pruefen.js'
|
||||
import { ladeZeiten, speichereTeam } from '../../services/zeiten/speicher.js'
|
||||
import { hauptplan, planNachKurz } from '../../services/zeiten/zeitlogik.js'
|
||||
import { driftWarnung, einbauanleitung, ticketStand } from '../../services/termine/einbauanleitung.js'
|
||||
import { freischaltHindernis } from '../../services/termine/freischaltung.js'
|
||||
import { ladeStand } from '../../services/termine/kontext.js'
|
||||
|
||||
/**
|
||||
* Funktionen (News-Pinnwand, Terminplaner) je Kundenprojekt im Ticketsystem (Kunden-Reiter "Funktionen").
|
||||
@@ -42,13 +49,13 @@ export function composeAdminNote(previous, { by, action, note }, now = new Date(
|
||||
return older ? `${entry} | ${older}`.slice(0, NOTE_MAX) : entry
|
||||
}
|
||||
|
||||
async function ticketExists(ticketId) {
|
||||
if (!ticketId) return false
|
||||
/** Das Ticket oder null (keine ticketId, Ticket geloescht); jeder andere Lesefehler wird weitergereicht. */
|
||||
async function readTicket(ticketId) {
|
||||
if (!ticketId) return null
|
||||
try {
|
||||
await getDocument(config.collections.workorders, ticketId)
|
||||
return true
|
||||
return await getDocument(config.collections.workorders, ticketId)
|
||||
} catch (err) {
|
||||
if (err.status === 404) return false
|
||||
if (err.status === 404) return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -76,16 +83,33 @@ async function zeitenZusatz(doc) {
|
||||
}
|
||||
}
|
||||
|
||||
// Die Funktionsdokumente des Kunden werden einmal gelesen und fuer alle Projekte und Funktionen verwendet
|
||||
/** Name des Kunden fuer die Anleitung; wie im Ticket (termineTicketDetails) die Kunden-ID, wenn er fehlt oder nicht lesbar ist (nur Kosmetik, kein Fehler). */
|
||||
async function customerName(customerId) {
|
||||
try {
|
||||
return (await customerOf(customerId))?.name || customerId
|
||||
} catch (err) {
|
||||
console.error(`[funktionen-admin] Kunde ${customerId} fuer die Anleitung nicht lesbar:`, err.message)
|
||||
return customerId
|
||||
}
|
||||
}
|
||||
|
||||
const ticketExists = async (ticketId) => (await readTicket(ticketId)) !== null
|
||||
|
||||
// Die Funktionsdokumente des Kunden werden einmal gelesen und fuer alle Projekte und Funktionen verwendet.
|
||||
// hindernis (freischaltHindernis, termine/freischaltung.js): was die Freischaltung eines Terminplaners in setup noch hindert
|
||||
// ('kein_abo' | 'keine_einstellung' | 'abo_unbekannt'), null = nichts; ausserhalb von setup und bei News immer null. Das Abo wird
|
||||
// je Anfrage einmal gelesen (ein gemeinsamer aboMemo fuer alle Projekte des Kunden), portalFeatures nicht erneut.
|
||||
async function adminView(customerId) {
|
||||
const docs = await listCustomerFeatureDocs(customerId)
|
||||
const projects = await listCustomerFeatures(customerId, { docs })
|
||||
const abo = aboMemo()
|
||||
for (const p of projects) {
|
||||
for (const key of FEATURE_KEYS) {
|
||||
const view = p.features[key]
|
||||
const doc = docs.find((d) => d.projectId === p.projectId && d.featureKey === key)
|
||||
view.adminNote = doc?.adminNote || ''
|
||||
view.ticketMissing = view.state === 'setup' && !(doc?.ticketId && (await ticketExists(doc.ticketId)))
|
||||
view.hindernis = key === 'termine' && doc?.state === 'setup' ? await freischaltHindernis(doc, { abo }) : null
|
||||
if (key === 'zeiten') Object.assign(view, await zeitenZusatz(doc))
|
||||
}
|
||||
}
|
||||
@@ -129,6 +153,55 @@ funktionenAdminRouter.get('/:customerId/zeiten/ersatz', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Einbauanleitung des Terminplaners eines Projekts fuer das WOMS-Panel (Task D8): die volle Fassung des Generators ohne `kurz` (die geht nur
|
||||
* ins Ticket), nur lesend. Gilt in jedem Zustand und bei jedem Modus (kein TERMINE_ENABLED-Gate) und ist kein Kundenzugriff (kein
|
||||
* customerViewLog, kein Pfad im Kundenrouter). Rechnung und Preis kommen live aus dem Dokument.
|
||||
* state: Zustand des Dokuments. ticketWoid, ticketVersion: Ticket des Dokuments und der Stand, auf dem es beruht (null ohne Ticket oder
|
||||
* ohne Standzeile). hindernis: nur in setup, was die Freischaltung noch hindert (siehe adminView), sonst null.
|
||||
* Ein gespeicherter Stand, der neuer ist als das Ticket, haengt eine Warnung ans Ende von warnungen (driftWarnung); ein geloeschtes
|
||||
* Ticket (404) ist keine Warnung und kein Fehler, jeder andere Lesefehler ist einer (keine stille Luecke).
|
||||
*/
|
||||
funktionenAdminRouter.get('/:customerId/termine/anleitung', async (req, res) => {
|
||||
try {
|
||||
const { customerId } = req.params
|
||||
const { projectId } = req.query
|
||||
if (typeof projectId !== 'string' || !projectId) return res.status(400).json({ error: 'Projekt fehlt' })
|
||||
const project = await assertOwnProject(customerId, projectId)
|
||||
const doc = await getFeatureDoc(customerId, projectId, 'termine')
|
||||
if (!doc) {
|
||||
throw Object.assign(new Error(`Für dieses Projekt gibt es noch ${FEATURE_LABELS.termine.keine}`), { status: 409 })
|
||||
}
|
||||
const stand = await ladeStand(doc.$id)
|
||||
const ticket = await readTicket(doc.ticketId)
|
||||
const ticketVersion = ticket ? ticketStand(ticket.details) : null
|
||||
const { stand: anleitungsStand, warnungen, abschnitte, text } = einbauanleitung({
|
||||
feature: doc,
|
||||
project,
|
||||
einstellung: stand?.einstellung ?? null,
|
||||
fragebogen: stand?.fragebogen ?? null,
|
||||
version: stand?.version ?? 0,
|
||||
updatedAt: stand?.updatedAt ?? null,
|
||||
kunde: { name: await customerName(customerId) },
|
||||
now: new Date(),
|
||||
})
|
||||
const drift = driftWarnung(ticketVersion, anleitungsStand.version)
|
||||
// Felder einzeln benannt: `kurz` (nur fuers Ticket) und alles, was der Generator spaeter dazubekommt, geht nie unbesehen nach aussen
|
||||
res.json({
|
||||
stand: anleitungsStand,
|
||||
warnungen: drift ? [...warnungen, drift] : warnungen,
|
||||
abschnitte,
|
||||
text,
|
||||
state: doc.state,
|
||||
ticketWoid: ticket?.woid || null,
|
||||
ticketVersion,
|
||||
hindernis: doc.state === 'setup' ? await freischaltHindernis(doc) : null,
|
||||
})
|
||||
} catch (err) {
|
||||
sendError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Oeffnungszeiten live einschalten (Team, ohne Zahlung): derselbe Pfad wie der Gratisweg des Kunden (Spec 5), mit unlockedBy 'team'
|
||||
* und ohne Zustimmungsnachweis und K24: Zustand setup, Neubau, Einbau-Ticket; das Ticket gibt spaeter frei. Die Notiz am Dokument
|
||||
@@ -162,31 +235,44 @@ async function zeitenAbschalten({ customerId, projectId, by, note }) {
|
||||
return nachAbschaltung(doc)
|
||||
}
|
||||
|
||||
// Aktivieren (ohne Zahlung) und Abschalten je Funktion: /news wie bisher, /termine neu, /zeiten ueber den Gratis-Pfad; jeder andere Schluessel -> 404
|
||||
// Je Funktion (/news, /termine, /zeiten; jeder andere Schluessel -> 404): activate (ohne Zahlung; bei /zeiten ueber den Gratis-Pfad),
|
||||
// disable, abnahme (Freischalten nach Abnahme, in setup, ohne das Ticket zu schliessen) und ticket (fehlendes Einrichtungs-Ticket
|
||||
// nachlegen, in setup). abnahme und ticket gibt es nur fuer Funktionen mit ticketFreischaltung im Register.
|
||||
const ACTIONS = ['activate', 'disable', 'abnahme', 'ticket']
|
||||
const TICKET_ACTIONS = ['abnahme', 'ticket']
|
||||
|
||||
funktionenAdminRouter.post('/:customerId/:key', async (req, res) => {
|
||||
try {
|
||||
const { customerId, key } = req.params
|
||||
if (!FEATURE_KEYS.includes(key)) return res.status(404).json({ error: 'Unbekannte Funktion' })
|
||||
const { projectId, action } = req.body || {}
|
||||
const note = typeof req.body?.note === 'string' ? req.body.note.trim().slice(0, 120) : ''
|
||||
if (action !== 'activate' && action !== 'disable') {
|
||||
if (!ACTIONS.includes(action)) {
|
||||
return res.status(400).json({ error: 'Unbekannte Aktion' })
|
||||
}
|
||||
if (typeof projectId !== 'string' || !projectId) {
|
||||
return res.status(400).json({ error: 'Projekt fehlt' })
|
||||
}
|
||||
if (TICKET_ACTIONS.includes(action) && featureConfig(key)?.ticketFreischaltung !== true) {
|
||||
return res.status(409).json({ error: 'Diese Aktion gibt es für diese Funktion nicht.' })
|
||||
}
|
||||
await assertOwnProject(customerId, projectId)
|
||||
const by = adminName(req)
|
||||
let ersatzTicket
|
||||
if (key === 'zeiten') {
|
||||
if (action === 'activate') await zeitenAktivieren({ customerId, projectId, by, note })
|
||||
else ersatzTicket = await zeitenAbschalten({ customerId, projectId, by, note })
|
||||
if (key === 'zeiten' && action === 'activate') {
|
||||
await zeitenAktivieren({ customerId, projectId, by, note })
|
||||
} else if (key === 'zeiten' && action === 'disable') {
|
||||
ersatzTicket = await zeitenAbschalten({ customerId, projectId, by, note })
|
||||
} else {
|
||||
await withLocks([`feature-admin:${projectId}`], async () => {
|
||||
const doc = await getFeatureDoc(customerId, projectId, key)
|
||||
const adminNote = composeAdminNote(doc?.adminNote, { by, action, note })
|
||||
if (action === 'activate') {
|
||||
await adminActivate(customerId, projectId, key, { by, adminNote })
|
||||
} else if (action === 'abnahme') {
|
||||
await freischaltenNachAbnahme({ customerId, projectId, key, by, adminNote })
|
||||
} else if (action === 'ticket') {
|
||||
await ticketNachlegen({ customerId, projectId, key, by, adminNote })
|
||||
} else {
|
||||
if (!doc) {
|
||||
throw Object.assign(new Error(`Für dieses Projekt gibt es noch ${FEATURE_LABELS[key].keine}`), { status: 409 })
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
listCustomerFeatures,
|
||||
markOrdered,
|
||||
runActivation,
|
||||
unterlagenAdressen,
|
||||
} from '../services/siteFeatures.js'
|
||||
import { actingPerson, speichereNachweisAusAnfrage, zustimmungFuerSitzung } from '../services/featureConsents.js'
|
||||
import { termineFuerKunde } from '../services/termine/schalter.js'
|
||||
@@ -23,7 +24,7 @@ import { einschaltenDeps, einschaltenZeiten, zeitenProjektAnsicht } from '../ser
|
||||
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 { bestellFehler, istFortsetzen, oeffentlicheConsent, pruefeAbo, pruefeInklusive, pruefeZustimmung } from '../services/featureOrder.js'
|
||||
import { aboMeldung, bestellFehler, istFortsetzen, oeffentlicheConsent, pruefeAbo, pruefeInklusive, pruefeZustimmung } from '../services/featureOrder.js'
|
||||
|
||||
import {
|
||||
ELIGIBILITY_MESSAGES,
|
||||
@@ -93,8 +94,10 @@ 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. 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).
|
||||
* 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 }
|
||||
@@ -108,7 +111,33 @@ async function termineAngebot(email, customerId, abos = aboMemo()) {
|
||||
} catch (err) {
|
||||
console.error(`[funktionen] Abo für den Terminplaner nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim())
|
||||
}
|
||||
return { verfuegbar: true, bestellbar, grund, preis: register?.priceLabel ?? '', einrichtung: register?.setupEstimate ?? '' }
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -187,6 +216,8 @@ router.get('/', requireSession, async (req, res) => {
|
||||
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
|
||||
|
||||
@@ -12,6 +12,7 @@ import { createWriteLimiter } from '../services/writeLimiter.js'
|
||||
import { verschiebeZeiten } from '../services/termine/ablauf.js'
|
||||
import { aktion, entsperre, manuell, sperre } from '../services/termine/ablaufBetrieb.js'
|
||||
import { EXPORT_SEITE, exportSeite, hole, imZeitraum, zaehleAnfragen, zaehleKuenftige } from '../services/termine/buchungen.js'
|
||||
import { einbauSnippet } from '../services/termine/einbauanleitung.js'
|
||||
import { fehlendePflichtangaben, neuerFeedToken, normalisiereEinstellung } from '../services/termine/einstellungen.js'
|
||||
import { loeschTag, loeschzeit } from '../services/termine/frist.js'
|
||||
import { BRANCHEN, bestellpruefung, bestellungAnsicht, leerFragebogen, normalisiereFragebogen } from '../services/termine/fragebogen.js'
|
||||
@@ -278,20 +279,6 @@ function feedLinks(ctx) {
|
||||
return links
|
||||
}
|
||||
|
||||
const esc = (text) => String(text).replace(/[&<>"]/g, (z) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[z])
|
||||
|
||||
/** Einbau-Code für die Website (Spezifikation Abschnitt 7): Platzhalter mit Ausweichtext, bleibt ohne JavaScript stehen. */
|
||||
function einbauSnippet(ctx) {
|
||||
const telefon = ctx.einstellung?.betrieb?.telefon
|
||||
const ausweich = telefon ? `Termine telefonisch unter ${esc(telefon)}.` : 'Termine telefonisch vereinbaren.'
|
||||
return [
|
||||
`<div data-webklar-termine="${esc(ctx.feature.siteKey)}" data-api="${esc(config.publicBaseUrl)}" data-modus="inline">`,
|
||||
` <p>${ausweich}</p>`,
|
||||
'</div>',
|
||||
`<script src="${esc(config.publicBaseUrl)}/embed/termine.js" defer></script>`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
router.use((_req, res, next) => {
|
||||
res.set('Cache-Control', 'no-store')
|
||||
next()
|
||||
@@ -311,7 +298,9 @@ async function aboFuerVorschau(customerId) {
|
||||
* Einstellungen und Entwurf für das Portal. Liest in jedem Zustand und auch ohne Funktionsdokument (entwurf: true).
|
||||
* Neben der Einstellung liefert die Antwort den Entwurf des Fragebogens (branchen, fragebogen, kundenansicht, betriebVorschlag
|
||||
* ohne Einstellung) und in `off` die Sicht der Bestellung: abo (bestellbar() oder null bei Lesefehler), offen (Punkte der
|
||||
* Bestellprüfung inklusive Abo-Punkt (h)) und bestellung (Texte und Preis des Registers). Einbau-Code und Kalender-Links
|
||||
* Bestellprüfung inklusive Abo-Punkt (h)) und bestellung (Texte und Preis des Registers). In `ordered` liefert sie bestellung und
|
||||
* offen ebenfalls, nur lesend (Ruling R110: „Zahlung fortsetzen“ ohne Nachweis zeigt dort wieder das Bestellformular); das Abo wird
|
||||
* dort nicht gelesen (abo null), offen hat keinen Abo-Punkt (h). Einbau-Code und Kalender-Links
|
||||
* gibt es nur, wo die Funktion läuft oder lief (active, disabled; Kalender-Links nur in active).
|
||||
*/
|
||||
router.get('/einstellungen', requireSession, withTermine({ entwurf: true }), route(async (req, res) => {
|
||||
@@ -322,6 +311,7 @@ router.get('/einstellungen', requireSession, withTermine({ entwurf: true }), rou
|
||||
const customerId = getSessionCustomerId(req)
|
||||
const register = featureConfig('termine')
|
||||
const abo = state === 'off' ? await aboFuerVorschau(customerId) : null
|
||||
const bestellbar = state === 'off' || state === 'ordered' // die Sicht der Bestellung (offen, bestellung) gibt es nur vor der Zahlung
|
||||
res.json({
|
||||
state,
|
||||
abgeschaltet: state === 'disabled' ? abgeschaltetAnsicht(ctx.feature) : null,
|
||||
@@ -334,11 +324,11 @@ router.get('/einstellungen', requireSession, withTermine({ entwurf: true }), rou
|
||||
kundenansicht: isKundenansicht(req.session),
|
||||
...(e ? {} : { betriebVorschlag: await betriebVorschlag(customerId, req.project) }),
|
||||
abo,
|
||||
offen: state === 'off' ? bestellpruefung({ einstellung: e, fragebogen: ctx.fragebogen, feature: register, abo }) : null,
|
||||
bestellung: state === 'off' ? await bestellungAnsicht(register) : null,
|
||||
offen: bestellbar ? bestellpruefung({ einstellung: e, fragebogen: ctx.fragebogen, feature: register, abo, mitAbo: state === 'off' }) : null,
|
||||
bestellung: bestellbar ? await bestellungAnsicht(register) : null,
|
||||
feeds: e && state === 'active' ? feedLinks(ctx) : {},
|
||||
siteKey: ctx.feature.siteKey || null,
|
||||
einbau: laeuft ? { snippet: einbauSnippet(ctx) } : {},
|
||||
einbau: laeuft ? { snippet: einbauSnippet({ siteKey: ctx.feature.siteKey, apiBase: config.publicBaseUrl, telefon: ctx.einstellung?.betrieb?.telefon }) } : {},
|
||||
anfragenOffen: laeuft ? await zaehleAnfragen(ctx.feature.$id) : 0,
|
||||
fehlt: e ? fehlendePflichtangaben(e) : [], // Pflichtangaben für Gäste (Ruling R41): solange etwas fehlt, ist die Funktion nicht buchbar
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user