Files
Webklar-Kundenbereich/server/routes/admin/funktionenAdmin.js
T
ClaudeandClaude Opus 5.5 e87140e5ba Merge remote-tracking branch 'origin/main' into feature/oeffnungszeiten
Zusammenführung von TP2-Abschluss und Webshop (origin/main cab9c33) mit Öffnungszeiten live. Beide
Funktionen bleiben vollständig: jeder Konflikt ist als Vereinigung gelöst, Reihenfolge der Funktionsschlüssel
überall news, termine, shop, zeiten.

Konfliktdateien (28): public/app.js, dashboard.html, funktionen.js, widerrufen.html; scripts/stripe-products.mjs;
server/config.js, index.js, middleware/kundenansichtGuard.js, routes/abo.js, routes/admin/funktionenAdmin.js,
routes/funktionen.js, services/customerAdmin.js, customerViewLog.js, featureRegistry.js, retention.js,
siteFeatures.js, stripeBilling.js; test/customerViewLog, featureFreischaltung, featureRegistry, funktionenRoutes,
funktionenTermine, kundenansichtGuard, mailTemplates, publicForms, siteFeatures, stripeProductsScript (je
.test.js) und test/support/schema.js.

Entscheidungen: Mailvorlagen G01–G15, K01–K30, T01–T21 lückenlos (K24–K26 Webshop, K27–K30 Öffnungszeiten);
Stripe-Katalog mit shopSetup und zeitenSetup; Widerruf kennt shop und zeiten; ABO_FUNKTIONEN bleibt analyse
und termine (kein zeiten); activate() schreibt die Zeiten-Felder nur über extra; kein Halter von
feature-activate:<id> ruft disable() auf; die Aufbewahrungs-Löschung löscht Zeiten-Daten vor dem Abschalten und
hält den Kunden bei einem Fehler (held); die Admin-Antwort trägt ersatzTicket und warnung; Hash-Routen webshop
und zeiten, Ansichten view-webshop und view-zeiten; assertOwnProject behält die Meldung von main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 06:12:23 +00:00

430 lines
23 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 { aboMemo } from '../../services/aboStatus.js'
import { validateTextform } from '../../services/analyse.js'
import { getDocument, updateDocument } from '../../services/appwriteAdmin.js'
import { featureConfig, featureHaken } from '../../services/featureRegistry.js'
import { withLocks } from '../../services/keyedLock.js'
import { featureLegalAttachments, featureLegalPaths } from '../../services/legalDocs.js'
import {
FEATURE_KEYS,
FEATURE_LABELS,
TEXTFORM_KEYS,
adminActivate,
assertOwnProject,
customerOf,
disable,
freischaltenNachAbnahme,
getFeatureDoc,
listCustomerFeatureDocs,
listCustomerFeatures,
textformFassung,
ticketNachlegen,
} from '../../services/siteFeatures.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'
import { driftWarnung, einbauanleitung, ticketStand } from '../../services/termine/einbauanleitung.js'
import { freischaltHindernis, meldeFreigeschaltet } from '../../services/termine/freischaltung.js'
import { ladeStand } from '../../services/termine/kontext.js'
import { shopTextformNoetig } from '../../services/shop/schalter.js'
import { termineTextformNoetig } from '../../services/termine/schalter.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
}
/** Das Ticket oder null (keine ticketId, Ticket geloescht); jeder andere Lesefehler wird weitergereicht. */
async function readTicket(ticketId) {
if (!ticketId) return null
try {
return await getDocument(config.collections.workorders, ticketId)
} catch (err) {
if (err.status === 404) return null
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,
}
}
/** Name des Kunden fuer die Anleitung; wie im Ticket (termineTicketDetails) die Kunden-ID, wenn er fehlt oder nicht lesbar ist (nur Kosmetik, kein Fehler). */
async function customerName(customerId) {
try {
return (await customerOf(customerId))?.name || customerId
} catch (err) {
console.error(`[funktionen-admin] Kunde ${customerId} fuer die Anleitung nicht lesbar:`, err.message)
return customerId
}
}
const K16_FEHLT = 'K16 mit Unterlagen NICHT gesendet – von Hand nachsenden'
/**
* Vermerkt in der Notiz des Funktionsdokuments, dass die K16 mit den Unterlagen nicht rausging (Staff-Aktivierung in Textform, die
* Unterlagen sind der dauerhafte Datentraeger). Best effort unter der Sperre der Verwaltung: scheitert das Schreiben, steht der Fehler im
* Log, die Antwort traegt die Warnung trotzdem.
*/
async function notiereK16Fehlt(projectId, doc) {
try {
await withLocks([`feature-admin:${projectId}`], async () => {
const frisch = await getDocument(config.collections.portalFeatures, doc.$id)
await updateDocument(config.collections.portalFeatures, doc.$id, {
adminNote: composeAdminNote(frisch.adminNote, { by: 'System', action: K16_FEHLT }),
})
})
} catch (err) {
console.error('[funktionen-admin] Notiz zur nicht gesendeten K16 nicht geschrieben:', err.message)
}
}
const ticketExists = async (ticketId) => (await readTicket(ticketId)) !== null
/** Verlangt die Aktivierung dieser Funktion fuer diese Adresse die Zustimmung in Textform? Nur Terminplaner und Webshop, je nach Modus und Pilotliste der Funktion. */
const textformVerlangt = { termine: termineTextformNoetig, shop: shopTextformNoetig }
/**
* Zustimmung des Kunden in Textform fuer die Aktivierung des Terminplaners oder des Webshops durch das Team (F13a; AVV Webshop,
* Vorbemerkung Abs. 2). null, wenn sie entfaellt: andere Funktion, Modus off oder pilot, Pilotadresse (termineTextformNoetig,
* shopTextformNoetig) oder ein Dokument, das nichts mehr aktiviert und dem nichts fehlt (schon aktiv, oder durch Kauf freigeschaltet,
* dann liegt die Zustimmung des Checkouts vor). Sonst { date, channel }; fehlt oder ist eine Angabe ungueltig (Datum vor der Fassung
* oder in der Zukunft, kein Weg), 400 - ohne dass etwas angelegt wird.
*/
async function textformFuerAktivierung({ body, customerId, key, doc }) {
if (!TEXTFORM_KEYS.includes(key)) return null
if (doc?.state === 'active' || doc?.unlockedByPurchase === true) return null
const kunde = await customerOf(customerId)
if (!textformVerlangt[key](kunde?.email)) return null
// nur Texte: validateTextform macht aus jedem Wert einen Text (aus ['chat'] wuerde 'chat')
const text = (wert) => (typeof wert === 'string' ? wert : '')
return validateTextform({ date: text(body?.textformDate), channel: text(body?.textformChannel) }, { minDate: textformFassung(key) })
}
/** Leistungsbeschreibung und AVV der Funktion als PDF fuer die K16 der Textform-Aktivierung; nicht lesbar => 503, bevor etwas geaendert wird. */
async function unterlagenAlsAnhang(key) {
try {
return await featureLegalAttachments(key, { ohne: ['widerruf'] })
} catch (err) {
console.error('[funktionen-admin] Rechtsunterlagen (PDF) fuer die Freischaltung nicht lesbar:', err.message)
const funktion = key === 'shop' ? 'Der Webshop' : 'Der Terminplaner'
throw Object.assign(new Error(`Die Rechtsunterlagen (PDF) sind nicht lesbar. ${funktion} wurde nicht freigeschaltet. Bitte später erneut versuchen.`), { status: 503 })
}
}
// Die Funktionsdokumente des Kunden werden einmal gelesen und fuer alle Projekte und Funktionen verwendet.
// hindernis (freischaltHindernis, termine/freischaltung.js): was die Freischaltung eines Terminplaners in setup noch hindert
// ('kein_abo' | 'keine_einstellung' | 'abo_unbekannt'), null = nichts; ausserhalb von setup und bei News immer null. Das Abo wird
// je Anfrage einmal gelesen (ein gemeinsamer aboMemo fuer alle Projekte des Kunden), portalFeatures nicht erneut.
async function adminView(customerId) {
const docs = await listCustomerFeatureDocs(customerId)
const projects = await listCustomerFeatures(customerId, { docs, includeArchived: true })
const abo = aboMemo()
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)))
view.hindernis = key === 'termine' && doc?.state === 'setup' ? await freischaltHindernis(doc, { abo }) : null
if (key === 'zeiten') Object.assign(view, await zeitenZusatz(doc))
}
}
return projects
}
/**
* Angaben des Terminplaners fuer die Karte im WOMS (F13a): textformNoetig = verlangt die Aktivierung dieses Kunden die Zustimmung in
* Textform (Modus on und Adresse nicht in der Pilotliste), avvVersion = Fassung der Unterlagen, auf die sich die Zustimmung bezieht,
* links = absolute, versionierte Adressen von Leistungsbeschreibung und AVV fuer die Chat-Vorlage (nie Adressen ohne Fassung).
*/
async function termineFeld(customerId) {
const kunde = await customerOf(customerId)
const { lb, avv } = featureLegalPaths('termine')
return {
textformNoetig: termineTextformNoetig(kunde?.email),
avvVersion: config.termine.avvVersion,
links: { lb: `${config.publicBaseUrl}${lb}`, avv: `${config.publicBaseUrl}${avv}` },
}
}
/** Dasselbe fuer den Webshop (AVV Webshop, Vorbemerkung Abs. 2): Fassung = config.shop.fassung, Adressen der versionierten Unterlagen des Webshops. */
async function shopFeld(customerId) {
const kunde = await customerOf(customerId)
const { lb, avv } = featureLegalPaths('shop')
return {
textformNoetig: shopTextformNoetig(kunde?.email),
avvVersion: config.shop.fassung,
links: { lb: `${config.publicBaseUrl}${lb}`, avv: `${config.publicBaseUrl}${avv}` },
}
}
funktionenAdminRouter.get('/:customerId', async (req, res) => {
try {
const projects = await adminView(req.params.customerId)
res.json({ projects, termine: await termineFeld(req.params.customerId), shop: await shopFeld(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)
}
})
/**
* Einbauanleitung des Terminplaners eines Projekts fuer das WOMS-Panel (Task D8): die volle Fassung des Generators ohne `kurz` (die geht nur
* ins Ticket), nur lesend. Gilt in jedem Zustand und bei jedem Modus (kein TERMINE_ENABLED-Gate) und ist kein Kundenzugriff (kein
* customerViewLog, kein Pfad im Kundenrouter). Rechnung und Preis kommen live aus dem Dokument.
* state: Zustand des Dokuments. ticketWoid, ticketVersion: Ticket des Dokuments und der Stand, auf dem es beruht (null ohne Ticket oder
* ohne Standzeile). hindernis: nur in setup, was die Freischaltung noch hindert (siehe adminView), sonst null.
* Ein gespeicherter Stand, der neuer ist als das Ticket, haengt eine Warnung ans Ende von warnungen (driftWarnung); ein geloeschtes
* Ticket (404) ist keine Warnung und kein Fehler, jeder andere Lesefehler ist einer (keine stille Luecke).
*/
funktionenAdminRouter.get('/:customerId/termine/anleitung', async (req, res) => {
try {
const { customerId } = req.params
const { projectId } = req.query
if (typeof projectId !== 'string' || !projectId) return res.status(400).json({ error: 'Projekt fehlt' })
const project = await assertOwnProject(customerId, projectId)
const doc = await getFeatureDoc(customerId, projectId, 'termine')
if (!doc) {
throw Object.assign(new Error(`Für dieses Projekt gibt es noch ${FEATURE_LABELS.termine.keine}`), { status: 409 })
}
const stand = await ladeStand(doc.$id)
const ticket = await readTicket(doc.ticketId)
const ticketVersion = ticket ? ticketStand(ticket.details) : null
const { stand: anleitungsStand, warnungen, abschnitte, text } = einbauanleitung({
feature: doc,
project,
einstellung: stand?.einstellung ?? null,
fragebogen: stand?.fragebogen ?? null,
version: stand?.version ?? 0,
updatedAt: stand?.updatedAt ?? null,
kunde: { name: await customerName(customerId) },
now: new Date(),
})
const drift = driftWarnung(ticketVersion, anleitungsStand.version)
// Felder einzeln benannt: `kurz` (nur fuers Ticket) und alles, was der Generator spaeter dazubekommt, geht nie unbesehen nach aussen
res.json({
stand: anleitungsStand,
warnungen: drift ? [...warnungen, drift] : warnungen,
abschnitte,
text,
state: doc.state,
ticketWoid: ticket?.woid || null,
ticketVersion,
hindernis: doc.state === 'setup' ? await freischaltHindernis(doc) : null,
})
} 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 K27: 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)
}
// Je Funktion (/news, /termine, /zeiten; jeder andere Schluessel -> 404): activate (ohne Zahlung; bei /zeiten ueber den Gratis-Pfad),
// disable, abnahme (Freischalten nach Abnahme, in setup, ohne das Ticket zu schliessen) und ticket (fehlendes Einrichtungs-Ticket
// nachlegen, in setup). abnahme und ticket gibt es nur fuer Funktionen mit ticketFreischaltung im Register.
const ACTIONS = ['activate', 'disable', 'abnahme', 'ticket']
const TICKET_ACTIONS = ['abnahme', 'ticket']
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 (!ACTIONS.includes(action)) {
return res.status(400).json({ error: 'Unbekannte Aktion' })
}
if (typeof projectId !== 'string' || !projectId) {
return res.status(400).json({ error: 'Projekt fehlt' })
}
if (TICKET_ACTIONS.includes(action) && featureConfig(key)?.ticketFreischaltung !== true) {
return res.status(409).json({ error: 'Diese Aktion gibt es für diese Funktion nicht.' })
}
await assertOwnProject(customerId, projectId)
const by = adminName(req)
let freigeschaltet = null // Terminplaner oder Webshop mit Zustimmung in Textform: K16 mit den Unterlagen nach der Sperre
let ersatzTicket
if (key === 'zeiten' && action === 'activate') {
await zeitenAktivieren({ customerId, projectId, by, note })
} else if (key === 'zeiten' && action === 'disable') {
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') {
const textform = await textformFuerAktivierung({ body: req.body, customerId, key, doc })
const attachments = textform ? await unterlagenAlsAnhang(key) : []
const aktiviert = await adminActivate(customerId, projectId, key, { by, adminNote, textform })
if (textform) freigeschaltet = { doc: aktiviert, attachments }
} else if (action === 'abnahme') {
await freischaltenNachAbnahme({ customerId, projectId, key, by, adminNote })
} else if (action === 'ticket') {
await ticketNachlegen({ 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 })
}
})
}
// Der Kunde bekommt die Unterlagen auf einem dauerhaften Datentraeger (Anhang); ein Versandfehler wird dort geloggt, nie geworfen.
// Ging K16 nicht raus (Versand, Kunde ohne Adresse), steht es in der Notiz und die Antwort traegt warnung: true - das Team sendet von Hand nach
let warnung = false
if (freigeschaltet && !(await meldeFreigeschaltet(freigeschaltet.doc, { attachments: freigeschaltet.attachments }))) {
warnung = true
await notiereK16Fehlt(projectId, freigeschaltet.doc)
}
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 } : {}),
...(warnung ? { warnung } : {}),
})
} 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)
}
})