Merge feature/terminplaner-tp2 (a629b25) in feature/oeffnungszeiten

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 04:42:31 +00:00
commit 1d5a565e9f
95 files changed
+12879 -546

No files matched your search

+410 -46
View File
@@ -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 })