K24–K26 sind seit dem Webshop-Livegang (main) vergeben; der Mail-Lader sucht die Vorlage über den Dateianfang, doppelte Nummern hätten die falsche Mail geschickt. Das Feld ist noch nicht in der Produktion angelegt (zeiten-setup.mjs lief noch nicht), daher jetzt ein sprechender Name. Spec und Plan behalten die alten Nummern (Hinweis oben im Dokument). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
182 lines
8.9 KiB
JavaScript
182 lines
8.9 KiB
JavaScript
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
|
|
}
|