Öffnungszeiten: Feiertags-Erinnerung und Jobs

erinnereFeiertage(now): stündlich, nur bei Schalter pilot/on (pilot: nur
freigegebene Kunden) und nur 08:00 bis 20:00 Berliner Zeit; je Funktion in
setup/active zuerst der Merker sha1(featureId|datum) je Tag (409 = schon
erinnert), danach eine Mail K25 mit allen neuen Tagen ohne BCC ans Team;
Merker älter als 30 Tage werden gelöscht. zeitenAboJob (täglich) und
zeitenErinnerungsJob (stündlich) mit eigener Überlappsperre, ohne Wurf;
in server/index.js nach 240 s bzw. 300 s gestartet, mit unref.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 03:40:07 +00:00
1 parent 9a14e874c0
commit a198c472bd
5 files changed
+1012

No files matched your search

+24
View File
@@ -26,6 +26,7 @@ import { purgeUnclaimedNewsImages } from './services/newsImages.js'
import { runActivation } from './services/siteFeatures.js'
import { runAboCheck, runAnalyseReconcile } from './services/analyse.js'
import { fuenfMinutenJob, minutenJob, tagesbericht, taeglicherJob } from './services/termine/jobs.js'
import { zeitenAboJob, zeitenErinnerungsJob } from './services/zeiten/jobs.js'
import giteaWebhookRoutes from './routes/webhook/gitea.js'
import pushTimeRoutes from './routes/pushTime.js'
import adminGiteaRoutes from './routes/admin/gitea.js'
@@ -224,6 +225,29 @@ const server = app.listen(config.port, '0.0.0.0', () => {
setTimeout(termineTagesJob, 180 * 1000).unref()
setInterval(termineTagesJob, 24 * 60 * 60 * 1000).unref()
// Oeffnungszeiten live: taeglich der Abo-Abgleich (4 Minuten nach dem Start beginnend; laeuft unabhaengig vom Schalter ZEITEN_ENABLED,
// ohne Zeiten-Dokumente kostet er eine Abfrage) und stuendlich die Feiertags-Erinnerung (5 Minuten nach dem Start beginnend; nur bei
// Schalter pilot/on und nur 08:00 bis 20:00 Berliner Zeit, sonst kehrt jeder Lauf sofort zurueck). Beide Jobs ueberlappen nicht und
// werfen nie; hier faengt der Rahmen trotzdem alles Unerwartete ab. Im Log stehen keine Kundendaten.
const zeitenAbo = () =>
zeitenAboJob(new Date())
.then((r) => {
if (r.aboEnde || r.abgeschaltet || r.wiederEin || r.geloescht || r.mails || r.fehler) {
console.log(`[zeiten] Abo-Abgleich: ${r.aboEnde ?? 0} Abo-Ende erkannt, ${r.abgeschaltet ?? 0} abgeschaltet, ${r.wiederEin ?? 0} wieder eingeschaltet, ${r.geloescht ?? 0} gelöscht, ${r.mails ?? 0} Mail(s), ${r.fehler ?? 0} Fehler`)
}
})
.catch((err) => console.warn('[zeiten] Abo-Abgleich fehlgeschlagen:', err.message))
setTimeout(zeitenAbo, 240 * 1000).unref()
setInterval(zeitenAbo, 24 * 60 * 60 * 1000).unref()
const zeitenErinnerung = () =>
zeitenErinnerungsJob(new Date())
.then((r) => {
if (r.merker || r.mails || r.fehler) console.log(`[zeiten] Feiertags-Erinnerung: ${r.merker ?? 0} Tag(e) vorgemerkt, ${r.mails ?? 0} Mail(s), ${r.fehler ?? 0} Fehler`)
})
.catch((err) => console.warn('[zeiten] Feiertags-Erinnerung fehlgeschlagen:', err.message))
setTimeout(zeitenErinnerung, 300 * 1000).unref()
setInterval(zeitenErinnerung, 60 * 60 * 1000).unref()
// Neue Gitea-Repos erkennen, Zuordnungs-Mail senden, Erinnerungen/Wiederholungen (alle 10 Minuten)
const repoAssign = () =>
runRepoAssignJob({ now: new Date() }).catch((err) =>
+181
View File
@@ -0,0 +1,181 @@
import { createHash } from 'node:crypto'
import { config } from '../../config.js'
import { createDocument, deleteDocument, getDocument, listAllDocuments, Query } from '../appwriteAdmin.js'
import { customerOf } from '../siteFeatures.js'
import { heute, plusTage } from '../termine/zeit.js'
import { pruefeNow } from '../termine/util.js'
import { k25Mail, sendeZeitenMail } from './mails.js'
import { zeitenFuerKunde, zeitenModus } from './schalter.js'
import { ladeZeiten } from './speicher.js'
import { erinnerungsTage, imErinnerungsfenster } from './zeitlogik.js'
/**
* Feiertags-Erinnerung von Öffnungszeiten live (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, Abschnitt 7 und Z18).
* erinnereFeiertage(now) ist der Baustein des Stundenjobs (zeitenErinnerungsJob in jobs.js, angeschlossen in server/index.js); er kennt
* keine Überlappungssperre: der Aufrufer lässt nie zwei Läufe gleichzeitig laufen (der Merker verhindert trotzdem jede doppelte Mail).
*
* Wann: nur bei Schalter `pilot` oder `on` (bei `pilot` nur für Kunden der Pilotliste, zeitenFuerKunde) und nur zwischen 08:00 und
* 20:00 Berliner Zeit. Sonst kehrt der Lauf sofort zurück, ohne eine einzige Abfrage.
*
* Für welche: jedes portalFeatures-Dokument mit featureKey 'zeiten' im Zustand setup oder active (seitenweise gelesen), das gespeicherte
* Zeiten hat. Welche Tage: zeitlogik.erinnerungsTage (1 bis 7 Berliner Tage, Feiertage und „bitte prüfen“-Tage des Landes, ohne Tage,
* an denen der Hauptplan zu ist, eine Ausnahme gilt oder der Kunde schon eine Abweichung gespeichert hat).
*
* Höchstens einmal: je Tag zuerst der Merker (zeitenErinnerungen, Dokument-ID = sha1(`featureId|datum`), 36 Zeichen); 409 = der Tag wurde
* schon erinnert, er fehlt in der Mail. Danach EINE Mail K25 je Dokument mit allen neu vorgemerkten Tagen, ohne Kopie ans Team
* (bccTeam false, R17c). Der Merker steht VOR der Mail: Stirbt der Server dazwischen oder scheitert der Versand, geht für diese Tage nie
* eine Mail raus ("lieber einmal zu wenig als doppelt"); die Mail wird nicht wiederholt, der Fehler steht im Log und in `fehler`.
* Hat der Kunde keine Adresse (Kundendokument fehlt oder ohne E-Mail), wird gar kein Merker angelegt: sobald es eine Adresse gibt,
* erinnert der nächste Lauf noch an die Tage, die noch im Fenster liegen.
*
* Aufräumen (im selben Lauf, nach den Erinnerungen): Merker, deren Datum mehr als 30 Berliner Tage zurückliegt, werden gelöscht
* (Spezifikation 2.2; das Datum ist der Tag des Feiertags, nach dem Fenster von 7 Tagen sind die Merker längst wirkungslos).
*
* Fehler: je Dokument gefangen (Lesen, Merker, Mailbau und Versand): ein kaputtes Dokument stoppt weder die anderen noch den Lauf. Das Log
* nennt weder Namen noch Adressen, nur Funktions-IDs.
*/
const KEY = 'zeiten'
/** Zustände, in denen die Funktion läuft und erinnert wird. */
const LAUFENDE_ZUSTAENDE = ['setup', 'active']
/** Merker, deren Datum länger als so viele Berliner Tage zurückliegt, werden gelöscht. */
const MERKER_AUFBEWAHRUNG_TAGE = 30
const SEITE = 100
const ADRESSE = /[^\s<>"',;()]+@[^\s<>"',;()]+/g
/** Fehlertext fürs Log: Name und gekürzte Meldung, E-Mail-Adressen unkenntlich. */
const fehlerText = (err) => `${err?.name ?? 'Fehler'} ${String(err?.message ?? '').replace(ADRESSE, '[Adresse]').slice(0, 200)}`
const portalFeatures = () => config.collections.portalFeatures
const merkerCollection = () => config.collections.zeitenErinnerungen
/** Dokument-ID des Merkers: sha1(featureId|datum) in Hex, die ersten 36 Zeichen (Appwrite-Grenze, beginnt mit Ziffer oder Buchstabe). */
export const merkerId = (featureId, datum) => createHash('sha1').update(`${featureId}|${datum}`).digest('hex').slice(0, 36)
const hatAdresse = (kunde) => typeof kunde?.email === 'string' && kunde.email.trim() !== ''
/** Projektname für die Mail; ein Lesefehler ergibt '' (die Mail geht trotzdem raus, der Merker steht schon). */
async function projektName(projectId) {
try {
const projekt = await getDocument(config.collections.websiteProjects, projectId)
return projekt?.projectName || projekt?.subdomain || ''
} catch (err) {
if (err?.status !== 404) console.error(`[zeiten-erinnerung] Projekt ${projectId} nicht lesbar: ${fehlerText(err)}`)
return ''
}
}
/** Je Tag den Merker anlegen; => die Einträge, die neu vorgemerkt wurden (409 = schon erinnert, fehlt). Andere Fehler zählen und überspringen den Tag. */
async function merkeVor(doc, tage, ctx) {
const neu = []
for (const eintrag of tage) {
try {
await createDocument(merkerCollection(), { featureId: doc.$id, datum: eintrag.tag, createdAt: ctx.jetzt }, merkerId(doc.$id, eintrag.tag))
ctx.erg.merker += 1
neu.push(eintrag)
} catch (err) {
if (err?.status === 409) continue
ctx.erg.fehler += 1
console.error(`[zeiten-erinnerung] Merker für Funktion ${doc.$id} nicht angelegt: ${fehlerText(err)}`)
}
}
return neu
}
/** Ein Dokument: Zeiten lesen, Tage bestimmen, Merker anlegen, K25 senden. Wirft bei Fehlern (der Aufrufer zählt fehler). */
async function bearbeiteDokument(doc, ctx) {
let kunde // undefined = noch nicht gelesen
const holeKunde = async () => {
if (kunde === undefined) kunde = doc.customerId ? await customerOf(doc.customerId) : null
return kunde
}
const ohneAdresse = () => console.warn(`[zeiten-erinnerung] Funktion ${doc.$id}: Kunde nicht mehr vorhanden oder ohne E-Mail-Adresse, keine Erinnerung`)
// Pilot: nur freigegebene Kunden (vor dem Lesen der Zeiten)
if (ctx.modus === 'pilot') {
const k = await holeKunde()
if (!hatAdresse(k)) return ohneAdresse()
if (!zeitenFuerKunde(k.email)) return undefined
}
const zeiten = await ladeZeiten(doc.$id)
if (zeiten?.daten == null) return undefined // der Kunde hat noch nichts eingetragen
ctx.erg.geprueft += 1
const tage = erinnerungsTage(zeiten.daten, ctx.now)
if (tage.length === 0) return undefined
const k = await holeKunde()
if (!hatAdresse(k)) return ohneAdresse()
const neu = await merkeVor(doc, tage, ctx)
if (neu.length === 0) return undefined // alles schon erinnert
const projekt = await projektName(doc.projectId)
const { sent, reason } = await sendeZeitenMail(k, k25Mail({ kunde: k, projekt, tage: neu }), { bccTeam: false })
if (sent) ctx.erg.mails += 1
else if (reason !== 'keine_adresse') ctx.erg.fehler += 1 // der Merker bleibt: keine Wiederholung
return undefined
}
/** Merker mit Datum vor dem Stichtag löschen. Ein Fehler bricht das Aufräumen ab (der nächste Lauf macht weiter). */
async function raeumeAuf(ctx) {
const stichtag = plusTage(heute(ctx.now), -MERKER_AUFBEWAHRUNG_TAGE)
let geloescht = 0
try {
const alte = await listAllDocuments(merkerCollection(), [Query.lessThan('datum', stichtag), Query.select(['datum'])], { pageSize: SEITE })
for (const merker of alte) {
try {
await deleteDocument(merkerCollection(), merker.$id)
geloescht += 1
} catch (err) {
if (err?.status === 404) continue // schon weg
throw err
}
}
} catch (err) {
ctx.erg.fehler += 1
console.error(`[zeiten-erinnerung] Aufräumen alter Merker abgebrochen: ${fehlerText(err)}`)
}
if (geloescht > 0) console.log(`[zeiten-erinnerung] ${geloescht} alte Merker gelöscht`)
}
/**
* Stündliche Feiertags-Erinnerung (siehe Kopf der Datei). Wirft nie (außer bei fehlendem oder ungültigem `now`, wie gleicheZeitenAboAb);
* jedes Problem steht im Log und in `fehler`.
* @param {Date} now Zeitpunkt des Laufs (Fenster, Tage und Aufräumen zählen nach der Berliner Zeit von now)
* @returns {Promise<{ geprueft: number, mails: number, merker: number, fehler: number }>}
* geprueft = Funktionen mit gespeicherten Zeiten, die in diesem Lauf bewertet wurden (freigegebener Kunde, Zustand setup/active);
* mails = versendete K25; merker = in diesem Lauf neu vorgemerkte Tage; fehler = Dokumente und Schritte, die scheiterten, sowie
* gescheiterte Mails
*/
export async function erinnereFeiertage(now) {
pruefeNow(now, 'erinnereFeiertage')
const erg = { geprueft: 0, mails: 0, merker: 0, fehler: 0 }
const modus = zeitenModus()
if (modus === 'off' || !imErinnerungsfenster(now)) return erg
const ctx = { erg, now, jetzt: now.toISOString(), modus }
try {
let dokumente
try {
dokumente = await listAllDocuments(portalFeatures(), [Query.equal('featureKey', KEY), Query.equal('state', LAUFENDE_ZUSTAENDE)])
} catch (err) {
erg.fehler += 1
console.error(`[zeiten-erinnerung] Funktionen nicht lesbar, die Erinnerung entfällt: ${fehlerText(err)}`)
dokumente = []
}
for (const doc of dokumente) {
try {
await bearbeiteDokument(doc, ctx)
} catch (err) {
erg.fehler += 1
console.error(`[zeiten-erinnerung] Funktion ${doc.$id} nicht bearbeitet: ${fehlerText(err)}`)
}
}
await raeumeAuf(ctx)
} catch (err) {
erg.fehler += 1
console.error(`[zeiten-erinnerung] Lauf abgebrochen: ${fehlerText(err)}`)
}
return erg
}
+61
View File
@@ -0,0 +1,61 @@
import { gleicheZeitenAboAb } from './aboAbgleich.js'
import { erinnereFeiertage } from './erinnerung.js'
/**
* Hintergrund-Jobs von Öffnungszeiten live (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, Abschnitte 5 und 7).
* server/index.js ruft sie mit new Date() auf; `now` ist hier immer ein Parameter.
*
* zeitenAboJob täglich (240 s nach dem Start beginnend) Abo-Abgleich (aboAbgleich.js): Abo-Ende erkennen, nach 30 Tagen
* abschalten, bei Rückkehr wieder einschalten, nach weiteren 30 Tagen
* löschen. Eigener Tagesjob, nicht an den Schalter ZEITEN_ENABLED
* gekoppelt (wie beim Terminplaner): ein beendetes Abo muss auch bei
* Schalter off zum Abschalten und zur Löschung führen. Ohne Zeiten-
* Dokumente kostet er eine Abfrage.
* zeitenErinnerungsJob stündlich (300 s nach dem Start beginnend) Feiertags-Erinnerung (erinnerung.js): nur bei Schalter pilot/on,
* nur 08:00 bis 20:00 Berliner Zeit, Merker älter als 30 Tage löschen
*
* Gemeinsame Regeln:
* - Keine überlappenden Läufe, je Job: läuft ein Job noch, kehrt der nächste Takt sofort mit { uebersprungen: true } zurück (einmal
* im Log je Überlappung). Die Sperre ist ein Merker im Speicher des Prozesses (ein Server, ein Prozess); die Jobs sperren einander nicht.
* - Ein Job wirft nie: ein unerwarteter Fehler (etwa ein ungültiges `now`) steht im Log, das Ergebnis ist { fehler: 1 }. Die Bausteine
* melden einzelne Fehler selbst im Ergebnis (`fehler`).
* - Das Log nennt weder Namen noch Adressen von Kunden.
*/
const laufend = new Set()
const ueberlappGemeldet = new Set()
const fehlerText = (err) => `${err?.name ?? 'Fehler'} ${String(err?.message ?? '').slice(0, 200)}`
/** Führt arbeit() aus, sofern dieser Job nicht schon läuft; fängt jeden Fehler. */
async function fuehreAus(job, arbeit) {
if (laufend.has(job)) {
if (!ueberlappGemeldet.has(job)) {
ueberlappGemeldet.add(job)
console.warn(`[zeiten-jobs] ${job}: der vorige Lauf ist noch nicht fertig, dieser Takt wird übersprungen`)
}
return { uebersprungen: true }
}
laufend.add(job)
try {
return await arbeit()
} catch (err) {
console.error(`[zeiten-jobs] ${job} fehlgeschlagen: ${fehlerText(err)}`)
return { fehler: 1 }
} finally {
laufend.delete(job)
ueberlappGemeldet.delete(job)
}
}
/** Täglicher Abo-Abgleich. => Ergebnis von gleicheZeitenAboAb | { uebersprungen: true } | { fehler: 1 } */
export const zeitenAboJob = (now) => fuehreAus('zeitenAboJob', () => gleicheZeitenAboAb(now))
/** Stündliche Feiertags-Erinnerung. => Ergebnis von erinnereFeiertage | { uebersprungen: true } | { fehler: 1 } */
export const zeitenErinnerungsJob = (now) => fuehreAus('zeitenErinnerungsJob', () => erinnereFeiertage(now))
/** Nur für Tests: alle Sperren freigeben. */
export function resetZeitenJobsForTests() {
laufend.clear()
ueberlappGemeldet.clear()
}