GET /api/team/funktionen/:customerId/termine/anleitung?projectId= liefert die volle Einbauanleitung (ohne kurz) fuer das WOMS-Panel: stand, warnungen mit Drift-Warnung gegen die Version im Ticket, abschnitte, text, state, ticketWoid, ticketVersion und hindernis (nur in setup). Nur lesend, in jedem Zustand und Modus, ohne customerViewLog. Die Teamansicht GET /:customerId traegt hindernis je Terminplaner in setup, mit einem Abo-Lauf je Anfrage und ohne portalFeatures erneut zu lesen. Die Einbau-Doku nennt die Anleitung je Kunde im WOMS-Panel und die Kurzfassung im Ticket. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
190 lines
8.6 KiB
JavaScript
190 lines
8.6 KiB
JavaScript
import { Router } from 'express'
|
||
import { config } from '../../config.js'
|
||
import { aboMemo } from '../../services/aboStatus.js'
|
||
import { getDocument } from '../../services/appwriteAdmin.js'
|
||
import { featureConfig } 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 { 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
|
||
}
|
||
}
|
||
|
||
/** 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
|
||
}
|
||
}
|
||
return projects
|
||
}
|
||
|
||
funktionenAdminRouter.get('/:customerId', async (req, res) => {
|
||
try {
|
||
res.json({ projects: await adminView(req.params.customerId) })
|
||
} 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)
|
||
}
|
||
})
|
||
|
||
// Je Funktion (/news, /termine; jeder andere Schluessel -> 404): activate (ohne Zahlung), 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)
|
||
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) })
|
||
} catch (err) {
|
||
sendError(res, err)
|
||
}
|
||
})
|