Kundenansicht P5: generisches Einschalten im Abo (einschalten, Heilung, Ticket-Marker)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1d149f50b1
commit
f5b43c55a9
3 files changed
+396
-5
No files matched your search
@@ -0,0 +1,162 @@
|
||||
import { config } from '../config.js'
|
||||
import { updateDocument } from './appwriteAdmin.js'
|
||||
import { aboDeckt } from './aboFunktionen.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). Keine Mails, kein
|
||||
* Stundenkonto, keine Team-Aktivierung (P6). 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.',
|
||||
})
|
||||
|
||||
/**
|
||||
* 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]
|
||||
if (grund !== 'beginnt_spaeter') return text
|
||||
const ms = Date.parse(ab)
|
||||
if (Number.isNaN(ms)) throw new TypeError('aboFehlerText: beginnt_spaeter braucht ab als Zeitpunkt')
|
||||
const [jahr, monat, tag] = berlinDay(new Date(ms)).split('-')
|
||||
return text.replace('{datum}', `${tag}.${monat}.${jahr}`)
|
||||
}
|
||||
|
||||
/** 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); Lesefehler sind 503, nie stillschweigend erlaubt. */
|
||||
async function pruefeAbo(customerId, key, now) {
|
||||
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 Konflikt mit Code; Heilung und ein frisches oder ausgeschaltetes Dokument sind frei. */
|
||||
function pruefeZustand(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 pruefeAbo(customerId, key, now)
|
||||
pruefeZustand(await getFeatureDoc(customerId, projectId, key))
|
||||
}
|
||||
const { doc } = await einschaltenZeiten({ customerId, projectId, by, staff, nachweis, now, abrechnung, vorpruefung })
|
||||
return { doc, ticketWoid: await ticketWoid(doc.ticketId) }
|
||||
}
|
||||
|
||||
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 pruefeAbo(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)
|
||||
pruefeZustand(bisher)
|
||||
if (heilbar(bisher)) {
|
||||
const gespeichert = Object.hasOwn(ABRECHNUNGS_ZEILEN, bisher.einrichtungAbrechnung) ? bisher.einrichtungAbrechnung : abrechnung
|
||||
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 })
|
||||
})
|
||||
return { doc, ticketWoid: await ticketWoid(doc.ticketId) }
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { pruefeAboFuerEinschalten } from '../featureOrder.js'
|
||||
import { featureConfig, ticketTexte } from '../featureRegistry.js'
|
||||
import { withLocks } from '../keyedLock.js'
|
||||
import { customerOf, ensureFeatureDoc, getFeatureDoc, stateFields, ticketWoid, unlockedByVon } from '../siteFeatures.js'
|
||||
import { createFeatureTicket } from '../ticketAdmin.js'
|
||||
import { ABRECHNUNGS_ZEILEN, createFeatureTicket } from '../ticketAdmin.js'
|
||||
import { k27Mail, sendeZeitenMail } from './mails.js'
|
||||
import { stosseNeubauAn } from './neubau.js'
|
||||
import { ladeZeiten } from './speicher.js'
|
||||
@@ -50,16 +50,24 @@ function notizDesTeams(staff, bisherige, now) {
|
||||
* by 'kunde': Abo frisch pruefen (kein_abo/gekuendigt/nicht_aktiv 409, Lesefehler 503), unlockedBy 'abo', K27 geht an den Kunden.
|
||||
* by 'team': keine Abo-Pruefung, unlockedBy 'team' (ein bisher gekauftes Dokument bleibt kauf), keine K27; staff ergibt die Notiz am Dokument.
|
||||
* nachweis: speichert den Zustimmungsnachweis (Schritt 3, nur wenn wirklich eingeschaltet wird); wirft er, bleibt der Zustand unveraendert.
|
||||
* abrechnung ('' | 'abo' | 'projekt', Abo-Modell der Kundenansicht neu, V7): ueberschreibt unlockedBy ('abo' | 'projekt'; ein gekauftes
|
||||
* Dokument bleibt kauf) und setzt einrichtungAbrechnung am Dokument (das Einrichtungs-Ticket traegt es in holeZeitenNach weiter).
|
||||
* Ohne abrechnung bleibt alles wie bisher. Ein anderer Wert ist ein TypeError.
|
||||
* vorpruefung: Funktion, die unter der Sperre statt pruefeAboFuerEinschalten laeuft (nur by 'kunde'); sie wirft, was der Aufrufer
|
||||
* als Fehler will (das Einschalten im Abo-Modell prueft damit Abo und Zustand mit eigenen Codes).
|
||||
* @returns {Promise<{ doc: object, changed: boolean }>} doc: das Dokument nach dem Nachholen; changed: false bei idempotentem Aufruf
|
||||
* @throws {Error} 409 (Abo, anderer Zustand), 503 (Abo nicht lesbar; oder Zustand gespeichert, aber Neubau/Ticket nicht fertig: erneut aufrufen)
|
||||
*/
|
||||
export async function einschaltenZeiten({ customerId, projectId, by = 'kunde', staff = null, nachweis = null, now = new Date() } = {}) {
|
||||
export async function einschaltenZeiten({ customerId, projectId, by = 'kunde', staff = null, nachweis = null, now = new Date(), abrechnung = '', vorpruefung = null } = {}) {
|
||||
if (by !== 'kunde' && by !== 'team') throw new TypeError(`einschaltenZeiten: by muss kunde oder team sein, nicht ${String(by)}`)
|
||||
if (abrechnung !== '' && abrechnung !== 'abo' && abrechnung !== 'projekt') {
|
||||
throw new TypeError(`einschaltenZeiten: abrechnung muss '', 'abo' oder 'projekt' sein, nicht ${String(abrechnung)}`)
|
||||
}
|
||||
const feature = featureConfig(KEY)
|
||||
|
||||
const { doc, changed } = await withLocks([`feature-order:${customerId}:${projectId}`], async () => {
|
||||
// 1 Abo frisch (nie aus einem Zwischenspeicher); fuer das Team entfaellt es
|
||||
if (by === 'kunde') await pruefeAboFuerEinschalten(customerId)
|
||||
if (by === 'kunde') await (vorpruefung ? vorpruefung() : pruefeAboFuerEinschalten(customerId))
|
||||
|
||||
// 2 Zustand: schon eingeschaltet (setup/active) ist idempotent; sonst nur aus off (der Kunde) bzw. off/ordered/disabled (das Team)
|
||||
const bisher = await getFeatureDoc(customerId, projectId, KEY)
|
||||
@@ -84,8 +92,9 @@ export async function einschaltenZeiten({ customerId, projectId, by = 'kunde', s
|
||||
const gespeichert = await updateDocument(config.collections.portalFeatures, vorhanden.$id, {
|
||||
...stateFields('setup'),
|
||||
featureKey: KEY,
|
||||
unlockedBy: gekauft ? 'kauf' : by === 'kunde' ? 'abo' : 'team',
|
||||
unlockedBy: gekauft ? 'kauf' : abrechnung || (by === 'kunde' ? 'abo' : 'team'),
|
||||
unlockedByPurchase: gekauft,
|
||||
...(abrechnung ? { einrichtungAbrechnung: abrechnung } : {}),
|
||||
...(gekauft ? {} : { einbau: '' }),
|
||||
aboEndeAt: null,
|
||||
behaltenSessionId: '',
|
||||
@@ -141,7 +150,9 @@ export async function holeZeitenNach(doc, { now = new Date() } = {}) {
|
||||
const customer = await customerOf(frisch.customerId)
|
||||
if (!frisch.ticketId) {
|
||||
const texte = await ticketTexte({ feature, doc: frisch, project, customer })
|
||||
const { ticketId } = await createFeatureTicket({ customerId: frisch.customerId, project, featureKey: KEY, siteKey: frisch.siteKey, ...texte })
|
||||
// Abo-Modell: die Abrechnung am Dokument (abo | projekt) geht ans Ticket; Dokumente ohne Marker (alte Ansicht) bleiben wie sie waren
|
||||
const abrechnung = Object.hasOwn(ABRECHNUNGS_ZEILEN, frisch.einrichtungAbrechnung) ? { abrechnung: frisch.einrichtungAbrechnung } : {}
|
||||
const { ticketId } = await createFeatureTicket({ customerId: frisch.customerId, project, featureKey: KEY, siteKey: frisch.siteKey, ...texte, ...abrechnung })
|
||||
frisch = await updateDocument(config.collections.portalFeatures, frisch.$id, { ticketId })
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user