import { config } from '../config.js' import { updateDocument } from './appwriteAdmin.js' import { aboDeckt } from './aboFunktionen.js' import { meldeEingeschaltet } from './aboFunktionenMeldung.js' import { berlinDay } from './deadlines.js' import { bestellFehler } from './featureOrder.js' import { featureConfig, ticketTexte } from './featureRegistry.js' import { withLocks } from './keyedLock.js' import { assertOwnProject, customerOf, ensureFeatureDoc, getFeatureDoc, notizVorn, stateFields, ticketWoid, unlockedByVon } from './siteFeatures.js' import { ABRECHNUNGS_ZEILEN, createFeatureTicket } from './ticketAdmin.js' import { einschaltenZeiten } from './zeiten/einschalten.js' /** * Generisches Einschalten einer Funktion im Abo-Modell (V7, Spec 5.4): ohne Zahlung, mit Einrichtungs-Ticket. Gleiches Muster wie * einschaltenZeiten (Sperre feature-order, Nachweis vor dem Zustand, Ticket danach, Heilung statt Doppelticket). Am Ende meldet * meldeEingeschaltet (aboFunktionenMeldung.js, P6) das Einschalten mit K38 an den Kunden und T24 ans Team; die Team-Aktivierung * (adminActivate) steht in siteFeatures.js. Fuer news, termine, shop und unterkunft laeuft alles hier; zeiten geht ueber * einschaltenZeiten (Neubau, Ticket und K27 stehen dort) mit einer Vorpruefung, die dieselben Codes liefert. */ /** Wie die Einrichtung abgerechnet wird: im Abo-Stundenkonto (abo) oder im Projekt enthalten (projekt). */ export const ABRECHNUNGEN = Object.freeze(['abo', 'projekt']) export const EINSCHALTEN_TEXTE = Object.freeze({ bereits: 'Diese Funktion ist für dieses Projekt schon eingeschaltet.', laeuft: 'Für diese Funktion läuft schon eine Bestellung.', abgeschaltet: 'Diese Funktion wurde für dieses Projekt abgeschaltet. Schreib uns im Chat, wenn du sie wieder möchtest.', ticket: 'Die Einrichtung konnte gerade nicht angelegt werden. Bitte versuch es gleich noch einmal.', abo: Object.freeze({ kein_abo: 'Für diese Funktion brauchst du ein laufendes Abo.', falsches_abo: 'Diese Funktion ist im Wartungs-Abo enthalten.', gekuendigt: 'Dein Abo ist gekündigt. Zum Einschalten brauchst du ein laufendes Abo.', nicht_aktiv: 'Dein Abo ist gerade nicht aktiv, zum Beispiel wegen einer offenen Zahlung. Sobald es wieder aktiv ist, kannst du die Funktion einschalten.', beginnt_spaeter: 'Dein Abo beginnt am {datum}. Ab dann kannst du einschalten.', }), nichtPruefbar: 'Gerade nicht prüfbar – bitte versuch es gleich noch einmal.', }) /** Berliner Kalendertag eines Zeitpunkts (ISO) als TT.MM.JJJJ; kein Zeitpunkt ist ein TypeError. */ export function datumDe(zeitpunkt) { const ms = Date.parse(zeitpunkt) if (Number.isNaN(ms)) throw new TypeError(`datumDe: kein Zeitpunkt: ${String(zeitpunkt)}`) const [jahr, monat, tag] = berlinDay(new Date(ms)).split('-') return `${tag}.${monat}.${jahr}` } /** * Kundentext zu einem Abo-Grund. Bei beginnt_spaeter steht der Berliner Tag von `ab` (ISO) als TT.MM.JJJJ im Text, * z. B. "Dein Abo beginnt am 03.11.2026. Ab dann kannst du einschalten." */ export function aboFehlerText(grund, ab = null) { if (!Object.hasOwn(EINSCHALTEN_TEXTE.abo, grund)) throw new TypeError(`aboFehlerText: unbekannter Grund ${String(grund)}`) const text = EINSCHALTEN_TEXTE.abo[grund] return grund === 'beginnt_spaeter' ? text.replace('{datum}', datumDe(ab)) : text } /** 409 abo_fehlt: body.grund, body.wechsel (Hosting-Kunde: Wechsel ins Wartungs-Abo anbieten) und bei beginnt_spaeter body.ab. */ const aboFehlt = (grund, ab) => bestellFehler(409, aboFehlerText(grund, ab), 'abo_fehlt', { grund, wechsel: grund === 'falsches_abo', ...(grund === 'beginnt_spaeter' ? { ab } : {}) }) const konflikt = (code, text) => bestellFehler(409, text, code) /** * Abo frisch lesen und gegen die Funktion pruefen (immer ohne Zwischenspeicher): 409 abo_fehlt oder nichts. Lesefehler sind 503, * nie stillschweigend erlaubt. Auch fuer die Vorpruefungen der Route. */ export async function pruefeAboDeckung(customerId, key, now = new Date()) { let ergebnis try { ergebnis = await aboDeckt(customerId, key, { now }) } catch (err) { console.error(`[funktionen] Abo für das Einschalten nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim()) throw bestellFehler(503, EINSCHALTEN_TEXTE.nichtPruefbar) } if (ergebnis.ok !== true) throw aboFehlt(ergebnis.grund, ergebnis.ab) } /** setup ohne Ticket und durch Abo oder Projekt eingeschaltet: der Dienst heilt (nur das Ticket), es entsteht nichts Neues. */ const heilbar = (doc) => doc?.state === 'setup' && !doc.ticketId && ['abo', 'projekt'].includes(unlockedByVon(doc)) /** * Zustand des Dokuments pruefen: wirft den 409 mit Code (bestellung_laeuft, abgeschaltet, bereits_eingeschaltet); ein fehlendes oder * ausgeschaltetes (off) Dokument und die Heilung (setup ohne Ticket, durch Abo oder Projekt) sind frei. Auch fuer die Vorpruefungen der Route. */ export function pruefeEinschaltZustand(doc) { if (!doc || doc.state === 'off') return if (doc.state === 'ordered') throw konflikt('bestellung_laeuft', EINSCHALTEN_TEXTE.laeuft) if (doc.state === 'disabled') throw konflikt('abgeschaltet', EINSCHALTEN_TEXTE.abgeschaltet) if (!heilbar(doc)) throw konflikt('bereits_eingeschaltet', EINSCHALTEN_TEXTE.bereits) } /** Notiz des Teams: staff.adminNote oder " : Eingeschaltet ()" vor der bisherigen Notiz; ohne beides keine. */ function teamNotiz(staff, bisherige, abrechnung, now) { if (typeof staff?.adminNote === 'string' && staff.adminNote) return staff.adminNote.slice(0, 300) if (!staff?.name) return null return notizVorn(bisherige, `${now.toISOString()} ${staff.name}: Eingeschaltet (${abrechnung})`) } /** Einrichtungs-Ticket anlegen und an das Dokument haengen; jeder Fehler hier ist 503 (der Zustand bleibt setup, der naechste Aufruf heilt). */ async function legeTicketAn(doc, { key, feature, project, customerId, abrechnung }) { try { const customer = await customerOf(customerId).catch(() => null) const texte = await ticketTexte({ feature, doc, project, customer }) const { ticketId } = await createFeatureTicket({ customerId, project, featureKey: key, siteKey: doc.siteKey, ...texte, abrechnung }) return await updateDocument(config.collections.portalFeatures, doc.$id, { ticketId }) } catch (err) { console.error(`[funktionen] Einrichtung ${key} (${doc.$id}) nicht angelegt: ${err?.name ?? 'Fehler'} ${err?.status ?? ''} ${err?.message ?? ''}`.trim()) throw Object.assign(new Error(EINSCHALTEN_TEXTE.ticket), { status: 503, cause: err }) } } /** * Funktion fuer ein Projekt einschalten (Zustand setup, Einrichtungs-Ticket mit Abrechnung). * @param {{ customerId: string, projectId: string, key: string, by: 'kunde'|'team', abrechnung: 'abo'|'projekt', * nachweis?: (() => Promise)|null, staff?: { name?: string, adminNote?: string }|null, now?: Date }} p * by 'kunde': das Abo wird unter der Sperre frisch gegen die Funktion geprueft (aboDeckt); by 'team': keine Abo-Pruefung. * nachweis: speichert den Zustimmungsnachweis, bevor der Zustand geaendert wird; wirft er, bleibt alles wie es war. * @returns {Promise<{ doc: object, ticketWoid: string|null }>} * @throws {TypeError} key ausserhalb des Registers (auch analyse), by oder abrechnung ungueltig * @throws {Error} 403/409 (Projekt), 409 bereits_eingeschaltet | bestellung_laeuft | abgeschaltet | abo_fehlt, 503 (Abo nicht lesbar, Ticket gescheitert) */ export async function einschalten({ customerId, projectId, key, by, abrechnung, nachweis = null, staff = null, now = new Date() } = {}) { const feature = featureConfig(key) if (!feature) throw new TypeError(`einschalten: unbekannte Funktion ${String(key)}`) if (by !== 'kunde' && by !== 'team') throw new TypeError(`einschalten: by muss kunde oder team sein, nicht ${String(by)}`) if (!ABRECHNUNGEN.includes(abrechnung)) throw new TypeError(`einschalten: abrechnung muss abo oder projekt sein, nicht ${String(abrechnung)}`) const project = await assertOwnProject(customerId, projectId, { forNewOrder: true }) // Oeffnungszeiten: Sperre, Nachweis, Zustand, Neubau, Ticket und K27 stehen in einschaltenZeiten; die Vorpruefung unter der Sperre // liefert dieselben Codes wie unten (nur fuer den Kunden, das Team schaltet wie bisher) if (key === 'zeiten') { const vorpruefung = async () => { await pruefeAboDeckung(customerId, key, now) pruefeEinschaltZustand(await getFeatureDoc(customerId, projectId, key)) } const { doc, changed } = await einschaltenZeiten({ customerId, projectId, by, staff, nachweis, now, abrechnung, vorpruefung }) const woid = await ticketWoid(doc.ticketId) // nur wenn dieser Aufruf den Zustand gesetzt hat (K27 sendet holeZeitenNach); ein idempotenter Aufruf meldet nichts if (changed === true) await meldeEingeschaltet({ doc, ticketWoid: woid, by, abrechnung }) return { doc, ticketWoid: woid } } // Abrechnung, mit der dieser Aufruf endet: bei der Heilung die am Dokument gespeicherte let abgerechnet = abrechnung const doc = await withLocks([`feature-order:${customerId}:${projectId}`], async () => { // 1 Abo frisch (nie aus einem Zwischenspeicher); fuer das Team entfaellt es if (by === 'kunde') await pruefeAboDeckung(customerId, key, now) // 2 Zustand: Heilung (setup ohne Ticket, durch Abo oder Projekt) legt nur das Ticket an const bisher = await getFeatureDoc(customerId, projectId, key) pruefeEinschaltZustand(bisher) if (heilbar(bisher)) { const gespeichert = Object.hasOwn(ABRECHNUNGS_ZEILEN, bisher.einrichtungAbrechnung) ? bisher.einrichtungAbrechnung : abrechnung abgerechnet = gespeichert return legeTicketAn(bisher, { key, feature, project, customerId, abrechnung: gespeichert }) } // 3 Zustimmung speichern, vor dem Zustand: scheitert sie, bleibt alles wie es war if (nachweis) await nachweis() // 4 Dokument auf setup. Reste einer frueheren Abschaltung gehoeren zum alten Lauf und enden hier. const vorhanden = bisher ?? (await ensureFeatureDoc(customerId, projectId, key)) const notiz = by === 'team' ? teamNotiz(staff, vorhanden.adminNote, abrechnung, now) : null const gespeichert = await updateDocument(config.collections.portalFeatures, vorhanden.$id, { ...stateFields('setup'), featureKey: key, unlockedBy: abrechnung, unlockedByPurchase: false, einrichtungAbrechnung: abrechnung, disabledAt: null, disabledReason: null, aboEndeMailAt: null, ...(notiz ? { adminNote: notiz } : {}), }) // 5 Ticket; scheitert es, bleibt setup ohne Ticket und der naechste Aufruf heilt return legeTicketAn(gespeichert, { key, feature, project, customerId, abrechnung }) }) // Der Aufruf hat den Zustand gesetzt oder das Ticket angelegt (Heilung zaehlt); die Meldung wirft nie const woid = await ticketWoid(doc.ticketId) await meldeEingeschaltet({ doc, ticketWoid: woid, by, abrechnung: abgerechnet }) return { doc, ticketWoid: woid } }