Terminplaner-Banner und Meldung nach Abschaltgrund mit Löschdatum

GET /api/termine/einstellungen liefert bei abgeschalteter Funktion
abgeschaltet { grund, seit, loeschungAm }. Die 409-Meldung bei
Schreibversuchen (Code abgeschaltet) und der Banner im Portal
unterscheiden Wartungs-Abo beendet, Abo vor der Einrichtung beendet und
die übrigen Gründe und nennen das Löschdatum, wenn es eines gibt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 18:41:03 +00:00
1 parent d66dd60a76
commit 36b36bcc4d
7 files changed
+268 -26

No files matched your search

+19
View File
@@ -456,3 +456,22 @@ export function aktivSeitText(iso) {
if (!zeit || Number.isNaN(zeit.getTime())) return ''
return `Aktiv seit ${datumKurz(heuteBerlin(zeit))}`
}
/**
* Text des Banners einer abgeschalteten Funktion nach Abschaltgrund. abgeschaltet = { grund, seit, loeschungAm } aus GET /api/termine/einstellungen
* (grund: 'abo_ende' | 'abo_ende_vor_einrichtung' | 'team' | 'kunde_geloescht' | null, loeschungAm: 'JJJJ-MM-TT' oder null; null/fehlend = bisheriger Satz).
* Liefert { davor, danach, mitExport }: mit Export steht zwischen den Teilen der Export-Link ([davor, Link 'exportieren', danach]), ohne
* Export (nur wenn das Abo vor der Einrichtung endete: es gibt nichts zu exportieren) folgen die Teile direkt aufeinander.
* Das Loeschdatum steht nur da, wenn loeschungAm ein gueltiger Kalendertag ist. Du-Form; der Wortlaut ist ein Vorschlag fuer die Rechtsrunde.
*/
export function abschaltText(abgeschaltet) {
const { grund, loeschungAm } = abgeschaltet || {}
const loeschen = istTag(loeschungAm) ? ` Ab dem ${datumKurz(loeschungAm)} löschen wir die Daten.` : ''
if (grund === 'abo_ende_vor_einrichtung') {
return { davor: 'Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo endete, bevor der Terminplaner eingerichtet war.', danach: loeschen, mitExport: false }
}
const davor = grund === 'abo_ende'
? 'Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo beendet ist. Du kannst Termine nur noch ansehen und '
: 'Der Terminplaner ist für diese Website abgeschaltet. Du kannst Termine nur noch ansehen und '
return { davor, danach: `.${loeschen}`, mitExport: true }
}
+12 -8
View File
@@ -8,7 +8,7 @@
hinweise() zeichnet die Hinweisleiste ueber den Reitern neu (z. B. wenn Telefon und Anschrift nach dem Speichern nicht mehr fehlen). Server: server/routes/termine.js. Tage sind Berliner Kalendertage 'JJJJ-MM-TT';
gerechnet wird in termine-view.js. Helfer ohne Zustand: termine-dom.js. Styles: termine.css. */
import { fehltText, heuteBerlin } from './termine-view.js'
import { abschaltText, fehltText, heuteBerlin } from './termine-view.js'
import { exportLink, fehlerKarte, h, infoKarte, request } from './termine-dom.js'
import { render as renderTermine } from './termine-tab-termine.js'
import { render as renderLeistungen } from './termine-tab-leistungen.js'
@@ -62,11 +62,15 @@ function projektwahl(s) {
const ladeDaten = (projectId) => request(`/api/termine/einstellungen?projectId=${encodeURIComponent(projectId)}`)
/** Hinweis fuer abgeschaltete Funktionen: nur noch ansehen und exportieren. */
const hinweisAbgeschaltet = (projectId) => h('div', { class: 'tm-box tm-box-warn', role: 'note' },
'Der Terminplaner ist für diese Website abgeschaltet. Du kannst Termine nur noch ansehen und ',
exportLink(projectId, 'exportieren'),
'.')
/**
* Hinweis fuer abgeschaltete Funktionen: Grund, Loeschdatum und (ausser vor der Einrichtung) der Export-Link, der einzige Link im Hinweis.
* abgeschaltet: { grund, seit, loeschungAm } aus GET /einstellungen (fehlt bei einer aelteren Antwort: dann der bisherige Satz).
*/
function hinweisAbgeschaltet(projectId, abgeschaltet) {
const { davor, danach, mitExport } = abschaltText(abgeschaltet)
// ein einziger Textblock in der Flex-Zeile der Box, sonst zerfaellt der Satz in drei Spalten (Text, Link, Punkt)
return h('div', { class: 'tm-box tm-box-warn', role: 'note' }, h('span', null, davor, mitExport && exportLink(projectId, 'exportieren'), danach))
}
/**
* Je nach Zustand der Funktion: nicht freigeschaltet, Vorlage waehlen oder die Reiter. Jeder Aufruf zaehlt s.lauf hoch: die Antwort
@@ -95,7 +99,7 @@ async function zeigeProjekt(s) {
if (daten.einstellung) zeigeReiter(s, { projekt, aktuell })
else if (s.nurLesen) {
s.buehne.replaceChildren(
hinweisAbgeschaltet(projekt.projectId),
hinweisAbgeschaltet(projekt.projectId, daten.abgeschaltet),
infoKarte('Keine Termine eingerichtet', 'Für diese Website wurden keine Einstellungen und Termine angelegt.', { chat: true }))
} else zeigeVorlagen(s, { projekt, aktuell })
}
@@ -197,7 +201,7 @@ function zeigeReiter(s, { projekt, aktuell }) {
function zeichneHinweise() {
const hinweise = []
if (nurLesen) {
hinweise.push(hinweisAbgeschaltet(projectId))
hinweise.push(hinweisAbgeschaltet(projectId, s.daten.abgeschaltet))
} else if ((s.daten.fehlt || []).length) {
hinweise.push(h('div', { class: 'tm-box tm-box-warn', role: 'note' },
h('span', null, `Noch nicht buchbar: Es fehlt ${fehltText(s.daten.fehlt) || 'eine Angabe zu deinem Betrieb'}. Solange das fehlt, können Gäste über deine Website nicht buchen.`),
+24 -1
View File
@@ -10,6 +10,7 @@ import { verschiebeZeiten } from '../services/termine/ablauf.js'
import { aktion, entsperre, manuell, sperre } from '../services/termine/ablaufBetrieb.js'
import { EXPORT_SEITE, exportSeite, hole, imZeitraum, zaehleAnfragen, zaehleKuenftige } from '../services/termine/buchungen.js'
import { fehlendePflichtangaben, neuerFeedToken, normalisiereEinstellung } from '../services/termine/einstellungen.js'
import { loeschTag, loeschzeit } from '../services/termine/frist.js'
import { kontextPerFeature, speichereEinstellung } from '../services/termine/kontext.js'
import { zustellFehler } from '../services/termine/outbox.js'
import { termineFuerKunde } from '../services/termine/schalter.js'
@@ -57,6 +58,8 @@ const MELDUNG = {
nichtVerfuegbar: 'Der Terminplaner ist noch nicht verfügbar.',
nichtAktiv: 'Der Terminplaner ist für diese Website nicht aktiv.',
abgeschaltet: 'Der Terminplaner ist für diese Website abgeschaltet. Du kannst Termine nur noch ansehen und exportieren.',
abgeschaltetAboEnde: 'Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo beendet ist. Du kannst Termine nur noch ansehen und exportieren.',
abgeschaltetVorEinrichtung: 'Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo endete, bevor der Terminplaner eingerichtet war.',
keineEinstellung: 'Bitte richte den Terminplaner zuerst ein.',
zuViele: 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.',
vorlage: 'Bitte wähle eine Vorlage aus der Liste.',
@@ -125,6 +128,25 @@ export function resetTermineRoutesForTests() {
// ---------------------------------------------------------------- Projekt und Funktion
/**
* Was die Abschaltung dem Kunden sagt: Grund (disabledReason, ohne Grund = Altbestand => null), Zeitpunkt (disabledAt) und der Berliner
* Tag, ab dem die Daten gelöscht werden dürfen (loeschungAm, 30 Tage nach disabledAt, termine/frist.js; null ohne lesbaren Zeitpunkt).
* Diese Route gehört nur zum Terminplaner, deshalb gibt es das Löschdatum hier immer (R105: bei News nie, siehe siteFeatures.featureView).
*/
const abgeschaltetAnsicht = (feature) => ({
grund: feature.disabledReason || null,
seit: feature.disabledAt || null,
loeschungAm: loeschTag(feature.disabledAt),
})
/** Antwort auf einen Schreibversuch bei abgeschalteter Funktion (409, Code `abgeschaltet`): je Grund ein Satz, mit Löschdatum, wenn es eines gibt. */
function abgeschaltetMeldung(feature) {
const grund = feature.disabledReason
const satz = grund === 'abo_ende' ? MELDUNG.abgeschaltetAboEnde : grund === 'abo_ende_vor_einrichtung' ? MELDUNG.abgeschaltetVorEinrichtung : MELDUNG.abgeschaltet
const ende = loeschzeit(feature.disabledAt)
return ende ? `${satz} Ab dem ${datumKurz(ende)} löschen wir die Daten.` : satz
}
const projectIdOf = (req) => {
const raw = req.query.projectId ?? req.body?.projectId
return typeof raw === 'string' ? raw : ''
@@ -149,7 +171,7 @@ const withTermine = ({ schreiben = false } = {}) => route(async (req, _res, next
const feature = await getFeatureDoc(customerId, projectId, 'termine')
const ctx = feature && ['active', 'disabled'].includes(feature.state) ? await kontextPerFeature(feature.$id) : null
if (!ctx || ctx.feature.customerId !== customerId) throw stop(409, MELDUNG.nichtAktiv, 'nicht_aktiv')
if (schreiben && ctx.feature.state !== 'active') throw stop(409, MELDUNG.abgeschaltet, 'abgeschaltet')
if (schreiben && ctx.feature.state !== 'active') throw stop(409, abgeschaltetMeldung(ctx.feature), 'abgeschaltet')
req.ctx = ctx
return next()
})
@@ -203,6 +225,7 @@ router.get('/einstellungen', requireSession, withTermine(), route(async (req, re
const e = ctx.einstellung
res.json({
state: ctx.feature.state,
abgeschaltet: ctx.feature.state === 'disabled' ? abgeschaltetAnsicht(ctx.feature) : null,
aktivSeit: ctx.feature.activatedAt || null,
einstellung: e ? fuerKunde(e) : null,
version: ctx.version,
+97 -11
View File
@@ -1102,10 +1102,29 @@ test('exportSeite: ohne Funktion, mit ungueltigem Cursor oder ungueltiger Grenze
// ---------------------------------------------------------------- Abgeschaltet (disabled)
test('abgeschaltete Funktion: Lesen und Export gehen, jedes Schreiben ist 409', async () => {
const disabled = featureDoc({ $id: 'f_p4_termine', projectId: 'p4', state: 'disabled', enabled: false, siteKey: 'cdefghijklmnopqr' })
const MELDUNG_ALT = 'Der Terminplaner ist für diese Website abgeschaltet. Du kannst Termine nur noch ansehen und exportieren.'
/** Jede Schreib-Route des Portals, wie sie bei einer abgeschalteten Funktion von Projekt `projectId` aufgerufen wird. */
const schreibVersuche = (projectId, einstellung) => [
['PATCH', '/api/termine/einstellungen', { projectId, version: 1, einstellung }],
['POST', '/api/termine/einstellungen/vorlage', { projectId, vorlage: 'salon' }],
['POST', '/api/termine/buchungen', { ...manuellBody(), projectId }],
['POST', '/api/termine/buchungen/b_alt/aktion', { projectId, aktion: 'absagen', grund: 'x' }],
['POST', `/api/termine/ressourcen/${R1}/feed`, { projectId }],
['DELETE', `/api/termine/buchungen/b_alt?projectId=${projectId}`],
]
/** Funktion von p4 abgeschaltet mit Einstellung und einer Buchung; `over` setzt Grund und Zeitpunkt. */
async function seedAbgeschaltet(over = {}) {
const disabled = featureDoc({ $id: 'f_p4_termine', projectId: 'p4', state: 'disabled', enabled: false, siteKey: 'cdefghijklmnopqr', ...over })
aw.seed('portalFeatures', disabled)
await speichere(einstellung(), disabled)
aw.seed('termineBookings', buchungsDoc('b_alt', { featureId: 'f_p4_termine', projectId: 'p4' }))
return disabled
}
test('abgeschaltete Funktion: Lesen und Export gehen, jedes Schreiben ist 409', async () => {
await seedAbgeschaltet()
const lesen = await send('GET', einstellungenUrl('p4'), { cookie })
assert.equal(lesen.status, 200)
assert.equal(lesen.body.state, 'disabled')
@@ -1117,15 +1136,7 @@ test('abgeschaltete Funktion: Lesen und Export gehen, jedes Schreiben ist 409',
assert.ok(export_.text.includes('Max Mustermann'))
assert.equal((await send('GET', `/api/termine/zeiten?projectId=p4&leistung=${S_SOFORT}&tag=${TAG}`, { cookie })).status, 200)
const schreiben = [
['PATCH', '/api/termine/einstellungen', { projectId: 'p4', version: 1, einstellung: lesen.body.einstellung }],
['POST', '/api/termine/einstellungen/vorlage', { projectId: 'p4', vorlage: 'salon' }],
['POST', '/api/termine/buchungen', { ...manuellBody(), projectId: 'p4' }],
['POST', '/api/termine/buchungen/b_alt/aktion', { projectId: 'p4', aktion: 'absagen', grund: 'x' }],
['POST', `/api/termine/ressourcen/${R1}/feed`, { projectId: 'p4' }],
['DELETE', '/api/termine/buchungen/b_alt?projectId=p4'],
]
for (const [method, pfad, body] of schreiben) {
for (const [method, pfad, body] of schreibVersuche('p4', lesen.body.einstellung)) {
const res = await send(method, pfad, { cookie, body })
assert.equal(res.status, 409, `${method} ${pfad}`)
assert.equal(res.body.code, 'abgeschaltet')
@@ -1134,6 +1145,81 @@ test('abgeschaltete Funktion: Lesen und Export gehen, jedes Schreiben ist 409',
assert.equal(aw.docs('termineConfig').get('f_p4_termine').version, 1)
})
test('abgeschaltet ohne Grund und Zeitpunkt (Altbestand): abgeschaltet leer, die Meldung bleibt der bisherige Satz', async () => {
await seedAbgeschaltet()
const lesen = await send('GET', einstellungenUrl('p4'), { cookie })
assert.deepEqual(lesen.body.abgeschaltet, { grund: null, seit: null, loeschungAm: null })
for (const [method, pfad, body] of schreibVersuche('p4', lesen.body.einstellung)) {
const res = await send(method, pfad, { cookie, body })
assert.equal(res.status, 409, `${method} ${pfad}`)
assert.equal(res.body.code, 'abgeschaltet')
assert.equal(res.body.error, MELDUNG_ALT, `${method} ${pfad}`)
}
})
test('abgeschaltet wegen Abo-Ende: Grund, Löschtag und Meldung mit Wartungs-Abo und Datum; Lesen und Export gehen weiter', async () => {
await seedAbgeschaltet({ disabledReason: 'abo_ende', disabledAt: '2026-10-04T23:30:00.000Z' })
const lesen = await send('GET', einstellungenUrl('p4'), { cookie })
assert.equal(lesen.status, 200)
// 23:30 UTC am 4.10. + 30 Tage = 3.11. 23:30 UTC = 4.11. 00:30 Berliner Zeit (Winterzeit)
assert.deepEqual(lesen.body.abgeschaltet, { grund: 'abo_ende', seit: '2026-10-04T23:30:00.000Z', loeschungAm: '2026-11-04' })
assert.equal((await send('GET', `/api/termine/buchungen?projectId=p4&von=${TAG}&bis=${TAG}`, { cookie })).status, 200)
assert.equal((await rohExport('p4')).res.status, 200)
for (const [method, pfad, body] of schreibVersuche('p4', lesen.body.einstellung)) {
const res = await send(method, pfad, { cookie, body })
assert.equal(res.status, 409, `${method} ${pfad}`)
assert.equal(res.body.code, 'abgeschaltet')
assert.ok(res.body.error.includes('Wartungs-Abo') && res.body.error.includes('04.11.2026'), `${method} ${pfad}: ${res.body.error}`)
assert.equal(res.body.error, 'Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo beendet ist. Du kannst Termine nur noch ansehen und exportieren. Ab dem 04.11.2026 löschen wir die Daten.')
}
assert.equal(aw.docs('termineBookings').get('b_alt').status, 'bestaetigt')
})
test('abgeschaltet, weil das Abo vor der Einrichtung endete: Meldung nennt "eingerichtet" und das Löschdatum', async () => {
await seedAbgeschaltet({ disabledReason: 'abo_ende_vor_einrichtung', disabledAt: '2026-10-04T10:00:00.000Z' })
const lesen = await send('GET', einstellungenUrl('p4'), { cookie })
assert.deepEqual(lesen.body.abgeschaltet, { grund: 'abo_ende_vor_einrichtung', seit: '2026-10-04T10:00:00.000Z', loeschungAm: '2026-11-03' })
for (const [method, pfad, body] of schreibVersuche('p4', lesen.body.einstellung)) {
const res = await send(method, pfad, { cookie, body })
assert.equal(res.status, 409, `${method} ${pfad}`)
assert.equal(res.body.code, 'abgeschaltet')
assert.ok(res.body.error.includes('eingerichtet') && res.body.error.includes('Wartungs-Abo endete'), `${method} ${pfad}: ${res.body.error}`)
assert.ok(res.body.error.endsWith('Ab dem 03.11.2026 löschen wir die Daten.'), res.body.error)
assert.ok(!/exportieren/.test(res.body.error), 'es gibt nichts zu exportieren')
}
})
test('abgeschaltet durch das Team oder wegen gelöschtem Konto: bisheriger Satz, mit Zeitpunkt zusätzlich das Löschdatum', async () => {
for (const grund of ['team', 'kunde_geloescht']) {
await seedAbgeschaltet({ disabledReason: grund, disabledAt: '2026-10-04T23:30:00.000Z' })
const lesen = await send('GET', einstellungenUrl('p4'), { cookie })
assert.deepEqual(lesen.body.abgeschaltet, { grund, seit: '2026-10-04T23:30:00.000Z', loeschungAm: '2026-11-04' })
const res = await send('PATCH', '/api/termine/einstellungen', { cookie, body: { projectId: 'p4', version: 1, einstellung: lesen.body.einstellung } })
assert.equal(res.status, 409, grund)
assert.equal(res.body.code, 'abgeschaltet')
assert.equal(res.body.error, `${MELDUNG_ALT} Ab dem 04.11.2026 löschen wir die Daten.`, grund)
}
})
test('abgeschaltet mit unlesbarem Zeitpunkt: Grund bleibt, Löschtag fehlt und die Meldung nennt kein Datum', async () => {
await seedAbgeschaltet({ disabledReason: 'abo_ende', disabledAt: 'kein Datum' })
const lesen = await send('GET', einstellungenUrl('p4'), { cookie })
assert.deepEqual(lesen.body.abgeschaltet, { grund: 'abo_ende', seit: 'kein Datum', loeschungAm: null })
const res = await send('PATCH', '/api/termine/einstellungen', { cookie, body: { projectId: 'p4', version: 1, einstellung: lesen.body.einstellung } })
assert.equal(res.status, 409)
assert.equal(res.body.error, 'Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo beendet ist. Du kannst Termine nur noch ansehen und exportieren.')
})
test('aktive Funktion: abgeschaltet ist null, auch wenn am Dokument noch Reste einer früheren Abschaltung stehen', async () => {
await speichere()
assert.equal((await lade()).abgeschaltet, null)
aw.seed('portalFeatures', featureDoc({ disabledReason: 'abo_ende', disabledAt: '2026-10-04T23:30:00.000Z' }))
resetKontextForTests()
const body = await lade()
assert.equal(body.state, 'active')
assert.equal(body.abgeschaltet, null)
})
// ---------------------------------------------------------------- Kundenansicht
test('Kundenansicht darf PATCH, POST und DELETE (mit Header) und es wird protokolliert; ohne Gastdaten im Protokoll', async () => {
+44 -1
View File
@@ -2,7 +2,7 @@ import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync, readdirSync } from 'node:fs'
import {
FILTER, ABSAGE_GRUENDE, anfragenText, bereichFuer, datumKurz, datumLang, endeZeit, fensterParsen, fensterText, filterStatus, gruppiereNachTag,
FILTER, ABSAGE_GRUENDE, abschaltText, anfragenText, bereichFuer, datumKurz, datumLang, endeZeit, fensterParsen, fensterText, filterStatus, gruppiereNachTag,
fehltText, fensterAufteilen, heuteBerlin, istSperre, istTag, monatVon, moeglicheAktionen, ressourcenWort, statusLabel, statusTon, tagPlus, tagUeberschrift, verschiebeTag, wocheVon, zeitNormal, zeitraumTitel,
} from '../public/termine-view.js'
import {
@@ -252,6 +252,49 @@ test('fehltText: Pflichtangaben für Gäste in Worten', () => {
})
// ---- Verdrahtung (Portal-Ansicht #termine)
test('abschaltText: Text je Abschaltgrund, mit und ohne Löschdatum, Du-Form', () => {
const alt = 'Der Terminplaner ist für diese Website abgeschaltet. Du kannst Termine nur noch ansehen und '
const loeschen = ' Ab dem 04.11.2026 löschen wir die Daten.'
// Abo beendet: Wartungs-Abo im Text, Export bleibt
assert.deepEqual(abschaltText({ grund: 'abo_ende', loeschungAm: '2026-11-04' }), {
davor: 'Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo beendet ist. Du kannst Termine nur noch ansehen und ',
danach: `.${loeschen}`,
mitExport: true,
})
assert.deepEqual(abschaltText({ grund: 'abo_ende', loeschungAm: null }), {
davor: 'Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo beendet ist. Du kannst Termine nur noch ansehen und ',
danach: '.',
mitExport: true,
})
// Abo endete vor der Einrichtung: nichts zu exportieren, also kein Export-Satz
const vor = abschaltText({ grund: 'abo_ende_vor_einrichtung', loeschungAm: '2026-11-04' })
assert.equal(vor.mitExport, false)
assert.equal(vor.davor + vor.danach, `Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo endete, bevor der Terminplaner eingerichtet war.${loeschen}`)
const vorOhne = abschaltText({ grund: 'abo_ende_vor_einrichtung', loeschungAm: null })
assert.equal(vorOhne.mitExport, false)
assert.equal(vorOhne.davor + vorOhne.danach, 'Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo endete, bevor der Terminplaner eingerichtet war.')
// Team, gelöschtes Konto, kein Grund (Altbestand), unbekannter Grund, fehlende Angabe: der bisherige Satz, mit Datum ergänzt
for (const grund of ['team', 'kunde_geloescht', null, undefined, 'frist', 5]) {
assert.deepEqual(abschaltText({ grund, loeschungAm: '2026-11-04' }), { davor: alt, danach: `.${loeschen}`, mitExport: true }, String(grund))
assert.deepEqual(abschaltText({ grund, loeschungAm: null }), { davor: alt, danach: '.', mitExport: true }, String(grund))
}
assert.deepEqual(abschaltText(), { davor: alt, danach: '.', mitExport: true })
assert.deepEqual(abschaltText(null), { davor: alt, danach: '.', mitExport: true })
// mitExport ist nur bei vor_einrichtung falsch
for (const grund of ['abo_ende', 'team', 'kunde_geloescht', null]) assert.equal(abschaltText({ grund }).mitExport, true, String(grund))
})
test('abschaltText: ein Datum, das kein Berliner Kalendertag ist, wird nicht angezeigt; nie Sie/Ihr', () => {
for (const kaputt of ['04.11.2026', '2026-13-45', '', 'morgen', 20261104, {}]) {
const t = abschaltText({ grund: 'abo_ende', loeschungAm: kaputt })
assert.ok(!/löschen/.test(t.davor + t.danach), `kein Datum für ${JSON.stringify(kaputt)}`)
}
for (const grund of ['abo_ende', 'abo_ende_vor_einrichtung', 'team', 'kunde_geloescht', null]) {
const t = abschaltText({ grund, loeschungAm: '2026-11-04' })
assert.doesNotMatch(t.davor + t.danach, /\bSie\b|\bIhr(e[nmrs]?)?\b/, String(grund))
}
})
const lies = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8')
test('Portal: Ansicht #termine ist verdrahtet, funktionen.js lädt keine Terminplaner-Module', () => {
+67
View File
@@ -87,6 +87,7 @@ function neueFixture() {
fx.daten = (state = fx.projekte[0].features.termine.state) => ({
state, aktivSeit: '2026-10-01T09:00:00.000Z', einstellung: fx.einstellung, version: fx.einstellung ? 3 : 0, vorlagen: fx.vorlagen, feeds: {}, siteKey: KEY,
einbau: { snippet: '' }, anfragenOffen: fx.buchungen.filter((b) => b.status === 'angefragt').length || fx.anfragenOffen, fehlt: fx.fehlt,
abgeschaltet: state === 'disabled' ? fx.abgeschaltet ?? { grund: null, seit: null, loeschungAm: null } : null,
})
return fx
}
@@ -735,6 +736,49 @@ async function pruefungen() {
})(),
})
// ------------------------------------------------------------ Abgeschaltet nach Grund (E11)
await check('fx_abgeschaltet_abo_ende', async (page) => {
await oeffne(page, '#termine')
await warteAnsicht(page)
const hinweis = page.locator('.tm-box-warn')
await hinweis.waitFor()
const text = await textVon(hinweis)
assert(text.includes('Wartungs-Abo') && text.includes('04.11.2026'), `Banner nennt Abo und Löschdatum: ${text}`)
assert(text.includes('nur noch ansehen'), `Banner: ${text}`)
eq(await page.locator('.tm-box-warn a').count(), 1, 'genau ein Link im Banner')
eq(await page.locator('.tm-box-warn a').innerText(), 'exportieren', 'Linktext')
eq(await page.locator('.tm-box-warn a').getAttribute('href'), '/api/termine/export.csv?projectId=p1', 'Export-Link')
eq(await page.locator('[data-werkzeug]').count(), 0, 'keine Schreib-Knöpfe')
await shot(page, 'fx-abgeschaltet-abo-ende')
}, {
fixture: (() => {
const fx = neueFixture()
fx.projekte[0].features.termine = { state: 'disabled' }
fx.abgeschaltet = { grund: 'abo_ende', seit: '2026-10-04T23:30:00.000Z', loeschungAm: '2026-11-04' }
return fx
})(),
})
await check('fx_abgeschaltet_vor_einrichtung', async (page) => {
await oeffne(page, '#termine')
await warteAnsicht(page)
const hinweis = page.locator('.tm-box-warn')
await hinweis.waitFor()
const text = await textVon(hinweis)
assert(text.includes('eingerichtet') && text.includes('Wartungs-Abo') && text.includes('04.11.2026'), `Banner: ${text}`)
assert(!text.includes('exportieren'), `Banner ohne Export-Satz: ${text}`)
eq(await hinweis.locator('a').count(), 0, 'kein Link: es gibt nichts zu exportieren')
assert((await textVon(page.locator('.tm-info'))).includes('Keine Termine eingerichtet'), 'Info-Karte')
}, {
fixture: (() => {
const fx = neueFixture()
fx.projekte[0].features.termine = { state: 'disabled' }
fx.einstellung = null
fx.abgeschaltet = { grund: 'abo_ende_vor_einrichtung', seit: '2026-10-04T23:30:00.000Z', loeschungAm: '2026-11-04' }
return fx
})(),
})
// ------------------------------------------------------------ Datumssprung mit Unsinn
await check('fx_datumssprung_unsinn', async (page) => {
await oeffne(page, '#termine')
@@ -1037,6 +1081,7 @@ async function pruefungen() {
await warteAnsicht(page)
const hinweis = await textVon(page.locator('.tm-box-warn'))
assert(hinweis.includes('abgeschaltet') && hinweis.includes('nur noch ansehen'), `Banner: ${hinweis}`)
assert(!hinweis.includes('Wartungs-Abo') && !hinweis.includes('löschen'), `ohne Grund und Zeitpunkt der bisherige Satz: ${hinweis}`)
eq(await page.locator('.tm-box-warn a').getAttribute('href'), '/api/termine/export.csv?projectId=p1', 'Export-Link')
eq(await page.locator('[data-werkzeug]').count(), 0, 'keine Schreib-Knoepfe oben')
await page.getByRole('button', { name: 'Tag', exact: true }).click()
@@ -1049,6 +1094,28 @@ async function pruefungen() {
eq(r, 409, 'Server: Schreiben bei abgeschalteter Funktion')
})
await check('echt_abgeschaltet_abo_ende', async (page) => {
await srv.setzeFunktion('disabled', { disabledReason: 'abo_ende', disabledAt: '2026-10-04T23:30:00.000Z' })
await oeffne(page, '#termine')
await warteAnsicht(page)
const hinweis = page.locator('.tm-box-warn')
await hinweis.waitFor()
const text = await textVon(hinweis)
assert(text.includes('Wartungs-Abo') && text.includes('04.11.2026'), `Banner nennt Abo und Löschdatum: ${text}`)
eq(await page.locator('.tm-box-warn a').count(), 1, 'genau ein Link im Banner')
eq(await page.locator('.tm-box-warn a').innerText(), 'exportieren', 'Linktext')
eq(await page.locator('.tm-box-warn a').getAttribute('href'), '/api/termine/export.csv?projectId=p1', 'Export-Link')
// die Schnittstelle nennt dasselbe beim Schreiben
const antwort = await page.evaluate(async () => {
const res = await fetch('/api/termine/buchungen/b_alt/aktion', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ projectId: 'p1', aktion: 'annehmen' }) })
return { status: res.status, body: await res.json() }
})
eq(antwort.status, 409, 'Server: Schreiben bei abgeschalteter Funktion')
eq(antwort.body.code, 'abgeschaltet', 'Code bleibt')
assert(antwort.body.error.includes('Wartungs-Abo') && antwort.body.error.includes('04.11.2026'), `Meldung: ${antwort.body.error}`)
await shot(page, 'echt-abgeschaltet-abo-ende')
})
await check('echt_390_liste_und_formular', async (page) => {
srv.aw.seed('termineBookings', {
$id: 'b_390', featureId: srv.FEATURE, customerId: srv.KUNDE, projectId: srv.PROJEKT, leistungId: S_DAMEN, leistungName: 'Damenschnitt', ressourceId: R_ANNA, ressourceName: 'Anna',
+5 -5
View File
@@ -188,13 +188,13 @@ export async function startTermineUiServer({ jetzt = JETZT, crossOrigin = false,
setzeUhr(datum) { versatz = new Date(datum).getTime() - Date.now() },
/** Portal-Funktion des Termin-Features (portalFeatures-Dokument). */
featureDoc(state = 'active') {
return { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'termine', state, enabled: state === 'active', siteKey: SITE_KEY, activatedAt: '2026-10-01T09:00:00.000Z' }
featureDoc(state = 'active', extra = {}) {
return { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'termine', state, enabled: state === 'active', siteKey: SITE_KEY, activatedAt: '2026-10-01T09:00:00.000Z', ...extra }
},
/** Zustand der Funktion aendern ('active' | 'disabled' | 'off'); der Kontext-Speicher des Servers wird geleert. */
async setzeFunktion(state) {
aw.seed('portalFeatures', srv.featureDoc(state))
/** Zustand der Funktion aendern ('active' | 'disabled' | 'off'); extra: weitere Felder des Dokuments (z. B. disabledReason, disabledAt). Der Kontext-Speicher des Servers wird geleert. */
async setzeFunktion(state, extra = {}) {
aw.seed('portalFeatures', srv.featureDoc(state, extra))
resetKontextForTests()
},