Webshop E4: Mails (G09-G14 an Käufer, K25/K26 an Händler), shop/mails.js, Mailversand der Warteschlange auch bei Terminplaner off

Käufermails laufen über die Warteschlange des Terminplaners (G09/G12 mit Vorrang),
G09 wird bei Überlänge um den AGB-Text gekürzt (Link statt Text). Der Mailteil des
Minutenjobs und die Aufräumschritte des Tagesjobs laufen auch bei TERMINE_ENABLED=off,
solange SHOP_ENABLED nicht off ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 08:56:40 +00:00
1 parent 38bd56fe8b
commit 0ebd2e6663
15 files changed
+1570 -26

No files matched your search

+390
View File
@@ -0,0 +1,390 @@
import { config } from '../../config.js'
import { renderMail, loadTemplate } from '../mailTemplates.js'
import { sendCustomerMail } from '../mailer.js'
import { customerOf } from '../siteFeatures.js'
import { einreihen } from '../termine/outbox.js'
import { pruefeNow, zeitMs } from '../termine/util.js'
import { datumKurz, uhrzeit } from '../termine/zeit.js'
import { euro } from './geld.js'
import { oeffentlicheRechtstexte } from './kontext.js'
import { gewaehrleistungsHinweis } from './rechtstexte.js'
/**
* Mails des Webshops (Spezifikation 2026-10-05-webshop-design.md, Abschnitte 5.3, 5.4 und 8; Plan Task E4).
*
* Kaeufer (G09 Bestellbestaetigung, G10 versendet, G11 abholbereit, G12 Eingang Widerruf, G13 Erstattung, G14 Zahlung
* fehlgeschlagen) laufen durch die Mail-Warteschlange des Terminplaners (termine/outbox.js einreihen): Absendername = Shopname,
* Absenderadresse bleibt SMTP_FROM, Antworten an die E-Mail des Haendlers, nie BCC. featureId = ID des portalFeatures-Dokuments des
* Shops, bookingId = Bestell-ID. Der Betreff steht in der Vorlage (G-Vorlagen des Shops haben keinen {{betreff}}-Wert). Die
* Empfaenger-Kappe der Terminplaner-Mails (limits.darfEmpfaenger) gilt hier nicht: G09 ist Vertragspflicht.
* Haendler (K25 neue Bestellung, K26 Widerruf) gehen direkt per sendCustomerMail an die Kunden-E-Mail, ohne BCC ans Team
* (die Mails enthalten Kaeuferdaten).
*
* Beide Wege werfen nie wegen eines Mailfehlers: Rueckgabe { ok: true, ... } | { ok: false, fehler }; der Fehlertext steht im Log (ohne
* Adresse), die Bestellung bleibt unberuehrt.
*
* Erwartete Form der Bestellung (`order` = shopOrders-Dokument; der JSON-Schnappschuss steht in `bestellung`, ersatzweise `daten`,
* als Text oder Objekt; alle Felder sind optional, Fehlendes erscheint als "–"):
* order.bestellnummer, order.kaeuferEmail, order.bezahltAt | order.erstelltAt
* snap.positionen[] { name, varianteText, menge, summeCent, summeText?, widerruf ('gesetzlich'|'verderblich'|'individuell'|'hygiene') }
* snap.summen | snap { zwischensummeCent, versandCent, gesamtCent, texte?: { zwischensumme, versand, gesamt, steuerHinweis } }
* snap.lieferart ('versand'|'abholung'), snap.land ('DE'|'AT')
* snap.kaeufer { name, email, telefon } (oder snap.name)
* snap.lieferadresse { name?, line1|strasse, line2?, postal_code|plz, city|ort, country|land }
* snap.zahlungsart 'card' | 'klarna' | 'sepa_debit' | Text
* snap.einstellungenAuszug { anbieter, lieferzeit, abholung } (sonst die aktuellen Einstellungen des Shops)
*/
const LEER = '–'
const GAST_ARTEN = Object.freeze(['G09', 'G10', 'G11', 'G12', 'G13', 'G14'])
/** Grenzen des Outbox-Schemas (html 100000, text 50000) mit etwas Luft. */
const MAX_HTML = 95000
const MAX_TEXT = 47000
const LANDNAMEN = { DE: 'Deutschland', AT: 'Österreich' }
const ZAHLUNGSARTEN = { card: 'Kreditkarte, Apple Pay, Google Pay', klarna: 'Klarna', sepa_debit: 'SEPA-Lastschrift' }
// Wie WIDERRUF_TEXTE in produkte.js (dort nicht exportiert)
const WIDERRUF_AUSSCHLUSS = {
verderblich: 'Vom Widerruf ausgeschlossen: schnell verderbliche Ware',
individuell: 'Vom Widerruf ausgeschlossen: nach Kundenwunsch angefertigt',
hygiene: 'Vom Widerruf ausgeschlossen: versiegelte Ware aus Hygienegründen, wenn die Versiegelung entfernt wurde',
}
const ABHOLUNG_HINWEIS = 'Bitte bringen Sie Ihre Bestellnummer mit.'
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
const hatText = (wert) => typeof wert === 'string' && wert.trim() !== ''
const text = (wert) => (typeof wert === 'string' ? wert.trim() : '')
const oderLeer = (wert) => (hatText(wert) ? wert.trim() : LEER)
const einzeilig = (wert) => text(wert).replace(/\s+/g, ' ')
// ---------------------------------------------------------------- Bausteine
/** Schnappschuss der Bestellung als Objekt ({} wenn es keinen oder keinen lesbaren gibt). */
function schnappschuss(order) {
for (const feld of ['bestellung', 'daten']) {
const roh = order?.[feld]
if (istObjekt(roh)) return roh
if (typeof roh === 'string' && roh !== '') {
try {
const geparst = JSON.parse(roh)
if (istObjekt(geparst)) return geparst
} catch {
// unlesbar: naechstes Feld
}
}
}
return {}
}
const einstellungenVon = (ctx) => ctx?.shop?.einstellungen ?? {}
const anbieterVon = (ctx, snap) => ({ ...(einstellungenVon(ctx).anbieter ?? {}), ...(istObjekt(snap?.einstellungenAuszug?.anbieter) ? snap.einstellungenAuszug.anbieter : {}) })
/** Name des Shops in Betreff, Kopf und Absender: Shopname, sonst Name des Anbieters. */
function shopName(ctx, snap = {}) {
return einzeilig(snap?.einstellungenAuszug?.shopname) || einzeilig(einstellungenVon(ctx).shopname) || einzeilig(anbieterVon(ctx, snap).name) || 'Webshop'
}
function anbieterZeile(a) {
const ort = [text(a.plz), text(a.ort)].filter(Boolean).join(' ')
const land = text(a.land) && a.land !== 'DE' ? (LANDNAMEN[a.land] ?? a.land) : ''
const anschrift = [text(a.strasse), ort, land].filter(Boolean).join(', ')
return [text(a.name), anschrift, text(a.email)].filter(Boolean).join(', ') || LEER
}
const siteKeyVon = (ctx) => ctx?.shop?.siteKey || ctx?.doc?.siteKey || ''
const shopSeite = (ctx) => `${config.publicBaseUrl}/shop/${siteKeyVon(ctx)}`
/** Akzentfarbe als #rrggbb (nur geprueft, damit nichts in das style-Attribut gelangt). */
const farbeVon = (e) => (/^#[0-9a-fA-F]{6}$/.test(e?.akzent ?? '') ? e.akzent : '#111111')
/** Menge × Name (Variante) – Summe, eine Zeile je Position. */
function positionenText(positionen) {
const zeilen = (Array.isArray(positionen) ? positionen : [])
.filter(istObjekt)
.map((p) => {
const variante = text(p.varianteText) ? ` (${einzeilig(p.varianteText)})` : ''
const summe = hatText(p.summeText) ? p.summeText.trim() : Number.isInteger(p.summeCent) ? euro(p.summeCent) : LEER
return `${Number.isInteger(p.menge) ? p.menge : LEER} × ${einzeilig(p.name) || LEER}${variante} – ${summe}`
})
return zeilen.length ? zeilen.join('\n') : LEER
}
function adresseText(a, fallbackName) {
if (!istObjekt(a)) return LEER
const feld = (...namen) => text(namen.map((n) => a[n]).find(hatText))
const land = feld('country', 'land')
const zeilen = [
feld('name') || text(fallbackName),
feld('line1', 'strasse'),
feld('line2'),
[feld('postal_code', 'plz'), feld('city', 'ort')].filter(Boolean).join(' '),
land && land !== 'DE' ? (LANDNAMEN[land] ?? land) : '',
].filter(Boolean)
return zeilen.length ? zeilen.join('\n') : LEER
}
function zahlungsartText(snap, e) {
const roh = text(snap.zahlungsart)
if (roh) return ZAHLUNGSARTEN[roh] ?? roh
return Array.isArray(e.zahlungsarten) && e.zahlungsarten.length === 1 ? (ZAHLUNGSARTEN[e.zahlungsarten[0]] ?? LEER) : 'Online-Zahlung'
}
function ausschlussText(positionen) {
return (Array.isArray(positionen) ? positionen : [])
.filter((p) => istObjekt(p) && Object.hasOwn(WIDERRUF_AUSSCHLUSS, p.widerruf))
.map((p) => `${einzeilig(p.name) || LEER}: ${WIDERRUF_AUSSCHLUSS[p.widerruf]}`)
.join('\n')
}
/** "TT.MM.JJJJ, HH:MM Uhr" (Berliner Zeit); ungueltige Zeitpunkte werfen. */
function eingangText(eingang) {
const ms = zeitMs(eingang)
if (!Number.isFinite(ms)) throw new TypeError('Eingang ist kein Zeitpunkt')
const d = new Date(ms)
return `${datumKurz(d)}, ${uhrzeit(d)} Uhr`
}
const datumText = (order, now) => {
const ms = zeitMs(order?.bezahltAt || order?.erstelltAt)
return datumKurz(Number.isFinite(ms) ? new Date(ms) : now)
}
// ---------------------------------------------------------------- Werte
/**
* Gemeinsame Werte der Kaeufermails aus dem Schnappschuss der Bestellung und dem Shop-Kontext: Kopf und Fuss (farbe, betrieb_name,
* anbieter_zeile, widerruf_url, impressum_url, datenschutz_url) und alle Werte von G09 (bestellnummer, datum, positionen, Summen,
* Lieferart, Adresse, Zahlungsart, Rechtstexte). `order` darf null sein (G12 ohne passende Bestellung): dann sind die
* Bestellwerte "–". Es sind mehr Werte als eine einzelne Vorlage braucht; anKaeufer reicht nur die deklarierten weiter.
* @param {{ doc?: object, shop: object }} ctx
* @param {object|null} order
* @param {Date} [now] Ersatzdatum, wenn die Bestellung keinen Zeitpunkt hat
* @returns {Record<string, string>}
*/
export function kaeuferWerte(ctx, order, now = new Date()) {
if (!istObjekt(ctx?.shop)) throw new TypeError('kaeuferWerte: ctx.shop fehlt')
const snap = schnappschuss(order)
const e = einstellungenVon(ctx)
const auszug = istObjekt(snap.einstellungenAuszug) ? snap.einstellungenAuszug : {}
const anbieter = anbieterVon(ctx, snap)
const abholung = { ...(e.abholung ?? {}), ...(istObjekt(auszug.abholung) ? auszug.abholung : {}) }
const rechtstexte = oeffentlicheRechtstexte(ctx) ?? {}
const summen = istObjekt(snap.summen) ? snap.summen : snap
const texte = istObjekt(summen.texte) ? summen.texte : {}
const cent = (wert) => (Number.isInteger(wert) ? euro(wert) : LEER)
const lieferart = snap.lieferart === 'abholung' ? 'abholung' : snap.lieferart === 'versand' ? 'versand' : ''
const kaeufer = istObjekt(snap.kaeufer) ? snap.kaeufer : {}
const land = text(snap.land)
const shop = shopSeite(ctx)
return {
farbe: farbeVon(e),
betrieb_name: shopName(ctx, snap),
anbieter_zeile: anbieterZeile(anbieter),
widerruf_url: `${shop}/widerruf`,
impressum_url: text(rechtstexte.impressumUrl) || shop,
datenschutz_url: text(rechtstexte.datenschutzUrl) || shop,
bestellnummer: oderLeer(order?.bestellnummer),
datum: datumText(order, now),
positionen: positionenText(snap.positionen),
zwischensumme: text(texte.zwischensumme) || cent(summen.zwischensummeCent),
versand: text(texte.versand) || (lieferart === 'abholung' ? 'Abholung – kostenlos' : cent(summen.versandCent)),
gesamt: text(texte.gesamt) || cent(summen.gesamtCent ?? order?.summeCent),
steuer_hinweis: text(texte.steuerHinweis) || (e.kleinunternehmer === true ? 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.' : LEER),
lieferart_text: lieferart === 'abholung' ? 'Abholung' : lieferart === 'versand' ? `Versand${land ? ` nach ${LANDNAMEN[land] ?? land}` : ''}` : LEER,
lieferadresse: adresseText(snap.lieferadresse ?? kaeufer.lieferadresse ?? kaeufer.adresse, kaeufer.name ?? snap.name),
abholort: oderLeer(abholung.adresse),
lieferzeit: oderLeer(auszug.lieferzeit ?? e.lieferzeit),
zahlungsart: zahlungsartText(snap, e),
ausschluss_text: ausschlussText(snap.positionen),
agb: oderLeer(rechtstexte.agb),
widerrufsbelehrung: oderLeer(rechtstexte.widerrufsbelehrung),
widerrufsformular: oderLeer(rechtstexte.widerrufsformular),
gewaehrleistung: gewaehrleistungsHinweis(),
// fuer G11 (Standard, wenn der Haendler keinen Hinweis hinterlegt hat)
hinweis: text(abholung.hinweis) || ABHOLUNG_HINWEIS,
}
}
/**
* Werte und Bloecke einer Kaeufermail (alle Werte von kaeuferWerte plus die Werte der Aktion aus `extra`, gefiltert auf die
* Platzhalter der Vorlage).
* extra je Art:
* G10 versanddienst, sendungsnummer (beide optional; Block sendung, wenn eines da ist)
* G11 hinweis (ersetzt den Hinweis des Haendlers)
* G12 name, bestellnummer (Eingaben des Kaeufers; ohne Angabe die der Bestellung), eingang (Date | ISO, Pflicht), nachricht
* G13 betragCent (Pflicht, ganze Cent), grund
*/
function kaeuferMail(art, ctx, order, extra, now) {
const alle = kaeuferWerte(ctx, order, now)
const blocks = {}
switch (art) {
case 'G09':
blocks.versand = schnappschuss(order).lieferart === 'versand'
blocks.abholung = schnappschuss(order).lieferart === 'abholung'
blocks.ausschluss = alle.ausschluss_text !== ''
break
case 'G10':
alle.versanddienst = oderLeer(extra.versanddienst)
alle.sendungsnummer = oderLeer(extra.sendungsnummer)
blocks.sendung = hatText(extra.versanddienst) || hatText(extra.sendungsnummer)
break
case 'G11':
if (hatText(extra.hinweis)) alle.hinweis = extra.hinweis.trim()
break
case 'G12':
alle.name = text(extra.name)
if (hatText(extra.bestellnummer)) alle.bestellnummer = extra.bestellnummer.trim()
alle.eingang = eingangText(extra.eingang)
alle.nachricht = text(extra.nachricht)
blocks.nachricht = alle.nachricht !== ''
break
case 'G13':
if (!Number.isInteger(extra.betragCent) || extra.betragCent <= 0) throw new TypeError('extra.betragCent muss eine positive ganze Zahl sein')
alle.betrag = euro(extra.betragCent)
alle.grund = text(extra.grund)
blocks.grund = alle.grund !== ''
break
default:
break
}
const vorlage = loadTemplate(art)
const values = Object.fromEntries(Object.entries(alle).filter(([schluessel]) => vorlage.placeholders.includes(schluessel)))
return { values, blocks: Object.fromEntries(Object.entries(blocks).filter(([name]) => vorlage.blocks.includes(name))) }
}
const zuLang = (mail) => mail.html.length > MAX_HTML || mail.text.length > MAX_TEXT
// ---------------------------------------------------------------- Einreihen
/**
* Reiht eine Kaeufermail (G09-G14) in die Warteschlange ein. Empfaenger ist extra.an, sonst order.kaeuferEmail. bookingId ist
* order.$id, sonst extra.bookingId (z. B. die ID des Widerrufs bei G12), sonst "-". Ist G09 zu lang fuer die Warteschlange
* (AGB, Widerrufsbelehrung und Formular koennen zusammen sehr lang sein), ersetzt der Server den AGB-Text durch einen Satz mit
* Link auf die AGB im Shop; Widerrufsbelehrung, Formular und Gewaehrleistungshinweis bleiben immer vollstaendig.
* @param {'G09'|'G10'|'G11'|'G12'|'G13'|'G14'} art
* @param {{ doc: object, shop: object }} ctx Shop-Kontext (kontext.js)
* @param {object|null} order shopOrders-Dokument (bei G12 ohne passende Bestellung null)
* @param {object} [extra] siehe kaeuferMail
* @param {Date} [now]
* @returns {Promise<{ ok: true, eintrag: object, gekuerzt: boolean } | { ok: false, fehler: string }>}
*/
export async function anKaeufer(art, ctx, order, extra = {}, now = new Date()) {
try {
if (!GAST_ARTEN.includes(art)) throw new TypeError(`${String(art).slice(0, 20)} ist keine Käufermail`)
pruefeNow(now, 'anKaeufer')
extra = extra ?? {}
const { values, blocks } = kaeuferMail(art, ctx, order, extra, now)
const an = hatText(extra.an) ? extra.an.trim() : text(order?.kaeuferEmail)
if (an === '') throw new TypeError('Empfänger fehlt')
let gekuerzt = false
let mail = renderMail(art, values, blocks)
if (art === 'G09' && zuLang(mail)) {
values.agb = `Die vollständigen AGB finden Sie unter ${shopSeite(ctx)}?ansicht=agb`
mail = renderMail(art, values, blocks)
gekuerzt = true
}
if (zuLang(mail)) throw new TypeError('Die Mail ist zu lang für die Warteschlange')
const e = einstellungenVon(ctx)
const eintrag = await einreihen(
{
featureId: ctx.doc.$id,
bookingId: order?.$id || (hatText(extra.bookingId) ? extra.bookingId : '-'),
art,
an,
fromName: shopName(ctx, schnappschuss(order)).slice(0, 120),
replyTo: text(anbieterVon(ctx, schnappschuss(order)).email) || text(e.anbieter?.email) || null,
template: art,
values,
blocks,
},
now,
)
return { ok: true, eintrag, gekuerzt }
} catch (err) {
console.warn(`[shop-mails] ${String(art).slice(0, 20)} nicht eingereiht: ${err?.name}: ${String(err?.message).slice(0, 120)}`)
return { ok: false, fehler: 'Die E-Mail an den Käufer konnte nicht eingereiht werden.' }
}
}
// ---------------------------------------------------------------- Haendler
/** Portal-Adresse der Webshop-Seite des Projekts. */
const portalUrl = (ctx) => `${config.publicBaseUrl}/dashboard.html#webshop?projekt=${encodeURIComponent(ctx?.shop?.projectId || ctx?.doc?.projectId || '')}`
/**
* Werte und Bloecke von K25 "Neue Bestellung" (an den Haendler) aus der bezahlten Bestellung.
* @param {{ ueberverkauft?: boolean }} [optionen] ueberverkauft: der Bestand reichte nicht (Block ueberverkauft)
* @returns {{ values: Record<string, string>, blocks: Record<string, boolean> }}
*/
export function haendlerK25(ctx, order, { ueberverkauft = false } = {}) {
const snap = schnappschuss(order)
const alle = kaeuferWerte(ctx, order)
const kaeufer = istObjekt(snap.kaeufer) ? snap.kaeufer : {}
return {
values: {
shopname: alle.betrieb_name,
bestellnummer: alle.bestellnummer,
gesamt: alle.gesamt,
positionen: alle.positionen,
lieferart_text: alle.lieferart_text,
kaeufer_name: oderLeer(kaeufer.name ?? snap.name),
portal_url: portalUrl(ctx),
},
blocks: { ueberverkauft: ueberverkauft === true, sepa: text(snap.zahlungsart) === 'sepa_debit' },
}
}
/**
* Werte und Bloecke von K26 "Widerruf eingegangen" (an den Haendler) aus dem Widerruf (shopWiderrufe-Dokument oder
* { name, email, bestellnummer, nachricht, eingang }); eingang = Date | ISO.
*/
export function haendlerK26(ctx, widerruf, now = new Date()) {
const nachricht = text(widerruf?.nachricht)
return {
values: {
shopname: shopName(ctx),
bestellnummer: oderLeer(widerruf?.bestellnummer),
kaeufer_name: oderLeer(widerruf?.name),
kaeufer_email: oderLeer(widerruf?.email),
eingang: eingangText(widerruf?.eingang ?? widerruf?.eingangAt ?? now),
nachricht,
portal_url: portalUrl(ctx),
},
blocks: { nachricht: nachricht !== '' },
}
}
/**
* Schickt eine Haendlermail (K25, K26) an die E-Mail des Kundenkontos, ohne BCC ans Team. kunde_name, shopname und portal_url
* ergaenzt die Funktion, wenn die Vorlage sie kennt und values sie nicht setzt.
* @param {string} template 'K25' | 'K26'
* @param {{ doc: object, shop: object }} ctx
* @param {Record<string, string>} values
* @param {Record<string, boolean>} [blocks]
* @returns {Promise<{ ok: true } | { ok: false, fehler: string }>}
*/
export async function anHaendler(template, ctx, values = {}, blocks = {}) {
try {
const kunde = await customerOf(ctx?.shop?.customerId)
if (!hatText(kunde?.email)) {
console.warn(`[shop-mails] ${String(template).slice(0, 20)} nicht gesendet: Kunde ohne E-Mail-Adresse`)
return { ok: false, fehler: 'Der Kunde hat keine E-Mail-Adresse.' }
}
const erlaubt = loadTemplate(template).placeholders
const standard = { kunde_name: text(kunde.name) || 'Kunde', shopname: shopName(ctx), portal_url: portalUrl(ctx) }
const alle = { ...standard, ...values }
await sendCustomerMail(kunde.email, {
template,
values: Object.fromEntries(Object.entries(alle).filter(([schluessel]) => erlaubt.includes(schluessel))),
blocks,
bccTeam: false,
})
return { ok: true }
} catch (err) {
// Nur Art und Code des Fehlers: Meldungen des Mailservers koennen die Adresse enthalten
console.warn(`[shop-mails] ${String(template).slice(0, 20)} nicht gesendet: ${err?.name} ${String(err?.code ?? err?.responseCode ?? err?.status ?? '').slice(0, 40)}`.trim())
return { ok: false, fehler: 'Die E-Mail an den Händler konnte nicht gesendet werden.' }
}
}
+63 -16
View File
@@ -23,6 +23,7 @@ import { LOESCHFRIST_TAGE } from './frist.js'
import { kontextPerFeature, ladeEinstellung } from './kontext.js'
import { anGast } from './mails.js'
import { beendeFestgefahrene, raeumeAuf, verarbeite } from './outbox.js'
import { shopModus } from '../shop/schalter.js'
import { termineAus } from './schalter.js'
import { buchungsNummer } from './token.js'
import { pruefeNow, zeitMs } from './util.js'
@@ -57,6 +58,12 @@ import { gibZellenFrei, raeumeVerwaisteZellen } from './zellen.js'
* (`eingeschraenkt: true`): nur der Abo-Abgleich und die Loeschung abgeschalteter Funktionen, damit ein beendetes Abo auch bei
* TERMINE_ENABLED=off zum Abschalten und zur Loeschung nach 30 Tagen fuehrt. Loeschfristen der Buchungen, Warteschlange und Zellen
* bleiben aus.
* - Webshop (SHOP_ENABLED != off): die Mail-Warteschlange gehoert auch den Kaeufermails des Shops (G09-G14, shop/mails.js). Deshalb
* laufen bei Modus off des Terminplaners, aber aktivem Shop, der Mailversand-Teil des Minutenjobs (Warteschlange senden und
* Betriebspruefung) und im Tagesjob das Beenden festgefahrener und das Loeschen alter Warteschlangen-Eintraege weiter; alles
* andere bleibt an den Terminplaner-Modus gebunden. Der Tagesjob ist dann eingeschraenkt (`eingeschraenkt: true`, mit
* `festgefahren` und `aufgeraeumt` im Ergebnis); gibt es keine bezahlten Terminplaner, laufen Abo-Abgleich und Loeschung
* abgeschalteter Funktionen nicht.
* - Keine ueberlappenden Laeufe: laeuft ein Job noch, wird der naechste Takt uebersprungen ({ uebersprungen: true }, einmal im
* Log je Ueberlappung). Die Sperre gilt je `job:teil` (fuehreAus mit { teil }): haengt beim Minutenjob der Verfall, versendet der
* naechste Takt trotzdem die Warteschlange und umgekehrt; uebersprungen gilt der Takt erst, wenn beide Teile uebersprungen sind
@@ -127,18 +134,21 @@ async function schritt(lauf, was, fn, ersatz) {
* Rahmen jedes Jobs: Schalter, Ueberlappung, Laufdaten. => Ergebnis von arbeit(lauf) | { aus: true } | { uebersprungen: true }
* `teil` (Name eines Teiljobs, z. B. „Verfall“) gibt dem Teil eine eigene Ueberlappungssperre `job:teil` und nennt ihn in der
* Warnung (`job (teil)`); `lauf.job` bleibt der Jobname (Fehlerlog, Warnung bei fehlender Collection).
* `trotzAus` (async, => wahr/falsch): im Modus off laeuft der Job nur, wenn es wahr liefert; dann steht `lauf.eingeschraenkt` auf true
* und der Job beschraenkt sich auf das, was auch bei abgeschaltetem Terminplaner gelten muss. Wirft es, wird gewarnt (ohne Kennungen)
* `trotzAus` (async, => falsch/wahr/Objekt): im Modus off laeuft der Job nur, wenn es etwas Wahres liefert; dann steht
* `lauf.eingeschraenkt` auf true, `lauf.freigabe` traegt die Antwort (Tagesjob: { termine, mails }) und der Job beschraenkt sich auf
* das, was auch bei abgeschaltetem Terminplaner gelten muss. Wirft es, wird gewarnt (ohne Kennungen)
* und { aus: true } geliefert; ohne `trotzAus` gilt Modus off wie bisher (sofort { aus: true }, ohne Abfrage). Die Ueberlappungssperre
* gilt unveraendert je Job (bzw. `job:teil`), der eingeschraenkte Lauf teilt sie mit dem vollen.
*/
async function fuehreAus(job, now, arbeit, { teil = null, trotzAus = null } = {}) {
const nowMs = pruefeNow(now, job)
let eingeschraenkt = false
let freigabe = null
if (termineAus()) {
if (trotzAus === null) return { aus: true }
try {
if (!(await trotzAus())) return { aus: true }
freigabe = await trotzAus()
if (!freigabe) return { aus: true }
} catch (err) {
console.warn(`[termine-jobs] ${job}: Modus off, die Prüfung auf bezahlte Terminplaner ist fehlgeschlagen, der Job bleibt aus: ${fehlerText(err)}`)
return { aus: true }
@@ -155,13 +165,16 @@ async function fuehreAus(job, now, arbeit, { teil = null, trotzAus = null } = {}
}
laufend.add(sperre)
try {
return await arbeit({ job, nowMs, fehler: 0, gedeckelt: 0, eingeschraenkt })
return await arbeit({ job, nowMs, fehler: 0, gedeckelt: 0, eingeschraenkt, freigabe })
} finally {
laufend.delete(sperre)
ueberlappGemeldet.delete(sperre)
}
}
/** Guard des Mailversand-Teils (fuehreAus: trotzAus): bei Modus off des Terminplaners laeuft er, solange der Webshop aktiv ist. */
const shopMailsLaufen = async () => shopModus() !== 'off'
/** Laufdaten fuer einen Baustein, der ohne Job-Rahmen aufgerufen wird (Tests). */
const einzelLauf = (job, now) => ({ job, nowMs: pruefeNow(now, job), fehler: 0, gedeckelt: 0 })
@@ -285,10 +298,15 @@ export async function minutenJob(now) {
await schritt(lauf, 'Betriebsprüfung', () => pruefeBetrieb(now), [])
return { faellig, gesendet, fehler: lauf.fehler }
},
{ teil: 'Mailversand' },
{ teil: 'Mailversand', trotzAus: shopMailsLaufen },
),
])
if (verfall.aus || mail.aus) return { aus: true }
if (verfall.aus && mail.aus) return { aus: true }
// Terminplaner aus, Webshop aktiv: nur der Mailversand-Teil lief
if (verfall.aus) {
if (mail.uebersprungen) return { uebersprungen: true }
return { eingeschraenkt: true, verfallen: 0, faellig: mail.faellig ?? 0, gesendet: mail.gesendet ?? 0, fehler: mail.fehler ?? 0 }
}
if (verfall.uebersprungen && mail.uebersprungen) return { uebersprungen: true }
return {
verfallen: verfall.verfallen ?? 0,
@@ -792,6 +810,23 @@ async function gibtBezahlteTerminplaner() {
return treffer.length > 0
}
/**
* Freigabe des Tagesjobs im Modus off (fuehreAus: trotzAus): false (aus) oder { termine, mails }. termine = es gibt ein bezahltes
* Termine-Dokument (Abo-Abgleich und Loeschung abgeschalteter Funktionen), mails = der Webshop ist aktiv (Warteschlange aufraeumen).
* Eine einzige Abfrage; scheitert sie bei aktivem Shop, laeuft nur der Mail-Teil (Warnung), sonst wirft sie wie bisher.
*/
async function tagesjobFreigabe() {
const mails = shopModus() !== 'off'
let termine = false
try {
termine = await gibtBezahlteTerminplaner()
} catch (err) {
if (!mails) throw err
console.warn(`[termine-jobs] taeglicherJob: die Prüfung auf bezahlte Terminplaner ist fehlgeschlagen, es läuft nur die Mail-Warteschlange: ${fehlerText(err)}`)
}
return termine || mails ? { termine, mails } : false
}
/**
* Abo-Abgleich (aboAbgleich.js) als Schritt des Tagesjobs: ein Fehler stoppt weder die Loeschung noch den Rest. Die Fehler des
* Abgleichs (Lese- und Versandfehler, schon im Log) kommen in lauf.fehler. => Zaehler des Abgleichs
@@ -864,7 +899,7 @@ export async function taeglicherJob(now) {
await schritt(lauf, 'Tagesjob-Meldung', () => meldeTagesjob(ergebnis, now), [])
return ergebnis
},
{ trotzAus: gibtBezahlteTerminplaner },
{ trotzAus: tagesjobFreigabe },
)
} catch (err) {
// Abbruch ausserhalb der Schritte: das Team erfaehrt es; der Fehler (Text im Log des Aufrufers) geht unveraendert weiter
@@ -880,19 +915,28 @@ export async function taeglicherJob(now) {
* und wird wie im vollen Lauf gemeldet (Fehler gehen als Vorfall ans Team).
*/
async function eingeschraenkterTagesjob(lauf, now) {
const aboAbgleich = await aboAbgleichSchritt(lauf, now)
const { gedeckelt: abgeschaltetGedeckelt, ...abgeschaltet } = await schritt(lauf, 'Abgeschaltete Funktionen', () => loescheAbgeschaltete(lauf), {
funktionen: 0,
buchungen: 0,
gedeckelt: 0,
})
// Ohne Freigabe-Angabe (Aufruf ohne tagesjobFreigabe) gilt das bisherige Verhalten: nur Terminplaner-Teile
const termine = lauf.freigabe?.termine !== false
const mails = lauf.freigabe?.mails === true
const aboAbgleich = termine ? await aboAbgleichSchritt(lauf, now) : aboErsatz()
const { gedeckelt: abgeschaltetGedeckelt, ...abgeschaltet } = termine
? await schritt(lauf, 'Abgeschaltete Funktionen', () => loescheAbgeschaltete(lauf), { funktionen: 0, buchungen: 0, gedeckelt: 0 })
: { funktionen: 0, buchungen: 0, gedeckelt: 0 }
// Webshop aktiv: festgefahrene Mails beenden (sie bleiben 48 Stunden als Zustellfehler sichtbar), dann alte Eintraege loeschen
const mailErgebnis = mails
? {
festgefahren: await schritt(lauf, 'Festgefahrene Mails', () => beendeFestgefahrene(now), 0),
aufgeraeumt: await schritt(lauf, 'Warteschlange aufräumen', () => raeumeAuf(now), 0),
}
: {}
const ergebnis = {
eingeschraenkt: true,
aboAbgleich,
abgeschaltet,
gedeckelt: { loeschfristen: 0, abgeschaltet: abgeschaltetGedeckelt, zellen: 0 },
gedeckelt: { loeschfristen: 0, abgeschaltet: abgeschaltetGedeckelt ?? 0, zellen: 0 },
geloescht: abgeschaltet.buchungen,
aktiveBetriebe: null,
...mailErgebnis,
fehler: lauf.fehler,
}
await schritt(lauf, 'Tagesjob-Meldung', () => meldeTagesjob(ergebnis, now), [])
@@ -926,9 +970,12 @@ export function tagesbericht(ergebnis) {
? ''
: `Abo-Abgleich ${zahl(abo.geprueft)} geprüft / ${zahl(abo.abgeschaltet)} abgeschaltet / ${zahl(abo.wiederEin)} wieder ein / ${zahl(abo.mails)} Mails / ${zahl(abo.ausgenommen)} ausgenommen, `
if (ergebnis.eingeschraenkt) {
// Webshop aktiv: dazu die Warteschlange (festgefahrene Mails beenden, alte Eintraege loeschen)
const mitMails = Number.isInteger(ergebnis.aufgeraeumt)
const mailSegment = mitMails ? `${ergebnis.aufgeraeumt} Outbox, ${zahl(ergebnis.festgefahren)} Mails nicht zugestellt, ` : ''
return (
`[termine] Tagesjob (Modus off, nur Abo-Abgleich und Löschung): ${a.buchungen} Buchungen gelöscht (abgeschaltete Funktionen ${a.buchungen}), ` +
`${a.funktionen} Funktionen bereinigt, ${deckel}${aboSegment}${ergebnis.fehler} Fehler`
`[termine] Tagesjob (Modus off, nur Abo-Abgleich und Löschung${mitMails ? ' sowie Mail-Warteschlange' : ''}): ${a.buchungen} Buchungen gelöscht (abgeschaltete Funktionen ${a.buchungen}), ` +
`${a.funktionen} Funktionen bereinigt, ${mailSegment}${deckel}${aboSegment}${ergebnis.fehler} Fehler`
)
}
const l = ergebnis.loeschfristen
+9 -5
View File
@@ -39,7 +39,7 @@ import { pruefeNow, pruefeText, zeitMs } from './util.js'
* pause Anmeldepause laeuft
* budget Plaetze im Fenster >= mailsProStunde
* reserve nachrangige Mails (G05, K20, K21) im Fenster >= max(1, floor(mailsProStunde x (1 - mailsReserve))): so bleibt
* ein Teil der Stunde den Vorrang-Mails (G01, G02, G08) und den normalen Mails vorbehalten
* ein Teil der Stunde den Vorrang-Mails (G01, G02, G08, Webshop G09, G12) und den normalen Mails vorbehalten
* kontingent Mails dieser Funktion im Fenster >= mailsProFunktion: eine Funktion kann die Stunde nicht allein fuellen
*
* Minutenjob (verarbeite): liest nie alle wartenden Eintraege. Bei Pause oder leerem Gesamtbudget endet er ohne Abfrage.
@@ -71,8 +71,12 @@ const ID_BLOCK = 50
const iso = (wertMs) => new Date(wertMs).toISOString()
/** Mails, die vor allen anderen rausgehen duerfen (Bestaetigung, Termin, Eingangsbestaetigung des Widerrufs). */
export const MAIL_VORRANG = Object.freeze(['G01', 'G02', 'G08'])
/**
* Mails, die vor allen anderen rausgehen duerfen (Bestaetigung, Termin, Eingangsbestaetigung des Widerrufs). Dazu die beiden
* Pflichtmails des Webshops: G09 Bestellbestaetigung (dauerhafter Datentraeger) und G12 Eingangsbestaetigung des Widerrufs. Die
* uebrigen Shop-Mails G10, G11, G13, G14 sind 'normal'.
*/
export const MAIL_VORRANG = Object.freeze(['G01', 'G02', 'G08', 'G09', 'G12'])
/** Mails, die zuerst warten (Erinnerung, Hinweise an den Betrieb): sie nehmen nie die ganze Stunde. */
export const MAIL_NACHRANGIG = Object.freeze(['G05', 'K20', 'K21'])
@@ -115,7 +119,7 @@ function gesamtBremse(nowMs) {
* 'pause' | 'budget' | 'reserve' | 'kontingent'. Nur lesen: belegt keinen Platz.
* @param {number} nowMs
* @param {string} featureId
* @param {string} art Vorlagen-ID der Mail ('G01' ... 'K21')
* @param {string} art Vorlagen-ID der Mail ('G01' ... 'K21', Shop: 'G09' ... 'G14')
* @returns {null|'pause'|'budget'|'reserve'|'kontingent'}
*/
export function pruefePlatz(nowMs, featureId, art) {
@@ -438,7 +442,7 @@ async function verarbeiteKlasse(klasse, nowMs, now, lauf) {
}
/**
* Minutenjob: sendet faellige wartende Eintraege, Vorrang-Mails (G01, G02, G08) vor normalen vor nachrangigen (G05, K20, K21),
* Minutenjob: sendet faellige wartende Eintraege, Vorrang-Mails (G01, G02, G08, G09, G12) vor normalen vor nachrangigen (G05, K20, K21),
* in jeder Klasse die faelligsten zuerst, bis Pause oder Stundenbudget bremsen. Er liest begrenzte Listen statt aller Eintraege
* (siehe Kopfkommentar); Eintraege, die Vorrang-Reserve oder Kontingent ihrer Funktion bremsen, bleiben `wartet`, und die uebrigen
* kommen trotzdem dran. Fehler bei einzelnen Eintraegen (z. B. Appwrite) stehen im Log und stoppen den Lauf nicht.