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 { k28Mail, 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 K28 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, K28 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, k28Mail({ 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 K28; 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 }