Files
Webklar-Kundenbereich/server/routes/admin/funktionenAdmin.js
T
ClaudeandClaude Opus 5.5 97e6971276 Ö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>
2026-10-05 02:39:43 +00:00

248 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Router } from 'express'
import { config } from '../../config.js'
import { getDocument } from '../../services/appwriteAdmin.js'
import { withLocks } from '../../services/keyedLock.js'
import {
FEATURE_KEYS,
FEATURE_LABELS,
adminActivate,
assertOwnProject,
disable,
getFeatureDoc,
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 { normalisiereTeam } from '../../services/zeiten/pruefen.js'
import { ladeZeiten, speichereTeam } from '../../services/zeiten/speicher.js'
import { hauptplan, planNachKurz } from '../../services/zeiten/zeitlogik.js'
/**
* Funktionen (News-Pinnwand, Terminplaner) je Kundenprojekt im Ticketsystem (Kunden-Reiter "Funktionen").
* Eingehaengt in routes/team/index.js unter /api/team/funktionen, dort hinter requireStaffJwt.
*/
export const funktionenAdminRouter = Router()
const NOTE_MAX = 300
const adminName = (req) => req.session.adminName || req.session.name || 'Admin'
function sendError(res, err) {
const status = err.status >= 400 && err.status < 600 ? err.status : 500
if (status >= 500) console.error('[funktionen-admin]', err.message)
return res.status(status).json({ error: err.message || 'Interner Serverfehler' })
}
/** Neuester Eintrag zuerst; aeltere Eintraege fallen vorn heraus, sobald 300 Zeichen voll sind. */
export function composeAdminNote(previous, { by, action, note }, now = new Date()) {
const entry = `${now.toISOString()} ${by}: ${action}${note ? ` – ${note}` : ''}`.slice(0, NOTE_MAX)
const older = String(previous || '')
return older ? `${entry} | ${older}`.slice(0, NOTE_MAX) : entry
}
async function ticketExists(ticketId) {
if (!ticketId) return false
try {
await getDocument(config.collections.workorders, ticketId)
return true
} catch (err) {
if (err.status === 404) return false
throw err
}
}
/**
* 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)
const projects = await listCustomerFeatures(customerId, { docs })
for (const p of projects) {
for (const key of FEATURE_KEYS) {
const view = p.features[key]
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
}
funktionenAdminRouter.get('/:customerId', async (req, res) => {
try {
res.json({ projects: await adminView(req.params.customerId) })
} catch (err) {
sendError(res, err)
}
})
/**
* Ersatz-HTML (statische Wochentabelle) eines Plans von Oeffnungszeiten live fuer den Einbau (Spec 4.5): { html }.
* Ohne Parameter plan gilt der Hauptplan; ist plan angegeben (auch leer), muss es diesen Kurznamen geben, sonst 404 und
* kein Rueckfall auf den Hauptplan (Ruling R9). Noch keine gespeicherten Zeiten -> 409. Nur lesend, ohne Sperre.
*/
funktionenAdminRouter.get('/:customerId/zeiten/ersatz', async (req, res) => {
try {
const { customerId } = req.params
const { projectId, plan } = req.query
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}` })
const daten = (await ladeZeiten(doc.$id))?.daten
const keineZeiten = () => res.status(409).json({ error: 'Der Kunde hat noch keine Zeiten eingetragen.' })
if (!Array.isArray(daten?.plaene) || daten.plaene.length === 0) return keineZeiten()
if (plan !== undefined) {
const gewaehlt = planNachKurz(daten, plan)
if (!gewaehlt) return res.status(404).json({ error: 'Plan nicht gefunden' })
return res.json({ html: ersatzHtml(daten, gewaehlt) })
}
const haupt = hauptplan(daten)
if (!haupt) return keineZeiten()
res.json({ html: ersatzHtml(daten, haupt) })
} catch (err) {
sendError(res, err)
}
})
/**
* 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
if (!FEATURE_KEYS.includes(key)) return res.status(404).json({ error: 'Unbekannte Funktion' })
const { projectId, action } = req.body || {}
const note = typeof req.body?.note === 'string' ? req.body.note.trim().slice(0, 120) : ''
if (action !== 'activate' && action !== 'disable') {
return res.status(400).json({ error: 'Unbekannte Aktion' })
}
if (typeof projectId !== 'string' || !projectId) {
return res.status(400).json({ error: 'Projekt fehlt' })
}
await assertOwnProject(customerId, projectId)
const by = adminName(req)
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 })
}
})
}
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 } : {}),
})
} 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)
}
})