184 lines
11 KiB
JavaScript
184 lines
11 KiB
JavaScript
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 "<ISO> <Name>: Eingeschaltet (<abrechnung>)" 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<unknown>)|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 }
|
||
}
|