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:
1 parent
d66dd60a76
commit
36b36bcc4d
7 files changed
+268
-26
No files matched your search
@@ -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
@@ -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.`),
|
||||
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
|
||||
Reference in new issue
Block a user