Files
ClaudeandClaude Opus 5.5 c7cd436ec2 Öffnungszeiten: Mails K24–K27 heißen jetzt K27–K30, Feld k24At heißt einschaltMailAt
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>
2026-10-06 06:00:17 +00:00

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
}