Merge feature/terminplaner-tp2 (a629b25) in feature/oeffnungszeiten
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
1d5a565e9f
95 files changed
+12879
-546
No files matched your search
+410
-46
@@ -4,9 +4,10 @@ import { createDocument, getDocument, listDocuments, updateDocument, Query } fro
|
||||
import { withLocks } from './keyedLock.js'
|
||||
import { sendAdminNotice, sendCustomerMail } from './mailer.js'
|
||||
import { getStripe, retrieveCheckoutSession } from './stripeBilling.js'
|
||||
import { anleitungsLink, featureConfig, featureHaken, featureKeys, ticketFreischaltungKeys, ticketTexte } from './featureRegistry.js'
|
||||
import { anleitungsLink, featureConfig, featureHaken, featureKeys, kuerze, ticketFreischaltungKeys, ticketTexte } from './featureRegistry.js'
|
||||
import { zustimmungFuerSitzung } from './featureConsents.js'
|
||||
import { aboLage, laeuft } from './aboStatus.js'
|
||||
import { berlinDateTime } from './berlinTime.js'
|
||||
import { loeschTag } from './termine/frist.js'
|
||||
import { createFeatureTicket } from './ticketAdmin.js'
|
||||
|
||||
@@ -416,20 +417,34 @@ export async function markPaid({ sessionId, invoiceId, invoiceUrl }) {
|
||||
// Oeffnungszeiten live: erst der Neubau (Zustand ist schon gespeichert), dann das Ticket mit dem frischen einbau (Spec 4.6)
|
||||
if (mitAboKauf) setup = await baueZeitenEin(setup, project)
|
||||
const customer = await customerOf(doc.customerId).catch(() => null)
|
||||
const feature = featureConfig(doc.featureKey)
|
||||
const texte = feature ? await ticketTexte({ feature, doc: setup, project, customer }) : {}
|
||||
const { ticketId } = await createFeatureTicket({
|
||||
customerId: doc.customerId,
|
||||
project,
|
||||
featureKey: doc.featureKey,
|
||||
siteKey: doc.siteKey,
|
||||
...texte,
|
||||
})
|
||||
const ticketId = await legeTicketAn(setup, project, customer)
|
||||
const updated = await updateDocument(config.collections.portalFeatures, setup.$id, { ticketId })
|
||||
return { doc: updated, created: true, fall: null }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Einrichtungs-Ticket einer bezahlten Funktion anlegen (Betreff und Details aus dem Register, ticketTexte) und seine ID liefern.
|
||||
* Die einzige Stelle, die das Ticket anlegt: markPaid (Zahlung) und ticketNachlegen (b, geloeschtes Ticket ersetzen). Schreibt nichts ins
|
||||
* Funktionsdokument (die ticketId traegt der Aufrufer ein) und sendet keine Mail.
|
||||
* @param {object} doc portalFeatures-Dokument (customerId, featureKey, siteKey)
|
||||
* @param {object} project websiteProjects-Dokument
|
||||
* @param {object|null} customer Kundendokument, best effort
|
||||
* @returns {Promise<string>} ID des neuen Tickets
|
||||
*/
|
||||
async function legeTicketAn(doc, project, customer) {
|
||||
const feature = featureConfig(doc.featureKey)
|
||||
const texte = feature ? await ticketTexte({ feature, doc, project, customer }) : {}
|
||||
const { ticketId } = await createFeatureTicket({
|
||||
customerId: doc.customerId,
|
||||
project,
|
||||
featureKey: doc.featureKey,
|
||||
siteKey: doc.siteKey,
|
||||
...texte,
|
||||
})
|
||||
return ticketId
|
||||
}
|
||||
|
||||
/**
|
||||
* Zahlung abgebrochen oder fehlgeschlagen: nur eine noch offene Bestellung faellt auf 'off' zurueck.
|
||||
* Unter demselben Schluessel wie markPaid, damit ein spaetes 'expired' nie eine gerade bezahlte
|
||||
@@ -648,6 +663,192 @@ export async function activateClosedTickets({ now = new Date(), customerId = nul
|
||||
return activated
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Team-Aktionen "Freischalten (Abnahme)" und "Ticket nachlegen" (WOMS, Kunden-Reiter Funktionen)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ABNAHME_NUR_SETUP = 'Freischalten (Abnahme) gibt es nur für Funktionen in Einrichtung.'
|
||||
const TICKET_NUR_SETUP = 'Ein Ticket nachlegen gibt es nur für Funktionen in Einrichtung.'
|
||||
/** Klartext und Status je Grund aus freischaltHindernis (termine/freischaltung.js); ein unbekannter Grund sperrt mit einem allgemeinen 409. */
|
||||
const ABNAHME_HINDERNIS = {
|
||||
kein_abo: [409, 'Der Kunde hat kein laufendes Wartungs-Abo. Freischalten ist erst mit laufendem Abo möglich.'],
|
||||
keine_einstellung: [409, 'Es sind noch keine Einstellungen gespeichert.'],
|
||||
abo_unbekannt: [503, 'Das Abo konnte gerade nicht geprüft werden.'],
|
||||
// Gruende des Registerhakens freischaltbar anderer Funktionen (Oeffnungszeiten live: eingetragene Zeiten), siehe freischaltHindernis oben
|
||||
'Voraussetzungen noch nicht erfüllt': [409, 'Die Voraussetzungen für das Freischalten sind noch nicht erfüllt.'],
|
||||
'Prüfung nicht möglich': [503, 'Die Voraussetzungen konnten gerade nicht geprüft werden.'],
|
||||
}
|
||||
|
||||
/** Funktionsdokument zu (Kunde, Projekt, Funktion) fuer eine Team-Aktion; ohne Dokument 409 "Für dieses Projekt gibt es noch {keine}". */
|
||||
async function dokumentFuerTeamAktion(customerId, projectId, key) {
|
||||
assertKey(key)
|
||||
const doc = await getFeatureDoc(customerId, projectId, key)
|
||||
if (!doc) throw httpError(409, `Für dieses Projekt gibt es noch ${FEATURE_LABELS[key].keine}`)
|
||||
return doc
|
||||
}
|
||||
|
||||
/** Das Dokument unter der Sperre frisch lesen; ist es inzwischen geloescht (Kunde geloescht), gilt dieselbe Meldung wie ohne Dokument. */
|
||||
async function frischesDokument(vorab, key) {
|
||||
try {
|
||||
return await getDocument(config.collections.portalFeatures, vorab.$id)
|
||||
} catch (err) {
|
||||
if (err.status === 404) throw httpError(409, `Für dieses Projekt gibt es noch ${FEATURE_LABELS[key].keine}`)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Was hindert die Abnahme? null = nichts. Nur Funktionen mit Haken freischaltbar im Register haben Voraussetzungen; fehlt der Haken
|
||||
* (News), gibt es keine Pruefung und kein Abo wird gelesen. Terminplaner: laufendes Wartungs-Abo und gespeicherte Einstellung, die
|
||||
* Gruende liefert freischaltHindernis (termine/freischaltung.js, erst im Aufruf geladen, weil es dieses Modul importiert). Jede andere
|
||||
* Funktion mit Haken (Oeffnungszeiten live) fragt wie der 5-Minuten-Job ihren Haken (freischaltHindernis oben, fail-closed): ihre
|
||||
* Voraussetzung hat nichts mit Abo und Einstellung des Terminplaners zu tun.
|
||||
*/
|
||||
async function abnahmeHindernis(doc) {
|
||||
if (typeof featureHaken(doc.featureKey).freischaltbar !== 'function') return null
|
||||
if (doc.featureKey !== 'termine') return freischaltHindernis(doc)
|
||||
const { freischaltHindernis: pruefe } = await import('./termine/freischaltung.js')
|
||||
return pruefe(doc)
|
||||
}
|
||||
|
||||
/**
|
||||
* Nacharbeit nach der Freischaltung: Haken nachFreischaltung der Funktion, falls das Register einen hat (Oeffnungszeiten: Ersatz-Hash
|
||||
* merken). Laeuft vor der K16; ein Fehler wird geloggt und bricht nichts ab (die Freischaltung ist dann schon geschehen).
|
||||
*/
|
||||
async function nacharbeitNachFreischaltung(doc) {
|
||||
const { nachFreischaltung } = featureHaken(doc.featureKey)
|
||||
if (typeof nachFreischaltung !== 'function') return
|
||||
try {
|
||||
await nachFreischaltung(doc)
|
||||
} catch (err) {
|
||||
console.error(`[funktionen] Nacharbeit nach der Freischaltung ${doc.featureKey} (${doc.$id}) fehlgeschlagen:`, err.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Freischalten (Abnahme)": eine Funktion in Einrichtung ('setup') wird freigeschaltet, ohne dass das Einrichtungs-Ticket geschlossen
|
||||
* wird. Gesetzt werden nur state/enabled 'active', activatedAt und adminNote; ticketId, Ticket und unlockedByPurchase bleiben.
|
||||
* Danach K16 (meldeFreigeschaltet, Fehler werden dort geloggt). Unter derselben Sperre feature-activate:<id> wie der Freischalt-Job
|
||||
* (activateClosedTickets) nach frischem Lesen: wer zuerst kommt, aktiviert und sendet; der andere sieht 'active' und tut nichts.
|
||||
* - Dokument fehlt: 409 "Für dieses Projekt gibt es noch {keine}".
|
||||
* - schon 'active': Erfolg ohne Schreibzugriff und ohne Mail, activated false.
|
||||
* - 'off' | 'ordered' | 'disabled': 409. Ein Hindernis (kein Wartungs-Abo, keine Einstellung): 409; Abo nicht lesbar: 503.
|
||||
* @param {{ customerId: string, projectId: string, key: string, by?: string, adminNote?: string, now?: Date }} args
|
||||
* @returns {Promise<{ doc: object, activated: boolean }>}
|
||||
*/
|
||||
export async function freischaltenNachAbnahme({ customerId, projectId, key, by, adminNote, now = new Date() }) {
|
||||
const vorab = await dokumentFuerTeamAktion(customerId, projectId, key)
|
||||
const ergebnis = await withLocks([`feature-activate:${vorab.$id}`], async () => {
|
||||
const doc = await frischesDokument(vorab, key)
|
||||
if (doc.state === 'active') return { doc, activated: false }
|
||||
if (doc.state !== 'setup') throw httpError(409, ABNAHME_NUR_SETUP)
|
||||
const grund = await abnahmeHindernis(doc)
|
||||
if (grund) {
|
||||
const [status, text] = ABNAHME_HINDERNIS[grund] ?? [409, 'Freischalten ist noch nicht möglich.']
|
||||
throw httpError(status, text)
|
||||
}
|
||||
const note = adminNote ?? byNote(by, 'Freigeschaltet (Abnahme)')
|
||||
const updated = await updateDocument(config.collections.portalFeatures, doc.$id, {
|
||||
...stateFields('active'),
|
||||
activatedAt: now.toISOString(),
|
||||
...(note ? { adminNote: note } : {}),
|
||||
})
|
||||
return { doc: updated, activated: true }
|
||||
})
|
||||
if (ergebnis.activated) {
|
||||
await nacharbeitNachFreischaltung(ergebnis.doc)
|
||||
const { meldeFreigeschaltet } = await import('./termine/freischaltung.js')
|
||||
await meldeFreigeschaltet(ergebnis.doc)
|
||||
}
|
||||
return ergebnis
|
||||
}
|
||||
|
||||
/** Ob das Ticket noch existiert; nur 404 heisst "geloescht", jeder andere Fehler wird weitergereicht. */
|
||||
async function ticketBesteht(ticketId) {
|
||||
try {
|
||||
await getDocument(config.collections.workorders, ticketId)
|
||||
return true
|
||||
} catch (err) {
|
||||
if (err.status === 404) return false
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** Die bezahlte Stripe-Sitzung des Dokuments; nicht bezahlt: 409, Stripe nicht erreichbar oder nicht konfiguriert: 503 (Ursache im Log). */
|
||||
async function bezahlteSitzung(doc) {
|
||||
if (!doc.stripeSessionId) throw httpError(409, 'Zu dieser Funktion ist keine Zahlung vermerkt.')
|
||||
let session
|
||||
try {
|
||||
session = await retrieveCheckoutSession(doc.stripeSessionId)
|
||||
} catch (err) {
|
||||
console.error(`[funktionen] Stripe-Sitzung ${doc.stripeSessionId} nicht abrufbar:`, err.message)
|
||||
throw httpError(503, 'Stripe konnte gerade nicht gefragt werden. Bitte später erneut versuchen.')
|
||||
}
|
||||
if (session?.payment_status !== 'paid') throw httpError(409, 'Die Zahlung ist noch nicht eingegangen.')
|
||||
return session
|
||||
}
|
||||
|
||||
/**
|
||||
* Ticket nachlegen (a): 'setup' ohne ticketId, das Ticket entstand nie. Die Zahlung wird bei Stripe geprueft und dann derselbe Weg wie
|
||||
* im Webhook gegangen (handleFeatureCheckoutEvent): Ticket, K15 und T16 entstehen genau einmal, ein spaeterer Stripe-Retry findet das
|
||||
* Ticket und sendet nichts. Keine Sperre feature-paid:<session> hier, markPaid nimmt sie selbst (withLocks ist nicht wiederbetretbar).
|
||||
*/
|
||||
async function ticketNachZahlung(doc, note) {
|
||||
const session = await bezahlteSitzung(doc)
|
||||
await handleFeatureCheckoutEvent({ type: 'checkout.session.completed', data: { object: session } })
|
||||
const nachher = await getDocument(config.collections.portalFeatures, doc.$id)
|
||||
if (!nachher.ticketId) return { doc: nachher, created: false }
|
||||
if (!note) return { doc: nachher, created: true }
|
||||
try {
|
||||
return { doc: await updateDocument(config.collections.portalFeatures, doc.$id, { adminNote: note }), created: true }
|
||||
} catch (err) {
|
||||
// Ticket und Mails sind raus; die Notiz ist nur ein Vermerk
|
||||
console.error(`[funktionen] Notiz zum nachgelegten Ticket von ${doc.$id} nicht gespeichert:`, err.message)
|
||||
return { doc: nachher, created: true }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ticket nachlegen (b): ticketId gesetzt, das Ticket aber geloescht. Ein neues Ticket ersetzt die ticketId; K15 und T16 gingen beim
|
||||
* ersten Mal raus, darum keine Mail. Laeuft unter der Sperre der Zahlung (feature-paid:<session>), damit kein Stripe-Retry daneben
|
||||
* schreibt; das Dokument wird darunter erneut gelesen.
|
||||
*/
|
||||
async function ersetzeGeloeschtesTicket(doc, note) {
|
||||
return withLocks([`feature-paid:${doc.stripeSessionId || doc.$id}`], async () => {
|
||||
const frisch = await getDocument(config.collections.portalFeatures, doc.$id)
|
||||
if (frisch.state !== 'setup') throw httpError(409, TICKET_NUR_SETUP)
|
||||
if (frisch.ticketId !== doc.ticketId) return { doc: frisch, created: false }
|
||||
const project = await getDocument(config.collections.websiteProjects, frisch.projectId)
|
||||
const customer = await customerOf(frisch.customerId).catch(() => null)
|
||||
const ticketId = await legeTicketAn(frisch, project, customer)
|
||||
const updated = await updateDocument(config.collections.portalFeatures, frisch.$id, { ticketId, ...(note ? { adminNote: note } : {}) })
|
||||
return { doc: updated, created: true }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* "Ticket nachlegen": nur fuer eine Funktion in Einrichtung ('setup'; sonst 409, ohne Dokument 409).
|
||||
* (a) ticketId leer: Zahlung bei Stripe pruefen (nicht bezahlt 409, Stripe nicht erreichbar 503) und den Weg des Webhooks gehen,
|
||||
* Ticket + K15 + T16 genau einmal (ticketNachZahlung);
|
||||
* (b) ticketId gesetzt, Ticket geloescht: neues Ticket, ticketId ersetzt, keine Mails (ersetzeGeloeschtesTicket);
|
||||
* (c) Ticket vorhanden: nichts, created false.
|
||||
* Eine Sperre feature-ticket:<id> je Dokument: gleichzeitige Aufrufe laufen nacheinander und sehen das Ergebnis des ersten
|
||||
* (ein Ticket, created nur einmal true).
|
||||
* @param {{ customerId: string, projectId: string, key: string, by?: string, adminNote?: string }} args
|
||||
* @returns {Promise<{ doc: object, created: boolean }>}
|
||||
*/
|
||||
export async function ticketNachlegen({ customerId, projectId, key, by, adminNote }) {
|
||||
const vorab = await dokumentFuerTeamAktion(customerId, projectId, key)
|
||||
const note = adminNote ?? byNote(by, 'Ticket nachgelegt')
|
||||
return withLocks([`feature-ticket:${vorab.$id}`], async () => {
|
||||
const doc = await frischesDokument(vorab, key)
|
||||
if (doc.state !== 'setup') throw httpError(409, TICKET_NUR_SETUP)
|
||||
if (!doc.ticketId) return ticketNachZahlung(doc, note)
|
||||
if (await ticketBesteht(doc.ticketId)) return { doc, created: false }
|
||||
return ersetzeGeloeschtesTicket(doc, note)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktive Funktion zu einem siteKey; ohne featureKey 'news' (Pinnwand), unbekannte Funktion oder siteKey => null.
|
||||
* states ersetzt die Vorgabe ['active'] (die Oeffnungszeiten antworten schon in 'setup', damit das Team die Leisten
|
||||
@@ -679,14 +880,29 @@ function einrichtungsPreis(feature) {
|
||||
return eur(amountCents)
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute, versionierte Adressen der Vertragsunterlagen einer Funktion aus dem Register (Fassung der Leistungsbeschreibung und
|
||||
* die Adressen von Leistungsbeschreibung, AVV und Widerrufsbelehrung); null bei Funktionen ohne Rechtsdokumente (News).
|
||||
* @returns {{ fassung: string, lb: string, avv: string, widerruf: string } | null}
|
||||
*/
|
||||
export function unterlagenAdressen(feature) {
|
||||
const { leistungsbeschreibung: lb, avv, widerruf } = feature ?? {}
|
||||
if (!lb?.url || !avv?.url || !widerruf?.url) return null
|
||||
const absolut = (pfad) => `${config.publicBaseUrl}${pfad}`
|
||||
return { fassung: lb.version, lb: absolut(lb.url), avv: absolut(avv.url), widerruf: absolut(widerruf.url) }
|
||||
}
|
||||
|
||||
/**
|
||||
* K15 (Bestellung eingegangen) einer Funktion: Vorlage, Platzhalterwerte und Bloecke ausschliesslich aus dem Register-Eintrag
|
||||
* (mails.*, setupEstimate, returnHash) und dem Katalogpreis. Die Werte decken genau die Platzhalter der Vorlage ab.
|
||||
* Ohne Anhaenge: die Rechtsunterlagen der Termine-Bestellung haengt F8 direkt in onFeaturePaid an.
|
||||
* Funktionen mit Rechtsdokumenten (Terminplaner): `unterlagen` (unterlagenAdressen) schaltet den Block anlagen ein und fuellt
|
||||
* fassung und die drei Adressen; `anhang` sagt, dass die PDFs an der Mail haengen (Block anhang in anlagen); `zustimmungenText`
|
||||
* (zustimmungenText) schaltet den Block zustimmungen ein. Die PDFs selbst gibt onFeaturePaid als `attachments` mit; es gibt keinen
|
||||
* Registerhaken dafuer.
|
||||
* behalten: der Kunde behaelt die Funktion nach dem Abo-Ende fuer einmalig 30 EUR (Oeffnungszeiten live): die Mail sagt, dass nichts
|
||||
* neu eingerichtet wird. Die Vorlage kennt dafuer zwei Gegenstuecke, behalten und neu (Normalfall); genau eins ist an.
|
||||
*/
|
||||
export function bestellMail(feature, { kundeName, projekt, invoiceUrl, behalten = false }) {
|
||||
export function bestellMail(feature, { kundeName, projekt, invoiceUrl, unterlagen = null, anhang = false, zustimmungenText = '', behalten = false }) {
|
||||
const { funktion, formen, preisHinweis, danach } = feature.mails
|
||||
return {
|
||||
template: 'K15',
|
||||
@@ -704,11 +920,47 @@ export function bestellMail(feature, { kundeName, projekt, invoiceUrl, behalten
|
||||
danach,
|
||||
portal_url: portalUrl(feature),
|
||||
rechnung_url: invoiceUrl || '',
|
||||
fassung: unterlagen?.fassung ?? '',
|
||||
lb_url: unterlagen?.lb ?? '',
|
||||
avv_url: unterlagen?.avv ?? '',
|
||||
widerruf_url: unterlagen?.widerruf ?? '',
|
||||
zustimmungen_text: zustimmungenText,
|
||||
},
|
||||
blocks: {
|
||||
rechnung: Boolean(invoiceUrl),
|
||||
anlagen: unterlagen !== null,
|
||||
anhang: unterlagen !== null && anhang === true,
|
||||
zustimmungen: zustimmungenText !== '',
|
||||
behalten: Boolean(behalten),
|
||||
neu: !behalten,
|
||||
},
|
||||
blocks: { rechnung: Boolean(invoiceUrl), behalten: Boolean(behalten), neu: !behalten },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wortlaut und Zeitpunkt der erteilten Zustimmungen fuer K15 (Du-Form): die Haken-Texte aus dem Nachweis zur bezahlten Sitzung, der
|
||||
* Zeitpunkt des Klicks (Europe/Berlin) und der Knopf, mit dem bestellt wurde. '' ohne verwertbaren Nachweis.
|
||||
* @param {{ texte?: string, acceptedAt?: string } | null} nachweis featureConsents-Dokument (JSON-Text im Feld texte)
|
||||
*/
|
||||
export function zustimmungenText(nachweis) {
|
||||
let texte
|
||||
try {
|
||||
texte = JSON.parse(nachweis?.texte ?? '')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
if (!Array.isArray(texte)) return ''
|
||||
const haken = texte.filter((t) => t?.haken === true && typeof t.text === 'string' && t.text !== '')
|
||||
if (haken.length === 0) return ''
|
||||
const am = new Date(nachweis.acceptedAt)
|
||||
const zeit = Number.isNaN(am.getTime()) ? '' : (({ date, time }) => ` am ${date} um ${time.slice(0, 2)}:${time.slice(2)} Uhr`)(berlinDateTime(am))
|
||||
const knopf = texte.find((t) => t?.id === 'knopf' && typeof t.text === 'string' && t.text !== '')
|
||||
return [
|
||||
`Du hast${zeit} im Kundenportal zugestimmt${knopf ? ` und mit „${knopf.text}“ bestellt` : ''}:`,
|
||||
...haken.map((t) => `– ${t.text}`),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** K16 (Funktion bereit) einer Funktion, Werte wie bei bestellMail aus dem Register-Eintrag. */
|
||||
export function bereitMail(feature, { kundeName, projekt }) {
|
||||
const { funktion, formen, bereit, knopf } = feature.mails
|
||||
@@ -780,7 +1032,7 @@ async function ticketWoidOf(doc) {
|
||||
/**
|
||||
* T16 an das Team (Funktion bestellt): alle Platzhalter und Bloecke der Vorlage; was nicht genannt wird, bleibt leer bzw. aus.
|
||||
* Ohne bekannte Funktion heisst sie in Betreff und Text "Funktion". Liefert das Ergebnis von sendAdminNotice ({ sent, reason? }); der
|
||||
* Block gesundheit bleibt hier aus (D4 fuellt ihn).
|
||||
* Der Block gesundheit (Terminplaner) steht nur, wenn der Aufrufer ihn mit gesundheit_text einschaltet (teamNotice).
|
||||
*/
|
||||
function sendT16({ funktion, ...werte }, bloecke) {
|
||||
return sendAdminNotice({
|
||||
@@ -798,15 +1050,66 @@ function sendT16({ funktion, ...werte }, bloecke) {
|
||||
gesundheit_text: '',
|
||||
...werte,
|
||||
},
|
||||
blocks: { zahlung_fehlgeschlagen: false, ungeklaert: false, kein_abo: false, bereits_aktiv: false, anleitung: false, gesundheit: false, abo: false, team: false, ...bloecke },
|
||||
blocks: {
|
||||
zahlung_fehlgeschlagen: false,
|
||||
ungeklaert: false,
|
||||
kein_abo: false,
|
||||
bereits_aktiv: false,
|
||||
anleitung: false,
|
||||
gesundheit: false,
|
||||
anhang_fehlt: false,
|
||||
abo: false,
|
||||
team: false,
|
||||
...bloecke,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** keinAbo: die Zahlung ist da, der Kunde hat aber kein laufendes Wartungs-Abo (Block kein_abo, nur bei Funktionen mit requiresWartungsAbo). */
|
||||
async function teamNotice(doc, customer, { failed = false, keinAbo = false } = {}) {
|
||||
/** Der Warntext des T16-Blocks gesundheit hat hoechstens so viele Zeichen (ganze Meldungen, danach "… und N weitere"). */
|
||||
const GESUNDHEIT_TEXT_MAX = 500
|
||||
|
||||
/** Meldungen als ein Text: durch "; " getrennt; passt es nicht, so viele ganze Meldungen wie moeglich und "… und N weitere" (nie mitten in einer Meldung). */
|
||||
function gesundheitText(meldungen) {
|
||||
const alle = meldungen.join('; ')
|
||||
if (alle.length <= GESUNDHEIT_TEXT_MAX) return alle
|
||||
let text = ''
|
||||
let n = 0
|
||||
for (const meldung of meldungen) {
|
||||
const naechster = text ? `${text}; ${meldung}` : meldung
|
||||
if (`${naechster} … und ${meldungen.length - n - 1} weitere`.length > GESUNDHEIT_TEXT_MAX) break
|
||||
text = naechster
|
||||
n += 1
|
||||
}
|
||||
return text ? `${text} … und ${meldungen.length - n} weitere` : kuerze(meldungen[0], GESUNDHEIT_TEXT_MAX, { endzeile: '…' })
|
||||
}
|
||||
|
||||
/**
|
||||
* T16-Hinweis "Gesundheitsbereich pruefen" (nur Terminplaner): die Treffer der Stichwortpruefung ueber die gespeicherte Einstellung des Betriebs
|
||||
* (gesundheitsMeldungen: wohlgeformt, einzeilig, Klartext; renderMail maskiert sie fuer HTML), hoechstens 500 Zeichen. '' ohne Treffer und bei
|
||||
* jedem Lesefehler: T16 geht dann ohne Block raus (nie Geld ohne Spur); das Log nennt keine Daten des Betriebs. Dynamische Importe, weil
|
||||
* termine/kontext.js dieses Modul importiert.
|
||||
*/
|
||||
async function gesundheitHinweis(doc) {
|
||||
if (doc.featureKey !== 'termine') return ''
|
||||
try {
|
||||
const [{ ladeEinstellung }, { gesundheitsMeldungen }] = await Promise.all([import('./termine/kontext.js'), import('./termine/einbauanleitung.js')])
|
||||
const meldungen = gesundheitsMeldungen((await ladeEinstellung(doc.$id))?.einstellung)
|
||||
return meldungen.length ? gesundheitText(meldungen) : ''
|
||||
} catch (err) {
|
||||
console.error(`[funktionen] Gesundheitshinweis fuer T16 (Funktion ${doc.$id}) nicht lesbar, der Block entfaellt:`, err.message)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* keinAbo: die Zahlung ist da, der Kunde hat aber kein laufendes Wartungs-Abo (Block kein_abo, nur bei Funktionen mit requiresWartungsAbo).
|
||||
* anhangFehlt: die Vertragsunterlagen sind nicht beim Kunden angekommen, das Team sendet sie von Hand nach (Block anhang_fehlt).
|
||||
*/
|
||||
async function teamNotice(doc, customer, { failed = false, keinAbo = false, anhangFehlt = false } = {}) {
|
||||
const mails = featureConfig(doc.featureKey)?.mails
|
||||
// Knopf zur Einbauanleitung nur bei Funktionen mit Anleitung und nur, wenn bestellt und ein Ticket angelegt wurde
|
||||
const anleitung = Boolean(mails?.anleitung) && !failed
|
||||
const gesundheit = failed ? '' : await gesundheitHinweis(doc)
|
||||
await sendT16(
|
||||
{
|
||||
kunde_name: customer?.name || customer?.email || doc.customerId,
|
||||
@@ -816,8 +1119,9 @@ async function teamNotice(doc, customer, { failed = false, keinAbo = false } = {
|
||||
woid: failed ? '' : await ticketWoidOf(doc),
|
||||
sitekey: failed ? '' : doc.siteKey || '',
|
||||
anleitung_url: anleitung ? anleitungsLink(doc.customerId, doc.projectId) : '',
|
||||
gesundheit_text: gesundheit,
|
||||
},
|
||||
{ zahlung_fehlgeschlagen: failed, anleitung, kein_abo: keinAbo }
|
||||
{ zahlung_fehlgeschlagen: failed, anleitung, kein_abo: keinAbo, gesundheit: gesundheit !== '', anhang_fehlt: anhangFehlt }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -986,6 +1290,62 @@ async function onBehaltenPaid(session) {
|
||||
await bucheBehalten(session)
|
||||
}
|
||||
|
||||
/** K15 einer Funktion ohne Rechtsdokumente (News): keine Anhaenge, keine Bloecke anlagen und zustimmungen. */
|
||||
const OHNE_RECHTSUNTERLAGEN = Object.freeze({ attachments: Object.freeze([]), unterlagen: null, anhang: false, zustimmungen: '', fehlt: false })
|
||||
|
||||
/**
|
||||
* Rechtsunterlagen der K15 einer Funktion mit Rechtsdokumenten: die PDFs und Adressen der Fassung, der der Kunde bei der Bestellung
|
||||
* zugestimmt hat (Fassung des Nachweises zur bezahlten Sitzung), und der Wortlaut seiner Zustimmungen. Das ist meist die aktuelle
|
||||
* Fassung (legalDocs.featureLegalAttachments, erst beim Aufruf geladen; kein Registerhaken). Wurde die Fassung zwischen Klick und
|
||||
* Zahlung angehoben, gehen die Unterlagen der vereinbarten Fassung raus, wenn es sie gibt; fehlen sie, geht K15 ohne PDFs und Links
|
||||
* raus (bei der aktuellen Fassung bleiben die Links, nur die PDFs fehlen). Ist der Nachweis nicht lesbar oder seine Fassung nicht bestimmbar, ist die vereinbarte Fassung unbekannt: nie die aktuelle
|
||||
* ungeprueft anhaengen, K15 geht ohne PDFs und Links raus. Ohne Nachweis (Altbestand) gilt die aktuelle Fassung. In allen Faellen ohne
|
||||
* Unterlagen ist fehlt true (T16 sagt "von Hand nachsenden") und der Grund steht im Log. Wirft nie.
|
||||
* @returns {Promise<{ attachments: object[], unterlagen: { fassung: string, lb: string, avv: string, widerruf: string } | null, anhang: boolean, zustimmungen: string, fehlt: boolean }>}
|
||||
*/
|
||||
async function k15Rechtsunterlagen(doc, feature, session) {
|
||||
const ohne = (zustimmungen) => ({ attachments: [], unterlagen: null, anhang: false, zustimmungen, fehlt: true })
|
||||
let nachweis = null
|
||||
try {
|
||||
nachweis = await zustimmungFuerSitzung(session.id)
|
||||
} catch (err) {
|
||||
console.error(`[funktionen] K15 (Funktion ${doc.$id}): Zustimmungsnachweis zu Sitzung ${session.id} nicht lesbar, die vereinbarte Fassung ist unbekannt - ohne Unterlagen, das Team sendet sie von Hand:`, err.message)
|
||||
return ohne('')
|
||||
}
|
||||
const zustimmungen = zustimmungenText(nachweis)
|
||||
const aktuell = feature.consent.version
|
||||
let vereinbart = aktuell
|
||||
if (nachweis) {
|
||||
try {
|
||||
vereinbart = JSON.parse(nachweis.fassungen)?.zustimmung
|
||||
} catch {
|
||||
vereinbart = undefined
|
||||
}
|
||||
if (typeof vereinbart !== 'string' || vereinbart === '') {
|
||||
console.error(`[funktionen] K15 (Funktion ${doc.$id}): Fassung im Zustimmungsnachweis nicht bestimmbar - ohne Unterlagen, das Team sendet sie von Hand`)
|
||||
return ohne(zustimmungen)
|
||||
}
|
||||
}
|
||||
try {
|
||||
const legal = await import('./legalDocs.js')
|
||||
const eigene = vereinbart === aktuell ? {} : { fassung: vereinbart }
|
||||
if (vereinbart !== aktuell) {
|
||||
console.error(`[funktionen] K15 (Funktion ${doc.$id}): Der Kunde hat der Fassung ${vereinbart} zugestimmt, aktuell ist ${aktuell} - die Unterlagen der vereinbarten Fassung gehen raus, wenn es sie gibt`)
|
||||
if (!(await legal.featureLegalReady(doc.featureKey, eigene))) throw new Error(`Unterlagen der Fassung ${vereinbart} fehlen`)
|
||||
}
|
||||
const attachments = await legal.featureLegalAttachments(doc.featureKey, eigene)
|
||||
if (attachments.length === 0) throw new Error('keine Anhaenge fuer diese Funktion')
|
||||
const adressen = legal.featureLegalPaths(doc.featureKey, eigene)
|
||||
const absolut = (pfad) => `${config.publicBaseUrl}${pfad}`
|
||||
const unterlagen = { fassung: vereinbart, lb: absolut(adressen.lb), avv: absolut(adressen.avv), widerruf: absolut(adressen.widerruf) }
|
||||
return { attachments, unterlagen, anhang: true, zustimmungen, fehlt: false }
|
||||
} catch (err) {
|
||||
console.error(`[funktionen] K15 (Funktion ${doc.$id}): Unterlagen nicht lesbar, K15 geht ohne PDFs raus:`, err.message)
|
||||
// die Seiten der aktuellen (= vereinbarten) Fassung bleiben verlinkt; Links auf eine andere Fassung gibt es nur, wenn deren Dateien da sind
|
||||
return { ...ohne(zustimmungen), unterlagen: vereinbart === aktuell ? unterlagenAdressen(feature) : null }
|
||||
}
|
||||
}
|
||||
|
||||
async function onFeaturePaid(session) {
|
||||
if (session.metadata?.zweck === 'behalten') return onBehaltenPaid(session)
|
||||
const { invoiceId, invoiceUrl } = await invoiceOf(session.invoice)
|
||||
@@ -1005,16 +1365,34 @@ async function onFeaturePaid(session) {
|
||||
} catch (err) {
|
||||
console.error('[funktionen] Kunde fuer K15/T16 nicht lesbar:', err.message)
|
||||
}
|
||||
// K15 mit den Vertragsunterlagen (Terminplaner: drei PDFs als dauerhafter Datentraeger, § 312f BGB). Die Bestaetigung geht vor dem
|
||||
// Anhang: sind die PDFs nicht lesbar oder kommt die Mail nicht an, bleibt die Zahlung nicht ohne Spur, T16 sagt dem Team
|
||||
// "Unterlagen von Hand nachsenden" (Block anhang_fehlt).
|
||||
const mitRechtsdokumenten = feature ? unterlagenAdressen(feature) !== null : false
|
||||
let anhangFehlt = false
|
||||
if (customer?.email) {
|
||||
try {
|
||||
if (!feature) throw new Error(`Funktion ${doc.featureKey} nicht im Register`)
|
||||
await sendCustomerMail(
|
||||
customer.email,
|
||||
bestellMail(feature, { kundeName: customer.name || '', projekt: await projectName(doc.projectId), invoiceUrl })
|
||||
)
|
||||
const rechtlich = mitRechtsdokumenten ? await k15Rechtsunterlagen(doc, feature, session) : OHNE_RECHTSUNTERLAGEN
|
||||
anhangFehlt = rechtlich.fehlt
|
||||
await sendCustomerMail(customer.email, {
|
||||
...bestellMail(feature, {
|
||||
kundeName: customer.name || '',
|
||||
projekt: await projectName(doc.projectId),
|
||||
invoiceUrl,
|
||||
unterlagen: rechtlich.unterlagen,
|
||||
anhang: rechtlich.anhang,
|
||||
zustimmungenText: rechtlich.zustimmungen,
|
||||
}),
|
||||
attachments: [...rechtlich.attachments],
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[funktionen] K15 fehlgeschlagen:', err.message)
|
||||
if (mitRechtsdokumenten) anhangFehlt = true
|
||||
}
|
||||
} else if (mitRechtsdokumenten) {
|
||||
console.error(`[funktionen] K15 (Funktion ${doc.$id}) nicht gesendet: beim Kunden ist keine E-Mail-Adresse hinterlegt - die Vertragsunterlagen muessen von Hand nachgesendet werden`)
|
||||
anhangFehlt = true
|
||||
}
|
||||
// Funktionen, die ein laufendes Wartungs-Abo voraussetzen: endete es vor der Zahlung, sagt T16 es dem Team (Block kein_abo).
|
||||
// Nach der Verbuchung geprueft; ein Lesefehler blockiert nichts (Log, Block bleibt aus), der Webhook antwortet nie deswegen 500.
|
||||
@@ -1027,7 +1405,7 @@ async function onFeaturePaid(session) {
|
||||
}
|
||||
}
|
||||
try {
|
||||
await teamNotice(doc, customer, { keinAbo })
|
||||
await teamNotice(doc, customer, { keinAbo, anhangFehlt })
|
||||
} catch (err) {
|
||||
console.error('[funktionen] T16 fehlgeschlagen:', err.message)
|
||||
}
|
||||
@@ -1094,32 +1472,18 @@ async function heileZeiten({ now }) {
|
||||
/**
|
||||
* Freischalt-Job: geschlossene Einrichtungs-Tickets -> 'active' (je Funktion nur, wenn ihr Haken freischaltbar es erlaubt; Oeffnungszeiten:
|
||||
* erst mit eingetragenen Zeiten), danach je Dokument der Haken nachFreischaltung (Oeffnungszeiten: Ersatz-Hash) und K16 an jeden
|
||||
* betroffenen Kunden. Zum Schluss, nur im Lauf des Jobs (ohne customerId), die Selbstheilung der Oeffnungszeiten (heileZeiten): die
|
||||
* Projektliste eines Kunden (GET /api/funktionen, mit customerId) schaltet nur frei und baut, bestellt und sendet nichts nach.
|
||||
* betroffenen Kunden (meldeFreigeschaltet: Fehler werden geloggt und halten weder die Aktivierung noch die uebrigen Mails auf).
|
||||
* termine/freischaltung.js importiert diese Datei, darum wird es hier erst im Aufruf geladen. Zum Schluss, nur im Lauf des Jobs
|
||||
* (ohne customerId), die Selbstheilung der Oeffnungszeiten (heileZeiten): die Projektliste eines Kunden (GET /api/funktionen, mit
|
||||
* customerId) schaltet nur frei und baut, bestellt und sendet nichts nach.
|
||||
*/
|
||||
export async function runActivation({ now = new Date(), customerId = null } = {}) {
|
||||
const activated = await activateClosedTickets({ now, customerId })
|
||||
for (const doc of activated) {
|
||||
// Nacharbeit vor der K16 und vor dem Adress-Check: auch ein Kunde ohne Adresse bekommt sie; ein Fehler bricht nichts ab
|
||||
const { nachFreischaltung } = featureHaken(doc.featureKey)
|
||||
if (typeof nachFreischaltung === 'function') {
|
||||
try {
|
||||
await nachFreischaltung(doc)
|
||||
} catch (err) {
|
||||
console.error(`[funktionen] Nacharbeit nach der Freischaltung ${doc.featureKey} (${doc.$id}) fehlgeschlagen:`, err.message)
|
||||
}
|
||||
}
|
||||
try {
|
||||
const customer = await customerOf(doc.customerId)
|
||||
if (!customer?.email) continue
|
||||
const feature = featureConfig(doc.featureKey)
|
||||
if (!feature) throw new Error(`Funktion ${doc.featureKey} nicht im Register`)
|
||||
await sendCustomerMail(
|
||||
customer.email,
|
||||
bereitMail(feature, { kundeName: customer.name || '', projekt: await projectName(doc.projectId) })
|
||||
)
|
||||
} catch (err) {
|
||||
console.error(`[funktionen] K16 an Kunde ${doc.customerId} fehlgeschlagen:`, err.message)
|
||||
if (activated.length > 0) {
|
||||
const { meldeFreigeschaltet } = await import('./termine/freischaltung.js')
|
||||
for (const doc of activated) {
|
||||
await nacharbeitNachFreischaltung(doc)
|
||||
await meldeFreigeschaltet(doc)
|
||||
}
|
||||
}
|
||||
if (!customerId) await heileZeiten({ now })
|
||||
|
||||
Reference in new issue
Block a user