Öffnungszeiten: Team-Aktionen und Ersatz-Ticket beim Abschalten

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 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 02:39:43 +00:00
1 parent 11dc4b2c9f
commit 97e6971276
3 files changed
+780 -13

No files matched your search

+125 -13
View File
@@ -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:<kunde>:<projekt>` (dieselbe wie im Checkout)
* und ist fuer das Team idempotent; Neubau und Ticket laufen danach bewusst ohne Sperre. Hier wird `feature-admin:<projekt>` 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:<projekt>`; 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:<projekt>` 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)
}
})
+95
View File
@@ -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 - <Projekt>" 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:<projekt>`: 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 <div data-webklar-zeiten ...> 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'
}
}