466 lines
25 KiB
JavaScript
466 lines
25 KiB
JavaScript
import { Router } from 'express'
|
||
import { config } from '../../config.js'
|
||
import { aboMemo } from '../../services/aboStatus.js'
|
||
import { aboModellFuerKunde } from '../../services/aboFunktionen.js'
|
||
import { ABRECHNUNGEN } from '../../services/aboModell.js'
|
||
import { FUNKTION_CHANNELS, 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 { unterkunftTextformNoetig } from '../../services/unterkunft/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)
|
||
// err.body (z. B. code, grund, wechsel der 409 abo_fehlt) geht flach in die Antwort; error bleibt der Text der Meldung
|
||
const zusatz = err.body && typeof err.body === 'object' && !Array.isArray(err.body) ? err.body : {}
|
||
return res.status(status).json({ ...zusatz, 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, Webshop und Buchungskalender, je nach Modus und Pilotliste der Funktion. */
|
||
const textformVerlangt = { termine: termineTextformNoetig, shop: shopTextformNoetig, unterkunft: unterkunftTextformNoetig }
|
||
|
||
/**
|
||
* 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), channels: FUNKTION_CHANNELS })
|
||
}
|
||
|
||
/** 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' : key === 'unterkunft' ? 'Der Buchungskalender' : '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}` },
|
||
}
|
||
}
|
||
|
||
/** Dasselbe fuer den Buchungskalender (AVV Buchungskalender, Vorbemerkung Abs. 2): Fassung = config.unterkunft.fassung. */
|
||
async function unterkunftFeld() {
|
||
const { lb, avv } = featureLegalPaths('unterkunft')
|
||
return {
|
||
textformNoetig: unterkunftTextformNoetig(),
|
||
avvVersion: config.unterkunft.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), unterkunft: await unterkunftFeld() })
|
||
} 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)
|
||
// Abo-Modell (Kundenansicht neu, V10): nur fuer Kunden der neuen Ansicht zaehlt abrechnung (Vorgabe 'abo'); sonst wird sie ignoriert und
|
||
// alles laeuft wie heute. Eine ungueltige Abrechnung ist 400, bevor etwas geschrieben wird.
|
||
const modell = action === 'activate' && (await aboModellFuerKunde(customerId))
|
||
let abrechnung = null
|
||
if (modell) {
|
||
abrechnung = req.body?.abrechnung === undefined ? 'abo' : req.body.abrechnung
|
||
if (!ABRECHNUNGEN.includes(abrechnung)) return res.status(400).json({ error: 'Unbekannte Abrechnung' })
|
||
}
|
||
const rueckmeldung = {} // adminActivate meldet hier die Warnung der Aufbauphase (abo_fehlt)
|
||
let freigeschaltet = null // Terminplaner oder Webshop mit Zustimmung in Textform: K16 mit den Unterlagen nach der Sperre
|
||
let ersatzTicket
|
||
if (key === 'zeiten' && action === 'activate' && modell) {
|
||
// Wie zeitenAktivieren ohne feature-admin: Neubau und Ticket laufen in einschalten, die Sperre wuerde dem Team jede Aktion am Projekt halten
|
||
const bisher = await getFeatureDoc(customerId, projectId, key)
|
||
const adminNote = composeAdminNote(bisher?.adminNote, { by, action, note })
|
||
await adminActivate(customerId, projectId, key, { by, adminNote, abrechnung, rueckmeldung })
|
||
} else 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 })
|
||
if (modell) {
|
||
// Die Unterlagen gehen mit K38 (aboFunktionenMeldung.js), nicht mit K16
|
||
await adminActivate(customerId, projectId, key, { by, adminNote, textform, abrechnung, rueckmeldung })
|
||
} else {
|
||
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 } : {}),
|
||
// Aufbauphase (E9): im Projekt eingebaut, obwohl noch kein Abo laeuft; der String unterscheidet sich vom alten warnung: true (K16 ging nicht raus)
|
||
...(rueckmeldung.warnung ? { warnung: rueckmeldung.warnung.code, grund: rueckmeldung.warnung.grund } : {}),
|
||
})
|
||
} 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)
|
||
}
|
||
})
|