From 97e6971276ccee30543752bcb84a4b1892674416 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 02:39:43 +0000 Subject: [PATCH] =?UTF-8?q?=C3=96ffnungszeiten:=20Team-Aktionen=20und=20Er?= =?UTF-8?q?satz-Ticket=20beim=20Abschalten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Team-Route für zeiten: Aktivieren läuft über den Gratis-Pfad (einschaltenZeiten, setup, unlockedBy team, Neubau, Einbau-Ticket, keine K24), Abschalten setzt disabled mit Grund team ohne Löschfrist und legt über nachAbschaltung bei geändertem Wochenplan das Ticket "Ersatz-Tabelle aktualisieren" an. PATCH /:customerId/zeiten/team speichert die Team-Einstellungen mit teamVersion. Die Team-Ansicht liefert für zeiten zusätzlich plaene, team, teamVersion und hindernis. Co-Authored-By: Claude Opus 5.5 --- server/routes/admin/funktionenAdmin.js | 138 +++++- server/services/zeiten/abschalten.js | 95 +++++ test/zeitenTeam.test.js | 560 +++++++++++++++++++++++++ 3 files changed, 780 insertions(+), 13 deletions(-) create mode 100644 server/services/zeiten/abschalten.js create mode 100644 test/zeitenTeam.test.js diff --git a/server/routes/admin/funktionenAdmin.js b/server/routes/admin/funktionenAdmin.js index 4e4b4f4..42121ce 100644 --- a/server/routes/admin/funktionenAdmin.js +++ b/server/routes/admin/funktionenAdmin.js @@ -12,8 +12,12 @@ import { listCustomerFeatureDocs, listCustomerFeatures, } from '../../services/siteFeatures.js' +import { featureHaken } from '../../services/featureRegistry.js' +import { nachAbschaltung } from '../../services/zeiten/abschalten.js' +import { einschaltenZeiten } from '../../services/zeiten/einschalten.js' import { ersatzHtml } from '../../services/zeiten/ersatz.js' -import { ladeZeiten } from '../../services/zeiten/speicher.js' +import { normalisiereTeam } from '../../services/zeiten/pruefen.js' +import { ladeZeiten, speichereTeam } from '../../services/zeiten/speicher.js' import { hauptplan, planNachKurz } from '../../services/zeiten/zeitlogik.js' /** @@ -49,6 +53,29 @@ async function ticketExists(ticketId) { } } +/** + * Zusatzfelder der Zeiten fuer das WOMS-Panel (Spec 8): plaene [{ kurz, name, haupt }] (Hauptplan zuerst, sonst in gespeicherter + * Reihenfolge; Kurznamen fuer data-plan und "Ersatz-HTML kopieren"), team und teamVersion (Team-Einstellungen mit ihrer Version fuer + * PATCH .../zeiten/team) und hindernis: 'zeiten_fehlen', solange die Einrichtung (setup) auf Zeiten des Kunden wartet, sonst null. + * Gelesen wird nur, wo es Daten geben kann (setup, active, disabled); sonst Vorgaben. hindernis fragt den Register-Haken freischaltbar + * (dieselbe Regel wie der 5-Minuten-Job, fail-closed). Wirft bei Lesefehlern, wie der Rest der Ansicht. + */ +async function zeitenZusatz(doc) { + const mitDaten = doc && (doc.state === 'setup' || doc.state === 'active' || doc.state === 'disabled') + const gespeichert = mitDaten ? await ladeZeiten(doc.$id) : null + const plaene = (Array.isArray(gespeichert?.daten?.plaene) ? gespeichert.daten.plaene : []) + .filter((p) => p && typeof p.kurz === 'string' && p.kurz !== '') + .map((p) => ({ kurz: p.kurz, name: typeof p.name === 'string' ? p.name : '', haupt: p.haupt === true })) + const { freischaltbar } = featureHaken('zeiten') + const wartet = doc?.state === 'setup' && typeof freischaltbar === 'function' && (await freischaltbar(doc)) !== true + return { + plaene: [...plaene.filter((p) => p.haupt), ...plaene.filter((p) => !p.haupt)], + team: gespeichert?.team ?? normalisiereTeam({}).team, + teamVersion: gespeichert?.teamVersion ?? 0, + hindernis: wartet ? 'zeiten_fehlen' : null, + } +} + // Die Funktionsdokumente des Kunden werden einmal gelesen und fuer alle Projekte und Funktionen verwendet async function adminView(customerId) { const docs = await listCustomerFeatureDocs(customerId) @@ -59,6 +86,7 @@ async function adminView(customerId) { const doc = docs.find((d) => d.projectId === p.projectId && d.featureKey === key) view.adminNote = doc?.adminNote || '' view.ticketMissing = view.state === 'setup' && !(doc?.ticketId && (await ticketExists(doc.ticketId))) + if (key === 'zeiten') Object.assign(view, await zeitenZusatz(doc)) } } return projects @@ -101,7 +129,40 @@ funktionenAdminRouter.get('/:customerId/zeiten/ersatz', async (req, res) => { } }) -// Aktivieren (ohne Zahlung) und Abschalten je Funktion: /news wie bisher, /termine neu; jeder andere Schluessel -> 404 +/** + * Oeffnungszeiten live einschalten (Team, ohne Zahlung): derselbe Pfad wie der Gratisweg des Kunden (Spec 5), mit unlockedBy 'team' + * und ohne Zustimmungsnachweis und K24: Zustand setup, Neubau, Einbau-Ticket; das Ticket gibt spaeter frei. Die Notiz am Dokument + * entsteht wie bei den anderen Funktionen (Zeit, Name, Aktion, Text). + * Sperren: einschaltenZeiten nimmt fuer die Zustandsentscheidung selbst `feature-order::` (dieselbe wie im Checkout) + * und ist fuer das Team idempotent; Neubau und Ticket laufen danach bewusst ohne Sperre. Hier wird `feature-admin:` nicht + * gehalten: sie wuerde dem Team (und dem Abo-Abgleich) fuer die Dauer von Neubau und Ticket jede Aktion an diesem Projekt sperren, + * ohne etwas zu schuetzen, denn holeZeitenNach liest den Zustand frisch und laesst ein inzwischen abgeschaltetes Dokument unberuehrt. + */ +async function zeitenAktivieren({ customerId, projectId, by, note }) { + const bisher = await getFeatureDoc(customerId, projectId, 'zeiten') + const adminNote = composeAdminNote(bisher?.adminNote, { by, action: 'activate', note }) + await einschaltenZeiten({ customerId, projectId, by: 'team', staff: { name: by, adminNote } }) +} + +/** + * Oeffnungszeiten live abschalten: disabled mit Grund team, ohne Loeschfrist (loeschungAm bleibt null, die Daten bleiben). + * Zustand und Notiz unter `feature-admin:`; das Ersatz-Ticket (nachAbschaltung, Netzwerk, wirft nie) danach ausserhalb der + * Sperre. Es laeuft bei jedem Aufruf, auch bei einem Doppelklick: ein schon angelegtes Ticket merkt sich der Ersatz-Hash. + * @returns {Promise<'angelegt'|'aktuell'|'fehler'>} + */ +async function zeitenAbschalten({ customerId, projectId, by, note }) { + const doc = await withLocks([`feature-admin:${projectId}`], async () => { + const bisher = await getFeatureDoc(customerId, projectId, 'zeiten') + if (!bisher) { + throw Object.assign(new Error(`Für dieses Projekt gibt es noch ${FEATURE_LABELS.zeiten.keine}`), { status: 409 }) + } + const adminNote = composeAdminNote(bisher.adminNote, { by, action: 'disable', note }) + return disable(bisher.$id, { by, adminNote, reason: 'team' }) + }) + return nachAbschaltung(doc) +} + +// Aktivieren (ohne Zahlung) und Abschalten je Funktion: /news wie bisher, /termine neu, /zeiten ueber den Gratis-Pfad; jeder andere Schluessel -> 404 funktionenAdminRouter.post('/:customerId/:key', async (req, res) => { try { const { customerId, key } = req.params @@ -116,20 +177,71 @@ funktionenAdminRouter.post('/:customerId/:key', async (req, res) => { } await assertOwnProject(customerId, projectId) const by = adminName(req) - await withLocks([`feature-admin:${projectId}`], async () => { - const doc = await getFeatureDoc(customerId, projectId, key) - const adminNote = composeAdminNote(doc?.adminNote, { by, action, note }) - if (action === 'activate') { - await adminActivate(customerId, projectId, key, { by, adminNote }) - } else { - if (!doc) { - throw Object.assign(new Error(`Für dieses Projekt gibt es noch ${FEATURE_LABELS[key].keine}`), { status: 409 }) + let ersatzTicket + if (key === 'zeiten') { + if (action === 'activate') await zeitenAktivieren({ customerId, projectId, by, note }) + else ersatzTicket = await zeitenAbschalten({ customerId, projectId, by, note }) + } else { + await withLocks([`feature-admin:${projectId}`], async () => { + const doc = await getFeatureDoc(customerId, projectId, key) + const adminNote = composeAdminNote(doc?.adminNote, { by, action, note }) + if (action === 'activate') { + await adminActivate(customerId, projectId, key, { by, adminNote }) + } else { + if (!doc) { + throw Object.assign(new Error(`Für dieses Projekt gibt es noch ${FEATURE_LABELS[key].keine}`), { status: 409 }) + } + await disable(doc.$id, { by, adminNote }) } - await disable(doc.$id, { by, adminNote }) - } + }) + } + res.json({ + projects: (await adminView(customerId)).filter((p) => p.projectId === projectId), + // Ergebnis des Ersatz-Tickets beim Abschalten der Zeiten (angelegt | aktuell | fehler), damit ein Fehler im Panel sichtbar wird + ...(ersatzTicket ? { ersatzTicket } : {}), }) - res.json({ projects: (await adminView(customerId)).filter((p) => p.projectId === projectId) }) } catch (err) { sendError(res, err) } }) + +/** Antwort der Team-Einstellungen: Pruefergebnis (400 mit fehler je Feld) und Versionskonflikt (409 mit code) wie die Kundenroute. */ +function sendEinstellungenError(res, err) { + if (err?.status === 400 && err.fehler && typeof err.fehler === 'object') { + return res.status(400).json({ error: err.message, fehler: err.fehler }) + } + if (err?.status === 409 && err.code === 'ZEITEN_VERALTET') { + return res.status(409).json({ error: err.message, code: err.code }) + } + return sendError(res, err) +} + +/** + * Team-Einstellungen von Oeffnungszeiten live (Spec 5 und 8): { projectId, teamVersion, navSelektor, kontaktleisteAus, hinweisleisteAus } + * -> { team, teamVersion }. PATCH, weil das Ticketsystem nur GET/POST/PATCH/DELETE durchlaesst. Die Version ist die des Teams + * (teamVersion aus der Ansicht); eine andere oder fehlende ergibt 409 ZEITEN_VERALTET, ungueltige Werte 400 mit fehler. + * Die Sperre `zeiten:` nimmt speichereTeam selbst; der Zustand wird nicht veraendert, darum keine feature-admin-Sperre. + * Nicht fuer eine ausgeschaltete Funktion (off, z. B. nach der Loeschung der Daten): 409, damit kein verwaistes Dokument entsteht. + */ +funktionenAdminRouter.patch('/:customerId/zeiten/team', async (req, res) => { + try { + const { customerId } = req.params + const { projectId, teamVersion, navSelektor, kontaktleisteAus, hinweisleisteAus } = req.body || {} + if (typeof projectId !== 'string' || !projectId) return res.status(400).json({ error: 'Projekt fehlt' }) + await assertOwnProject(customerId, projectId) + const doc = await getFeatureDoc(customerId, projectId, 'zeiten') + if (!doc) return res.status(404).json({ error: `Für dieses Projekt gibt es noch ${FEATURE_LABELS.zeiten.keine}` }) + if (doc.state === 'off') { + return res.status(409).json({ error: `${FEATURE_LABELS.zeiten.name} ist für dieses Projekt nicht eingeschaltet.` }) + } + const gespeichert = await speichereTeam({ + feature: doc, + eingabe: { navSelektor, kontaktleisteAus, hinweisleisteAus }, + teamVersion, + by: `team:${req.staff?.appwriteUserId ?? 'unbekannt'}`, + }) + res.json(gespeichert) + } catch (err) { + sendEinstellungenError(res, err) + } +}) diff --git a/server/services/zeiten/abschalten.js b/server/services/zeiten/abschalten.js new file mode 100644 index 0000000..ecb69e6 --- /dev/null +++ b/server/services/zeiten/abschalten.js @@ -0,0 +1,95 @@ +import { config } from '../../config.js' +import { berlinDateTime } from '../berlinTime.js' +import { getDocument } from '../appwriteAdmin.js' +import { createFeatureTicket } from '../ticketAdmin.js' +import { ersatzHtml, wochenplanHash } from './ersatz.js' +import { ladeZeiten, setzeErsatzHash } from './speicher.js' +import { hauptplan } from './zeitlogik.js' + +/** + * Nach dem Abschalten von Oeffnungszeiten live (Spec 4.5): steht in den Platzhaltern der Kundenseite noch eine Ersatz-Tabelle, die + * nicht mehr zum aktuellen Wochenplan passt, bekommt das Team ein WOMS-Ticket "Ersatz-Tabelle aktualisieren - " mit dem + * aktuellen Ersatz-HTML (gehoert zum enthaltenen Einbau). Ohne Skript zeigt die Seite ab jetzt nur noch diese Tabelle. + * + * Aufrufer: die Team-Route (disable) und spaeter der Abo-Abgleich (Abo-Ende). Aufruf NACH dem Speichern des Zustands und ausserhalb + * jeder Sperre `feature-admin:`: das Ticket legt eine Netzwerkanfrage an, und Sperren sind nicht wiederbetretbar. + */ + +const THEMA = 'Ersatz-Tabelle aktualisieren' + +/** Text des Tickets: Kopfzeilen wie bei den Einbau-Tickets, Anlass, Aufgabe und das aktuelle Ersatz-HTML des Hauptplans. */ +function ticketDetails({ doc, project, daten, haupt, now }) { + const projektName = project?.projectName || project?.subdomain || '' + const domain = project?.liveDomain || project?.previewUrl || project?.subdomain || '' + return [ + 'Funktion: zeiten', + `Projekt: ${projektName}`, + `Domain: ${domain}`, + `siteKey: ${doc.siteKey ?? ''}`, + '', + `Öffnungszeiten live wurde am ${berlinDateTime(now).date} abgeschaltet. Der Wochenplan weicht von der Tabelle ab, die beim Einbau auf die Seite gesetzt wurde (oder der damalige Stand ist nicht bekannt).`, + 'Aufgabe: Auf der Kundenseite in jedem Platzhalter
die Tabelle durch die aktuelle ersetzen. Ohne Skript zeigt die Seite nur noch diese Tabelle.', + 'Weitere Pläne: WOMS → Funktionen → Ersatz-HTML kopieren.', + '', + haupt.kurz ? `Ersatz-HTML (Hauptplan ${haupt.kurz}):` : 'Ersatz-HTML (Hauptplan):', + ersatzHtml(daten, haupt), + ].join('\n') +} + +/** Das Projekt des Dokuments; ein geloeschtes Projekt (404) ist kein Fehler, der Name fehlt dann im Ticket. */ +async function ladeProjekt(projectId) { + try { + return await getDocument(config.collections.websiteProjects, projectId) + } catch (err) { + if (err?.status === 404) return null + throw err + } +} + +/** + * Pruefen, ob die Ersatz-Tabelle der Seite noch zum Wochenplan passt, und sonst das Ticket anlegen. + * Ruling R23: ein leerer oder fehlender gemerkter Hash (ersatzHash) gilt als "unbekannt", also wie geaendert, sobald Zeiten da sind + * (schlimmstenfalls ein unnoetiges Ticket). Nach dem Ticket wird der Hash auf den aktuellen Stand gesetzt: er gehoert dann zur Tabelle, + * die das Team einsetzt, und ein zweiter Aufruf (Doppelklick, spaeterer Lauf) legt kein zweites Ticket an. Scheitert das Ticket, + * bleibt der Hash, und der naechste Aufruf versucht es erneut. + * @param {{ $id: string, customerId?: string, projectId?: string, siteKey?: string }} doc portalFeatures-Dokument der Zeiten + * (aus disable(): der Stand nach dem Abschalten) + * @param {{ now?: Date }} [options] + * @returns {Promise<'angelegt'|'aktuell'|'fehler'>} angelegt: Ticket entstanden; aktuell: nichts zu tun (keine Zeiten oder Hash gleich); + * fehler: nicht pruefbar oder Ticket nicht angelegt (im Log ohne Kundendaten). Wirft nie. + */ +export async function nachAbschaltung(doc, { now = new Date() } = {}) { + try { + if (typeof doc?.$id !== 'string' || doc.$id === '') throw new TypeError('Dokument ohne $id') + const gespeichert = await ladeZeiten(doc.$id) + const daten = gespeichert?.daten ?? null + const haupt = hauptplan(daten) + // ohne Zeiten wurde nie eine Tabelle zum Ersetzen vorbereitet + if (!haupt) return 'aktuell' + const hash = wochenplanHash(daten) + if (gespeichert.ersatzHash !== '' && gespeichert.ersatzHash === hash) return 'aktuell' + + const project = await ladeProjekt(doc.projectId) + const projektName = project?.projectName || project?.subdomain || '' + await createFeatureTicket({ + customerId: doc.customerId, + project, + featureKey: 'zeiten', + siteKey: doc.siteKey, + topic: projektName ? `${THEMA} – ${projektName}` : THEMA, + details: ticketDetails({ doc, project, daten, haupt, now }), + }) + + // Das Ticket steht; scheitert nur das Merken, entsteht beim naechsten Abschalten ein zweites (Log, kein Fehler) + try { + await setzeErsatzHash(doc, hash) + } catch (err) { + console.error(`[zeiten] Ersatz-Hash für Projekt ${doc.projectId} nach dem Ticket nicht gemerkt: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim()) + } + return 'angelegt' + } catch (err) { + // nie Kundendaten ins Log: Art und Statuscode, keine Meldung (Appwrite-Meldungen koennen Feldinhalte nennen) + console.error(`[zeiten] Ersatz-Ticket nach dem Abschalten (Projekt ${doc?.projectId ?? '?'}): ${err?.name ?? 'Fehler'} ${err?.status ?? ''} ${err?.type ?? ''}`.replace(/\s+/g, ' ').trim()) + return 'fehler' + } +} diff --git a/test/zeitenTeam.test.js b/test/zeitenTeam.test.js new file mode 100644 index 0000000..04dcbac --- /dev/null +++ b/test/zeitenTeam.test.js @@ -0,0 +1,560 @@ +import { test, after, beforeEach, afterEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { mailSink } from './support/mailSink.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.js' + +// Team-Aktionen fuer Oeffnungszeiten live (Aufgabe 19, Spec 4.5, 5 und 8): POST /api/team/funktionen/:customerId/zeiten +// (activate ueber den Gratis-Pfad einschaltenZeiten, disable + nachAbschaltung), PATCH /:customerId/zeiten/team und die +// erweiterte Ansicht (plaene, team, teamVersion, hindernis) hinter requireStaffJwt. +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + oeffnungszeiten: schemaKeys('oeffnungszeiten'), + zeitenErinnerungen: schemaKeys('zeitenErinnerungen'), + }, + // Datumsfelder wie in Appwrite: Unlesbares oder '' waere dort ein 400 (leeren geht nur mit null) + datetimes: { + ...ZEITEN_DATETIMES, + portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'k24At'], + }, + defaultLimit: 25, +}) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) + +const { startTestApp, send, post } = await import('./support/testApp.js') +const { seedFixtures } = await import('./support/fixtures.js') +const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js') +const { withLocks } = await import('../server/services/keyedLock.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { zeitenDeps } = await import('../server/services/zeiten/neubau.js') +const { speichereZeiten, speichereTeam, setzeErsatzHash, ladeZeiten } = await import('../server/services/zeiten/speicher.js') +const { ersatzHtml, wochenplanHash } = await import('../server/services/zeiten/ersatz.js') +const { hauptplan } = await import('../server/services/zeiten/zeitlogik.js') +const { nachAbschaltung } = await import('../server/services/zeiten/abschalten.js') +const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js') +const { default: teamRoutes } = await import('../server/routes/team/index.js') + +const app = await startTestApp({ '/api/team': teamRoutes }) +after(() => Promise.all([app.close(), aw.close()])) +after(() => setMailTransportForTests(null)) +seedFixtures(aw) +aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' }) + +const STAFF = { headers: { Authorization: 'Bearer jwt-staff' } } +const JETZT = new Date('2026-10-05T08:00:00.000Z') +const SITE_KEY = 'abcdefghijklmnop' +const P1 = 'p_aaaaaaa1' +const P2 = 'p_aaaaaaa2' + +const projekt = (id, over = {}) => ({ $id: id, customerId: 'c1', projectName: id === 'p1' ? 'Salon Haarlinie' : `Projekt ${id}`, subdomain: `sub-${id}`, repoFullName: `JUSN/${id}`, previewMode: 'auto', previewDetected: 'static', ...over }) +const featureDoc = (projectId, over = {}) => ({ + $id: `f_${projectId}_zeiten`, + customerId: 'c1', + projectId, + featureKey: 'zeiten', + state: 'setup', + enabled: false, + unlockedBy: 'team', + unlockedByPurchase: false, + siteKey: SITE_KEY, + ...over, +}) +const zeitenDoc = (projectId = 'p1') => aw.docs('portalFeatures').get(`f_${projectId}_zeiten`) +const tickets = () => [...aw.docs('workorders').values()] + +function tage(woche = {}) { + const t = {} + for (let d = 1; d <= 7; d += 1) { + const w = woche[d] + t[d] = w ? { modus: 'zeiten', fenster: [w] } : { modus: 'zu', fenster: [] } + } + return t +} +const planDaten = (id, name, extra = {}) => ({ id, name, haupt: false, tage: tage({ 1: ['09:00', '18:00'] }), ...extra }) +const eingabe = (plaene) => ({ land: 'RP', plaene, feiertage: {}, ausnahmen: [], hinweise: [], kontakt: { an: false, telefon: '', adresse: '', whatsapp: null } }) +const EIN_PLAN = () => eingabe([planDaten(P1, 'Standard', { haupt: true })]) +const ZWEI_PLAENE = () => eingabe([planDaten(P1, 'Standard', { haupt: true }), planDaten(P2, 'Weihnachten', { tage: tage({ 2: ['10:00', '14:00'] }) })]) +/** Wie EIN_PLAN, aber mit anderen Oeffnungszeiten des Hauptplans (neue Version 1 noetig, wenn EIN_PLAN schon gespeichert ist). */ +const GEAENDERT = () => eingabe([planDaten(P1, 'Standard', { haupt: true, tage: tage({ 1: ['08:00', '12:00'], 5: ['08:00', '12:00'] }) })]) + +/** Speichert Zeiten wie der Kunde (die Normalisierung vergibt Kurznamen). */ +const mitZeiten = (doc, daten, version = 0) => speichereZeiten({ feature: doc, eingabe: daten, version, now: JETZT }) + +const aktion = (action, extra = {}, { key = 'zeiten', projectId = 'p1', opts = STAFF } = {}) => + post(`/api/team/funktionen/c1/${key}`, { projectId, action, ...extra }, opts) +const abschalten = (extra, opts) => aktion('disable', extra, opts) +const einstellungen = (body, opts = STAFF) => send('PATCH', '/api/team/funktionen/c1/zeiten/team', { ...opts, body }) +const ansicht = async () => (await send('GET', '/api/team/funktionen/c1', STAFF)).body.projects +const zeitenSicht = async (projectId = 'p1') => (await ansicht()).find((p) => p.projectId === projectId).features.zeiten + +let sink +let rebuilds +let logs +const originalRebuild = zeitenDeps.rebuild +beforeEach(async () => { + for (const col of ['portalFeatures', 'oeffnungszeiten', 'zeitenErinnerungen', 'workorders', 'websiteProjects']) { + for (const id of [...aw.docs(col).keys()]) await deleteDocument(col, id) + } + resetZeitenCacheForTests() + aw.seed('customers', { $id: 'c1', appwriteUserId: 'u1', email: 'k@example.invalid', name: 'Kunde Eins', status: 'active', portalAccessEnabled: true }) + aw.seed('websiteProjects', projekt('p1')) + aw.seed('websiteProjects', projekt('p2')) + aw.seed('websiteProjects', projekt('p3', { customerId: 'c2' })) + sink = mailSink() + setMailTransportForTests(sink) + rebuilds = [] + zeitenDeps.rebuild = async (p) => { + rebuilds.push({ projektId: p.$id, state: zeitenDoc(p.$id)?.state }) + return { queued: true } + } + logs = [] + const merke = (...args) => logs.push(args.map((a) => (a instanceof Error ? a.message : String(a))).join(' ')) + mock.method(console, 'error', merke) + mock.method(console, 'warn', merke) + mock.method(console, 'log', () => {}) +}) +afterEach(() => { + mock.restoreAll() + zeitenDeps.rebuild = originalRebuild +}) + +// ------------------------------------------------------------------------------------------------ Aktivieren + +test('Team aktiviert zeiten: setup, unlockedBy team, Neubau, Ticket, kein K24', async () => { + const res = await aktion('activate', { note: 'Pilot' }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.deepEqual(res.body.projects.map((p) => p.projectId), ['p1']) + const { zeiten, news, termine } = res.body.projects[0].features + assert.equal(zeiten.state, 'setup', 'nicht direkt active (das Einbau-Ticket gibt frei)') + assert.equal(zeiten.unlockedBy, 'team') + assert.equal(zeiten.einbau, 'angestossen') + assert.equal(typeof zeiten.ticketWoid, 'string') + assert.equal(zeiten.ticketMissing, false) + assert.match(zeiten.adminNote, /^\d{4}-\d\d-\d\dT[\d:.]+Z Andrej: activate – Pilot$/) + assert.equal(news.state, 'off') + assert.equal(termine.state, 'off') + + const doc = zeitenDoc() + assert.deepEqual([doc.featureKey, doc.state, doc.enabled, doc.unlockedBy, doc.unlockedByPurchase], ['zeiten', 'setup', false, 'team', false]) + assert.match(doc.siteKey, /^[a-z2-7]{16}$/) + assert.equal(doc.einbau, 'angestossen') + assert.ok(doc.ticketId) + assert.ok(!doc.k24At, 'keine K24 an den Kunden') + assert.equal(aw.docs('portalFeatures').size, 1, 'kein Dokument fuer News oder Terminplaner') + assert.equal(aw.docs('featureConsents').size, 0, 'kein Zustimmungsnachweis fuer das Team') + + // genau ein Neubau (nach dem Speichern des Zustands) und genau ein Einbau-Ticket + assert.deepEqual(rebuilds, [{ projektId: 'p1', state: 'setup' }]) + assert.equal(tickets().length, 1) + assert.equal(tickets()[0].type, 'Webpage') + assert.equal(tickets()[0].customerId, 'c1') + assert.match(tickets()[0].details, /data-wk-zeiten/) + assert.equal(sink.sent.length, 0, 'keine Mail') +}) + +test('Team aktiviert zeiten zweimal: idempotent, kein zweiter Neubau, kein zweites Ticket, keine zweite Notiz', async () => { + const erste = await aktion('activate', { note: 'Pilot' }) + const notiz = erste.body.projects[0].features.zeiten.adminNote + const zweite = await aktion('activate', { note: 'nochmal' }) + assert.equal(zweite.status, 200, JSON.stringify(zweite.body)) + assert.equal(zweite.body.projects[0].features.zeiten.state, 'setup') + assert.equal(zweite.body.projects[0].features.zeiten.adminNote, notiz) + assert.equal(rebuilds.length, 1) + assert.equal(tickets().length, 1) + assert.equal(sink.sent.length, 0) +}) + +test('Team aktiviert eine abgeschaltete Funktion wieder: setup, Abschaltgrund geräumt, vorhandenes Ticket bleibt', async () => { + aw.seed('workorders', { $id: 't_alt', woid: '10001', topic: 'alt', status: 'Closed', customerId: 'c1' }) + aw.seed('portalFeatures', featureDoc('p1', { state: 'disabled', disabledAt: '2026-09-01T10:00:00.000Z', disabledReason: 'team', ticketId: 't_alt', einbau: 'angestossen', adminNote: 'alte Notiz' })) + const res = await aktion('activate') + assert.equal(res.status, 200, JSON.stringify(res.body)) + const doc = zeitenDoc() + assert.deepEqual([doc.state, doc.disabledAt ?? null, doc.disabledReason ?? null, doc.unlockedBy], ['setup', null, null, 'team']) + assert.equal(doc.ticketId, 't_alt', 'kein zweites Ticket') + assert.equal(tickets().length, 1) + assert.deepEqual(rebuilds, [{ projektId: 'p1', state: 'setup' }], 'der Neubau setzt einbau neu') + assert.match(doc.adminNote, / Andrej: activate \| alte Notiz$/) +}) + +test('Aktivieren: fremdes oder unbekanntes Projekt 403, unbekannte Aktion 400, ohne Anmeldung 401, nichts entsteht', async () => { + assert.equal((await aktion('activate', {}, { projectId: 'p3' })).status, 403) + assert.equal((await aktion('activate', {}, { projectId: 'gibtsnicht' })).status, 403) + assert.equal((await aktion('activate', {}, { opts: {} })).status, 401) + assert.equal((await aktion('einschalten')).status, 400) + assert.equal((await post('/api/team/funktionen/c1/zeiten', { action: 'activate' }, STAFF)).status, 400) + assert.equal(aw.docs('portalFeatures').size, 0) + assert.equal(tickets().length, 0) + assert.deepEqual(rebuilds, []) +}) + +test('Aktivieren: Ticket nicht anlegbar ⇒ 503, Zustand setup gespeichert, ein zweiter Klick holt nach', async () => { + aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Appwrite kaputt' }) + const kaputt = await aktion('activate') + assert.equal(kaputt.status, 503, JSON.stringify(kaputt.body)) + assert.equal(zeitenDoc().state, 'setup') + assert.ok(!zeitenDoc().ticketId) + assert.equal(tickets().length, 0) + + const heil = await aktion('activate') + assert.equal(heil.status, 200, JSON.stringify(heil.body)) + assert.ok(zeitenDoc().ticketId) + assert.equal(tickets().length, 1) + assert.equal(rebuilds.length, 1, 'der Neubau war schon angestossen') +}) + +test('Aktivieren: Neubau und Ticket laufen ohne die Sperre feature-admin (sie blockiert das Team nicht)', async () => { + let frei = null + zeitenDeps.rebuild = async () => { + const start = Date.now() + // waitMs: haengt die Sperre, laeuft der Aufruf erst nach waitMs los; ist sie frei, sofort + await withLocks(['feature-admin:p1'], async () => {}, { waitMs: 400 }) + frei = Date.now() - start < 250 + return { queued: true } + } + assert.equal((await aktion('activate')).status, 200) + assert.equal(frei, true) +}) + +test('News und Terminplaner laufen weiter über den alten Pfad (active, kein Ticket)', async () => { + const res = await aktion('activate', { note: 'Kulanz' }, { key: 'news' }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal(res.body.projects[0].features.news.state, 'active') + assert.equal(res.body.projects[0].features.zeiten.state, 'off') + assert.equal(tickets().length, 0) + assert.deepEqual(rebuilds, []) +}) + +// ------------------------------------------------------------------------------------------------ Einstellungen + +test('Team-Einstellungen mit Version: gespeichert, Kundendaten unberührt, zweiter Versuch mit alter Version 409', async () => { + const doc = featureDoc('p1', { state: 'active', enabled: true }) + aw.seed('portalFeatures', doc) + const gespeichert = await mitZeiten(doc, EIN_PLAN()) + const body = { projectId: 'p1', teamVersion: 0, navSelektor: 'header.site-header', kontaktleisteAus: true, hinweisleisteAus: false } + + const res = await einstellungen(body) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.deepEqual(res.body, { team: { navSelektor: 'header.site-header', kontaktleisteAus: true, hinweisleisteAus: false }, teamVersion: 1 }) + + const nachher = await ladeZeiten(doc.$id) + assert.deepEqual(nachher.team, res.body.team) + assert.equal(nachher.teamVersion, 1) + assert.deepEqual([nachher.version, nachher.daten], [gespeichert.version, gespeichert.daten], 'Daten und Version des Kunden bleiben') + assert.equal(aw.docs('oeffnungszeiten').get(doc.$id).updatedBy, 'team:u_staff2') + + // mit der alten Version: 409, nichts geaendert + const veraltet = await einstellungen({ ...body, navSelektor: 'nav', kontaktleisteAus: false }) + assert.equal(veraltet.status, 409) + assert.equal(veraltet.body.code, 'ZEITEN_VERALTET') + assert.equal(typeof veraltet.body.error, 'string') + assert.deepEqual((await ladeZeiten(doc.$id)).team, res.body.team) + // ohne Version gilt dasselbe (nie blind schreiben) + const { teamVersion: _weg, ...ohneVersion } = body + assert.equal((await einstellungen(ohneVersion)).status, 409) + + // mit der neuen Version geht es weiter; leerer Selektor loescht ihn + const weiter = await einstellungen({ ...body, teamVersion: 1, navSelektor: '' }) + assert.equal(weiter.status, 200) + assert.deepEqual(weiter.body, { team: { navSelektor: null, kontaktleisteAus: true, hinweisleisteAus: false }, teamVersion: 2 }) +}) + +test('Team-Einstellungen: ungültiger Selektor ⇒ 400 mit Feldfehler, nichts gespeichert', async () => { + const doc = featureDoc('p1') + aw.seed('portalFeatures', doc) + for (const navSelektor of ['', 'a'.repeat(121), 'div{color:red}', 42]) { + const res = await einstellungen({ projectId: 'p1', teamVersion: 0, navSelektor, kontaktleisteAus: false, hinweisleisteAus: false }) + assert.equal(res.status, 400, String(navSelektor)) + assert.equal(typeof res.body.error, 'string') + assert.equal(typeof res.body.fehler?.navSelektor, 'string', String(navSelektor)) + } + const res = await einstellungen({ projectId: 'p1', teamVersion: 0, navSelektor: 'header', kontaktleisteAus: 'ja', hinweisleisteAus: false }) + assert.equal(res.status, 400) + assert.equal(typeof res.body.fehler?.kontaktleisteAus, 'string') + assert.equal(await ladeZeiten(doc.$id), null, 'kein Dokument angelegt') + // ein gueltiger Aufruf nach den Fehlern hat noch Version 0 + assert.equal((await einstellungen({ projectId: 'p1', teamVersion: 0, navSelektor: 'header' })).status, 200) +}) + +test('Team-Einstellungen: Anmeldung, Projekt, Funktion und Zustand werden geprüft', async () => { + const body = { projectId: 'p1', teamVersion: 0, navSelektor: 'header' } + assert.equal((await einstellungen(body, {})).status, 401) + assert.equal((await einstellungen({ ...body, projectId: undefined })).status, 400) + assert.equal((await einstellungen({ ...body, projectId: 'p3' })).status, 403) + assert.equal((await einstellungen({ ...body, projectId: 'gibtsnicht' })).status, 403) + // eigenes Projekt ohne Funktionsdokument + const keine = await einstellungen(body) + assert.equal(keine.status, 404) + assert.match(keine.body.error, /Öffnungszeiten live/) + // Funktion aus (zum Beispiel nach der Datenloeschung): keine verwaisten Einstellungen + aw.seed('portalFeatures', featureDoc('p1', { state: 'off' })) + const aus = await einstellungen(body) + assert.equal(aus.status, 409) + assert.equal(aw.docs('oeffnungszeiten').size, 0) + // abgeschaltet: Einstellungen duerfen noch bearbeitet werden (die Daten sind da) + await updateDocument('portalFeatures', 'f_p1_zeiten', { state: 'disabled', disabledReason: 'team', disabledAt: JETZT.toISOString() }) + assert.equal((await einstellungen(body)).status, 200) +}) + +// ------------------------------------------------------------------------------------------------ Abschalten + +test('Abschalten durch Team: disabled, Grund team, keine Löschfrist, Daten bleiben, keine Mail', async () => { + const doc = featureDoc('p1', { state: 'active', enabled: true, activatedAt: JETZT.toISOString() }) + aw.seed('portalFeatures', doc) + await mitZeiten(doc, EIN_PLAN()) + const vorher = await ladeZeiten(doc.$id) + await setzeErsatzHash(doc, wochenplanHash(vorher.daten)) + + const res = await abschalten({ note: 'Kunde wünscht' }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + const sicht = res.body.projects[0].features.zeiten + assert.equal(sicht.state, 'disabled') + assert.equal(sicht.disabledReason, 'team') + assert.ok(sicht.disabledAt) + assert.equal(sicht.loeschungAm, null, 'bei Grund team gibt es keine Loeschfrist') + assert.match(sicht.adminNote, /Andrej: disable – Kunde wünscht$/) + + const nach = zeitenDoc() + assert.deepEqual([nach.state, nach.enabled, nach.disabledReason], ['disabled', false, 'team']) + assert.deepEqual((await ladeZeiten(doc.$id)).daten, vorher.daten, 'die Zeiten bleiben') + assert.equal(sink.sent.length, 0) + assert.equal(res.body.ersatzTicket, 'aktuell') + assert.equal(tickets().length, 0) +}) + +test('Abschalten ohne Funktionsdokument: 409; zweites Abschalten verschiebt disabledAt nicht', async () => { + const keine = await abschalten() + assert.equal(keine.status, 409) + assert.match(keine.body.error, /Öffnungszeiten live/) + assert.equal(aw.docs('portalFeatures').size, 0) + + const doc = featureDoc('p1', { state: 'active', enabled: true }) + aw.seed('portalFeatures', doc) + assert.equal((await abschalten()).status, 200) + const erstes = zeitenDoc().disabledAt + assert.ok(erstes) + assert.equal((await abschalten()).status, 200) + assert.equal(zeitenDoc().disabledAt, erstes) + assert.equal(zeitenDoc().disabledReason, 'team') +}) + +test('Abschalten mit geändertem Wochenplan ⇒ Ersatz-Ticket mit aktuellem Ersatz-HTML', async () => { + const doc = featureDoc('p1', { state: 'active', enabled: true }) + aw.seed('portalFeatures', doc) + const erst = await mitZeiten(doc, EIN_PLAN()) + await setzeErsatzHash(doc, wochenplanHash(erst.daten)) + // der Kunde aendert nach dem Einbau den Wochenplan + const jetzt = await mitZeiten(doc, GEAENDERT(), erst.version) + assert.notEqual(wochenplanHash(jetzt.daten), wochenplanHash(erst.daten)) + + const res = await abschalten({ note: 'Abo gekündigt' }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal(res.body.ersatzTicket, 'angelegt') + assert.equal(res.body.projects[0].features.zeiten.state, 'disabled') + + assert.equal(tickets().length, 1) + const [ticket] = tickets() + assert.equal(ticket.topic, 'Ersatz-Tabelle aktualisieren – Salon Haarlinie') + assert.equal(ticket.type, 'Webpage') + assert.equal(ticket.status, 'Open') + assert.equal(ticket.customerId, 'c1') + assert.ok(ticket.details.includes(ersatzHtml(jetzt.daten, hauptplan(jetzt.daten))), 'aktuelles Ersatz-HTML im Ticket') + assert.ok(!ticket.details.includes(ersatzHtml(erst.daten, hauptplan(erst.daten))), 'nicht das alte') + assert.match(ticket.details, /Salon Haarlinie/) + assert.match(ticket.details, new RegExp(`siteKey: ${SITE_KEY}`)) + assert.match(ticket.details, /data-webklar-zeiten/) + assert.doesNotMatch(ticket.details, /24:00|24 Uhr/) + + // ein zweiter Klick auf Abschalten legt kein zweites Ticket an (der Hash ist jetzt der des Tickets) + assert.equal((await ladeZeiten(doc.$id)).ersatzHash, wochenplanHash(jetzt.daten)) + const nochmal = await abschalten() + assert.equal(nochmal.status, 200) + assert.equal(nochmal.body.ersatzTicket, 'aktuell') + assert.equal(tickets().length, 1) +}) + +test('Abschalten: das Ersatz-Ticket entsteht ohne die Sperre feature-admin (sie blockiert das Team nicht)', async () => { + const doc = featureDoc('p1', { state: 'active', enabled: true }) + aw.seed('portalFeatures', doc) + const erst = await mitZeiten(doc, EIN_PLAN()) + await setzeErsatzHash(doc, wochenplanHash(erst.daten)) + await mitZeiten(doc, GEAENDERT(), erst.version) + + const echt = globalThis.fetch + let frei = null + mock.method(globalThis, 'fetch', async (url, init) => { + if (init?.method === 'POST' && String(url).includes('/collections/workorders/documents') && frei === null) { + const start = Date.now() + // waitMs: haengt die Sperre, laeuft der Aufruf erst nach waitMs los; ist sie frei, sofort + await withLocks(['feature-admin:p1'], async () => {}, { waitMs: 400 }) + frei = Date.now() - start < 250 + } + return echt(url, init) + }) + const res = await abschalten() + assert.equal(res.body.ersatzTicket, 'angelegt', JSON.stringify(res.body)) + assert.equal(frei, true) +}) + +test('Abschalten ohne Änderung des Wochenplans ⇒ kein Ersatz-Ticket', async () => { + const doc = featureDoc('p1', { state: 'active', enabled: true }) + aw.seed('portalFeatures', doc) + const erst = await mitZeiten(doc, EIN_PLAN()) + await setzeErsatzHash(doc, wochenplanHash(erst.daten)) + // Name und Ausnahmen aendern den Hash nicht (nur tage des Hauptplans) + const mitAusnahme = EIN_PLAN() + mitAusnahme.plaene[0].name = 'Hauptzeiten' + mitAusnahme.ausnahmen = [{ id: 'x_urlaub01', art: 'urlaub', von: '2026-12-21', bis: '2026-12-27', text: 'Weihnachtsurlaub', modus: 'zu', fenster: [], plan: null }] + await mitZeiten(doc, mitAusnahme, erst.version) + + const res = await abschalten() + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal(res.body.ersatzTicket, 'aktuell') + assert.equal(tickets().length, 0) +}) + +test('Abschalten ohne gemerkten Ersatz-Hash, aber mit Zeiten ⇒ Ersatz-Ticket (R23: unbekannt gilt als geändert)', async () => { + const doc = featureDoc('p1') + aw.seed('portalFeatures', doc) + await mitZeiten(doc, EIN_PLAN()) + assert.equal((await ladeZeiten(doc.$id)).ersatzHash, '') + const res = await abschalten() + assert.equal(res.body.ersatzTicket, 'angelegt') + assert.equal(tickets().length, 1) +}) + +test('Abschalten: Ticket nicht anlegbar ⇒ trotzdem disabled, ersatzTicket fehler, Hash bleibt, nächster Klick versucht es erneut', async () => { + const doc = featureDoc('p1', { state: 'active', enabled: true }) + aw.seed('portalFeatures', doc) + const erst = await mitZeiten(doc, EIN_PLAN()) + await setzeErsatzHash(doc, wochenplanHash(erst.daten)) + await mitZeiten(doc, GEAENDERT(), erst.version) + const alterHash = wochenplanHash(erst.daten) + + aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Appwrite kaputt' }) + const kaputt = await abschalten() + assert.equal(kaputt.status, 200, JSON.stringify(kaputt.body)) + assert.equal(kaputt.body.ersatzTicket, 'fehler') + assert.equal(kaputt.body.projects[0].features.zeiten.state, 'disabled') + assert.equal(tickets().length, 0) + assert.equal((await ladeZeiten(doc.$id)).ersatzHash, alterHash, 'Hash unveraendert: der naechste Versuch legt das Ticket an') + assert.ok(logs.some((zeile) => zeile.includes('Ersatz')), 'steht im Log') + assert.ok(logs.every((zeile) => !/Salon Haarlinie|Standard|Kunde Eins|k@example/.test(zeile)), 'keine Kundendaten im Log') + + const heil = await abschalten() + assert.equal(heil.body.ersatzTicket, 'angelegt') + assert.equal(tickets().length, 1) +}) + +// ------------------------------------------------------------------------------------------------ nachAbschaltung + +test('nachAbschaltung: keine Zeiten (kein Dokument, nur Team, nur Nebenplan) ⇒ aktuell, kein Ticket', async () => { + const doc = featureDoc('p1') + aw.seed('portalFeatures', doc) + assert.equal(await nachAbschaltung(doc, { now: JETZT }), 'aktuell', 'ohne oeffnungszeiten-Dokument') + await speichereTeam({ feature: doc, eingabe: { navSelektor: 'header' }, teamVersion: 0, by: 'team:u_staff2' }) + assert.equal(await nachAbschaltung(doc, { now: JETZT }), 'aktuell', 'nur Team-Einstellungen') + const neben = featureDoc('p2') + aw.seed('portalFeatures', neben) + aw.seed('oeffnungszeiten', { + $id: neben.$id, customerId: 'c1', projectId: 'p2', featureId: neben.$id, version: 1, teamVersion: 0, + daten: JSON.stringify({ land: 'RP', plaene: [{ id: P2, kurz: 'nebenan', name: 'Nebenplan', haupt: false, tage: tage({ 1: ['09:00', '18:00'] }) }] }), + }) + assert.equal(await nachAbschaltung(neben, { now: JETZT }), 'aktuell', 'nur ein Nebenplan') + assert.equal(tickets().length, 0) +}) + +test('nachAbschaltung: wirft nie (Lesefehler, kaputte Eingabe) und liefert fehler', async () => { + const doc = featureDoc('p1') + aw.seed('portalFeatures', doc) + await mitZeiten(doc, EIN_PLAN()) + aw.failNext({ collection: 'oeffnungszeiten', method: 'GET', status: 500, message: 'Lesen kaputt' }) + assert.equal(await nachAbschaltung(doc, { now: JETZT }), 'fehler') + assert.equal(await nachAbschaltung(null, { now: JETZT }), 'fehler') + assert.equal(await nachAbschaltung({}, { now: JETZT }), 'fehler') + assert.equal(await nachAbschaltung(undefined), 'fehler') + assert.equal(tickets().length, 0) + assert.ok(logs.every((zeile) => !/Standard|Salon Haarlinie/.test(zeile))) +}) + +test('nachAbschaltung: Hash gleich ⇒ aktuell, Hash fehlt oder weicht ab ⇒ angelegt (einmal je Wochenplan-Stand)', async () => { + const doc = featureDoc('p1') + aw.seed('portalFeatures', doc) + const erst = await mitZeiten(doc, EIN_PLAN()) + await setzeErsatzHash(doc, wochenplanHash(erst.daten)) + assert.equal(await nachAbschaltung(doc, { now: JETZT }), 'aktuell') + await setzeErsatzHash(doc, 'ffffffffffffffff') + assert.equal(await nachAbschaltung(doc, { now: JETZT }), 'angelegt') + assert.equal(await nachAbschaltung(doc, { now: JETZT }), 'aktuell', 'der Hash ist jetzt der des Tickets') + assert.equal(tickets().length, 1) +}) + +test('nachAbschaltung: Projekt nicht mehr da ⇒ Ticket ohne Projektnamen, kein Fehler', async () => { + const doc = featureDoc('p1') + aw.seed('portalFeatures', doc) + await mitZeiten(doc, EIN_PLAN()) + await deleteDocument('websiteProjects', 'p1') + assert.equal(await nachAbschaltung(doc, { now: JETZT }), 'angelegt') + assert.equal(tickets()[0].topic, 'Ersatz-Tabelle aktualisieren') +}) + +// ------------------------------------------------------------------------------------------------ Ansicht + +test('adminView zeigt Pläne, team und hindernis (nur für zeiten)', async () => { + // p1: eingerichtet mit zwei Plaenen und Team-Einstellungen, p2: setup ohne Zeiten + const eins = featureDoc('p1') + aw.seed('portalFeatures', eins) + await mitZeiten(eins, ZWEI_PLAENE()) + await speichereTeam({ feature: eins, eingabe: { navSelektor: 'header', kontaktleisteAus: true }, teamVersion: 0, by: 'team:u_staff2' }) + aw.seed('portalFeatures', featureDoc('p2', { siteKey: 'bbbbbbbbbbbbbbbb' })) + + const projekte = await ansicht() + const z1 = projekte.find((p) => p.projectId === 'p1').features.zeiten + assert.deepEqual(z1.plaene, [ + { kurz: 'standard', name: 'Standard', haupt: true }, + { kurz: 'weihnachten', name: 'Weihnachten', haupt: false }, + ]) + assert.deepEqual(z1.team, { navSelektor: 'header', kontaktleisteAus: true, hinweisleisteAus: false }) + assert.equal(z1.teamVersion, 1) + assert.equal(z1.hindernis, null) + + const z2 = projekte.find((p) => p.projectId === 'p2').features.zeiten + assert.deepEqual(z2.plaene, []) + assert.deepEqual(z2.team, { navSelektor: null, kontaktleisteAus: false, hinweisleisteAus: false }) + assert.equal(z2.teamVersion, 0) + assert.equal(z2.hindernis, 'zeiten_fehlen') + + // andere Funktionen bekommen die Felder nicht + for (const key of ['news', 'termine']) { + for (const feld of ['plaene', 'team', 'teamVersion', 'hindernis']) assert.equal(feld in projekte[0].features[key], false, `${key}.${feld}`) + } +}) + +test('adminView: hindernis nur in setup und nur wenn der Haken freischaltbar nicht erfüllt ist; Hauptplan steht zuerst', async () => { + // Hauptplan nicht an erster Stelle gespeichert + const nebenZuerst = featureDoc('p1') + aw.seed('portalFeatures', nebenZuerst) + await mitZeiten(nebenZuerst, eingabe([planDaten(P2, 'Weihnachten'), planDaten(P1, 'Standard', { haupt: true })])) + assert.deepEqual((await zeitenSicht('p1')).plaene.map((p) => p.kurz), ['standard', 'weihnachten']) + assert.equal((await zeitenSicht('p1')).hindernis, null) + + // alle Tage zu: bleibt in setup haengen + await deleteDocument('oeffnungszeiten', nebenZuerst.$id) + await mitZeiten(nebenZuerst, eingabe([planDaten(P1, 'Standard', { haupt: true, tage: tage() })])) + assert.equal((await zeitenSicht('p1')).hindernis, 'zeiten_fehlen') + + // gleicher Zustand, aber aktiv oder abgeschaltet: kein Hindernis (es gibt nichts mehr freizuschalten) + await updateDocument('portalFeatures', nebenZuerst.$id, { state: 'active', enabled: true }) + assert.equal((await zeitenSicht('p1')).hindernis, null) + await updateDocument('portalFeatures', nebenZuerst.$id, { state: 'disabled', enabled: false, disabledReason: 'team', disabledAt: JETZT.toISOString() }) + assert.equal((await zeitenSicht('p1')).hindernis, null) +}) + +test('adminView ohne Funktionsdokument: leere Pläne, Standard-Team, Version 0, kein Hindernis', async () => { + const z = await zeitenSicht('p1') + assert.equal(z.state, 'off') + assert.deepEqual([z.plaene, z.team, z.teamVersion, z.hindernis], [[], { navSelektor: null, kontaktleisteAus: false, hinweisleisteAus: false }, 0, null]) +})