import { Router } from 'express' import { config } from '../../config.js' import { aboMemo } from '../../services/aboStatus.js' import { getDocument } from '../../services/appwriteAdmin.js' import { featureConfig, featureHaken } from '../../services/featureRegistry.js' import { withLocks } from '../../services/keyedLock.js' import { FEATURE_KEYS, FEATURE_LABELS, adminActivate, assertOwnProject, customerOf, disable, freischaltenNachAbnahme, getFeatureDoc, listCustomerFeatureDocs, listCustomerFeatures, 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 } from '../../services/termine/freischaltung.js' import { ladeStand } from '../../services/termine/kontext.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 ticketExists = async (ticketId) => (await readTicket(ticketId)) !== null // 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 }) 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 } 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) } }) /** * 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 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) } // 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 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') { await adminActivate(customerId, projectId, key, { by, adminNote }) } 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 }) } }) } 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:` 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) } })