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
@@ -1,54 +1,13 @@
|
||||
// Veroeffentlichte Rechtstexte der Website-Analyse (je Fassung feste Dateien unter public/rechtliches/)
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import { Router } from 'express'
|
||||
import { config } from '../config.js'
|
||||
// Veroeffentlichte Rechtstexte der Website-Analyse (je Fassung feste Dateien unter public/rechtliches/):
|
||||
// duenner Wrapper um die Dokument-Registry legalDocs.js (Satz 'analyse')
|
||||
import { legalPaths as registryPaths, legalPdfAttachments as registryAttachments, legalPdfFiles as registryFiles, legalRouter } from './legalDocs.js'
|
||||
|
||||
const publicDir = fileURLToPath(new URL('../../public/', import.meta.url))
|
||||
const VERSION_RE = /^\d{4}-\d{2}-\d{2}$/
|
||||
export const legalPaths = (version) => registryPaths('analyse', version)
|
||||
|
||||
function checkVersion(version) {
|
||||
if (!VERSION_RE.test(String(version))) throw new Error('Analyse: ungültige Fassung')
|
||||
return String(version)
|
||||
}
|
||||
|
||||
export function legalPaths(version) {
|
||||
const v = checkVersion(version)
|
||||
return {
|
||||
avv: `/rechtliches/avv-website-analyse-${v}.html`,
|
||||
lb: `/rechtliches/leistungsbeschreibung-website-analyse-${v}.html`,
|
||||
}
|
||||
}
|
||||
|
||||
export function legalPdfFiles(version) {
|
||||
const v = checkVersion(version)
|
||||
return {
|
||||
avv: { filename: `AVV-Website-Analyse-${v}.pdf`, path: join(publicDir, 'rechtliches', `avv-website-analyse-${v}.pdf`) },
|
||||
lb: { filename: `Leistungsbeschreibung-Website-Analyse-${v}.pdf`, path: join(publicDir, 'rechtliches', `leistungsbeschreibung-website-analyse-${v}.pdf`) },
|
||||
}
|
||||
}
|
||||
export const legalPdfFiles = (version) => registryFiles('analyse', version)
|
||||
|
||||
/** Beide PDFs als Mail-Anhaenge; wirft, wenn eine Datei fehlt oder nicht lesbar ist. */
|
||||
export async function legalPdfAttachments(version) {
|
||||
const files = legalPdfFiles(version)
|
||||
return Promise.all(
|
||||
[files.avv, files.lb].map(async (f) => ({
|
||||
filename: f.filename,
|
||||
content: await readFile(f.path),
|
||||
contentType: 'application/pdf',
|
||||
}))
|
||||
)
|
||||
}
|
||||
export const legalPdfAttachments = (version) => registryAttachments('analyse', version)
|
||||
|
||||
/** Adressen ohne Fassung liefern immer die aktuelle geprüfte Fassung (config.analyse.avvVersion). */
|
||||
export function analyseLegalRouter() {
|
||||
const router = Router()
|
||||
const send = (key) => (_req, res, next) => {
|
||||
const file = join(publicDir, legalPaths(config.analyse.avvVersion)[key])
|
||||
res.sendFile(file, (err) => err && next(err.code === 'ENOENT' ? undefined : err))
|
||||
}
|
||||
router.get('/avv-website-analyse.html', send('avv'))
|
||||
router.get('/leistungsbeschreibung-website-analyse.html', send('lb'))
|
||||
return router
|
||||
}
|
||||
export const analyseLegalRouter = () => legalRouter({ saetze: ['analyse'] })
|
||||
@@ -28,14 +28,15 @@ async function termineBestellpruefung({ customerId, projectId, feature }) {
|
||||
|
||||
/**
|
||||
* Freischaltpruefung des Terminplaners (Spec 4.5: die Aktivierung bleibt gesperrt, bis ein Abo laeuft): die Einrichtung
|
||||
* ist erst freizugeben, wenn ein Wartungs-Abo laeuft und eine Einstellung gespeichert ist. Fail-closed: ein Lesefehler
|
||||
* (Abo, Einstellung) wirft, activateClosedTickets haelt das Dokument dann in 'setup'. Der Aufrufer liest das Dokument
|
||||
* unter der Sperre frisch und uebergibt es; auch das Abo wird hier frisch gelesen, nie aus einem Zwischenspeicher.
|
||||
* ist erst freizugeben, wenn ein Wartungs-Abo laeuft und eine Einstellung gespeichert ist. Das entscheidet
|
||||
* freischaltHindernis (termine/freischaltung.js, auch die Gruende dort); dieser Haken gibt nur bei null frei. Fail-closed:
|
||||
* ein nicht lesbares Abo ('abo_unbekannt') gibt nie frei, ein Lesefehler der Einstellung wirft, activateClosedTickets haelt
|
||||
* das Dokument dann in 'setup'. Der Aufrufer liest das Dokument unter der Sperre frisch und uebergibt es; auch das Abo wird
|
||||
* hier frisch gelesen (freischaltHindernis ohne eingereichten Zwischenspeicher), nie aus einem Zwischenspeicher.
|
||||
*/
|
||||
async function termineFreischaltbar(doc) {
|
||||
const [{ ladeEinstellung }, { laeuft }] = await Promise.all([import('./termine/kontext.js'), import('./aboStatus.js')])
|
||||
if (!(await laeuft(doc.customerId))) return false
|
||||
return (await ladeEinstellung(doc.$id)) !== null
|
||||
const { freischaltHindernis } = await import('./termine/freischaltung.js')
|
||||
return (await freischaltHindernis(doc)) === null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,14 +69,34 @@ async function zeitenNachFreischaltung(doc) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Platzhalter der Widerrufsbelehrung mit Muster-Widerrufsformular, die die Bestellseite vor dem Knopf zeigt und der
|
||||
* Zustimmungsnachweis speichert. Block F (Task F7) ersetzt den Haken belehrung durch widerrufsbelehrungFunktion().
|
||||
* Platzhalter der Widerrufsbelehrung der Oeffnungszeiten (Haken belehrung von zeiten). Der Terminplaner liefert seine echte
|
||||
* Belehrung mit termineBelehrung (Task F7); fuer die Oeffnungszeiten bleibt dieser Platzhalter, bis ihr Belehrungstext feststeht.
|
||||
*/
|
||||
export const BELEHRUNG_PLATZHALTER = 'ENTWURF (Block F): Widerrufsbelehrung mit Muster-Widerrufsformular.'
|
||||
|
||||
/**
|
||||
* Vertragsunterlagen des Terminplaners abrufbar? (Stufe 5b des Checkouts, vor jeder Stripe-Sitzung): HTML-Seiten und PDFs der
|
||||
* aktuellen Fassung sind da und lesbar (legalDocs.featureLegalReady, Task F8; fail-closed, wirft nie). Geladen wird legalDocs erst
|
||||
* beim Aufruf.
|
||||
*/
|
||||
async function termineUnterlagenBereit() {
|
||||
const { featureLegalReady } = await import('./legalDocs.js')
|
||||
return featureLegalReady('termine')
|
||||
}
|
||||
|
||||
/**
|
||||
* Widerrufsbelehrung mit Muster-Widerrufsformular der Funktionen (Gesetzesmuster, Sie-Form), die die Bestellseite vor dem Knopf
|
||||
* zeigt und der Zustimmungsnachweis speichert: dieselbe Zeichenkette an beiden Stellen (Task F7, Text aus Task F3). Geladen
|
||||
* wird sie erst beim Aufruf (kein Importzyklus).
|
||||
*/
|
||||
async function termineBelehrung() {
|
||||
const { widerrufsbelehrungFunktion } = await import('./termine/rechtstexte.js')
|
||||
return widerrufsbelehrungFunktion()
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard-Haken je Funktion (Funktionen, nicht Daten). B6 fuellt verfuegbar, bestellpruefung und unterlagenBereit
|
||||
* (Standard immer bereit; F8 haengt die Rechtsunterlagen ein), B7 belehrung (Text der Belehrung, der vor dem Knopf steht
|
||||
* (Terminplaner: featureLegalReady, F8), B7 belehrung (Text der Belehrung, der vor dem Knopf steht
|
||||
* und im Nachweis gespeichert wird) und B11 freischaltbar. News hat keine Standard-Haken, also auch keinen belehrung-Eintrag
|
||||
* im Nachweis. Feste Funktionen, damit featureHaken bei jedem Aufruf dieselben liefert.
|
||||
*/
|
||||
@@ -84,8 +105,8 @@ const STANDARD_HAKEN = {
|
||||
termine: {
|
||||
verfuegbar: (email) => termineFuerKunde(email),
|
||||
bestellpruefung: termineBestellpruefung,
|
||||
unterlagenBereit: async () => true,
|
||||
belehrung: async () => BELEHRUNG_PLATZHALTER,
|
||||
unterlagenBereit: termineUnterlagenBereit,
|
||||
belehrung: termineBelehrung,
|
||||
freischaltbar: termineFreischaltbar,
|
||||
},
|
||||
// Oeffnungszeiten live: keine Bestellpruefung (die Einstellung entsteht erst im Editor); belehrung und
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
// Dokument-Registry der veroeffentlichten Rechtstexte (je Fassung feste Dateien unter public/rechtliches/):
|
||||
// Saetze 'analyse' (AVV, Leistungsbeschreibung der Website-Analyse) und 'termine' (Leistungsbeschreibung, AVV und
|
||||
// Widerrufsbelehrung mit Muster-Widerrufsformular des Terminplaners). analyseLegal.js ist ein duenner Wrapper darum.
|
||||
// Die Fassung ist der Berliner Kalendertag der Texte (JJJJ-MM-TT); eine ausgelieferte Fassung wird nie ueberschrieben.
|
||||
import { access, open, readFile, stat } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import { Router } from 'express'
|
||||
import { config } from '../config.js'
|
||||
|
||||
const publicDir = fileURLToPath(new URL('../../public/', import.meta.url))
|
||||
/** Wurzel der ausgelieferten Dateien (Standard: public/); nur Tests tauschen sie gegen ein Verzeichnis mit Kopien aus. */
|
||||
let basisDir = publicDir
|
||||
/** Nur fuer Tests: Verzeichnis, in dem legalPdfFiles und featureLegalReady die Dateien suchen (null = public/). */
|
||||
export function setLegalDirForTests(dir) {
|
||||
basisDir = dir ?? publicDir
|
||||
}
|
||||
const VERSION_RE = /^\d{4}-\d{2}-\d{2}$/
|
||||
|
||||
/**
|
||||
* Dokumente je Satz in Anhangsreihenfolge: slug = Dateiname der HTML-Seite und des PDFs (<slug>-<Fassung>.html|pdf),
|
||||
* pdf = Dateiname des Mail-Anhangs (<pdf>-<Fassung>.pdf). config.js baut die Adressen des Terminplaners aus denselben
|
||||
* Slugs (config.js darf diese Datei nicht importieren); test/legalDocs.test.js gleicht beide ab.
|
||||
*/
|
||||
const SAETZE = {
|
||||
analyse: {
|
||||
avv: { slug: 'avv-website-analyse', pdf: 'AVV-Website-Analyse' },
|
||||
lb: { slug: 'leistungsbeschreibung-website-analyse', pdf: 'Leistungsbeschreibung-Website-Analyse' },
|
||||
},
|
||||
termine: {
|
||||
lb: { slug: 'leistungsbeschreibung-terminplaner', pdf: 'Leistungsbeschreibung-Terminplaner' },
|
||||
avv: { slug: 'avv-terminplaner', pdf: 'AVV-Terminplaner' },
|
||||
widerruf: { slug: 'widerrufsbelehrung-funktionen', pdf: 'Widerrufsbelehrung-Muster-Widerrufsformular' },
|
||||
},
|
||||
}
|
||||
|
||||
/** Satz, dessen Dokumente eine Funktion (Registerschluessel) mit der Bestellung bekommt; News hat keine. */
|
||||
const FUNKTION_SATZ = { news: null, termine: 'termine' }
|
||||
|
||||
/** Aktuelle Fassung je Satz, bei jedem Aufruf aus config gelesen (Tests aendern sie zur Laufzeit). */
|
||||
const aktuelleFassung = (satz) => (satz === 'analyse' ? config.analyse.avvVersion : config.termine.avvVersion)
|
||||
|
||||
function satzDokumente(satz) {
|
||||
if (typeof satz !== 'string' || !Object.hasOwn(SAETZE, satz)) throw new Error('Rechtstexte: unbekannter Satz')
|
||||
return SAETZE[satz]
|
||||
}
|
||||
|
||||
function pruefeFassung(version) {
|
||||
if (!VERSION_RE.test(String(version))) throw new Error('Rechtstexte: ungültige Fassung')
|
||||
return String(version)
|
||||
}
|
||||
|
||||
/** Adressen der versionierten HTML-Seiten: { [doc]: '/rechtliches/<slug>-<Fassung>.html' }. */
|
||||
export function legalPaths(satz, version) {
|
||||
const dokumente = satzDokumente(satz)
|
||||
const v = pruefeFassung(version)
|
||||
return Object.fromEntries(Object.entries(dokumente).map(([doc, d]) => [doc, `/rechtliches/${d.slug}-${v}.html`]))
|
||||
}
|
||||
|
||||
/** PDF je Dokument: { [doc]: { filename (Anhangsname), path (Datei unter public/rechtliches/) } }. */
|
||||
export function legalPdfFiles(satz, version) {
|
||||
const dokumente = satzDokumente(satz)
|
||||
const v = pruefeFassung(version)
|
||||
return Object.fromEntries(
|
||||
Object.entries(dokumente).map(([doc, d]) => [doc, { filename: `${d.pdf}-${v}.pdf`, path: join(basisDir, 'rechtliches', `${d.slug}-${v}.pdf`) }])
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* PDFs als Mail-Anhaenge in der Reihenfolge des Satzes (termine: lb, avv, widerruf; analyse: avv, lb); wirft, wenn eine
|
||||
* Datei fehlt oder nicht lesbar ist. ohne: Dokumente, die nicht angehaengt werden (sie muessen nicht vorhanden sein).
|
||||
*/
|
||||
export async function legalPdfAttachments(satz, version, { ohne = [] } = {}) {
|
||||
const dateien = legalPdfFiles(satz, version)
|
||||
for (const doc of ohne) {
|
||||
if (!Object.hasOwn(dateien, doc)) throw new Error('Rechtstexte: unbekanntes Dokument')
|
||||
}
|
||||
return Promise.all(
|
||||
Object.entries(dateien)
|
||||
.filter(([doc]) => !ohne.includes(doc))
|
||||
.map(async ([, f]) => ({ filename: f.filename, content: await readFile(f.path), contentType: 'application/pdf' }))
|
||||
)
|
||||
}
|
||||
|
||||
function funktionSatz(featureKey) {
|
||||
if (typeof featureKey !== 'string' || !Object.hasOwn(FUNKTION_SATZ, featureKey)) throw new Error('Rechtstexte: unbekannte Funktion')
|
||||
return FUNKTION_SATZ[featureKey]
|
||||
}
|
||||
|
||||
/**
|
||||
* Anhaenge der Bestellmail einer Funktion in der aktuellen Fassung: termine => Satz 'termine', news => []. Wirft bei
|
||||
* unbekannter Funktion und bei fehlender Datei (der Aufrufer entscheidet, was dann geschieht). opts.fassung waehlt eine andere
|
||||
* (ausgelieferte) Fassung, etwa die, der ein Kunde bei der Bestellung zugestimmt hat; sonst gilt die aktuelle.
|
||||
*/
|
||||
export async function featureLegalAttachments(featureKey, { fassung, ...opts } = {}) {
|
||||
const satz = funktionSatz(featureKey)
|
||||
return satz ? legalPdfAttachments(satz, fassung ?? aktuelleFassung(satz), opts) : []
|
||||
}
|
||||
|
||||
/**
|
||||
* Adressen der HTML-Seiten der Funktion ({ lb, avv, widerruf }, versioniert) in der aktuellen oder der gewaehlten Fassung
|
||||
* (opts.fassung); news => null. Wirft bei unbekannter Funktion oder ungueltiger Fassung.
|
||||
*/
|
||||
export function featureLegalPaths(featureKey, { fassung } = {}) {
|
||||
const satz = funktionSatz(featureKey)
|
||||
return satz ? legalPaths(satz, fassung ?? aktuelleFassung(satz)) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist die Datei ein lesbares PDF? Grösse > 0 (stat) und die ersten fünf Bytes "%PDF-"; nur diese fünf Bytes werden gelesen, nie die
|
||||
* ganze Datei (featureLegalReady läuft bei jeder Bestellung, vor jeder Stripe-Sitzung). Wirft bei jedem Lesefehler.
|
||||
*/
|
||||
async function istLesbaresPdf(pfad) {
|
||||
const info = await stat(pfad)
|
||||
if (!info.isFile() || info.size <= 0) return false
|
||||
const datei = await open(pfad, 'r')
|
||||
try {
|
||||
const kopf = Buffer.alloc(5)
|
||||
const { bytesRead } = await datei.read(kopf, 0, 5, 0)
|
||||
return bytesRead === 5 && kopf.toString('latin1') === '%PDF-'
|
||||
} finally {
|
||||
await datei.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* true, wenn alle Rechtsdokumente der Funktion in der aktuellen Fassung vorliegen: HTML-Seiten (die die Bestellseite
|
||||
* verlinkt) und PDFs (die Mail anhaengt; je PDF stat und die ersten fuenf Bytes, nicht der ganze Inhalt). Unbekannte
|
||||
* Funktion und jeder Lesefehler: false (fail-closed), nie ein Wurf. opts.fassung prueft eine andere Fassung als die aktuelle.
|
||||
*/
|
||||
export async function featureLegalReady(featureKey, { fassung } = {}) {
|
||||
try {
|
||||
const satz = funktionSatz(featureKey)
|
||||
if (!satz) return true
|
||||
const v = fassung ?? aktuelleFassung(satz)
|
||||
await Promise.all(Object.values(legalPaths(satz, v)).map((adresse) => access(join(basisDir, adresse))))
|
||||
const lesbar = await Promise.all(Object.values(legalPdfFiles(satz, v)).map((f) => istLesbaresPdf(f.path)))
|
||||
return lesbar.every(Boolean)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adressen ohne Fassung (/rechtliches/<slug>.html) liefern die Datei der aktuellen Fassung aus config. Nur fuer
|
||||
* Menschen und die WOMS-Karten: keine Mail und kein Zustimmungstext darf sie verlinken, denn sie aendern sich mit der
|
||||
* Fassung. Gehoert vor express.static; fehlt die Datei, geht die Anfrage weiter (404). saetze: welche Saetze bedient
|
||||
* werden, dir: Verzeichnis der Dateien (Standard public/rechtliches, Tests uebergeben ein eigenes).
|
||||
*/
|
||||
export function legalRouter({ saetze = Object.keys(SAETZE), dir = join(publicDir, 'rechtliches') } = {}) {
|
||||
const router = Router()
|
||||
for (const satz of saetze) {
|
||||
for (const d of Object.values(satzDokumente(satz))) {
|
||||
router.get(`/${d.slug}.html`, (_req, res, next) => {
|
||||
const datei = join(dir, `${d.slug}-${pruefeFassung(aktuelleFassung(satz))}.html`)
|
||||
res.sendFile(datei, (err) => err && next(err.code === 'ENOENT' ? undefined : err))
|
||||
})
|
||||
}
|
||||
}
|
||||
return router
|
||||
}
|
||||
+410
-46
@@ -4,9 +4,10 @@ import { createDocument, getDocument, listDocuments, updateDocument, Query } fro
|
||||
import { withLocks } from './keyedLock.js'
|
||||
import { sendAdminNotice, sendCustomerMail } from './mailer.js'
|
||||
import { getStripe, retrieveCheckoutSession } from './stripeBilling.js'
|
||||
import { anleitungsLink, featureConfig, featureHaken, featureKeys, ticketFreischaltungKeys, ticketTexte } from './featureRegistry.js'
|
||||
import { anleitungsLink, featureConfig, featureHaken, featureKeys, kuerze, ticketFreischaltungKeys, ticketTexte } from './featureRegistry.js'
|
||||
import { zustimmungFuerSitzung } from './featureConsents.js'
|
||||
import { aboLage, laeuft } from './aboStatus.js'
|
||||
import { berlinDateTime } from './berlinTime.js'
|
||||
import { loeschTag } from './termine/frist.js'
|
||||
import { createFeatureTicket } from './ticketAdmin.js'
|
||||
|
||||
@@ -416,20 +417,34 @@ export async function markPaid({ sessionId, invoiceId, invoiceUrl }) {
|
||||
// Oeffnungszeiten live: erst der Neubau (Zustand ist schon gespeichert), dann das Ticket mit dem frischen einbau (Spec 4.6)
|
||||
if (mitAboKauf) setup = await baueZeitenEin(setup, project)
|
||||
const customer = await customerOf(doc.customerId).catch(() => null)
|
||||
const feature = featureConfig(doc.featureKey)
|
||||
const texte = feature ? await ticketTexte({ feature, doc: setup, project, customer }) : {}
|
||||
const { ticketId } = await createFeatureTicket({
|
||||
customerId: doc.customerId,
|
||||
project,
|
||||
featureKey: doc.featureKey,
|
||||
siteKey: doc.siteKey,
|
||||
...texte,
|
||||
})
|
||||
const ticketId = await legeTicketAn(setup, project, customer)
|
||||
const updated = await updateDocument(config.collections.portalFeatures, setup.$id, { ticketId })
|
||||
return { doc: updated, created: true, fall: null }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Einrichtungs-Ticket einer bezahlten Funktion anlegen (Betreff und Details aus dem Register, ticketTexte) und seine ID liefern.
|
||||
* Die einzige Stelle, die das Ticket anlegt: markPaid (Zahlung) und ticketNachlegen (b, geloeschtes Ticket ersetzen). Schreibt nichts ins
|
||||
* Funktionsdokument (die ticketId traegt der Aufrufer ein) und sendet keine Mail.
|
||||
* @param {object} doc portalFeatures-Dokument (customerId, featureKey, siteKey)
|
||||
* @param {object} project websiteProjects-Dokument
|
||||
* @param {object|null} customer Kundendokument, best effort
|
||||
* @returns {Promise<string>} ID des neuen Tickets
|
||||
*/
|
||||
async function legeTicketAn(doc, project, customer) {
|
||||
const feature = featureConfig(doc.featureKey)
|
||||
const texte = feature ? await ticketTexte({ feature, doc, project, customer }) : {}
|
||||
const { ticketId } = await createFeatureTicket({
|
||||
customerId: doc.customerId,
|
||||
project,
|
||||
featureKey: doc.featureKey,
|
||||
siteKey: doc.siteKey,
|
||||
...texte,
|
||||
})
|
||||
return ticketId
|
||||
}
|
||||
|
||||
/**
|
||||
* Zahlung abgebrochen oder fehlgeschlagen: nur eine noch offene Bestellung faellt auf 'off' zurueck.
|
||||
* Unter demselben Schluessel wie markPaid, damit ein spaetes 'expired' nie eine gerade bezahlte
|
||||
@@ -648,6 +663,192 @@ export async function activateClosedTickets({ now = new Date(), customerId = nul
|
||||
return activated
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Team-Aktionen "Freischalten (Abnahme)" und "Ticket nachlegen" (WOMS, Kunden-Reiter Funktionen)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ABNAHME_NUR_SETUP = 'Freischalten (Abnahme) gibt es nur für Funktionen in Einrichtung.'
|
||||
const TICKET_NUR_SETUP = 'Ein Ticket nachlegen gibt es nur für Funktionen in Einrichtung.'
|
||||
/** Klartext und Status je Grund aus freischaltHindernis (termine/freischaltung.js); ein unbekannter Grund sperrt mit einem allgemeinen 409. */
|
||||
const ABNAHME_HINDERNIS = {
|
||||
kein_abo: [409, 'Der Kunde hat kein laufendes Wartungs-Abo. Freischalten ist erst mit laufendem Abo möglich.'],
|
||||
keine_einstellung: [409, 'Es sind noch keine Einstellungen gespeichert.'],
|
||||
abo_unbekannt: [503, 'Das Abo konnte gerade nicht geprüft werden.'],
|
||||
// Gruende des Registerhakens freischaltbar anderer Funktionen (Oeffnungszeiten live: eingetragene Zeiten), siehe freischaltHindernis oben
|
||||
'Voraussetzungen noch nicht erfüllt': [409, 'Die Voraussetzungen für das Freischalten sind noch nicht erfüllt.'],
|
||||
'Prüfung nicht möglich': [503, 'Die Voraussetzungen konnten gerade nicht geprüft werden.'],
|
||||
}
|
||||
|
||||
/** Funktionsdokument zu (Kunde, Projekt, Funktion) fuer eine Team-Aktion; ohne Dokument 409 "Für dieses Projekt gibt es noch {keine}". */
|
||||
async function dokumentFuerTeamAktion(customerId, projectId, key) {
|
||||
assertKey(key)
|
||||
const doc = await getFeatureDoc(customerId, projectId, key)
|
||||
if (!doc) throw httpError(409, `Für dieses Projekt gibt es noch ${FEATURE_LABELS[key].keine}`)
|
||||
return doc
|
||||
}
|
||||
|
||||
/** Das Dokument unter der Sperre frisch lesen; ist es inzwischen geloescht (Kunde geloescht), gilt dieselbe Meldung wie ohne Dokument. */
|
||||
async function frischesDokument(vorab, key) {
|
||||
try {
|
||||
return await getDocument(config.collections.portalFeatures, vorab.$id)
|
||||
} catch (err) {
|
||||
if (err.status === 404) throw httpError(409, `Für dieses Projekt gibt es noch ${FEATURE_LABELS[key].keine}`)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Was hindert die Abnahme? null = nichts. Nur Funktionen mit Haken freischaltbar im Register haben Voraussetzungen; fehlt der Haken
|
||||
* (News), gibt es keine Pruefung und kein Abo wird gelesen. Terminplaner: laufendes Wartungs-Abo und gespeicherte Einstellung, die
|
||||
* Gruende liefert freischaltHindernis (termine/freischaltung.js, erst im Aufruf geladen, weil es dieses Modul importiert). Jede andere
|
||||
* Funktion mit Haken (Oeffnungszeiten live) fragt wie der 5-Minuten-Job ihren Haken (freischaltHindernis oben, fail-closed): ihre
|
||||
* Voraussetzung hat nichts mit Abo und Einstellung des Terminplaners zu tun.
|
||||
*/
|
||||
async function abnahmeHindernis(doc) {
|
||||
if (typeof featureHaken(doc.featureKey).freischaltbar !== 'function') return null
|
||||
if (doc.featureKey !== 'termine') return freischaltHindernis(doc)
|
||||
const { freischaltHindernis: pruefe } = await import('./termine/freischaltung.js')
|
||||
return pruefe(doc)
|
||||
}
|
||||
|
||||
/**
|
||||
* Nacharbeit nach der Freischaltung: Haken nachFreischaltung der Funktion, falls das Register einen hat (Oeffnungszeiten: Ersatz-Hash
|
||||
* merken). Laeuft vor der K16; ein Fehler wird geloggt und bricht nichts ab (die Freischaltung ist dann schon geschehen).
|
||||
*/
|
||||
async function nacharbeitNachFreischaltung(doc) {
|
||||
const { nachFreischaltung } = featureHaken(doc.featureKey)
|
||||
if (typeof nachFreischaltung !== 'function') return
|
||||
try {
|
||||
await nachFreischaltung(doc)
|
||||
} catch (err) {
|
||||
console.error(`[funktionen] Nacharbeit nach der Freischaltung ${doc.featureKey} (${doc.$id}) fehlgeschlagen:`, err.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Freischalten (Abnahme)": eine Funktion in Einrichtung ('setup') wird freigeschaltet, ohne dass das Einrichtungs-Ticket geschlossen
|
||||
* wird. Gesetzt werden nur state/enabled 'active', activatedAt und adminNote; ticketId, Ticket und unlockedByPurchase bleiben.
|
||||
* Danach K16 (meldeFreigeschaltet, Fehler werden dort geloggt). Unter derselben Sperre feature-activate:<id> wie der Freischalt-Job
|
||||
* (activateClosedTickets) nach frischem Lesen: wer zuerst kommt, aktiviert und sendet; der andere sieht 'active' und tut nichts.
|
||||
* - Dokument fehlt: 409 "Für dieses Projekt gibt es noch {keine}".
|
||||
* - schon 'active': Erfolg ohne Schreibzugriff und ohne Mail, activated false.
|
||||
* - 'off' | 'ordered' | 'disabled': 409. Ein Hindernis (kein Wartungs-Abo, keine Einstellung): 409; Abo nicht lesbar: 503.
|
||||
* @param {{ customerId: string, projectId: string, key: string, by?: string, adminNote?: string, now?: Date }} args
|
||||
* @returns {Promise<{ doc: object, activated: boolean }>}
|
||||
*/
|
||||
export async function freischaltenNachAbnahme({ customerId, projectId, key, by, adminNote, now = new Date() }) {
|
||||
const vorab = await dokumentFuerTeamAktion(customerId, projectId, key)
|
||||
const ergebnis = await withLocks([`feature-activate:${vorab.$id}`], async () => {
|
||||
const doc = await frischesDokument(vorab, key)
|
||||
if (doc.state === 'active') return { doc, activated: false }
|
||||
if (doc.state !== 'setup') throw httpError(409, ABNAHME_NUR_SETUP)
|
||||
const grund = await abnahmeHindernis(doc)
|
||||
if (grund) {
|
||||
const [status, text] = ABNAHME_HINDERNIS[grund] ?? [409, 'Freischalten ist noch nicht möglich.']
|
||||
throw httpError(status, text)
|
||||
}
|
||||
const note = adminNote ?? byNote(by, 'Freigeschaltet (Abnahme)')
|
||||
const updated = await updateDocument(config.collections.portalFeatures, doc.$id, {
|
||||
...stateFields('active'),
|
||||
activatedAt: now.toISOString(),
|
||||
...(note ? { adminNote: note } : {}),
|
||||
})
|
||||
return { doc: updated, activated: true }
|
||||
})
|
||||
if (ergebnis.activated) {
|
||||
await nacharbeitNachFreischaltung(ergebnis.doc)
|
||||
const { meldeFreigeschaltet } = await import('./termine/freischaltung.js')
|
||||
await meldeFreigeschaltet(ergebnis.doc)
|
||||
}
|
||||
return ergebnis
|
||||
}
|
||||
|
||||
/** Ob das Ticket noch existiert; nur 404 heisst "geloescht", jeder andere Fehler wird weitergereicht. */
|
||||
async function ticketBesteht(ticketId) {
|
||||
try {
|
||||
await getDocument(config.collections.workorders, ticketId)
|
||||
return true
|
||||
} catch (err) {
|
||||
if (err.status === 404) return false
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** Die bezahlte Stripe-Sitzung des Dokuments; nicht bezahlt: 409, Stripe nicht erreichbar oder nicht konfiguriert: 503 (Ursache im Log). */
|
||||
async function bezahlteSitzung(doc) {
|
||||
if (!doc.stripeSessionId) throw httpError(409, 'Zu dieser Funktion ist keine Zahlung vermerkt.')
|
||||
let session
|
||||
try {
|
||||
session = await retrieveCheckoutSession(doc.stripeSessionId)
|
||||
} catch (err) {
|
||||
console.error(`[funktionen] Stripe-Sitzung ${doc.stripeSessionId} nicht abrufbar:`, err.message)
|
||||
throw httpError(503, 'Stripe konnte gerade nicht gefragt werden. Bitte später erneut versuchen.')
|
||||
}
|
||||
if (session?.payment_status !== 'paid') throw httpError(409, 'Die Zahlung ist noch nicht eingegangen.')
|
||||
return session
|
||||
}
|
||||
|
||||
/**
|
||||
* Ticket nachlegen (a): 'setup' ohne ticketId, das Ticket entstand nie. Die Zahlung wird bei Stripe geprueft und dann derselbe Weg wie
|
||||
* im Webhook gegangen (handleFeatureCheckoutEvent): Ticket, K15 und T16 entstehen genau einmal, ein spaeterer Stripe-Retry findet das
|
||||
* Ticket und sendet nichts. Keine Sperre feature-paid:<session> hier, markPaid nimmt sie selbst (withLocks ist nicht wiederbetretbar).
|
||||
*/
|
||||
async function ticketNachZahlung(doc, note) {
|
||||
const session = await bezahlteSitzung(doc)
|
||||
await handleFeatureCheckoutEvent({ type: 'checkout.session.completed', data: { object: session } })
|
||||
const nachher = await getDocument(config.collections.portalFeatures, doc.$id)
|
||||
if (!nachher.ticketId) return { doc: nachher, created: false }
|
||||
if (!note) return { doc: nachher, created: true }
|
||||
try {
|
||||
return { doc: await updateDocument(config.collections.portalFeatures, doc.$id, { adminNote: note }), created: true }
|
||||
} catch (err) {
|
||||
// Ticket und Mails sind raus; die Notiz ist nur ein Vermerk
|
||||
console.error(`[funktionen] Notiz zum nachgelegten Ticket von ${doc.$id} nicht gespeichert:`, err.message)
|
||||
return { doc: nachher, created: true }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ticket nachlegen (b): ticketId gesetzt, das Ticket aber geloescht. Ein neues Ticket ersetzt die ticketId; K15 und T16 gingen beim
|
||||
* ersten Mal raus, darum keine Mail. Laeuft unter der Sperre der Zahlung (feature-paid:<session>), damit kein Stripe-Retry daneben
|
||||
* schreibt; das Dokument wird darunter erneut gelesen.
|
||||
*/
|
||||
async function ersetzeGeloeschtesTicket(doc, note) {
|
||||
return withLocks([`feature-paid:${doc.stripeSessionId || doc.$id}`], async () => {
|
||||
const frisch = await getDocument(config.collections.portalFeatures, doc.$id)
|
||||
if (frisch.state !== 'setup') throw httpError(409, TICKET_NUR_SETUP)
|
||||
if (frisch.ticketId !== doc.ticketId) return { doc: frisch, created: false }
|
||||
const project = await getDocument(config.collections.websiteProjects, frisch.projectId)
|
||||
const customer = await customerOf(frisch.customerId).catch(() => null)
|
||||
const ticketId = await legeTicketAn(frisch, project, customer)
|
||||
const updated = await updateDocument(config.collections.portalFeatures, frisch.$id, { ticketId, ...(note ? { adminNote: note } : {}) })
|
||||
return { doc: updated, created: true }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* "Ticket nachlegen": nur fuer eine Funktion in Einrichtung ('setup'; sonst 409, ohne Dokument 409).
|
||||
* (a) ticketId leer: Zahlung bei Stripe pruefen (nicht bezahlt 409, Stripe nicht erreichbar 503) und den Weg des Webhooks gehen,
|
||||
* Ticket + K15 + T16 genau einmal (ticketNachZahlung);
|
||||
* (b) ticketId gesetzt, Ticket geloescht: neues Ticket, ticketId ersetzt, keine Mails (ersetzeGeloeschtesTicket);
|
||||
* (c) Ticket vorhanden: nichts, created false.
|
||||
* Eine Sperre feature-ticket:<id> je Dokument: gleichzeitige Aufrufe laufen nacheinander und sehen das Ergebnis des ersten
|
||||
* (ein Ticket, created nur einmal true).
|
||||
* @param {{ customerId: string, projectId: string, key: string, by?: string, adminNote?: string }} args
|
||||
* @returns {Promise<{ doc: object, created: boolean }>}
|
||||
*/
|
||||
export async function ticketNachlegen({ customerId, projectId, key, by, adminNote }) {
|
||||
const vorab = await dokumentFuerTeamAktion(customerId, projectId, key)
|
||||
const note = adminNote ?? byNote(by, 'Ticket nachgelegt')
|
||||
return withLocks([`feature-ticket:${vorab.$id}`], async () => {
|
||||
const doc = await frischesDokument(vorab, key)
|
||||
if (doc.state !== 'setup') throw httpError(409, TICKET_NUR_SETUP)
|
||||
if (!doc.ticketId) return ticketNachZahlung(doc, note)
|
||||
if (await ticketBesteht(doc.ticketId)) return { doc, created: false }
|
||||
return ersetzeGeloeschtesTicket(doc, note)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktive Funktion zu einem siteKey; ohne featureKey 'news' (Pinnwand), unbekannte Funktion oder siteKey => null.
|
||||
* states ersetzt die Vorgabe ['active'] (die Oeffnungszeiten antworten schon in 'setup', damit das Team die Leisten
|
||||
@@ -679,14 +880,29 @@ function einrichtungsPreis(feature) {
|
||||
return eur(amountCents)
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute, versionierte Adressen der Vertragsunterlagen einer Funktion aus dem Register (Fassung der Leistungsbeschreibung und
|
||||
* die Adressen von Leistungsbeschreibung, AVV und Widerrufsbelehrung); null bei Funktionen ohne Rechtsdokumente (News).
|
||||
* @returns {{ fassung: string, lb: string, avv: string, widerruf: string } | null}
|
||||
*/
|
||||
export function unterlagenAdressen(feature) {
|
||||
const { leistungsbeschreibung: lb, avv, widerruf } = feature ?? {}
|
||||
if (!lb?.url || !avv?.url || !widerruf?.url) return null
|
||||
const absolut = (pfad) => `${config.publicBaseUrl}${pfad}`
|
||||
return { fassung: lb.version, lb: absolut(lb.url), avv: absolut(avv.url), widerruf: absolut(widerruf.url) }
|
||||
}
|
||||
|
||||
/**
|
||||
* K15 (Bestellung eingegangen) einer Funktion: Vorlage, Platzhalterwerte und Bloecke ausschliesslich aus dem Register-Eintrag
|
||||
* (mails.*, setupEstimate, returnHash) und dem Katalogpreis. Die Werte decken genau die Platzhalter der Vorlage ab.
|
||||
* Ohne Anhaenge: die Rechtsunterlagen der Termine-Bestellung haengt F8 direkt in onFeaturePaid an.
|
||||
* Funktionen mit Rechtsdokumenten (Terminplaner): `unterlagen` (unterlagenAdressen) schaltet den Block anlagen ein und fuellt
|
||||
* fassung und die drei Adressen; `anhang` sagt, dass die PDFs an der Mail haengen (Block anhang in anlagen); `zustimmungenText`
|
||||
* (zustimmungenText) schaltet den Block zustimmungen ein. Die PDFs selbst gibt onFeaturePaid als `attachments` mit; es gibt keinen
|
||||
* Registerhaken dafuer.
|
||||
* behalten: der Kunde behaelt die Funktion nach dem Abo-Ende fuer einmalig 30 EUR (Oeffnungszeiten live): die Mail sagt, dass nichts
|
||||
* neu eingerichtet wird. Die Vorlage kennt dafuer zwei Gegenstuecke, behalten und neu (Normalfall); genau eins ist an.
|
||||
*/
|
||||
export function bestellMail(feature, { kundeName, projekt, invoiceUrl, behalten = false }) {
|
||||
export function bestellMail(feature, { kundeName, projekt, invoiceUrl, unterlagen = null, anhang = false, zustimmungenText = '', behalten = false }) {
|
||||
const { funktion, formen, preisHinweis, danach } = feature.mails
|
||||
return {
|
||||
template: 'K15',
|
||||
@@ -704,11 +920,47 @@ export function bestellMail(feature, { kundeName, projekt, invoiceUrl, behalten
|
||||
danach,
|
||||
portal_url: portalUrl(feature),
|
||||
rechnung_url: invoiceUrl || '',
|
||||
fassung: unterlagen?.fassung ?? '',
|
||||
lb_url: unterlagen?.lb ?? '',
|
||||
avv_url: unterlagen?.avv ?? '',
|
||||
widerruf_url: unterlagen?.widerruf ?? '',
|
||||
zustimmungen_text: zustimmungenText,
|
||||
},
|
||||
blocks: {
|
||||
rechnung: Boolean(invoiceUrl),
|
||||
anlagen: unterlagen !== null,
|
||||
anhang: unterlagen !== null && anhang === true,
|
||||
zustimmungen: zustimmungenText !== '',
|
||||
behalten: Boolean(behalten),
|
||||
neu: !behalten,
|
||||
},
|
||||
blocks: { rechnung: Boolean(invoiceUrl), behalten: Boolean(behalten), neu: !behalten },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wortlaut und Zeitpunkt der erteilten Zustimmungen fuer K15 (Du-Form): die Haken-Texte aus dem Nachweis zur bezahlten Sitzung, der
|
||||
* Zeitpunkt des Klicks (Europe/Berlin) und der Knopf, mit dem bestellt wurde. '' ohne verwertbaren Nachweis.
|
||||
* @param {{ texte?: string, acceptedAt?: string } | null} nachweis featureConsents-Dokument (JSON-Text im Feld texte)
|
||||
*/
|
||||
export function zustimmungenText(nachweis) {
|
||||
let texte
|
||||
try {
|
||||
texte = JSON.parse(nachweis?.texte ?? '')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
if (!Array.isArray(texte)) return ''
|
||||
const haken = texte.filter((t) => t?.haken === true && typeof t.text === 'string' && t.text !== '')
|
||||
if (haken.length === 0) return ''
|
||||
const am = new Date(nachweis.acceptedAt)
|
||||
const zeit = Number.isNaN(am.getTime()) ? '' : (({ date, time }) => ` am ${date} um ${time.slice(0, 2)}:${time.slice(2)} Uhr`)(berlinDateTime(am))
|
||||
const knopf = texte.find((t) => t?.id === 'knopf' && typeof t.text === 'string' && t.text !== '')
|
||||
return [
|
||||
`Du hast${zeit} im Kundenportal zugestimmt${knopf ? ` und mit „${knopf.text}“ bestellt` : ''}:`,
|
||||
...haken.map((t) => `– ${t.text}`),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** K16 (Funktion bereit) einer Funktion, Werte wie bei bestellMail aus dem Register-Eintrag. */
|
||||
export function bereitMail(feature, { kundeName, projekt }) {
|
||||
const { funktion, formen, bereit, knopf } = feature.mails
|
||||
@@ -780,7 +1032,7 @@ async function ticketWoidOf(doc) {
|
||||
/**
|
||||
* T16 an das Team (Funktion bestellt): alle Platzhalter und Bloecke der Vorlage; was nicht genannt wird, bleibt leer bzw. aus.
|
||||
* Ohne bekannte Funktion heisst sie in Betreff und Text "Funktion". Liefert das Ergebnis von sendAdminNotice ({ sent, reason? }); der
|
||||
* Block gesundheit bleibt hier aus (D4 fuellt ihn).
|
||||
* Der Block gesundheit (Terminplaner) steht nur, wenn der Aufrufer ihn mit gesundheit_text einschaltet (teamNotice).
|
||||
*/
|
||||
function sendT16({ funktion, ...werte }, bloecke) {
|
||||
return sendAdminNotice({
|
||||
@@ -798,15 +1050,66 @@ function sendT16({ funktion, ...werte }, bloecke) {
|
||||
gesundheit_text: '',
|
||||
...werte,
|
||||
},
|
||||
blocks: { zahlung_fehlgeschlagen: false, ungeklaert: false, kein_abo: false, bereits_aktiv: false, anleitung: false, gesundheit: false, abo: false, team: false, ...bloecke },
|
||||
blocks: {
|
||||
zahlung_fehlgeschlagen: false,
|
||||
ungeklaert: false,
|
||||
kein_abo: false,
|
||||
bereits_aktiv: false,
|
||||
anleitung: false,
|
||||
gesundheit: false,
|
||||
anhang_fehlt: false,
|
||||
abo: false,
|
||||
team: false,
|
||||
...bloecke,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** keinAbo: die Zahlung ist da, der Kunde hat aber kein laufendes Wartungs-Abo (Block kein_abo, nur bei Funktionen mit requiresWartungsAbo). */
|
||||
async function teamNotice(doc, customer, { failed = false, keinAbo = false } = {}) {
|
||||
/** Der Warntext des T16-Blocks gesundheit hat hoechstens so viele Zeichen (ganze Meldungen, danach "… und N weitere"). */
|
||||
const GESUNDHEIT_TEXT_MAX = 500
|
||||
|
||||
/** Meldungen als ein Text: durch "; " getrennt; passt es nicht, so viele ganze Meldungen wie moeglich und "… und N weitere" (nie mitten in einer Meldung). */
|
||||
function gesundheitText(meldungen) {
|
||||
const alle = meldungen.join('; ')
|
||||
if (alle.length <= GESUNDHEIT_TEXT_MAX) return alle
|
||||
let text = ''
|
||||
let n = 0
|
||||
for (const meldung of meldungen) {
|
||||
const naechster = text ? `${text}; ${meldung}` : meldung
|
||||
if (`${naechster} … und ${meldungen.length - n - 1} weitere`.length > GESUNDHEIT_TEXT_MAX) break
|
||||
text = naechster
|
||||
n += 1
|
||||
}
|
||||
return text ? `${text} … und ${meldungen.length - n} weitere` : kuerze(meldungen[0], GESUNDHEIT_TEXT_MAX, { endzeile: '…' })
|
||||
}
|
||||
|
||||
/**
|
||||
* T16-Hinweis "Gesundheitsbereich pruefen" (nur Terminplaner): die Treffer der Stichwortpruefung ueber die gespeicherte Einstellung des Betriebs
|
||||
* (gesundheitsMeldungen: wohlgeformt, einzeilig, Klartext; renderMail maskiert sie fuer HTML), hoechstens 500 Zeichen. '' ohne Treffer und bei
|
||||
* jedem Lesefehler: T16 geht dann ohne Block raus (nie Geld ohne Spur); das Log nennt keine Daten des Betriebs. Dynamische Importe, weil
|
||||
* termine/kontext.js dieses Modul importiert.
|
||||
*/
|
||||
async function gesundheitHinweis(doc) {
|
||||
if (doc.featureKey !== 'termine') return ''
|
||||
try {
|
||||
const [{ ladeEinstellung }, { gesundheitsMeldungen }] = await Promise.all([import('./termine/kontext.js'), import('./termine/einbauanleitung.js')])
|
||||
const meldungen = gesundheitsMeldungen((await ladeEinstellung(doc.$id))?.einstellung)
|
||||
return meldungen.length ? gesundheitText(meldungen) : ''
|
||||
} catch (err) {
|
||||
console.error(`[funktionen] Gesundheitshinweis fuer T16 (Funktion ${doc.$id}) nicht lesbar, der Block entfaellt:`, err.message)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* keinAbo: die Zahlung ist da, der Kunde hat aber kein laufendes Wartungs-Abo (Block kein_abo, nur bei Funktionen mit requiresWartungsAbo).
|
||||
* anhangFehlt: die Vertragsunterlagen sind nicht beim Kunden angekommen, das Team sendet sie von Hand nach (Block anhang_fehlt).
|
||||
*/
|
||||
async function teamNotice(doc, customer, { failed = false, keinAbo = false, anhangFehlt = false } = {}) {
|
||||
const mails = featureConfig(doc.featureKey)?.mails
|
||||
// Knopf zur Einbauanleitung nur bei Funktionen mit Anleitung und nur, wenn bestellt und ein Ticket angelegt wurde
|
||||
const anleitung = Boolean(mails?.anleitung) && !failed
|
||||
const gesundheit = failed ? '' : await gesundheitHinweis(doc)
|
||||
await sendT16(
|
||||
{
|
||||
kunde_name: customer?.name || customer?.email || doc.customerId,
|
||||
@@ -816,8 +1119,9 @@ async function teamNotice(doc, customer, { failed = false, keinAbo = false } = {
|
||||
woid: failed ? '' : await ticketWoidOf(doc),
|
||||
sitekey: failed ? '' : doc.siteKey || '',
|
||||
anleitung_url: anleitung ? anleitungsLink(doc.customerId, doc.projectId) : '',
|
||||
gesundheit_text: gesundheit,
|
||||
},
|
||||
{ zahlung_fehlgeschlagen: failed, anleitung, kein_abo: keinAbo }
|
||||
{ zahlung_fehlgeschlagen: failed, anleitung, kein_abo: keinAbo, gesundheit: gesundheit !== '', anhang_fehlt: anhangFehlt }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -986,6 +1290,62 @@ async function onBehaltenPaid(session) {
|
||||
await bucheBehalten(session)
|
||||
}
|
||||
|
||||
/** K15 einer Funktion ohne Rechtsdokumente (News): keine Anhaenge, keine Bloecke anlagen und zustimmungen. */
|
||||
const OHNE_RECHTSUNTERLAGEN = Object.freeze({ attachments: Object.freeze([]), unterlagen: null, anhang: false, zustimmungen: '', fehlt: false })
|
||||
|
||||
/**
|
||||
* Rechtsunterlagen der K15 einer Funktion mit Rechtsdokumenten: die PDFs und Adressen der Fassung, der der Kunde bei der Bestellung
|
||||
* zugestimmt hat (Fassung des Nachweises zur bezahlten Sitzung), und der Wortlaut seiner Zustimmungen. Das ist meist die aktuelle
|
||||
* Fassung (legalDocs.featureLegalAttachments, erst beim Aufruf geladen; kein Registerhaken). Wurde die Fassung zwischen Klick und
|
||||
* Zahlung angehoben, gehen die Unterlagen der vereinbarten Fassung raus, wenn es sie gibt; fehlen sie, geht K15 ohne PDFs und Links
|
||||
* raus (bei der aktuellen Fassung bleiben die Links, nur die PDFs fehlen). Ist der Nachweis nicht lesbar oder seine Fassung nicht bestimmbar, ist die vereinbarte Fassung unbekannt: nie die aktuelle
|
||||
* ungeprueft anhaengen, K15 geht ohne PDFs und Links raus. Ohne Nachweis (Altbestand) gilt die aktuelle Fassung. In allen Faellen ohne
|
||||
* Unterlagen ist fehlt true (T16 sagt "von Hand nachsenden") und der Grund steht im Log. Wirft nie.
|
||||
* @returns {Promise<{ attachments: object[], unterlagen: { fassung: string, lb: string, avv: string, widerruf: string } | null, anhang: boolean, zustimmungen: string, fehlt: boolean }>}
|
||||
*/
|
||||
async function k15Rechtsunterlagen(doc, feature, session) {
|
||||
const ohne = (zustimmungen) => ({ attachments: [], unterlagen: null, anhang: false, zustimmungen, fehlt: true })
|
||||
let nachweis = null
|
||||
try {
|
||||
nachweis = await zustimmungFuerSitzung(session.id)
|
||||
} catch (err) {
|
||||
console.error(`[funktionen] K15 (Funktion ${doc.$id}): Zustimmungsnachweis zu Sitzung ${session.id} nicht lesbar, die vereinbarte Fassung ist unbekannt - ohne Unterlagen, das Team sendet sie von Hand:`, err.message)
|
||||
return ohne('')
|
||||
}
|
||||
const zustimmungen = zustimmungenText(nachweis)
|
||||
const aktuell = feature.consent.version
|
||||
let vereinbart = aktuell
|
||||
if (nachweis) {
|
||||
try {
|
||||
vereinbart = JSON.parse(nachweis.fassungen)?.zustimmung
|
||||
} catch {
|
||||
vereinbart = undefined
|
||||
}
|
||||
if (typeof vereinbart !== 'string' || vereinbart === '') {
|
||||
console.error(`[funktionen] K15 (Funktion ${doc.$id}): Fassung im Zustimmungsnachweis nicht bestimmbar - ohne Unterlagen, das Team sendet sie von Hand`)
|
||||
return ohne(zustimmungen)
|
||||
}
|
||||
}
|
||||
try {
|
||||
const legal = await import('./legalDocs.js')
|
||||
const eigene = vereinbart === aktuell ? {} : { fassung: vereinbart }
|
||||
if (vereinbart !== aktuell) {
|
||||
console.error(`[funktionen] K15 (Funktion ${doc.$id}): Der Kunde hat der Fassung ${vereinbart} zugestimmt, aktuell ist ${aktuell} - die Unterlagen der vereinbarten Fassung gehen raus, wenn es sie gibt`)
|
||||
if (!(await legal.featureLegalReady(doc.featureKey, eigene))) throw new Error(`Unterlagen der Fassung ${vereinbart} fehlen`)
|
||||
}
|
||||
const attachments = await legal.featureLegalAttachments(doc.featureKey, eigene)
|
||||
if (attachments.length === 0) throw new Error('keine Anhaenge fuer diese Funktion')
|
||||
const adressen = legal.featureLegalPaths(doc.featureKey, eigene)
|
||||
const absolut = (pfad) => `${config.publicBaseUrl}${pfad}`
|
||||
const unterlagen = { fassung: vereinbart, lb: absolut(adressen.lb), avv: absolut(adressen.avv), widerruf: absolut(adressen.widerruf) }
|
||||
return { attachments, unterlagen, anhang: true, zustimmungen, fehlt: false }
|
||||
} catch (err) {
|
||||
console.error(`[funktionen] K15 (Funktion ${doc.$id}): Unterlagen nicht lesbar, K15 geht ohne PDFs raus:`, err.message)
|
||||
// die Seiten der aktuellen (= vereinbarten) Fassung bleiben verlinkt; Links auf eine andere Fassung gibt es nur, wenn deren Dateien da sind
|
||||
return { ...ohne(zustimmungen), unterlagen: vereinbart === aktuell ? unterlagenAdressen(feature) : null }
|
||||
}
|
||||
}
|
||||
|
||||
async function onFeaturePaid(session) {
|
||||
if (session.metadata?.zweck === 'behalten') return onBehaltenPaid(session)
|
||||
const { invoiceId, invoiceUrl } = await invoiceOf(session.invoice)
|
||||
@@ -1005,16 +1365,34 @@ async function onFeaturePaid(session) {
|
||||
} catch (err) {
|
||||
console.error('[funktionen] Kunde fuer K15/T16 nicht lesbar:', err.message)
|
||||
}
|
||||
// K15 mit den Vertragsunterlagen (Terminplaner: drei PDFs als dauerhafter Datentraeger, § 312f BGB). Die Bestaetigung geht vor dem
|
||||
// Anhang: sind die PDFs nicht lesbar oder kommt die Mail nicht an, bleibt die Zahlung nicht ohne Spur, T16 sagt dem Team
|
||||
// "Unterlagen von Hand nachsenden" (Block anhang_fehlt).
|
||||
const mitRechtsdokumenten = feature ? unterlagenAdressen(feature) !== null : false
|
||||
let anhangFehlt = false
|
||||
if (customer?.email) {
|
||||
try {
|
||||
if (!feature) throw new Error(`Funktion ${doc.featureKey} nicht im Register`)
|
||||
await sendCustomerMail(
|
||||
customer.email,
|
||||
bestellMail(feature, { kundeName: customer.name || '', projekt: await projectName(doc.projectId), invoiceUrl })
|
||||
)
|
||||
const rechtlich = mitRechtsdokumenten ? await k15Rechtsunterlagen(doc, feature, session) : OHNE_RECHTSUNTERLAGEN
|
||||
anhangFehlt = rechtlich.fehlt
|
||||
await sendCustomerMail(customer.email, {
|
||||
...bestellMail(feature, {
|
||||
kundeName: customer.name || '',
|
||||
projekt: await projectName(doc.projectId),
|
||||
invoiceUrl,
|
||||
unterlagen: rechtlich.unterlagen,
|
||||
anhang: rechtlich.anhang,
|
||||
zustimmungenText: rechtlich.zustimmungen,
|
||||
}),
|
||||
attachments: [...rechtlich.attachments],
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[funktionen] K15 fehlgeschlagen:', err.message)
|
||||
if (mitRechtsdokumenten) anhangFehlt = true
|
||||
}
|
||||
} else if (mitRechtsdokumenten) {
|
||||
console.error(`[funktionen] K15 (Funktion ${doc.$id}) nicht gesendet: beim Kunden ist keine E-Mail-Adresse hinterlegt - die Vertragsunterlagen muessen von Hand nachgesendet werden`)
|
||||
anhangFehlt = true
|
||||
}
|
||||
// Funktionen, die ein laufendes Wartungs-Abo voraussetzen: endete es vor der Zahlung, sagt T16 es dem Team (Block kein_abo).
|
||||
// Nach der Verbuchung geprueft; ein Lesefehler blockiert nichts (Log, Block bleibt aus), der Webhook antwortet nie deswegen 500.
|
||||
@@ -1027,7 +1405,7 @@ async function onFeaturePaid(session) {
|
||||
}
|
||||
}
|
||||
try {
|
||||
await teamNotice(doc, customer, { keinAbo })
|
||||
await teamNotice(doc, customer, { keinAbo, anhangFehlt })
|
||||
} catch (err) {
|
||||
console.error('[funktionen] T16 fehlgeschlagen:', err.message)
|
||||
}
|
||||
@@ -1094,32 +1472,18 @@ async function heileZeiten({ now }) {
|
||||
/**
|
||||
* Freischalt-Job: geschlossene Einrichtungs-Tickets -> 'active' (je Funktion nur, wenn ihr Haken freischaltbar es erlaubt; Oeffnungszeiten:
|
||||
* erst mit eingetragenen Zeiten), danach je Dokument der Haken nachFreischaltung (Oeffnungszeiten: Ersatz-Hash) und K16 an jeden
|
||||
* betroffenen Kunden. Zum Schluss, nur im Lauf des Jobs (ohne customerId), die Selbstheilung der Oeffnungszeiten (heileZeiten): die
|
||||
* Projektliste eines Kunden (GET /api/funktionen, mit customerId) schaltet nur frei und baut, bestellt und sendet nichts nach.
|
||||
* betroffenen Kunden (meldeFreigeschaltet: Fehler werden geloggt und halten weder die Aktivierung noch die uebrigen Mails auf).
|
||||
* termine/freischaltung.js importiert diese Datei, darum wird es hier erst im Aufruf geladen. Zum Schluss, nur im Lauf des Jobs
|
||||
* (ohne customerId), die Selbstheilung der Oeffnungszeiten (heileZeiten): die Projektliste eines Kunden (GET /api/funktionen, mit
|
||||
* customerId) schaltet nur frei und baut, bestellt und sendet nichts nach.
|
||||
*/
|
||||
export async function runActivation({ now = new Date(), customerId = null } = {}) {
|
||||
const activated = await activateClosedTickets({ now, customerId })
|
||||
for (const doc of activated) {
|
||||
// Nacharbeit vor der K16 und vor dem Adress-Check: auch ein Kunde ohne Adresse bekommt sie; ein Fehler bricht nichts ab
|
||||
const { nachFreischaltung } = featureHaken(doc.featureKey)
|
||||
if (typeof nachFreischaltung === 'function') {
|
||||
try {
|
||||
await nachFreischaltung(doc)
|
||||
} catch (err) {
|
||||
console.error(`[funktionen] Nacharbeit nach der Freischaltung ${doc.featureKey} (${doc.$id}) fehlgeschlagen:`, err.message)
|
||||
}
|
||||
}
|
||||
try {
|
||||
const customer = await customerOf(doc.customerId)
|
||||
if (!customer?.email) continue
|
||||
const feature = featureConfig(doc.featureKey)
|
||||
if (!feature) throw new Error(`Funktion ${doc.featureKey} nicht im Register`)
|
||||
await sendCustomerMail(
|
||||
customer.email,
|
||||
bereitMail(feature, { kundeName: customer.name || '', projekt: await projectName(doc.projectId) })
|
||||
)
|
||||
} catch (err) {
|
||||
console.error(`[funktionen] K16 an Kunde ${doc.customerId} fehlgeschlagen:`, err.message)
|
||||
if (activated.length > 0) {
|
||||
const { meldeFreigeschaltet } = await import('./termine/freischaltung.js')
|
||||
for (const doc of activated) {
|
||||
await nacharbeitNachFreischaltung(doc)
|
||||
await meldeFreigeschaltet(doc)
|
||||
}
|
||||
}
|
||||
if (!customerId) await heileZeiten({ now })
|
||||
|
||||
@@ -228,7 +228,8 @@ export function pruefeEingabe(e, roh, now, { manuell = false, bestaetigung = fal
|
||||
|
||||
// Zustimmungen
|
||||
const zustimmungen = istObjekt(roh.zustimmungen) ? roh.zustimmungen : {}
|
||||
const stornoNoetig = !manuell && typeof e.storno?.text === 'string' && e.storno.text !== ''
|
||||
// Stornobedingungen gelten nur fuer Leistungen mit Preis: bei kostenlosen Leistungen kein Pflichthaken und kein Nachweis
|
||||
const stornoNoetig = !manuell && hatPreis(leistung) && typeof e.storno?.text === 'string' && e.storno.text !== ''
|
||||
if (stornoNoetig && zustimmungen.storno !== true) fehl('storno', MELDUNG.storno)
|
||||
const vorzeitigNoetig = !manuell && leistung && start ? brauchtZustimmungVorzeitig(e, leistung, start, now) : false
|
||||
if (vorzeitigNoetig && zustimmungen.vorzeitig !== true) fehl('vorzeitig', MELDUNG.vorzeitig)
|
||||
|
||||
@@ -0,0 +1,856 @@
|
||||
import { config } from '../../config.js'
|
||||
import { TICKET_DETAILS_MAX, TICKET_GEKUERZT, anleitungsLink, kuerze } from '../featureRegistry.js'
|
||||
import { gesundheitsWarnungen } from './fragebogen.js'
|
||||
import {
|
||||
VORGABE_FARBE, fehlendePflichtangaben, hatPreis, preisHinweis, preisText, sichtbareLeistungen, text as bereinigterText, zahlungText, zeigtWiderruf,
|
||||
} from './einstellungen.js'
|
||||
import { RECHTSTEXTE_ENTWURF, datenschutzAbsatz } from './rechtstexte.js'
|
||||
import { datumKurz, heute } from './zeit.js'
|
||||
|
||||
/**
|
||||
* Einbau des Terminplaners auf der Website des Kunden (Spezifikation 2026-10-03-terminplaner-tp2-verkauf-design.md, Abschnitt
|
||||
* Einbauanleitung; Anleitung fuer das Team: docs/funktionen/termine-einbau.md).
|
||||
*
|
||||
* Zwei Teile in einer Datei:
|
||||
* - Bausteine (esc, einbauSnippet, Farbfunktionen, cssVorschlag): reine Funktionen ohne Appwrite und ohne config, die Adresse
|
||||
* des Portals (`apiBase`) und alle Werte kommen als Parameter. Der Einbau-Code stand bis TP2 in routes/termine.js;
|
||||
* GET /api/termine/einstellungen ruft ihn von hier auf (Modus inline, die Kundenansicht bleibt unveraendert).
|
||||
* - Generator einbauanleitung() (ganz unten): setzt aus den Einstellungen des Betriebs die Anleitung fuer das Team zusammen.
|
||||
* Auch er ist rein (kein I/O, keine Zufallswerte); config wird nur beim Aufruf gelesen (Portal-Adresse, Haltezeit, WOMS-Adresse,
|
||||
* Preis). Er nutzt die reinen Hilfen gesundheitsWarnungen (fragebogen.js) und datenschutzAbsatz (rechtstexte.js).
|
||||
*
|
||||
* Die Farbfunktionen (luminanz, textFarbeAuf) sind 1:1 die aus public/embed/termine.js (Marken @farben-anfang/@farben-ende),
|
||||
* bewusst kopiert und nicht importiert: das Widget ist ein eigenstaendiges Browser-Skript. test/termineEmbedStatic.test.js
|
||||
* haelt beide fuer 4096 Farben gleich; wer eine Seite aendert, aendert die andere mit.
|
||||
*/
|
||||
|
||||
const FARBE_RE = /^#[0-9a-fA-F]{6}$/
|
||||
const KNOPF_MAX = 40
|
||||
const MIN_RAHMEN = 3 // WCAG 2.1 AA fuer Bedienelemente und Rahmen (1.4.11): der Rahmen freier Tage steht in der Akzentfarbe
|
||||
|
||||
// ---------------------------------------------------------------- Text
|
||||
|
||||
/** Maskiert &, <, >, " fuer Text und Attributwerte in doppelten Anfuehrungszeichen. null und undefined werden zu ''. */
|
||||
export function esc(text) {
|
||||
return String(text ?? '').replace(/[&<>"]/g, (z) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[z])
|
||||
}
|
||||
|
||||
/** Knopf-Text fuer data-knopf: Zeilenumbrueche und Steuerzeichen zu Leerzeichen, hoechstens 40 Zeichen (nie mitten in einem Zeichen). */
|
||||
function knopfTextBereinigt(roh) {
|
||||
if (typeof roh !== 'string') return ''
|
||||
const eine = roh.replace(/\r\n/g, ' ').replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, ' ').trim()
|
||||
return Array.from(eine).slice(0, KNOPF_MAX).join('')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Einbau-Code
|
||||
|
||||
/**
|
||||
* Einbau-Code fuer die Website: Platzhalter mit Ausweichtext (bleibt ohne JavaScript stehen) und das Skript.
|
||||
* modus: 'inline' (Standard, die Strecke steht in der Seite) | 'knopf' (ein Knopf oeffnet einen Dialog); jeder andere Wert gilt
|
||||
* als 'inline'. knopfText gilt nur im Modus knopf und nur, wenn er nicht leer ist; sonst nimmt das Widget "Termin buchen".
|
||||
*/
|
||||
export function einbauSnippet({ siteKey, apiBase, telefon, modus = 'inline', knopfText } = {}) {
|
||||
const knopf = modus === 'knopf' ? knopfTextBereinigt(knopfText) : ''
|
||||
const ausweich = telefon ? `Termine telefonisch unter ${esc(telefon)}.` : 'Termine telefonisch vereinbaren.'
|
||||
const attribute = [
|
||||
`data-webklar-termine="${esc(siteKey)}"`,
|
||||
`data-api="${esc(apiBase)}"`,
|
||||
`data-modus="${modus === 'knopf' ? 'knopf' : 'inline'}"`,
|
||||
...(knopf ? [`data-knopf="${esc(knopf)}"`] : []),
|
||||
]
|
||||
return [
|
||||
`<div ${attribute.join(' ')}>`,
|
||||
` <p>${ausweich}</p>`,
|
||||
'</div>',
|
||||
`<script src="${esc(apiBase)}/embed/termine.js" defer></script>`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Farben
|
||||
|
||||
/** Relative Leuchtdichte nach WCAG 2.1 fuer '#rrggbb' (wie im Widget; erwartet eine gueltige Farbe). */
|
||||
export function luminanz(farbe) {
|
||||
const rgb = [1, 3, 5].map((i) => {
|
||||
const c = parseInt(farbe.slice(i, i + 2), 16) / 255
|
||||
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
|
||||
})
|
||||
return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]
|
||||
}
|
||||
|
||||
/** Kontrastverhaeltnis zweier Leuchtdichten (1 bis 21), wie kontrast() im Widget. */
|
||||
const verhaeltnis = (a, b) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
|
||||
|
||||
/** Kontrastverhaeltnis zweier Farben '#rrggbb' (1 bis 21, Reihenfolge egal). */
|
||||
export function kontrast(hexA, hexB) {
|
||||
return verhaeltnis(luminanz(hexA), luminanz(hexB))
|
||||
}
|
||||
|
||||
/**
|
||||
* Schrift auf der Betriebsfarbe: Weiss oder das dunkle Grau, je nachdem was mehr Kontrast gibt; erreicht keines 4,5 : 1
|
||||
* (mittlere Toene), nimmt sie Schwarz (mindestens 4,58 : 1 fuer jede Farbe). 1:1 aus dem Widget.
|
||||
*/
|
||||
export function textFarbeAuf(farbe) {
|
||||
const l = luminanz(farbe)
|
||||
const weiss = verhaeltnis(l, 1)
|
||||
const dunkel = verhaeltnis(l, luminanz('#111827'))
|
||||
const beste = Math.max(weiss, dunkel)
|
||||
if (beste < 4.5 && verhaeltnis(l, 0) > beste) return '#000000'
|
||||
return weiss >= dunkel ? '#ffffff' : '#111827'
|
||||
}
|
||||
|
||||
/** Kontrast mit einer Nachkommastelle und deutschem Komma, abgerundet ("unter 3 : 1" bleibt auch im Text unter 3). */
|
||||
const kontrastAnzeige = (wert) => (Math.floor(wert * 10) / 10).toFixed(1).replace('.', ',')
|
||||
|
||||
/**
|
||||
* CSS-Vorschlag fuer die Website aus der Betriebsfarbe (Anleitung Abschnitt 3): Kommentar plus
|
||||
* `[data-webklar-termine]{ --wkt-akzent; --wkt-akzent-text }`. Nur eine gueltige '#rrggbb' (gross oder klein) wird uebernommen;
|
||||
* alles andere ersetzt VORGABE_FARBE, der Rohtext kommt nie in die Ausgabe.
|
||||
* Rueckgabe: css, akzent (die verwendete Farbe, klein), akzentText (Schrift darauf), kontrastText (Akzent gegen Schrift),
|
||||
* kontrastSeite (Akzent gegen Weiss), hinweise (Warnungen, leer wenn alles passt). Der Rahmen der freien Tage steht in der
|
||||
* Akzentfarbe und braucht gegen den Hintergrund mindestens 3 : 1; das Widget hat standardmaessig einen weissen Hintergrund.
|
||||
*/
|
||||
export function cssVorschlag(farbe) {
|
||||
const akzent = typeof farbe === 'string' && FARBE_RE.test(farbe) ? farbe.toLowerCase() : VORGABE_FARBE
|
||||
const akzentText = textFarbeAuf(akzent)
|
||||
const textKontrast = kontrast(akzent, akzentText)
|
||||
const seitenKontrast = kontrast(akzent, '#ffffff')
|
||||
const hinweise = []
|
||||
if (seitenKontrast < MIN_RAHMEN) {
|
||||
hinweise.push(
|
||||
`Kontrast gegen Weiß: nur ${kontrastAnzeige(seitenKontrast)} : 1. Der Rahmen der freien Tage steht in der Akzentfarbe ${akzent} und braucht ` +
|
||||
`mindestens ${MIN_RAHMEN} : 1 gegen den Hintergrund. Auf weißem Grund wähle eine dunklere Farbe, auf dunklem Grund setze --wkt-hintergrund passend.`
|
||||
)
|
||||
}
|
||||
const css = [
|
||||
`/* Terminplaner: Farbe der Knöpfe und Schrift darauf, aus der Betriebsfarbe. Kontrast der Schrift: ${kontrastAnzeige(textKontrast)} : 1. */`,
|
||||
'[data-webklar-termine]{',
|
||||
` --wkt-akzent:${akzent};`,
|
||||
` --wkt-akzent-text:${akzentText};`,
|
||||
'}',
|
||||
].join('\n')
|
||||
return { css, akzent, akzentText, kontrastText: textKontrast, kontrastSeite: seitenKontrast, hinweise }
|
||||
}
|
||||
|
||||
// ================================================================ Generator einbauanleitung()
|
||||
// Task D3a: Modell, Leistungen, Ressourcen, Regeln, Gastfelder, Benachrichtigungen. Task D3b: Einbau, Snippet, CSS, Datenschutz-Absaetze,
|
||||
// Checkliste, Abrechnung, die Warnungen und der Volltext. Task D4: die Kurzfassung fuer das Einrichtungs-Ticket (`kurz`), ticketStand und driftWarnung.
|
||||
//
|
||||
// Feldweissliste: gelesen werden nur ausdruecklich benannte Felder, nie ganze Objekte. Nie ausgegeben werden feedToken (Kalender-
|
||||
// Abo der Ressource), die Betriebs-E-Mail (sie steht nur in den Datenschutz-Absaetzen), Beschreibungen der Leistungen und alles,
|
||||
// was sonst an den Objekten haengt. Kundentexte (Namen, Titel, Storno-Text, Wuensche zum Einbau) stehen unmaskiert als Text; wer sie
|
||||
// anzeigt, setzt sie als Text ein. Einzeilige Werte werden auf eine Zeile gebracht, mehrzeilige eingerueckt, damit ein Kundentext
|
||||
// keine Ueberschrift vortaeuschen kann. Wuensche des Kunden stehen nie im Snippet und nie im CSS (nur der Knopf-Text, maskiert,
|
||||
// als data-knopf).
|
||||
|
||||
/** Kennzeichnung der Gaestetexte als Entwurf; erste Warnung, solange `entwurf` gilt (Standard RECHTSTEXTE_ENTWURF). */
|
||||
export const ENTWURF_BANNER = 'ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen'
|
||||
|
||||
const WOCHENTAGE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
||||
const TAG_RE = /^\d{4}-\d{2}-\d{2}$/
|
||||
const MAX_ABWESENHEITEN = 20
|
||||
const MAX_SONDEROEFFNUNGEN = 20
|
||||
const NICHT_ANGEGEBEN = 'nicht angegeben'
|
||||
const GAESTE = { verbraucher: 'Privatpersonen (Verbraucher)', unternehmer: 'Unternehmen', gemischt: 'Privatpersonen und Unternehmen' }
|
||||
const BUCHUNGSART = { sofort: 'sofort buchbar', anfrage: 'nur als Anfrage' }
|
||||
const GAST_FELD = { aus: 'aus', optional: 'optional', pflicht: 'Pflicht' }
|
||||
|
||||
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
|
||||
const liste = (wert) => (Array.isArray(wert) ? wert : [])
|
||||
const anzahl = (n, einzahl, mehrzahl) => `${n} ${n === 1 ? einzahl : mehrzahl}`
|
||||
const imWort = (map, schluessel, vorgabe) => (typeof schluessel === 'string' && Object.hasOwn(map, schluessel) ? map[schluessel] : vorgabe)
|
||||
|
||||
/** Einzeiliger Text: Leerraum, Umbrueche und Steuerzeichen werden zu einem Leerzeichen, Rand getrimmt; Nicht-Text = ''. */
|
||||
const zeile = (wert) => (typeof wert === 'string' ? wert.replace(/[\p{Cc}\p{Zl}\p{Zp}\s]+/gu, ' ').trim() : '')
|
||||
|
||||
/** '2026-10-05' => '05.10.2026'. */
|
||||
const tagText = (tag) => tag.split('-').reverse().join('.')
|
||||
|
||||
/** [['09:00','12:30'],['14:00','18:00']] => '09:00–12:30, 14:00–18:00'; Unbrauchbares faellt weg. */
|
||||
function fensterText(fenster) {
|
||||
return liste(fenster)
|
||||
.filter((f) => Array.isArray(f) && f.length === 2 && typeof f[0] === 'string' && typeof f[1] === 'string')
|
||||
.map(([von, bis]) => `${zeile(von)}–${zeile(bis)}`)
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
/** Wochenplan { 1: [['09:00','12:30'], …], … } (ISO-Wochentage) als Zeilen 'Mo 09:00–12:30, 14:00–18:00', Montag bis Sonntag, ohne leere Tage. */
|
||||
export function wochenplanText(plan) {
|
||||
const aus = []
|
||||
for (let tag = 1; tag <= 7; tag += 1) {
|
||||
const text = fensterText(istObjekt(plan) ? plan[tag] : null)
|
||||
if (text) aus.push(`${WOCHENTAGE[tag - 1]} ${text}`)
|
||||
}
|
||||
return aus
|
||||
}
|
||||
|
||||
/** Die ersten `max` Eintraege, bei mehr als `max` gefolgt von '… und N weitere'. Aendert die Liste nicht. */
|
||||
export function kuerzeListe(eintraege, max) {
|
||||
if (eintraege.length <= max) return [...eintraege]
|
||||
return [...eintraege.slice(0, max), `… und ${eintraege.length - max} weitere`]
|
||||
}
|
||||
|
||||
const neuerAbschnitt = (id, titel, text, kopierbar = false) => ({ id, titel, text, kopierbar })
|
||||
|
||||
/** Zeilen als Aufzaehlung: '- Eintrag'. */
|
||||
const aufzaehlung = (eintraege) => eintraege.map((eintrag) => `- ${eintrag}`)
|
||||
|
||||
// ---------------------------------------------------------------- Abschnitt Modell
|
||||
|
||||
function standText(d) {
|
||||
if (!Number.isInteger(d.version) || d.version < 1) return 'keine gespeicherte Version'
|
||||
return `Version ${d.version}${d.geaendert ? `, zuletzt geändert ${d.geaendert}` : ''}`
|
||||
}
|
||||
|
||||
function abschnittModell(d) {
|
||||
const { e, betrieb: b } = d
|
||||
const aktive = (eintraege) => eintraege.filter((x) => x.aktiv).length
|
||||
const angabe = (wert) => zeile(wert) || NICHT_ANGEGEBEN
|
||||
const gaeste = imWort(GAESTE, e.recht?.gaesteSind, GAESTE.verbraucher) + (e.recht?.freizeit === true ? ', Freizeitleistung' : '')
|
||||
return neuerAbschnitt('modell', 'Modell', [
|
||||
`Erzeugt am ${datumKurz(d.now)}`,
|
||||
`Stand der Einstellungen: ${standText(d)}`,
|
||||
...(d.kunde ? [`Kunde: ${d.kunde}`] : []),
|
||||
...(d.projekt ? [`Projekt: ${d.projekt}`] : []),
|
||||
`Betrieb: ${angabe(b.name)}`,
|
||||
`Anschrift: ${angabe(b.adresse)}`,
|
||||
`Telefon: ${angabe(b.telefon)}`,
|
||||
`Impressum: ${angabe(b.impressumUrl)}`,
|
||||
`Datenschutzerklärung: ${angabe(b.datenschutzUrl)}`,
|
||||
...(zeile(b.agbUrl) ? [`AGB: ${zeile(b.agbUrl)}`] : []),
|
||||
...(zeile(b.websiteUrl) ? [`Website: ${zeile(b.websiteUrl)}`] : []),
|
||||
`Farbe: ${d.farbe}`,
|
||||
`Anrede der Gäste: ${e.anrede === 'du' ? 'Du' : 'Sie'}`,
|
||||
`Gäste: ${gaeste}`,
|
||||
`Umfang: ${anzahl(d.ressourcen.length, 'Person oder Platz', 'Personen und Plätze')} (${aktive(d.ressourcen)} aktiv), `
|
||||
+ `${anzahl(d.leistungen.length, 'Leistung', 'Leistungen')} (${aktive(d.leistungen)} aktiv)`,
|
||||
`Portal-Adresse (data-api): ${d.portal}`,
|
||||
].join('\n'))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Abschnitt Leistungen
|
||||
|
||||
/** 'Personen: Anna, Ben' | 'Plätze: …' | 'Personen und Plätze: …'; unbekannte Zuordnungen fallen weg. */
|
||||
function zuordnungText(ids, nachId) {
|
||||
const treffer = liste(ids).map((id) => nachId.get(id)).filter(Boolean)
|
||||
if (treffer.length === 0) return 'keine Person und kein Platz zugeordnet'
|
||||
const plaetze = treffer.filter((r) => r.typ === 'platz').length
|
||||
const label = plaetze === 0 ? 'Personen' : plaetze === treffer.length ? 'Plätze' : 'Personen und Plätze'
|
||||
return `${label}: ${treffer.map((r) => r.name).join(', ')}`
|
||||
}
|
||||
|
||||
function leistungsZeile(l, nachId) {
|
||||
const dauer = Number.isFinite(l.dauer) ? `${l.dauer} Min.` : 'Dauer fehlt'
|
||||
const pause = Number.isFinite(l.pufferNach) && l.pufferNach > 0 ? ` (+ ${l.pufferNach} Min. Pause danach)` : ''
|
||||
return `${zeile(l.name) || 'ohne Namen'}${l.aktiv ? '' : ' (inaktiv)'}: ${dauer}${pause}, ${preisText(l) || 'kein Preis'}, `
|
||||
+ `${imWort(BUCHUNGSART, l.buchungsart, zeile(l.buchungsart) || BUCHUNGSART.sofort)}, ${zuordnungText(l.ressourcen, nachId)}`
|
||||
}
|
||||
|
||||
function abschnittLeistungen(d) {
|
||||
if (d.leistungen.length === 0) return neuerAbschnitt('leistungen', 'Leistungen', 'Keine Leistungen angelegt.')
|
||||
const aktiv = d.leistungen.filter((l) => l.aktiv).length
|
||||
return neuerAbschnitt('leistungen', 'Leistungen', [
|
||||
`${anzahl(d.leistungen.length, 'Leistung', 'Leistungen')} (${aktiv} aktiv):`,
|
||||
...aufzaehlung(d.leistungen.map((l) => leistungsZeile(l, d.nachId))),
|
||||
].join('\n'))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Abschnitt Ressourcen
|
||||
|
||||
function ressourcenBlock(r) {
|
||||
const plan = wochenplanText(r.plan)
|
||||
return [
|
||||
`- ${zeile(r.name) || 'ohne Namen'} (${r.typ === 'platz' ? 'Platz' : 'Person'}${r.aktiv ? '' : ', inaktiv'})`,
|
||||
...(plan.length ? plan : ['keine Zeiten eingetragen']).map((z) => ` ${z}`),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Abwesenheiten, die heute oder spaeter enden (laufende bleiben), nach Beginn sortiert. */
|
||||
function abwesenheitsZeilen(d) {
|
||||
const eintraege = liste(d.e.abwesenheiten)
|
||||
.filter((a) => istObjekt(a) && TAG_RE.test(a.von) && TAG_RE.test(a.bis) && a.bis >= d.heuteTag)
|
||||
.sort((a, b) => (a.von < b.von ? -1 : a.von > b.von ? 1 : a.bis < b.bis ? -1 : a.bis > b.bis ? 1 : 0))
|
||||
.map((a) => {
|
||||
const zeitraum = a.von === a.bis ? tagText(a.von) : `${tagText(a.von)}–${tagText(a.bis)}`
|
||||
const wer = a.ressourceId ? d.nachId.get(a.ressourceId)?.name ?? 'unbekannt' : 'alle'
|
||||
return `${zeitraum}: ${zeile(a.titel) || 'Abwesenheit'} (${wer})`
|
||||
})
|
||||
return aufzaehlung(kuerzeListe(eintraege, MAX_ABWESENHEITEN))
|
||||
}
|
||||
|
||||
/** Sonderoeffnungszeiten ab heute ([] = an diesem Tag geschlossen), nach Tag sortiert. */
|
||||
function sonderoeffnungsZeilen(d) {
|
||||
const roh = istObjekt(d.e.sonderoeffnungen) ? d.e.sonderoeffnungen : {}
|
||||
const eintraege = Object.keys(roh)
|
||||
.filter((tag) => TAG_RE.test(tag) && tag >= d.heuteTag)
|
||||
.sort()
|
||||
.map((tag) => `${tagText(tag)}: ${fensterText(roh[tag]) || 'geschlossen'}`)
|
||||
return aufzaehlung(kuerzeListe(eintraege, MAX_SONDEROEFFNUNGEN))
|
||||
}
|
||||
|
||||
function abschnittRessourcen(d) {
|
||||
const abwesenheiten = abwesenheitsZeilen(d)
|
||||
const sonder = sonderoeffnungsZeilen(d)
|
||||
return neuerAbschnitt('ressourcen', 'Personen und Plätze', [
|
||||
d.ressourcen.length ? d.ressourcen.map(ressourcenBlock).join('\n') : 'Keine Personen oder Plätze angelegt.',
|
||||
...(abwesenheiten.length ? [['Abwesenheiten (ab heute):', ...abwesenheiten].join('\n')] : []),
|
||||
...(sonder.length ? [['Sonderöffnungszeiten (ab heute, für alle):', ...sonder].join('\n')] : []),
|
||||
].join('\n\n'))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Abschnitt Regeln
|
||||
|
||||
/** Vorlauf in der groessten glatten Einheit: '45 Minuten', '2 Stunden', '1 Tag'; 0 = kein Vorlauf. */
|
||||
function vorlaufText(minuten) {
|
||||
if (!Number.isFinite(minuten)) return NICHT_ANGEGEBEN
|
||||
if (minuten === 0) return 'kein Vorlauf'
|
||||
if (minuten % 1440 === 0) return anzahl(minuten / 1440, 'Tag', 'Tage')
|
||||
if (minuten % 60 === 0) return anzahl(minuten / 60, 'Stunde', 'Stunden')
|
||||
return anzahl(minuten, 'Minute', 'Minuten')
|
||||
}
|
||||
|
||||
function stornoZeilen(storno) {
|
||||
const text = typeof storno?.text === 'string' ? storno.text.trim() : ''
|
||||
if (!text) return ['Stornobedingungen: keine hinterlegt']
|
||||
return [`Stornobedingungen (Fassung ${zeile(storno.version) || 'ohne Angabe'}):`, ...text.split(/\r\n|\r|\n/).map((z) => ` ${z}`)]
|
||||
}
|
||||
|
||||
function abschnittRegeln(d) {
|
||||
const { e } = d
|
||||
const r = istObjekt(e.regeln) ? e.regeln : {}
|
||||
const mit = (wert, f) => (Number.isFinite(wert) ? f(wert) : NICHT_ANGEGEBEN)
|
||||
return neuerAbschnitt('regeln', 'Regeln', [
|
||||
`Raster: ${mit(r.raster, (n) => anzahl(n, 'Minute', 'Minuten'))}`,
|
||||
`Vorlauf: ${vorlaufText(r.vorlaufMin)}`,
|
||||
`Buchungszeitraum: ${mit(r.horizontTage, (n) => `${anzahl(n, 'Tag', 'Tage')} im Voraus`)}`,
|
||||
`Absage durch Gäste: ${mit(r.absageBisStd, (n) => (n === 0 ? 'bis zum Terminbeginn' : `bis ${anzahl(n, 'Stunde', 'Stunden')} vor dem Termin`))}`,
|
||||
`Offene Termine je E-Mail-Adresse: ${mit(r.maxOffeneJeEmail, (n) => `höchstens ${n}`)}`,
|
||||
`Person wählbar: ${r.personWaehlbar === false ? 'nein' : 'ja'}`,
|
||||
`Feiertage: ${e.feiertageGeschlossen === false ? 'geöffnet wie im Wochenplan' : 'geschlossen'} (Bundesland ${zeile(e.bundesland) || NICHT_ANGEGEBEN})`,
|
||||
zahlungText(e),
|
||||
`Preishinweis: ${preisHinweis(e)}`,
|
||||
zeigtWiderruf(e)
|
||||
? 'Widerruf: Widerrufsbelehrung und Widerrufsfunktion werden Gästen angezeigt'
|
||||
: 'Widerruf: keine Widerrufsbelehrung (Gäste sind keine Verbraucher oder es ist keine Leistung sichtbar)',
|
||||
...stornoZeilen(e.storno),
|
||||
].join('\n'))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Abschnitt Gastfelder
|
||||
|
||||
function abschnittGastfelder(d) {
|
||||
const g = istObjekt(d.e.gastFelder) ? d.e.gastFelder : {}
|
||||
const zusatz = liste(g.zusatz).filter(istObjekt).map((z) => {
|
||||
const art = z.typ === 'auswahl' ? `Auswahl: ${liste(z.optionen).map(zeile).filter(Boolean).join(', ')}` : 'Text'
|
||||
return `${zeile(z.label) || 'ohne Beschriftung'} (${art}; ${z.pflicht === true ? 'Pflicht' : 'optional'})`
|
||||
})
|
||||
return neuerAbschnitt('gastfelder', 'Angaben der Gäste', [
|
||||
'Name: Pflicht',
|
||||
'E-Mail-Adresse: Pflicht',
|
||||
`Telefon: ${imWort(GAST_FELD, g.telefon, NICHT_ANGEGEBEN)}`,
|
||||
`Nachricht: ${imWort(GAST_FELD, g.nachricht, NICHT_ANGEGEBEN)}`,
|
||||
...(zusatz.length ? [`Zusatzfelder (${zusatz.length}):`, ...aufzaehlung(zusatz)] : ['Zusatzfelder: keine']),
|
||||
].join('\n'))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Abschnitt Benachrichtigungen
|
||||
|
||||
function abschnittBenachrichtigungen(d) {
|
||||
const { e } = d
|
||||
const sichtbar = sichtbareLeistungen(e)
|
||||
const sofort = sichtbar.some((l) => l.buchungsart !== 'anfrage')
|
||||
const anfrage = sichtbar.some((l) => l.buchungsart === 'anfrage')
|
||||
const widerruf = zeigtWiderruf(e)
|
||||
const erinnerung = e.erinnerung?.anbieten === true && Number.isFinite(e.erinnerung.stundenVorher)
|
||||
const haltezeit = Number.isFinite(d.haltezeit) ? ` (gültig ${anzahl(d.haltezeit, 'Minute', 'Minuten')})` : ''
|
||||
return neuerAbschnitt('benachrichtigungen', 'Benachrichtigungen', [
|
||||
`Gäste werden mit „${e.anrede === 'du' ? 'Du' : 'Sie'}“ angesprochen. Absender ist der Betriebsname (${zeile(d.betrieb.name) || NICHT_ANGEGEBEN}), `
|
||||
+ 'Antworten gehen an den Betrieb.',
|
||||
['Mails an Gäste:', ...aufzaehlung([
|
||||
`Bestätigungslink zur E-Mail-Adresse${haltezeit}`,
|
||||
...(sofort ? ['Terminbestätigung (sofort buchbare Leistungen)'] : []),
|
||||
...(anfrage ? ['Eingang und Ablehnung von Terminanfragen (Leistungen mit Anfrage)'] : []),
|
||||
...(erinnerung ? [`Erinnerung ${anzahl(e.erinnerung.stundenVorher, 'Stunde', 'Stunden')} vor dem Termin (nur wenn der Gast sie ankreuzt)`] : []),
|
||||
'Absage und Verschiebung',
|
||||
...(widerruf ? ['Eingangsbestätigung des Widerrufs'] : []),
|
||||
])].join('\n'),
|
||||
['Mails an den Betrieb (Adresse aus den Einstellungen, steht nicht in dieser Anleitung):', ...aufzaehlung([
|
||||
...(sofort ? ['Neue Buchung'] : []),
|
||||
...(anfrage ? ['Neue Terminanfrage'] : []),
|
||||
'Absage oder Verschiebung durch den Gast',
|
||||
...(widerruf ? ['Widerruf'] : []),
|
||||
])].join('\n'),
|
||||
'Kalender-Abo (ICS): je Person oder Platz im Portal unter Termine. Der Link ist geheim und steht nicht in dieser Anleitung.',
|
||||
].join('\n\n'))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Wuensche des Kunden (Fragebogen, Schritt Einbau)
|
||||
|
||||
const EINBAU_MODI = ['inline', 'knopf']
|
||||
const MODUS_TEXT = {
|
||||
inline: 'inline – die Buchungsstrecke steht direkt in der Seite',
|
||||
knopf: 'knopf – ein Knopf öffnet die Buchungsstrecke in einem Dialog',
|
||||
}
|
||||
// Grenzen wie in fragebogen.js (TEXTGRENZEN): hier erneut angewandt, die Anleitung traut dem gespeicherten Fragebogen nicht blind
|
||||
const WUNSCH_GRENZEN = { knopfText: 40, stelle: 300, gestaltung: 1000, bemerkung: 1000 }
|
||||
const WUNSCH_MEHRZEILIG = new Set(['stelle', 'gestaltung', 'bemerkung'])
|
||||
|
||||
/** Modus (ohne Fragebogen oder ohne gueltige Wahl: inline) und die vier Wunschtexte, bereinigt, gekuerzt und wohlgeformt. */
|
||||
function kundenwunsch(fragebogen) {
|
||||
const einbau = istObjekt(fragebogen) && istObjekt(fragebogen.einbau) ? fragebogen.einbau : {}
|
||||
const modusGewaehlt = EINBAU_MODI.includes(einbau.modus)
|
||||
const wunsch = { modus: modusGewaehlt ? einbau.modus : 'inline', modusGewaehlt }
|
||||
for (const [feld, max] of Object.entries(WUNSCH_GRENZEN)) {
|
||||
wunsch[feld] = bereinigterText(einbau[feld], max, { mehrzeilig: WUNSCH_MEHRZEILIG.has(feld) }).toWellFormed()
|
||||
}
|
||||
return wunsch
|
||||
}
|
||||
|
||||
/** ' Stelle: erste Zeile' und weitere Zeilen um vier Leerzeichen eingerueckt (nie am Zeilenanfang, also nie eine Ueberschrift). */
|
||||
function wunschZeilen(bezeichnung, wert) {
|
||||
if (!wert) return []
|
||||
const [erste, ...weitere] = wert.split('\n')
|
||||
return [` ${bezeichnung}: ${erste}`, ...weitere.map((z) => (z === '' ? '' : ` ${z}`))]
|
||||
}
|
||||
|
||||
function kundenwunschBlock(wunsch) {
|
||||
const zeilen = [
|
||||
...wunschZeilen('Knopf-Text', wunsch.knopfText),
|
||||
...wunschZeilen('Stelle', wunsch.stelle),
|
||||
...wunschZeilen('Gestaltung', wunsch.gestaltung),
|
||||
...wunschZeilen('Bemerkung', wunsch.bemerkung),
|
||||
]
|
||||
return zeilen.length ? ['Kundenwunsch (Text des Kunden):', ...zeilen].join('\n') : 'Kundenwunsch (Text des Kunden): keine Angaben im Fragebogen'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Abschnitte Einbau, Snippet, CSS
|
||||
|
||||
function abschnittEinbau(d) {
|
||||
const w = d.wunsch
|
||||
const quelle = w.modusGewaehlt ? 'Wahl des Kunden im Fragebogen' : 'Standard, der Fragebogen nennt keinen Modus'
|
||||
const telefon = zeile(d.betrieb.telefon) || NICHT_ANGEGEBEN
|
||||
const impressum = zeile(d.betrieb.impressumUrl) || NICHT_ANGEGEBEN
|
||||
const datenschutz = zeile(d.betrieb.datenschutzUrl) || NICHT_ANGEGEBEN
|
||||
return neuerAbschnitt('einbau', 'Einbau', [
|
||||
`siteKey: ${d.siteKey || NICHT_ANGEGEBEN}`,
|
||||
`Modus: ${MODUS_TEXT[w.modus]} (${quelle})`,
|
||||
['So wird eingebaut:', ...aufzaehlung([
|
||||
'Platzhalter und Skript aus dem Abschnitt „Einbau-Code (Snippet)“ in die Seite setzen: den Platzhalter an die Stelle, die der Kunde nennt '
|
||||
+ '(siehe Kundenwunsch), das Skript einmal pro Seite, am besten vor dem schließenden body-Tag. Das Skript hat keine Abhängigkeiten, '
|
||||
+ 'setzt keine Cookies und lädt nichts von Dritten.',
|
||||
'Der Ausweichtext im Platzhalter gehört der Website: Er bleibt ohne JavaScript, bei falschem siteKey, abgeschalteter Funktion, fehlenden '
|
||||
+ `Pflichtangaben und Netzfehlern stehen. Telefon des Betriebs: ${telefon}. Gestalte ihn im Stil der Seite.`,
|
||||
'Aussehen: den Abschnitt „Aussehen (CSS)“ in die CSS der Website übernehmen. Weitere Variablen stehen in docs/funktionen/termine-einbau.md, Abschnitt 3.',
|
||||
'Datenschutz: den Absatz „Online-Terminbuchung“ (Abschnitte „Datenschutz-Absatz für die Website“) in die Datenschutzerklärung der Website aufnehmen, '
|
||||
+ 'bevor das Widget freigeschaltet wird.',
|
||||
`Impressum und Datenschutzerklärung: Die URLs stehen in den Betriebsangaben im Portal (Impressum: ${impressum}; Datenschutzerklärung: ${datenschutz}), `
|
||||
+ 'das Widget verlinkt sie im Fuß.',
|
||||
])].join('\n'),
|
||||
kundenwunschBlock(w),
|
||||
].join('\n\n'))
|
||||
}
|
||||
|
||||
/** Einbau-Code aus den Angaben des Betriebs und dem Modus des Fragebogens; Wuensche ausser dem Knopf-Text kommen nie hinein. */
|
||||
const baueSnippet = (d) =>
|
||||
einbauSnippet({ siteKey: d.siteKey, apiBase: d.portal, telefon: d.telefon, modus: d.wunsch.modus, knopfText: d.wunsch.knopfText })
|
||||
|
||||
const abschnittSnippet = (d) => neuerAbschnitt('snippet', 'Einbau-Code (Snippet)', baueSnippet(d), true)
|
||||
|
||||
const abschnittCss = (d) => neuerAbschnitt('css', 'Aussehen (CSS)', cssVorschlag(d.farbe).css, true)
|
||||
|
||||
// ---------------------------------------------------------------- Abschnitte Datenschutz-Absatz (Sie, Du)
|
||||
|
||||
const DATENSCHUTZ_HINWEIS =
|
||||
'Der Datenschutz-Absatz konnte nicht erzeugt werden: In den Einstellungen fehlt der Name oder die E-Mail-Adresse des Betriebs. '
|
||||
+ 'Beides im Portal eintragen lassen und die Anleitung neu laden.'
|
||||
|
||||
/** { sie, du } oder null, wenn Name oder E-Mail-Adresse des Betriebs fehlen (datenschutzAbsatz wirft dann einen TypeError). */
|
||||
function datenschutzTexte(einstellung) {
|
||||
try {
|
||||
return { sie: datenschutzAbsatz(einstellung, 'sie'), du: datenschutzAbsatz(einstellung, 'du') }
|
||||
} catch (err) {
|
||||
if (err instanceof TypeError) return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** Nur ein echter Absatz ist kopierbar; der Hinweis, dass er fehlt, nie (er gehoert nicht in eine Datenschutzerklaerung). */
|
||||
const abschnittDatenschutz = (anrede) => (d) => {
|
||||
const id = `datenschutz_${anrede}`
|
||||
const titel = `Datenschutz-Absatz für die Website (${anrede === 'du' ? 'Du' : 'Sie'}-Form)`
|
||||
const absatz = d.datenschutz?.[anrede]
|
||||
return absatz ? neuerAbschnitt(id, titel, absatz, true) : neuerAbschnitt(id, titel, DATENSCHUTZ_HINWEIS)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Abschnitt Abnahme-Checkliste
|
||||
|
||||
// Wer den Datenschutz-Absatz einfuegt: die Regel der Leistungsbeschreibung (Nr. 4 Abs. 9); Checkliste und Schritt 4 der Kurzfassung teilen sie
|
||||
const DATENSCHUTZ_REGEL =
|
||||
'liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügen wir den Absatz beim Einbau ein, das gehört zur Einrichtung und wird nicht auf das Stundenkontingent angerechnet; '
|
||||
+ 'sonst den Absatz als Textbaustein per Chat an den Kunden senden, der ihn selbst einfügt'
|
||||
|
||||
function abschnittCheckliste(d) {
|
||||
const { e } = d
|
||||
const widerruf = zeigtWiderruf(e)
|
||||
const preis = sichtbareLeistungen(e).some(hatPreis)
|
||||
const knopf = d.wunsch.modus === 'knopf'
|
||||
const impressum = zeile(d.betrieb.impressumUrl) || 'fehlt'
|
||||
const datenschutz = zeile(d.betrieb.datenschutzUrl) || 'fehlt'
|
||||
const wo = d.anleitungsLink ? ` (Kunde → Reiter Funktionen, Zeile Terminplaner: ${d.anleitungsLink})` : ''
|
||||
const punkte = [
|
||||
// vor der Freischaltung
|
||||
'Einbau-Code eingefügt (Abschnitt „Einbau-Code (Snippet)“). Das Widget bleibt unsichtbar bis zur Freischaltung: Bis dahin steht nur der Ausweichtext auf der Seite.',
|
||||
...(knopf ? ['Modus knopf: --wkt-akzent immer setzen (Abschnitt „Aussehen (CSS)“). Der Knopf kennt die Betriebsfarbe erst nach dem Klick und nimmt sonst #1d4ed8.'] : []),
|
||||
'Ausweichtext geprüft: JavaScript aus oder falscher siteKey: Der Text steht, nichts ist kaputt.',
|
||||
`Datenschutz-Absatz „Online-Terminbuchung“ steht in der Datenschutzerklärung der Website (Fassung ${e.anrede === 'du' ? 'Du' : 'Sie'}; ${DATENSCHUTZ_REGEL}).`,
|
||||
`URLs von Impressum (${impressum}) und Datenschutzerklärung (${datenschutz}) stimmen und beginnen mit https://, sonst fehlen die Links im Fuß des Widgets.`,
|
||||
`„Freischalten (Abnahme)“ im Ticketsystem ausgelöst${wo}. Erst danach antwortet die öffentliche Schnittstelle (nur im Zustand active), und das Widget erscheint.`,
|
||||
// am sichtbaren Widget
|
||||
'Probebuchung bis „Fast geschafft“ durchgespielt: Die Bestätigungs-E-Mail kommt an, der Link bestätigt, die zweite E-Mail (mit Kalenderdatei) kommt an, im Portal steht der Termin. '
|
||||
+ 'Danach den Testtermin stornieren (Verwaltungslink aus der E-Mail oder im Portal).',
|
||||
...(widerruf ? ['Widerrufsbelehrung ist im Widget aufklappbar (Verbraucher-Betrieb, auch bei nur Anfrage-Leistungen).'] : []),
|
||||
...(widerruf && preis ? ['Der Haken für den vorzeitigen Beginn erscheint bei Leistungen mit Preis für Termine in den nächsten 14 Tagen.'] : []),
|
||||
...(widerruf ? ['Der Knopf „Vertrag widerrufen“ steht im Fuß, das Formular funktioniert.'] : []),
|
||||
...(widerruf && knopf
|
||||
? ['Modus knopf: Widerrufsknopf dauerhaft neben dem Buchungsknopf (nicht erst im Dialog); er öffnet den Dialog gleich beim Widerrufsformular.']
|
||||
: []),
|
||||
'Kontrast geprüft: Text auf dem Hintergrund und Schrift auf dem Akzent mindestens 4,5 : 1, Rand gegen den Hintergrund mindestens 3 : 1; globale Regeln des Themes (button, input) stören nicht.',
|
||||
'Handy (390 px Breite): kein seitliches Scrollen, Knöpfe gut treffbar, der Dialog passt in den Bildschirm; Zoom 200 %: nichts wird abgeschnitten.',
|
||||
'Nur mit der Tastatur bedienen: Fokus immer sichtbar, Monatsraster mit Pfeilen, Pos1/Ende und Bild ↑/↓, Esc schließt den Dialog.',
|
||||
]
|
||||
return neuerAbschnitt('checkliste', 'Abnahme-Checkliste', punkte.map((p) => `- [ ] ${p}`).join('\n'))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Abschnitt Abrechnung
|
||||
|
||||
const RECHNUNG_RE = /^https:\/\/\S+$/i
|
||||
|
||||
/** Preis der Einrichtung aus dem Register (config.features.termine.priceLabel), sonst „149 €“. */
|
||||
function einrichtungsPreis() {
|
||||
const preis = zeile(config.features?.termine?.priceLabel)
|
||||
return preis || '149 €'
|
||||
}
|
||||
|
||||
function abschnittAbrechnung(d) {
|
||||
if (d.feature?.unlockedByPurchase !== true) {
|
||||
return neuerAbschnitt('abrechnung', 'Abrechnung', 'Abrechnung: ohne Zahlung freigeschaltet (Pilot/Kulanz), keine Abrechnung.')
|
||||
}
|
||||
const rechnung = zeile(d.feature.invoiceUrl)
|
||||
return neuerAbschnitt('abrechnung', 'Abrechnung', [
|
||||
`Abrechnung: Einrichtung bezahlt (${einrichtungsPreis()}, Rechnung ${RECHNUNG_RE.test(rechnung) ? rechnung : 'folgt im Portal'}), Zeit im Arbeitsblatt, nicht auf Abo-Stunden.`,
|
||||
'Leistungsumfang: ein Widget an einer Stelle, Gestaltung nach Fragebogen, Einbau; Mehrwünsche (zweites Widget, Knöpfe je Leistung, Sonderdesign) nach Stunden, vorher im Chat abstimmen.',
|
||||
].join('\n'))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Warnungen
|
||||
|
||||
const WARNUNG_PFLICHT = {
|
||||
'betrieb.telefon': 'Telefonnummer des Betriebs fehlt: Gäste können nicht buchen, das Widget zeigt nur den Ausweichtext.',
|
||||
'betrieb.adresse':
|
||||
'Anschrift des Betriebs fehlt (sie steht in der Widerrufsbelehrung): Gäste können nicht buchen, das Widget zeigt nur den Ausweichtext.',
|
||||
}
|
||||
const WARNUNG_OHNE_URL = 'fehlt: Im Fuß des Widgets fehlt der Link.'
|
||||
const WARNUNG_OHNE_DATENSCHUTZ = 'Datenschutz-Absatz nicht erzeugbar: Name oder E-Mail-Adresse des Betriebs fehlt in den Einstellungen.'
|
||||
|
||||
/**
|
||||
* Meldungen aus gesundheitsWarnungen (Block C) tragen Namen des Kunden: gekuerzt in UTF-16-Einheiten (ein halbes Zeichenpaar ist moeglich)
|
||||
* und ungemaskiert. Hier werden sie wohlgeformt und einzeilig; sie bleiben Klartext, der Anzeiger setzt sie als Text ein (nie als HTML).
|
||||
*/
|
||||
const meldungText = (wert) => zeile(typeof wert === 'string' ? wert.toWellFormed() : '')
|
||||
|
||||
/**
|
||||
* Die Gesundheitsmeldungen einer Einstellung (hoechstens 10, Pruefung aus Block C) in der Form, in der sie im Panel, im Ticket und in T16 stehen:
|
||||
* wohlgeformt, einzeilig, Klartext. Ohne Einstellung (null) und ohne Treffer leer.
|
||||
*/
|
||||
export function gesundheitsMeldungen(einstellung) {
|
||||
return gesundheitsWarnungen(einstellung).map(meldungText)
|
||||
}
|
||||
|
||||
/**
|
||||
* Warnungen fuer das Team in fester Reihenfolge, in drei Teilen: davor (Banner, Pflichtangaben, Impressum, Datenschutz-URL), die Gesundheits-
|
||||
* meldungen, danach (Kontrast, Datenschutz-Absatz). warnungen = davor + gesundheit + danach; die Kurzfassung (Ticket) setzt die Gesundheit
|
||||
* in einen eigenen Absatz und zaehlt sie nicht zu ihren fuenf Warnungen.
|
||||
*/
|
||||
function warnungsTeile(d) {
|
||||
const davor = []
|
||||
if (d.entwurf) davor.push(ENTWURF_BANNER)
|
||||
for (const feld of fehlendePflichtangaben(d.e)) davor.push(WARNUNG_PFLICHT[feld] ?? `Pflichtangabe fehlt: ${feld}`)
|
||||
if (!zeile(d.betrieb.impressumUrl)) davor.push(`Impressum-URL ${WARNUNG_OHNE_URL}`)
|
||||
if (!zeile(d.betrieb.datenschutzUrl)) davor.push(`Datenschutz-URL ${WARNUNG_OHNE_URL}`)
|
||||
const danach = [...cssVorschlag(d.farbe).hinweise]
|
||||
if (!d.datenschutz) danach.push(WARNUNG_OHNE_DATENSCHUTZ)
|
||||
return { davor, gesundheit: gesundheitsMeldungen(d.e), danach }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Kurzfassung fuer das Einrichtungs-Ticket (Task D4)
|
||||
|
||||
// `kurz` steht in ticket.details (Klartext, das WOMS zeigt es mit pre-wrap) und ist die einzige Fassung, die ins Ticket geht: ohne Datenschutz-Absatz
|
||||
// (4,8 k Zeichen je Variante), ohne Token. Absaetze durch eine Leerzeile; der erste ist der Kopf und endet mit der Konfigurationsstand-Zeile, die
|
||||
// ticketStand liest. Platz: TICKET_DETAILS_MAX. Statt mitten im Satz abzuschneiden, ist es eine Budgetliste: Kopf, Gesundheitsabsatz, Snippet,
|
||||
// Schritte und Verweis bleiben immer (eine Gesundheitsmeldung verschwindet nie unter Platzdruck); passt die Summe nicht, fallen erst die Zaehlungen
|
||||
// weg, dann die uebrigen Warnungen (die letzte zuerst), dann der Kundenwunsch-Block. Nur wenn selbst die Pflichtteile nicht passen (ein unge-
|
||||
// pruefter Wert sprengt sie), kuerzt kuerze() am Ende an einer Zeilengrenze mit der Schlusszeile des Tickets.
|
||||
|
||||
const KURZ_NAMEN = 8 // so viele Namen je Zaehlung, danach "… und N weitere"
|
||||
const KURZ_NAME_MAX = 60
|
||||
const KURZ_KOPF_MAX = 120
|
||||
const KURZ_WARNUNGEN = 5
|
||||
const GESUNDHEIT_ABSATZ = 'Gesundheitsbereich prüfen:'
|
||||
const KURZ_OHNE_LINK = 'Verweis: im WOMS unter Kunden → Funktionen, Anleitung des Projekts'
|
||||
|
||||
const kopfWert = (wert) => kuerze(zeile(wert), KURZ_KOPF_MAX, { endzeile: '…' })
|
||||
const kurzName = (name) => kuerze(zeile(name) || 'ohne Namen', KURZ_NAME_MAX, { endzeile: '…' })
|
||||
|
||||
/** 'Konfigurationsstand: Version N, TT.MM.JJJJ' | '… Version N' (ohne Datum) | '… Version 0 (noch keine Einstellungen gespeichert)' | '… (Einstellungen nicht lesbar)'. */
|
||||
function standZeile(version, geaendert, hatEinstellung) {
|
||||
const n = Number.isInteger(version) && version >= 1 ? version : 0
|
||||
if (n === 0) return 'Konfigurationsstand: Version 0 (noch keine Einstellungen gespeichert)'
|
||||
if (!hatEinstellung) return `Konfigurationsstand: Version ${n} (Einstellungen nicht lesbar)`
|
||||
return `Konfigurationsstand: Version ${n}${geaendert ? `, ${geaendert}` : ''}`
|
||||
}
|
||||
|
||||
/** Kopf: Funktion, Projekt, Domain (wie createFeatureTicket: liveDomain, sonst previewUrl, sonst subdomain), Kunde, siteKey, Stand. */
|
||||
function kurzKopf(k) {
|
||||
const domain = zeile(k.project?.liveDomain) || zeile(k.project?.previewUrl) || zeile(k.project?.subdomain)
|
||||
return [
|
||||
'Funktion: Terminplaner',
|
||||
`Projekt: ${kopfWert(k.project?.projectName) || kopfWert(k.project?.subdomain)}`,
|
||||
`Domain: ${kopfWert(domain)}`,
|
||||
`Kunde: ${kopfWert(k.kunde)}`,
|
||||
`siteKey: ${k.siteKey}`,
|
||||
standZeile(k.version, k.geaendert, k.hatEinstellung),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** ' Stelle: erste Zeile' und die weiteren Zeilen um vier Leerzeichen eingerueckt; Leerzeilen fallen weg (nie ein Absatzumbruch im Kundentext). */
|
||||
function kurzWunschZeilen(bezeichnung, wert) {
|
||||
const [erste, ...weitere] = wert.split('\n').filter((z) => z !== '')
|
||||
return erste === undefined ? [] : [` ${bezeichnung}: ${erste}`, ...weitere.map((z) => ` ${z}`)]
|
||||
}
|
||||
|
||||
/** Kundenwunsch: Modus (ohne Wahl: Standard) und die vier Wunschtexte des Kunden (Text des Kunden, nie im Snippet ausser dem Knopf-Text). */
|
||||
function kurzWunsch(w) {
|
||||
const zeilen = [
|
||||
...kurzWunschZeilen('Knopf-Text', w.knopfText),
|
||||
...kurzWunschZeilen('Stelle', w.stelle),
|
||||
...kurzWunschZeilen('Gestaltung', w.gestaltung),
|
||||
...kurzWunschZeilen('Bemerkung', w.bemerkung),
|
||||
]
|
||||
return [
|
||||
'Kundenwunsch (Text des Kunden):',
|
||||
` Modus: ${w.modus}${w.modusGewaehlt ? '' : ' (Standard)'}`,
|
||||
...(zeilen.length ? zeilen : [' Sonst keine Angaben im Fragebogen']),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** '- 12 Leistungen (11 aktiv): A, B, … und 4 weitere': Anzahl, aktive (nur wenn es welche gibt und gezaehlt wird) und die ersten 8 Namen. */
|
||||
function kurzZaehlung(eintraege, einzahl, mehrzahl, { mitAktiv = false } = {}) {
|
||||
const n = eintraege.length
|
||||
const aktiv = mitAktiv && n > 0 ? ` (${eintraege.filter((x) => x.aktiv).length} aktiv)` : ''
|
||||
const namen = kuerzeListe(eintraege.map((x) => kurzName(x.name)), KURZ_NAMEN).join(', ')
|
||||
return `- ${anzahl(n, einzahl, mehrzahl)}${aktiv}${n > 0 ? `: ${namen}` : ''}`
|
||||
}
|
||||
|
||||
function kurzUmfang(k) {
|
||||
return [
|
||||
'Umfang:',
|
||||
kurzZaehlung(k.leistungen, 'Leistung', 'Leistungen', { mitAktiv: true }),
|
||||
kurzZaehlung(k.ressourcen, 'Person oder Platz', 'Personen und Plätze', { mitAktiv: true }),
|
||||
kurzZaehlung(k.zusatzfelder.map((z) => ({ name: z.label })), 'Zusatzfeld', 'Zusatzfelder'),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Die sechs Schritte des Teams; Schritt 6 nennt den Preis nur, wenn die Einrichtung bezahlt ist (unlockedByPurchase === true). */
|
||||
function kurzSchritte(k) {
|
||||
const form = !k.hatEinstellung ? 'Sie- oder Du-Form nach der Anrede der Gäste' : k.anrede === 'du' ? 'Du-Form' : 'Sie-Form'
|
||||
const bezahlt = k.feature?.unlockedByPurchase === true ? ` (Einrichtung bezahlt, ${einrichtungsPreis()})` : ''
|
||||
return [
|
||||
'Schritte:',
|
||||
'1. WOMS-Panel öffnen (Kunde → Reiter Funktionen → Anleitung des Projekts): dort stehen alle Einstellungen, die Kopierknöpfe und die Abnahme-Checkliste.',
|
||||
'2. Einbau-Code einfügen: den Code oben an der Stelle einsetzen, die der Kunde nennt (siehe Kundenwunsch), das Skript einmal pro Seite.',
|
||||
'3. Gestaltung und Kontrast: das CSS aus der Anleitung (Abschnitt „Aussehen (CSS)“) in die Website übernehmen und den Kontrast prüfen.',
|
||||
`4. Datenschutz-Absatz (${form}) aus der Anleitung in die Datenschutzerklärung der Website aufnehmen, bevor freigeschaltet wird (${DATENSCHUTZ_REGEL}).`,
|
||||
'5. „Freischalten (Abnahme)“ im WOMS auslösen, danach eine Probebuchung durchspielen und den Testtermin wieder stornieren (Checkliste in der Anleitung).',
|
||||
`6. Ticket erledigen und die Zeit im Arbeitsblatt buchen. Nicht auf das Stundenkontingent anrechnen${bezahlt}.`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Kurzfassung aus den vorbereiteten Teilen. k: feature, siteKey, project, kunde, version, geaendert, hatEinstellung, anrede, wunsch, leistungen,
|
||||
* ressourcen, zusatzfelder, warnungen (ohne Gesundheit), gesundheit (Meldungen), snippet, anleitungsLink.
|
||||
*/
|
||||
function kurzfassung(k) {
|
||||
const pflicht = {
|
||||
kopf: kurzKopf(k),
|
||||
gesundheit: k.gesundheit.length ? [GESUNDHEIT_ABSATZ, ...k.gesundheit.map((m) => `- ${m}`)].join('\n') : '',
|
||||
snippet: `Einbau-Code (Snippet):\n${k.snippet}`,
|
||||
schritte: kurzSchritte(k),
|
||||
verweis: k.anleitungsLink ? `Verweis: ${k.anleitungsLink}` : KURZ_OHNE_LINK,
|
||||
}
|
||||
const umfang = k.hatEinstellung ? kurzUmfang(k) : ''
|
||||
const wunsch = kurzWunsch(k.wunsch)
|
||||
const warnungen = k.warnungen.slice(0, KURZ_WARNUNGEN).map((w) => `- ${w}`)
|
||||
let mitUmfang = Boolean(umfang)
|
||||
let mitWunsch = true
|
||||
|
||||
const zusammen = () =>
|
||||
[
|
||||
pflicht.kopf,
|
||||
mitWunsch ? wunsch : '',
|
||||
mitUmfang ? umfang : '',
|
||||
warnungen.length ? ['Warnungen:', ...warnungen].join('\n') : '',
|
||||
pflicht.gesundheit,
|
||||
pflicht.snippet,
|
||||
pflicht.schritte,
|
||||
pflicht.verweis,
|
||||
].filter(Boolean).join('\n\n').toWellFormed()
|
||||
|
||||
let text = zusammen()
|
||||
if (text.length > TICKET_DETAILS_MAX) {
|
||||
mitUmfang = false
|
||||
text = zusammen()
|
||||
}
|
||||
while (text.length > TICKET_DETAILS_MAX && warnungen.length > 0) {
|
||||
warnungen.pop()
|
||||
text = zusammen()
|
||||
}
|
||||
if (text.length > TICKET_DETAILS_MAX) {
|
||||
mitWunsch = false
|
||||
text = zusammen()
|
||||
}
|
||||
return text.length > TICKET_DETAILS_MAX ? kuerze(text, TICKET_DETAILS_MAX, { anZeile: true, endzeile: TICKET_GEKUERZT }) : text
|
||||
}
|
||||
|
||||
const STAND_RE = /^Konfigurationsstand: Version (\d{1,9})(?!\d)/m
|
||||
|
||||
/**
|
||||
* Die Version der Einstellungen, auf der ein Ticket beruht: die erste Zeile "Konfigurationsstand: Version N" am Zeilenanfang (die Wuensche des
|
||||
* Kunden stehen eingerueckt und koennen sie nicht nachahmen). Ohne die Zeile, mit mehr als neun Stellen oder bei Nicht-Text null; Version 0 ist ein Stand.
|
||||
*/
|
||||
export function ticketStand(details) {
|
||||
if (typeof details !== 'string') return null
|
||||
const treffer = STAND_RE.exec(details)
|
||||
return treffer ? Number(treffer[1]) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Hinweis fuers Team, wenn die Einstellungen seit der Bestellung gespeichert wurden: ticketVersion aus ticketStand, jetzt die aktuelle Version.
|
||||
* Nur bei zwei ganzen Zahlen und jetzt > ticketVersion, sonst null (auch ohne Stand im Ticket).
|
||||
*/
|
||||
export function driftWarnung(ticketVersion, jetzt) {
|
||||
if (!Number.isInteger(ticketVersion) || !Number.isInteger(jetzt) || jetzt <= ticketVersion) return null
|
||||
return `Einstellungen seit der Bestellung geändert (Ticket Version ${ticketVersion}, jetzt ${jetzt}).`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Zusammenbau
|
||||
|
||||
const ABSCHNITTE = [
|
||||
abschnittModell, abschnittLeistungen, abschnittRessourcen, abschnittRegeln, abschnittGastfelder, abschnittBenachrichtigungen,
|
||||
abschnittEinbau, abschnittSnippet, abschnittCss, abschnittDatenschutz('sie'), abschnittDatenschutz('du'), abschnittCheckliste, abschnittAbrechnung,
|
||||
]
|
||||
|
||||
/** Alle Abschnitte mit Ueberschrift '## Titel', durch eine Leerzeile getrennt. */
|
||||
const volltext = (abschnitte) => abschnitte.map((a) => `## ${a.titel}\n${a.text}`).join('\n\n')
|
||||
|
||||
/** 'Projektname (Domain)' | 'Projektname' | 'Domain' | '' (Domain = liveDomain, sonst previewUrl, sonst subdomain). */
|
||||
function projektText(project) {
|
||||
const name = zeile(project?.projectName) || zeile(project?.subdomain)
|
||||
const domain = zeile(project?.liveDomain) || zeile(project?.previewUrl) || zeile(project?.subdomain)
|
||||
return name && domain && name !== domain ? `${name} (${domain})` : name || domain
|
||||
}
|
||||
|
||||
/**
|
||||
* Anleitung fuer den Einbau des Terminplaners bei einem Betrieb (Team-Sicht): rein, kein I/O, gleiche Eingabe und gleiches `now`
|
||||
* ergeben gleiche Ausgabe (deepEqual). config wird beim Aufruf gelesen.
|
||||
*
|
||||
* @param {object} p
|
||||
* @param {object} [p.feature] portalFeatures-Dokument der Funktion (gelesen: siteKey, customerId, projectId, unlockedByPurchase,
|
||||
* invoiceUrl; der Registereintrag config.features.termine wird nicht gebraucht)
|
||||
* @param {object} [p.project] websiteProjects-Dokument (gelesen: projectName, subdomain, liveDomain, previewUrl)
|
||||
* @param {object|null} p.einstellung gespeicherte Einstellung (termineConfig.einstellung); null = noch keine
|
||||
* @param {object|null} [p.fragebogen] geparster Fragebogen oder null (gelesen: einbau.modus, knopfText, stelle, gestaltung, bemerkung)
|
||||
* @param {number} p.version Version der Einstellungen (0 = noch nie gespeichert)
|
||||
* @param {Date} p.now Zeitpunkt der Erzeugung; ein ungueltiges now wirft einen TypeError
|
||||
* @param {string|null} [p.updatedAt] ISO-Zeitpunkt der letzten Aenderung der Einstellungen
|
||||
* @param {{name?: string}|null} [p.kunde] gelesen wird nur der Name
|
||||
* @param {boolean} [p.entwurf] Rechtstexte sind noch ENTWURF (erste Warnung ENTWURF_BANNER); Standard RECHTSTEXTE_ENTWURF
|
||||
* @returns {{stand: {version: number, updatedAt: string|null}, warnungen: string[], abschnitte: Array<{id: string, titel: string, text: string, kopierbar: boolean}>, text: string, kurz: string}}
|
||||
* abschnitte: 13 Stueck in fester Reihenfolge (modell bis abrechnung), kopierbar nur snippet, css und die beiden Datenschutz-Absaetze.
|
||||
* text: alle Abschnitte mit Ueberschrift, aber nur die Datenschutz-Fassung, die zu einstellung.anrede passt ('du', sonst Sie).
|
||||
* Ohne Einstellung (null): nur der Abschnitt snippet und die Warnung "Keine Einstellungen gespeichert".
|
||||
* kurz: die Kurzfassung fuer ticket.details (Klartext, hoechstens TICKET_DETAILS_MAX Zeichen, erster Absatz = Kopf mit der Konfigurationsstand-Zeile,
|
||||
* ohne Datenschutz-Absatz und ohne Token); auch ohne Einstellung gefuellt (Kopf mit Version 0, Snippet, Schritte, Verweis).
|
||||
*/
|
||||
export function einbauanleitung({ feature, project, einstellung, fragebogen, version, now, updatedAt = null, kunde = null, entwurf = RECHTSTEXTE_ENTWURF }) {
|
||||
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError('einbauanleitung: now fehlt')
|
||||
const stand = { version, updatedAt }
|
||||
const banner = entwurf ? [ENTWURF_BANNER] : []
|
||||
const geaendertAm = updatedAt ? new Date(updatedAt) : null
|
||||
const gemeinsam = {
|
||||
feature,
|
||||
project,
|
||||
siteKey: zeile(feature?.siteKey),
|
||||
portal: zeile(config.publicBaseUrl),
|
||||
wunsch: kundenwunsch(fragebogen),
|
||||
anleitungsLink: feature && zeile(feature.customerId) && zeile(feature.projectId) ? anleitungsLink(zeile(feature.customerId), zeile(feature.projectId)) : '',
|
||||
version,
|
||||
geaendert: geaendertAm && !Number.isNaN(geaendertAm.getTime()) ? datumKurz(geaendertAm) : '',
|
||||
}
|
||||
|
||||
if (!istObjekt(einstellung)) {
|
||||
const abschnitte = [abschnittSnippet({ ...gemeinsam, telefon: '' })]
|
||||
const warnungen = [...banner, 'Keine Einstellungen gespeichert']
|
||||
const kurz = kurzfassung({
|
||||
...gemeinsam,
|
||||
kunde: zeile(kunde?.name),
|
||||
hatEinstellung: false,
|
||||
anrede: null,
|
||||
leistungen: [],
|
||||
ressourcen: [],
|
||||
zusatzfelder: [],
|
||||
warnungen,
|
||||
gesundheit: [],
|
||||
snippet: abschnitte[0].text,
|
||||
})
|
||||
return { stand, warnungen, abschnitte, text: volltext(abschnitte), kurz }
|
||||
}
|
||||
|
||||
const ressourcen = liste(einstellung.ressourcen).filter(istObjekt)
|
||||
const betrieb = istObjekt(einstellung.betrieb) ? einstellung.betrieb : {}
|
||||
const daten = {
|
||||
...gemeinsam,
|
||||
e: einstellung,
|
||||
now,
|
||||
entwurf: Boolean(entwurf),
|
||||
heuteTag: heute(now),
|
||||
betrieb,
|
||||
telefon: zeile(betrieb.telefon),
|
||||
kunde: zeile(kunde?.name),
|
||||
projekt: projektText(project),
|
||||
farbe: typeof einstellung.farbe === 'string' && FARBE_RE.test(einstellung.farbe) ? einstellung.farbe.toLowerCase() : VORGABE_FARBE,
|
||||
haltezeit: config.termine?.holdMinutes,
|
||||
ressourcen,
|
||||
leistungen: liste(einstellung.leistungen).filter(istObjekt),
|
||||
nachId: new Map(ressourcen.map((r) => [r.id, { typ: r.typ, name: zeile(r.name) || 'ohne Namen' }])),
|
||||
datenschutz: datenschutzTexte(einstellung),
|
||||
}
|
||||
const abschnitte = ABSCHNITTE.map((baue) => baue(daten))
|
||||
// der Volltext fuehrt nur die Datenschutz-Fassung, die zur Anrede der Gaeste passt
|
||||
const andere = einstellung.anrede === 'du' ? 'datenschutz_sie' : 'datenschutz_du'
|
||||
const teile = warnungsTeile(daten)
|
||||
const kurz = kurzfassung({
|
||||
...daten,
|
||||
hatEinstellung: true,
|
||||
anrede: einstellung.anrede,
|
||||
zusatzfelder: liste(istObjekt(einstellung.gastFelder) ? einstellung.gastFelder.zusatz : null).filter(istObjekt),
|
||||
warnungen: [...teile.davor, ...teile.danach],
|
||||
gesundheit: teile.gesundheit,
|
||||
snippet: abschnitte.find((a) => a.id === 'snippet').text,
|
||||
})
|
||||
return {
|
||||
stand,
|
||||
warnungen: [...teile.davor, ...teile.gesundheit, ...teile.danach],
|
||||
abschnitte,
|
||||
text: volltext(abschnitte.filter((a) => a.id !== andere)),
|
||||
kurz,
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { heute, zuUtc } from './zeit.js'
|
||||
* Fehler sind { [pfad]: text } mit Pfaden wie 'betrieb.name' oder 'ressourcen.0.plan.1'; Texte in Du-Form (Portal).
|
||||
*/
|
||||
|
||||
const VORGABE_FARBE = '#7c3aed'
|
||||
export const VORGABE_FARBE = '#7c3aed'
|
||||
const GRENZEN = { ressourcen: 20, leistungen: 50, abwesenheiten: 100, sonderoeffnungen: 200, zusatzfelder: 5, optionen: 10, json: 200000 }
|
||||
const RASTER = [5, 10, 15, 20, 30, 60]
|
||||
const ERINNERUNG_STUNDEN = [2, 12, 24, 48]
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { aboMemo } from '../aboStatus.js'
|
||||
import { featureConfig } from '../featureRegistry.js'
|
||||
import { sendCustomerMail } from '../mailer.js'
|
||||
import { bereitMail, customerOf, projectName } from '../siteFeatures.js'
|
||||
import { ladeEinstellung } from './kontext.js'
|
||||
|
||||
// Freischalt-Hindernisse und Freigabe-Mail des Terminplaners (TP2 D6, aus B11 herausgeloest). Beide Funktionen stehen hier,
|
||||
// damit der Standardhaken freischaltbar (featureRegistry.js), der Freischalt-Job (siteFeatures.runActivation) und die
|
||||
// Staff-Aktivierung (F13a) dieselbe Antwort und denselben Mailweg nutzen. siteFeatures.js und featureRegistry.js laden diese
|
||||
// Datei erst im Aufruf (dynamisches import()), weil sie selbst siteFeatures.js importiert.
|
||||
|
||||
/**
|
||||
* Was hindert die Freischaltung dieses Termine-Dokuments? null = nichts; sonst der Grund:
|
||||
* 'kein_abo' kein laufendes Wartungs-Abo (inkl. beendet, Wechsel-Widerruf E1; past_due und vorgemerkte Kuendigung laufen noch)
|
||||
* 'keine_einstellung' Abo laeuft, aber es ist noch keine Einstellung gespeichert
|
||||
* 'abo_unbekannt' das Abo war nicht lesbar: nie freischalten, wenn etwas nicht lesbar war (der Fehler wird geloggt)
|
||||
* Reihenfolge: Abo zuerst, dann Einstellung; ohne Abo wird die Einstellung gar nicht gelesen. Ein Lesefehler bei der
|
||||
* Einstellung wirft (anders als beim Abo steht dafuer kein Grundcode bereit): der Aufrufer, activateClosedTickets, haelt das
|
||||
* Dokument dann in 'setup'.
|
||||
* abo: Fragen an das Abo, { laeuft(customerId) }; Standard aboMemo() je Aufruf, also frisch gelesen. Wer viele Dokumente
|
||||
* im Listenlauf prueft, uebergibt einen gemeinsamen aboMemo(); Pruefungen unter einer Sperre lesen frisch (Standard).
|
||||
* @param {{ $id: string, customerId: string, featureKey?: string }} doc portalFeatures-Dokument
|
||||
* @returns {Promise<null | 'kein_abo' | 'keine_einstellung' | 'abo_unbekannt'>}
|
||||
*/
|
||||
export async function freischaltHindernis(doc, { abo = aboMemo() } = {}) {
|
||||
let laeuft
|
||||
try {
|
||||
laeuft = await abo.laeuft(doc.customerId)
|
||||
} catch (err) {
|
||||
console.error(`[funktionen] Freischaltung ${doc.featureKey || 'termine'} (${doc.$id}) nicht prüfbar:`, err.message)
|
||||
return 'abo_unbekannt'
|
||||
}
|
||||
if (!laeuft) return 'kein_abo'
|
||||
if ((await ladeEinstellung(doc.$id)) === null) return 'keine_einstellung'
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* K16 (Funktion bereit) an den Kunden eines freigeschalteten Dokuments, Werte aus dem Register-Eintrag der Funktion (bereitMail).
|
||||
* Fehler (Kunde, Register, Versand) werden geloggt und nie geworfen: die Freischaltung ist dann schon geschehen, und eine Mail,
|
||||
* die nicht rausgeht, darf die uebrigen Dokumente des Laufs nicht aufhalten. Kunde ohne Adresse: keine Mail, kein Fehler.
|
||||
* attachments: Anhaenge der Mail, z. B. die PDFs der Staff-Aktivierung (F13a).
|
||||
* @param {{ customerId: string, projectId: string, featureKey: string }} doc portalFeatures-Dokument
|
||||
* @param {{ attachments?: object[] }} [optionen]
|
||||
*/
|
||||
export async function meldeFreigeschaltet(doc, { attachments = [] } = {}) {
|
||||
try {
|
||||
const customer = await customerOf(doc.customerId)
|
||||
if (!customer?.email) return
|
||||
const feature = featureConfig(doc.featureKey)
|
||||
if (!feature) throw new Error(`Funktion ${doc.featureKey} nicht im Register`)
|
||||
const mail = bereitMail(feature, { kundeName: customer.name || '', projekt: await projectName(doc.projectId) })
|
||||
await sendCustomerMail(customer.email, { ...mail, attachments })
|
||||
} catch (err) {
|
||||
console.error(`[funktionen] K16 an Kunde ${doc.customerId} fehlgeschlagen:`, err.message)
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,44 @@ export async function ladeEinstellung(featureId) {
|
||||
return gelesen?.einstellung ? { ...gelesen, fragebogen: fragebogenAus(doc) } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Fragebogen aus einem termineConfig-Dokument, so wie er gespeichert ist (geparstes Objekt) oder null. Anders als fragebogenAus kein
|
||||
* leerer Fragebogen als Ersatz: fehlt das Attribut, ist es leer oder kein JSON-Objekt, ist das Ergebnis null (kaputtes JSON mit einer Logzeile).
|
||||
*/
|
||||
function fragebogenRoh(doc) {
|
||||
const roh = doc.fragebogen
|
||||
if (roh === undefined || roh === null || roh === '') return null
|
||||
try {
|
||||
const fragebogen = JSON.parse(roh)
|
||||
if (!istObjekt(fragebogen)) throw new Error('kein Objekt')
|
||||
return fragebogen
|
||||
} catch (err) {
|
||||
console.error(`[termine-kontext] Fragebogen ${doc.$id} ist unlesbar: ${err.message}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Der gespeicherte Stand einer Funktion fuer das Einrichtungs-Ticket und die Anleitung (TP2 D4): Einstellung, Version, Zeitpunkt der letzten
|
||||
* Aenderung und Fragebogen, ohne Modus- und Zustandspruefung. Anders als ladeEinstellung unterscheidet er "noch nie gespeichert" von "unlesbar":
|
||||
* null nur ohne termineConfig-Dokument; unlesbares JSON macht nur das jeweilige Feld null (die Version des Dokuments bleibt). Fehlt Collection
|
||||
* oder Datenbank, wird der Fehler weitergereicht.
|
||||
* @param {string} featureId ID des portalFeatures-Dokuments
|
||||
* @returns {Promise<{ einstellung: object|null, version: number, updatedAt: string|null, fragebogen: object|null }|null>}
|
||||
*/
|
||||
export async function ladeStand(featureId) {
|
||||
pruefeText(featureId, 'featureId')
|
||||
const doc = await holeOderNull(config.collections.termineConfig, featureId)
|
||||
if (!doc) return null
|
||||
const { einstellung, version } = liesDokument(doc)
|
||||
return {
|
||||
einstellung,
|
||||
version,
|
||||
updatedAt: typeof doc.updatedAt === 'string' && doc.updatedAt !== '' ? doc.updatedAt : null,
|
||||
fragebogen: fragebogenRoh(doc),
|
||||
}
|
||||
}
|
||||
|
||||
async function baueKontext(feature) {
|
||||
const [project, doc] = await Promise.all([
|
||||
feature.projectId ? holeOderNull(config.collections.websiteProjects, feature.projectId) : null,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen.
|
||||
*
|
||||
* Rechtstext-Bausteine des Terminplaners fuer Gaeste (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 11).
|
||||
* Reine Funktionen und Konstanten, ohne Appwrite und ohne config. Die Texte sind Rechtstexte und stehen deshalb immer
|
||||
* in der Sie-Form (auch bei Du-Betrieben); nur der Datenschutz-Hinweis unter dem Formular folgt der Anrede des Betriebs.
|
||||
* Reine Funktionen und Konstanten, ohne Appwrite; sie nutzen Hilfsfunktionen aus einstellungen.js (zeigtWiderruf) und lesen
|
||||
* config.termine.holdMinutes (Gueltigkeit des Bestaetigungslinks im Datenschutz-Absatz, bei jedem Aufruf gelesen), sonst keine Konfiguration. Die Texte
|
||||
* sind Rechtstexte und stehen deshalb immer in der Sie-Form (auch bei Du-Betrieben); nur der Datenschutz-Hinweis unter dem
|
||||
* Formular und der Datenschutz-Absatz folgen der Anrede.
|
||||
*
|
||||
* widerrufsbelehrung: Muster der Anlage 1 und Muster-Widerrufsformular der Anlage 2 zu Art. 246a § 1 Abs. 2 Satz 2 EGBGB
|
||||
* (docs/rechtliches/quellen/, abgerufen 2026-10-02), Gestaltungshinweise fuer einen Dienstleistungsvertrag angewendet:
|
||||
@@ -13,10 +13,28 @@
|
||||
* Absatz zum elektronischen Webformular (es gibt nur die Widerrufsfunktion). test/termineRechtstexte.test.js prueft
|
||||
* den Wortlaut gegen die Quelldateien.
|
||||
*
|
||||
* datenschutzAbsatz: Absatz „Online-Terminbuchung“ fuer die Datenschutzerklaerung der Website des Betriebs (Betrieb = Verantwortlicher).
|
||||
* Der Wortlaut steht auch in docs/rechtliches/termine-datenschutz-absatz.md (Fassung Sie / Fassung Du); test/termineRechtstexte.test.js
|
||||
* haelt beide gleich. Aenderungen am Text immer in Markdown und Funktion gemeinsam.
|
||||
*
|
||||
* Die Rueckgabe ist reiner Text mit Zeilenumbruechen und NICHT HTML-maskiert: Die Mailvorlage maskiert beim Einsetzen.
|
||||
*/
|
||||
|
||||
export const RECHTSTEXTE_VERSION = '2026-10-02'
|
||||
import { config } from '../../config.js'
|
||||
import { zeigtWiderruf } from './einstellungen.js'
|
||||
|
||||
/**
|
||||
* Fassung der Gaestetexte (steht im Nachweis jeder Buchung, archiviert in docs/rechtliches/archiv/termine-gaestetexte-<Fassung>.md).
|
||||
* Gleich config.termine.avvVersion (test/termineRechtstexte.test.js haelt beide gleich); eine Textaenderung nach der Auslieferung
|
||||
* bekommt ein neues Datum, ein neues Archiv und einen neuen Hash-Pin. Buchungen aus aelteren Fassungen behalten ihre Fassung.
|
||||
*/
|
||||
export const RECHTSTEXTE_VERSION = '2026-10-05'
|
||||
|
||||
/**
|
||||
* Kennzeichen, ob die Gaestetexte noch als Entwurf (nur Pilot, vor dem Einsatz bei fremden Betrieben zu pruefen) gelten. Ist es true,
|
||||
* nennt die Einbauanleitung das als erste Warnung. Seit der Endfassung der Gaestetexte (Fassung 2026-10-05) false.
|
||||
*/
|
||||
export const RECHTSTEXTE_ENTWURF = false
|
||||
|
||||
const ZEILENUMBRUCH = '\n'
|
||||
const ABSATZ = '\n\n'
|
||||
@@ -48,12 +66,18 @@ const verbinde = (teile) => teile.filter(Boolean).join(', ')
|
||||
const FRIST = 'des Vertragsabschlusses.' // Gestaltungshinweis a): Dienstleistungsvertrag
|
||||
|
||||
// Widerrufsfunktion: Satzrahmen des Gesetzes wörtlich („Sie können Ihr Widerrufsrecht auch online unter … ausüben.“); in die
|
||||
// Lücke „… [Internetadresse oder anderen geeigneten Hinweis …]“ kommt ein geeigneter Hinweis statt einer Internetadresse.
|
||||
// Lücke „… [Internetadresse oder anderen geeigneten Hinweis …]“ kommt bei den Gästen ein geeigneter Hinweis statt einer
|
||||
// Internetadresse, bei der Bestellung einer Funktion (WEBklar als Unternehmer) die Adresse der Widerrufsseite.
|
||||
const EINGANGSBESTAETIGUNG =
|
||||
'Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) '
|
||||
+ 'unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs.'
|
||||
|
||||
const WIDERRUFSFUNKTION =
|
||||
'Sie können Ihr Widerrufsrecht auch online unter „Vertrag widerrufen“ (Schaltfläche im Terminbereich auf unserer Website '
|
||||
+ 'sowie Link „Termin verwalten“ in Ihrer Bestätigungs-E-Mail) ausüben. '
|
||||
+ 'Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) '
|
||||
+ 'unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs.'
|
||||
+ EINGANGSBESTAETIGUNG
|
||||
|
||||
const WIDERRUFSFUNKTION_WEBKLAR = `Sie können Ihr Widerrufsrecht auch online unter https://project.webklar.com/widerrufen.html ausüben. ${EINGANGSBESTAETIGUNG}`
|
||||
|
||||
// Wertersatz bei Dienstleistungen; „oder Lieferung von Wasser/Gas/Strom/Fernwärme [Unzutreffendes streichen]“ ist gestrichen.
|
||||
const WERTERSATZ =
|
||||
@@ -66,7 +90,7 @@ function kontakt({ name, adresse, telefon, email }) {
|
||||
return verbinde([name, adresse, `Telefon: ${telefon}`, `E-Mail: ${email}`])
|
||||
}
|
||||
|
||||
function belehrungsAbsaetze(daten) {
|
||||
function belehrungsAbsaetze(daten, widerrufsfunktion = WIDERRUFSFUNKTION) {
|
||||
return [
|
||||
'Widerrufsbelehrung',
|
||||
'Widerrufsrecht',
|
||||
@@ -75,7 +99,7 @@ function belehrungsAbsaetze(daten) {
|
||||
`Um Ihr Widerrufsrecht auszuüben, müssen Sie uns (${kontakt(daten)}) mittels einer eindeutigen Erklärung `
|
||||
+ '(z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. '
|
||||
+ 'Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.',
|
||||
WIDERRUFSFUNKTION,
|
||||
widerrufsfunktion,
|
||||
'Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.',
|
||||
'Folgen des Widerrufs',
|
||||
'Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten '
|
||||
@@ -88,9 +112,9 @@ function belehrungsAbsaetze(daten) {
|
||||
]
|
||||
}
|
||||
|
||||
function formularAbsaetze({ name, adresse, email }) {
|
||||
function formularAbsaetze({ name, adresse, email }, anZeile = `An ${verbinde([name, adresse, email])}:`) {
|
||||
const zeilen = [
|
||||
`An ${verbinde([name, adresse, email])}:`,
|
||||
anZeile,
|
||||
'Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*)',
|
||||
'Bestellt am (*)/erhalten am (*)',
|
||||
'Name des/der Verbraucher(s)',
|
||||
@@ -117,12 +141,41 @@ export function widerrufsbelehrung(betrieb) {
|
||||
return [...belehrungsAbsaetze(daten), ...formularAbsaetze(daten)].join(ABSATZ)
|
||||
}
|
||||
|
||||
/** WEBklar als Unternehmer (Bestellung einer Funktion im Kundenportal): Kontaktangaben der Widerrufsbelehrung. */
|
||||
export const WEBKLAR_KONTAKT = Object.freeze({
|
||||
name: 'WEBklar GbR',
|
||||
adresse: 'Am Schwimmbad 10, 67722 Winnweiler',
|
||||
telefon: '0176 23726355',
|
||||
email: 'support@webklar.com',
|
||||
})
|
||||
|
||||
/**
|
||||
* Widerrufsbelehrung und Muster-Widerrufsformular fuer die Bestellung der Einrichtung einer Funktion (Terminplaner, News):
|
||||
* WEBklar ist der Unternehmer, Sie-Form, Text mit Zeilenumbruechen wie widerrufsbelehrung. Gleiche Bausteine wie bei den
|
||||
* Gaesten (Frist „des Vertragsabschlusses.“, Wertersatz fuer Dienstleistungen), aber die Widerrufsfunktion nennt die Adresse
|
||||
* der Widerrufsseite (Wortlaut wie in den Abo-Bedingungen, Abschnitt Widerrufsbelehrung) und die „An“-Zeile des Formulars nennt
|
||||
* die E-Mail-Adresse mit „E-Mail:“. Quelle des Dokuments docs/rechtliches/widerrufsbelehrung-funktionen.md; test/termineRechtstexte.test.js
|
||||
* haelt beide gleich und gleicht die Belehrung mit public/abo-bedingungen.html ab.
|
||||
*/
|
||||
export function widerrufsbelehrungFunktion() {
|
||||
const daten = betriebsdaten(WEBKLAR_KONTAKT, { vollstaendig: true })
|
||||
const an = `An ${verbinde([daten.name, daten.adresse, `E-Mail: ${daten.email}`])}:`
|
||||
return [...belehrungsAbsaetze(daten, WIDERRUFSFUNKTION_WEBKLAR), ...formularAbsaetze(daten, an)].join(ABSATZ)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Hinweis unter dem Buchungsformular (recherche-recht.md 11.2)
|
||||
|
||||
const LOESCHFRIST_ABGESAGT_TAGE = 30
|
||||
const LOESCHFRIST_UNBESTAETIGT_STUNDEN = 48
|
||||
const AUFBEWAHRUNG_VORGABE_TAGE = 90
|
||||
|
||||
/**
|
||||
* Aufbewahrung vergangener Termine in Tagen; ein nicht ganzzahliger Wert ist die Vorgabe (90). Den Bereich 14 bis 365 stellt die
|
||||
* Einstellung sicher (normalisiereEinstellung); der Loeschlauf (jobs.js) prueft ihn zusaetzlich beim Lesen.
|
||||
*/
|
||||
const aufbewahrungNachTermin = (einstellung) =>
|
||||
Number.isInteger(einstellung?.aufbewahrungTage) ? einstellung.aufbewahrungTage : AUFBEWAHRUNG_VORGABE_TAGE
|
||||
|
||||
/**
|
||||
* Kurzer Datenschutz-Hinweis (Art. 13 DSGVO gestuft) vor dem Absenden; Sie oder Du nach einstellung.anrede.
|
||||
* Die Loeschfristen entsprechen dem Loeschlauf: nicht bestaetigt 48 Stunden nach Ablauf der Bestaetigungsfrist, abgesagt
|
||||
@@ -132,7 +185,7 @@ const AUFBEWAHRUNG_VORGABE_TAGE = 90
|
||||
export function formularHinweis(einstellung) {
|
||||
const daten = betriebsdaten(einstellung?.betrieb)
|
||||
const du = einstellung.anrede === 'du'
|
||||
const tage = Number.isInteger(einstellung.aufbewahrungTage) ? einstellung.aufbewahrungTage : AUFBEWAHRUNG_VORGABE_TAGE
|
||||
const tage = aufbewahrungNachTermin(einstellung)
|
||||
const verantwortlich = verbinde([daten.name, daten.adresse])
|
||||
const frist = `bis ${tage} Tage nach dem Termin, abgesagte Termine ${LOESCHFRIST_ABGESAGT_TAGE} Tage, `
|
||||
+ `nicht bestätigte Buchungen ${LOESCHFRIST_UNBESTAETIGT_STUNDEN} Stunden nach Ablauf der Bestätigungsfrist.`
|
||||
@@ -147,6 +200,180 @@ export function formularHinweis(einstellung) {
|
||||
+ `Mehr erfahren Sie in unserer Datenschutzerklärung. Verantwortlich: ${verantwortlich}.`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Datenschutz-Absatz „Online-Terminbuchung“ (recherche-recht.md 11.1)
|
||||
|
||||
const DS_PROTOKOLL_TAGE = 14 // Server-Protokolle (Datenschutzerklaerung von WEBklar, Abschnitt 3)
|
||||
const DS_EMAIL_WARTESCHLANGE =
|
||||
'Bis zum Versand liegt die E-Mail in einer Warteschlange; nach dem Versand wird dort die Empfängeradresse samt Inhalt gelöscht, '
|
||||
+ 'nicht zustellbare E-Mails nach 48 Stunden.'
|
||||
|
||||
/** Auftragsverarbeiter und Server: in beiden Anreden derselbe Text. */
|
||||
const DS_AUFTRAGSVERARBEITER =
|
||||
'Wer verarbeitet die Daten in unserem Auftrag? Den Terminkalender betreibt die WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, '
|
||||
+ 'als unser Auftragsverarbeiter nach Art. 28 DSGVO. Die Daten liegen auf Servern der Hetzner Online GmbH, Industriestraße 25, '
|
||||
+ '91710 Gunzenhausen, in Deutschland (Rechenzentren Falkenstein und Nürnberg). Der E-Mail-Versand läuft ebenfalls über Hetzner. '
|
||||
+ 'Eine Übermittlung in Drittländer findet nicht statt.'
|
||||
|
||||
const wenn = (bedingung, text) => (bedingung ? text : '')
|
||||
/** Sätze eines Absatzes, durch ein Leerzeichen getrennt; ein weggelassener Satz (leer) hinterlässt keine Lücke. */
|
||||
const saetze = (...liste) => liste.filter(Boolean).join(' ')
|
||||
|
||||
/**
|
||||
* Die fuenf bedingten Stellen des Absatzes (in der Markdown-Quelle „[[wenn …: …]]“): Telefon-Feld, Nachricht oder Zusatzfelder,
|
||||
* Erinnerung, Widerruf (wie der Widerrufsknopf im Widget: zeigtWiderruf), Telefonnummer des Betriebs.
|
||||
*/
|
||||
function datenschutzBedingungen(einstellung, daten) {
|
||||
const gast = einstellung.gastFelder || {}
|
||||
return {
|
||||
telefonFeld: gast.telefon !== 'aus',
|
||||
freitext: gast.nachricht !== 'aus' || (Array.isArray(gast.zusatz) && gast.zusatz.length > 0),
|
||||
erinnerung: Boolean(einstellung.erinnerung?.anbieten),
|
||||
widerruf: zeigtWiderruf(einstellung),
|
||||
telefon: daten.telefon !== '',
|
||||
}
|
||||
}
|
||||
|
||||
// Absaetze der Sie-Fassung, je Absatz eine kleine Funktion; d = { firma, verantwortlich, email, telefon, tage, haltMinuten, ...Bedingungen }
|
||||
const DS_SIE = [
|
||||
() => 'Online-Terminbuchung',
|
||||
() => 'Auf unserer Website können Sie Termine online buchen. Dafür verarbeiten wir die folgenden Daten:',
|
||||
(d) =>
|
||||
'Welche Daten? Ihren Namen, Ihre E-Mail-Adresse, den gewählten Termin, die gewählte Leistung und die gewählte Person oder den gewählten Platz'
|
||||
+ wenn(d.telefonFeld, ', auf Wunsch Ihre Telefonnummer')
|
||||
+ wenn(d.freitext, ' und die Angaben, die Sie uns im Formular mitteilen')
|
||||
+ '. Außerdem speichern wir, wann Sie den Termin bestätigt haben, welche Fassung der Hinweise Sie bei der Buchung gesehen haben '
|
||||
+ 'und welche Zustimmungen Sie erteilt haben'
|
||||
+ wenn(d.erinnerung, ', zum Beispiel ob Sie eine Erinnerung gewünscht haben')
|
||||
+ '.',
|
||||
(d) =>
|
||||
saetze(
|
||||
'Wofür und auf welcher Grundlage?',
|
||||
'Wir brauchen die Daten, um Ihren Termin zu verwalten, Ihnen den Termin zu bestätigen und Sie über Änderungen oder Absagen zu informieren.',
|
||||
'Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertrag über den gebuchten Termin, bei einer Terminanfrage vorvertragliche Maßnahmen auf Ihre Anfrage).',
|
||||
wenn(
|
||||
d.erinnerung,
|
||||
'Wenn Sie eine Erinnerung vor dem Termin wünschen, beruht diese auf Ihrer Einwilligung nach Art. 6 Abs. 1 lit. a DSGVO. '
|
||||
+ 'Sie können sie jederzeit mit Wirkung für die Zukunft widerrufen, indem Sie uns eine kurze Nachricht senden oder den Termin über den Link in der E-Mail absagen.',
|
||||
),
|
||||
wenn(
|
||||
d.widerruf,
|
||||
'Widerrufen Sie einen Vertrag über die Schaltfläche „Vertrag widerrufen“, speichern wir Ihre Erklärung mit Datum und Uhrzeit '
|
||||
+ 'und senden Ihnen eine Eingangsbestätigung (Art. 6 Abs. 1 lit. c DSGVO in Verbindung mit § 356a BGB).',
|
||||
),
|
||||
'Zum Schutz vor Missbrauch (zum Beispiel automatisierten Buchungen) und um die Zahl der Anfragen zu begrenzen, merken wir uns Ihre IP-Adresse '
|
||||
+ 'kurzzeitig nur im Arbeitsspeicher des Servers, nicht dauerhaft (Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist der sichere Betrieb der Terminbuchung).',
|
||||
),
|
||||
(d) =>
|
||||
'E-Mails. Sie erhalten eine E-Mail, in der Sie Ihre E-Mail-Adresse und den Termin per Link bestätigen, danach eine Bestätigung'
|
||||
+ wenn(d.erinnerung, ' und, wenn Sie das angekreuzt haben, eine Erinnerung')
|
||||
+ `. Sagen wir den Termin ab oder verschieben ihn, informieren wir Sie ebenfalls per E-Mail. Die E-Mails enthalten keine Werbung und keine Tracking-Pixel. `
|
||||
+ `Absender ist ${d.firma}, Ihre Antworten gehen an uns. ${DS_EMAIL_WARTESCHLANGE}`,
|
||||
() => DS_AUFTRAGSVERARBEITER,
|
||||
() =>
|
||||
'Beim Aufruf der Terminbuchung. Beim Aufruf dieser Seite lädt Ihr Browser das Programm der Terminbuchung vom Server der WEBklar GbR (project.webklar.com), '
|
||||
+ 'beim Öffnen der Terminbuchung zusätzlich die Termine und Texte. Dabei werden technisch bedingt Ihre IP-Adresse und Angaben zu Ihrem Browser übertragen. '
|
||||
+ `Der Server speichert sie kurzzeitig in Protokollen und löscht sie nach ${DS_PROTOKOLL_TAGE} Tagen (Art. 6 Abs. 1 lit. f DSGVO; berechtigtes Interesse ist der sichere und funktionsfähige Betrieb). `
|
||||
+ 'Wir setzen dafür keine Cookies und speichern nichts auf Ihrem Gerät.',
|
||||
(d) =>
|
||||
`Wie lange speichern wir? Nicht bestätigte Buchungen (der Bestätigungslink gilt ${d.haltMinuten} Minuten) löschen wir ${LOESCHFRIST_UNBESTAETIGT_STUNDEN} Stunden nach Ablauf dieser Frist. `
|
||||
+ `Bestätigte Termine löschen wir ${d.tage} Tage nach dem Termin, abgesagte oder abgelehnte Termine ${LOESCHFRIST_ABGESAGT_TAGE} Tage nach der Absage oder Ablehnung. `
|
||||
+ 'Terminanfragen, die wir bis zum Terminbeginn nicht beantworten, behandeln wir als abgelehnt. '
|
||||
+ 'Wird die Online-Terminbuchung abgeschaltet, zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten 30 Tage nach dem Abschalten gelöscht. '
|
||||
+ 'Gesetzliche Aufbewahrungspflichten für Geschäftsunterlagen (zum Beispiel Rechnungen) bleiben davon unberührt; sie erfüllen wir außerhalb des Terminkalenders.',
|
||||
(d) =>
|
||||
saetze(
|
||||
'Pflicht zur Angabe. Ohne Name, E-Mail-Adresse und Terminwahl können wir keinen Termin online vergeben.',
|
||||
wenn(d.telefon, `Sie können uns auch telefonisch erreichen: ${d.telefonnummer}.`),
|
||||
'Eine automatisierte Entscheidungsfindung findet nicht statt. Bitte machen Sie bei der Buchung keine Angaben zu Ihrer Gesundheit.',
|
||||
),
|
||||
(d) =>
|
||||
'Ihre Rechte. Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit sowie das Recht, '
|
||||
+ 'der Verarbeitung auf Grundlage berechtigter Interessen zu widersprechen (Art. 15 bis 21 DSGVO). Eine erteilte Einwilligung können Sie jederzeit widerrufen. '
|
||||
+ `Sie können sich bei einer Datenschutzaufsichtsbehörde beschweren. Verantwortlich ist ${d.verantwortlich}, E-Mail ${d.email}.`,
|
||||
]
|
||||
|
||||
// Absaetze der Du-Fassung, gleicher Aufbau wie DS_SIE
|
||||
const DS_DU = [
|
||||
() => 'Online-Terminbuchung',
|
||||
() => 'Auf unserer Website kannst du Termine online buchen. Dafür verarbeiten wir die folgenden Daten:',
|
||||
(d) =>
|
||||
'Welche Daten? Deinen Namen, deine E-Mail-Adresse, den gewählten Termin, die gewählte Leistung und die gewählte Person oder den gewählten Platz'
|
||||
+ wenn(d.telefonFeld, ', auf Wunsch deine Telefonnummer')
|
||||
+ wenn(d.freitext, ' und die Angaben, die du uns im Formular mitteilst')
|
||||
+ '. Außerdem speichern wir, wann du den Termin bestätigt hast, welche Fassung der Hinweise du bei der Buchung gesehen hast '
|
||||
+ 'und welche Zustimmungen du erteilt hast'
|
||||
+ wenn(d.erinnerung, ', zum Beispiel ob du eine Erinnerung gewünscht hast')
|
||||
+ '.',
|
||||
(d) =>
|
||||
saetze(
|
||||
'Wofür und auf welcher Grundlage?',
|
||||
'Wir brauchen die Daten, um deinen Termin zu verwalten, dir den Termin zu bestätigen und dich über Änderungen oder Absagen zu informieren.',
|
||||
'Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertrag über den gebuchten Termin, bei einer Terminanfrage vorvertragliche Maßnahmen auf deine Anfrage).',
|
||||
wenn(
|
||||
d.erinnerung,
|
||||
'Wenn du eine Erinnerung vor dem Termin wünschst, beruht diese auf deiner Einwilligung nach Art. 6 Abs. 1 lit. a DSGVO. '
|
||||
+ 'Du kannst sie jederzeit mit Wirkung für die Zukunft widerrufen, indem du uns eine kurze Nachricht sendest oder den Termin über den Link in der E-Mail absagst.',
|
||||
),
|
||||
wenn(
|
||||
d.widerruf,
|
||||
'Widerrufst du einen Vertrag über die Schaltfläche „Vertrag widerrufen“, speichern wir deine Erklärung mit Datum und Uhrzeit '
|
||||
+ 'und senden dir eine Eingangsbestätigung (Art. 6 Abs. 1 lit. c DSGVO in Verbindung mit § 356a BGB).',
|
||||
),
|
||||
'Zum Schutz vor Missbrauch (zum Beispiel automatisierten Buchungen) und um die Zahl der Anfragen zu begrenzen, merken wir uns deine IP-Adresse '
|
||||
+ 'kurzzeitig nur im Arbeitsspeicher des Servers, nicht dauerhaft (Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist der sichere Betrieb der Terminbuchung).',
|
||||
),
|
||||
(d) =>
|
||||
'E-Mails. Du erhältst eine E-Mail, in der du deine E-Mail-Adresse und den Termin per Link bestätigst, danach eine Bestätigung'
|
||||
+ wenn(d.erinnerung, ' und, wenn du das angekreuzt hast, eine Erinnerung')
|
||||
+ `. Sagen wir den Termin ab oder verschieben ihn, informieren wir dich ebenfalls per E-Mail. Die E-Mails enthalten keine Werbung und keine Tracking-Pixel. `
|
||||
+ `Absender ist ${d.firma}, deine Antworten gehen an uns. ${DS_EMAIL_WARTESCHLANGE}`,
|
||||
() => DS_AUFTRAGSVERARBEITER,
|
||||
() =>
|
||||
'Beim Aufruf der Terminbuchung. Beim Aufruf dieser Seite lädt dein Browser das Programm der Terminbuchung vom Server der WEBklar GbR (project.webklar.com), '
|
||||
+ 'beim Öffnen der Terminbuchung zusätzlich die Termine und Texte. Dabei werden technisch bedingt deine IP-Adresse und Angaben zu deinem Browser übertragen. '
|
||||
+ `Der Server speichert sie kurzzeitig in Protokollen und löscht sie nach ${DS_PROTOKOLL_TAGE} Tagen (Art. 6 Abs. 1 lit. f DSGVO; berechtigtes Interesse ist der sichere und funktionsfähige Betrieb). `
|
||||
+ 'Wir setzen dafür keine Cookies und speichern nichts auf deinem Gerät.',
|
||||
(d) =>
|
||||
`Wie lange speichern wir? Nicht bestätigte Buchungen (der Bestätigungslink gilt ${d.haltMinuten} Minuten) löschen wir ${LOESCHFRIST_UNBESTAETIGT_STUNDEN} Stunden nach Ablauf dieser Frist. `
|
||||
+ `Bestätigte Termine löschen wir ${d.tage} Tage nach dem Termin, abgesagte oder abgelehnte Termine ${LOESCHFRIST_ABGESAGT_TAGE} Tage nach der Absage oder Ablehnung. `
|
||||
+ 'Terminanfragen, die wir bis zum Terminbeginn nicht beantworten, behandeln wir als abgelehnt. '
|
||||
+ 'Wird die Online-Terminbuchung abgeschaltet, zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten 30 Tage nach dem Abschalten gelöscht. '
|
||||
+ 'Gesetzliche Aufbewahrungspflichten für Geschäftsunterlagen (zum Beispiel Rechnungen) bleiben davon unberührt; sie erfüllen wir außerhalb des Terminkalenders.',
|
||||
(d) =>
|
||||
saetze(
|
||||
'Pflicht zur Angabe. Ohne Name, E-Mail-Adresse und Terminwahl können wir keinen Termin online vergeben.',
|
||||
wenn(d.telefon, `Du kannst uns auch telefonisch erreichen: ${d.telefonnummer}.`),
|
||||
'Eine automatisierte Entscheidungsfindung findet nicht statt. Bitte mach bei der Buchung keine Angaben zu deiner Gesundheit.',
|
||||
),
|
||||
(d) =>
|
||||
'Deine Rechte. Du hast das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit sowie das Recht, '
|
||||
+ 'der Verarbeitung auf Grundlage berechtigter Interessen zu widersprechen (Art. 15 bis 21 DSGVO). Eine erteilte Einwilligung kannst du jederzeit widerrufen. '
|
||||
+ `Du kannst dich bei einer Datenschutzaufsichtsbehörde beschweren. Verantwortlich ist ${d.verantwortlich}, E-Mail ${d.email}.`,
|
||||
]
|
||||
|
||||
/**
|
||||
* Absatz „Online-Terminbuchung“ fuer die Datenschutzerklaerung der Website des Betriebs, als reiner Text (nicht HTML-maskiert):
|
||||
* Absaetze durch eine Leerzeile, erste Zeile „Online-Terminbuchung“. anrede === 'du' ist die Du-Fassung, alles andere die Sie-Fassung
|
||||
* (wie formularHinweis); die Anrede kommt aus dem Parameter, nicht aus einstellung.anrede. Sätze, die zu abgeschalteten Funktionen
|
||||
* gehören, fehlen (Telefon-Feld, Nachricht oder Zusatzfelder, Erinnerung, Widerruf, Telefonnummer des Betriebs, siehe datenschutzBedingungen).
|
||||
* Die Aufbewahrung nach dem Termin ist einstellung.aufbewahrungTage (ungültig: 90). Fehlt der Betriebsname oder die E-Mail-Adresse:
|
||||
* TypeError (wie bei den anderen Rechtstexten); eine fehlende Anschrift steht einfach nicht im Text.
|
||||
*/
|
||||
export function datenschutzAbsatz(einstellung, anrede) {
|
||||
const daten = betriebsdaten(einstellung?.betrieb)
|
||||
const absaetze = anrede === 'du' ? DS_DU : DS_SIE
|
||||
const d = {
|
||||
...datenschutzBedingungen(einstellung, daten),
|
||||
firma: daten.name,
|
||||
verantwortlich: verbinde([daten.name, daten.adresse]),
|
||||
email: daten.email,
|
||||
telefonnummer: daten.telefon,
|
||||
tage: aufbewahrungNachTermin(einstellung),
|
||||
haltMinuten: config.termine.holdMinutes, // Gueltigkeit des Bestaetigungslinks, bei jedem Aufruf gelesen
|
||||
}
|
||||
return absaetze.map((absatz) => absatz(d)).join(ABSATZ)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Zustimmungen (Haken) und Hinweis zu Gesundheitsangaben
|
||||
|
||||
/**
|
||||
@@ -162,8 +389,12 @@ export const ZUSTIMMUNG_VORZEITIG = (name) =>
|
||||
/** Pflicht-Haken, wenn der Betrieb Stornobedingungen hat. */
|
||||
export const ZUSTIMMUNG_STORNO = 'Ich habe die Stornobedingungen gelesen.'
|
||||
|
||||
/** Freiwilliger Haken (nie vorangekreuzt), nur wenn der Betrieb Erinnerungen anbietet; stunden = einstellung.erinnerung.stundenVorher. */
|
||||
export const ZUSTIMMUNG_ERINNERUNG = (stunden) => `Ich möchte ${stunden} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten.`
|
||||
/**
|
||||
* Freiwilliger Haken (nie vorangekreuzt), nur wenn der Betrieb Erinnerungen anbietet; stunden = einstellung.erinnerung.stundenVorher.
|
||||
* Der zweite Satz weist auf das Widerrufsrecht der Einwilligung hin (Art. 7 Abs. 3 DSGVO); der Hash im Nachweis deckt beide Saetze.
|
||||
*/
|
||||
export const ZUSTIMMUNG_ERINNERUNG = (stunden) =>
|
||||
`Ich möchte ${stunden} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen.`
|
||||
|
||||
/** Hinweis ueber dem Nachrichtenfeld (Gesundheitsbereich ist ausgeschlossen, Spezifikation Abschnitt 4). */
|
||||
export const GESUNDHEIT_HINWEIS = 'Bitte keine Gesundheitsangaben.'
|
||||
@@ -1,10 +1,48 @@
|
||||
// STUB (Block B) – ersetzt Task D4
|
||||
import { minimaleTicketDetails } from '../featureRegistry.js'
|
||||
import { TICKET_DETAILS_MAX, kuerze, minimaleTicketDetails } from '../featureRegistry.js'
|
||||
import { einbauanleitung } from './einbauanleitung.js'
|
||||
import { ladeStand } from './kontext.js'
|
||||
|
||||
/**
|
||||
* Details des Einrichtungs-Tickets fuer den Terminplaner (config.features.termine.ticket.details).
|
||||
* ctx = { feature, doc, project, customer }. Block D4 ersetzt den Inhalt durch einbauanleitung(...).kurz; Signatur und ctx bleiben.
|
||||
* Details des Einrichtungs-Tickets fuer den Terminplaner (config.features.termine.ticket.details, TP2 Task D4).
|
||||
* ctx = { feature, doc, project, customer } wie ihn markPaid uebergibt: feature = Registereintrag (der Generator braucht ihn nicht),
|
||||
* doc = portalFeatures-Dokument (siteKey, $id, customerId, projectId, unlockedByPurchase kommen von dort), project = websiteProjects-Dokument,
|
||||
* customer = Kunde oder null (best effort).
|
||||
*
|
||||
* Inhalt: einbauanleitung(...).kurz, gebaut aus dem gespeicherten Stand der Einstellungen (ladeStand). Die Funktion wirft nie: bezahlt ist bezahlt,
|
||||
* das Ticket entsteht immer. Scheitert das Lesen oder der Generator, stehen die Kopfzeilen, der Hinweis, dass die Anleitung nicht erzeugt werden
|
||||
* konnte, und der Verweis auf das WOMS im Ticket (notfallDetails). Das Log nennt keine Daten des Betriebs.
|
||||
*/
|
||||
export async function termineTicketDetails(ctx) {
|
||||
return `${minimaleTicketDetails(ctx)}\nEinrichtung bezahlt (149 €), Zeit nicht auf das Stundenkontingent anrechnen.`
|
||||
try {
|
||||
const { doc, project, customer } = ctx
|
||||
const stand = await ladeStand(doc.$id)
|
||||
const { kurz } = einbauanleitung({
|
||||
feature: doc,
|
||||
project,
|
||||
einstellung: stand?.einstellung ?? null,
|
||||
fragebogen: stand?.fragebogen ?? null,
|
||||
version: stand?.version ?? 0,
|
||||
updatedAt: stand?.updatedAt ?? null,
|
||||
kunde: { name: customer?.name || doc.customerId || '' },
|
||||
now: new Date(),
|
||||
})
|
||||
if (typeof kurz !== 'string' || kurz.trim() === '') throw new Error('Kurzfassung leer')
|
||||
return kurz
|
||||
} catch (err) {
|
||||
console.error(`[termine-ticket] Kurzfassung der Einbauanleitung nicht erzeugbar, Notfall-Details: ${err?.message}`)
|
||||
return notfallDetails(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
const ANLEITUNG_FEHLT = 'Anleitung konnte nicht erzeugt werden, im WOMS öffnen (Verweis oben).'
|
||||
const KONTINGENT = 'Nicht auf das Stundenkontingent anrechnen.'
|
||||
|
||||
/** Kopfzeilen und Verweis (minimaleTicketDetails, Funktion wie im Kopf der Kurzfassung) plus Hinweis; wirft nie, auch nicht bei einem unbrauchbaren ctx. */
|
||||
function notfallDetails(ctx) {
|
||||
try {
|
||||
const kopf = minimaleTicketDetails({ ...ctx, feature: { key: 'Terminplaner' } })
|
||||
return kuerze(`${kopf}\n${ANLEITUNG_FEHLT}\n${KONTINGENT}`.toWellFormed(), TICKET_DETAILS_MAX)
|
||||
} catch {
|
||||
return `Funktion: Terminplaner\nAnleitung konnte nicht erzeugt werden, im WOMS öffnen (Kunden → Funktionen).\n${KONTINGENT}`
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { neueId, normalisiereEinstellung } from './einstellungen.js'
|
||||
import { hatPreis, neueId, normalisiereEinstellung } from './einstellungen.js'
|
||||
|
||||
/**
|
||||
* Branchen-Vorlagen des Terminplaners (Spezifikation Abschnitt 9, Erstes Oeffnen): Daten, kein Code je Nische.
|
||||
@@ -206,7 +206,8 @@ export function ausVorlage(key, betrieb, now) {
|
||||
regeln: { ...v.regeln, maxOffeneJeEmail: 3, personWaehlbar: true },
|
||||
gastFelder: structuredClone(v.gastFelder),
|
||||
erinnerung: { anbieten: true, stundenVorher: 24 },
|
||||
storno: { text: v.storno },
|
||||
// Stornobedingungen gelten nur fuer Leistungen mit Preis: hat keine Leistung der Vorlage einen, gibt es keinen Vorschlag
|
||||
storno: { text: v.leistungen.some(hatPreis) ? v.storno : '' },
|
||||
recht: { ...v.recht },
|
||||
zahlung: { modus: 'vorort', vorOrtArten: [...ZAHLARTEN] },
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user