Ö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:
1 parent
9a14e874c0
commit
a198c472bd
5 files changed
+1012
No files matched your search
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user