intermediate
Digest
E-Mail
Kadenz

Digest-E-Mails & Kadenz

Wie der stuendliche compliance-digest-Cron durch city_contacts laeuft, faellige Empfaenger auswaehlt, die E-Mail-Payload baut und last_digest_sent_at aktualisiert.

Levy Fleets TeamMay 18, 202610 min read

Digest-E-Mails & Kadenz

Stadtkontakte waehlen, wie oft sie einen Compliance-Digest wollen -- taeglich, woechentlich oder monatlich. Ein stuendlicher Cron geht jeden city_contacts-Eintrag durch, prueft, ob das Kadenzfenster abgelaufen ist, und baut (falls ja) den Digest und sendet ihn.

Kadenzoptionen

digest_frequencyE-Mail kommtAm besten fuer
dailyMorgens nach Schluss des vorherigen Tages (Ortszeit der Zustaendigkeit)Operativ orientierte Stadtkontakte
weeklyMontagmorgen, fuer die vergangene Mo-SoLeitende Compliance-Officer
monthly1. des Monats, fuer den vergangenen KalendermonatGenehmigungspruefer, Auditoren

Kadenz ist pro Kontakt, nicht pro Zustaendigkeit. Gaengiges Muster: leitender Compliance-Officer bekommt woechentlich, ein Ops-Analyst taeglich, ein Auditor monatlich.

Der stuendliche Cron

/api/cron/compliance-digest laeuft stuendlich (0 * * * * in vercel.json). Er:

  1. Geht city_contacts-Zeilen mit portal_access = true durch.
  2. Prueft fuer jede Zeile das Kadenzfenster:
    • Daily: last_digest_sent_at < (heute 00:00 Ortszeit der Zustaendigkeit)
    • Weekly: last_digest_sent_at < (Montag 00:00 dieser Woche Ortszeit) und es ist Montag
    • Monthly: last_digest_sent_at < (1. des Monats 00:00) und es ist der 1.
  3. Ruft fuer jeden faelligen Kontakt buildComplianceReport({jurisdictionId, period, date}) zur Berechnung der Payload auf.
  4. Ruft buildDigestEmail(report, contact.locale) zum Formatieren von Betreff + HTML + Text auf.
  5. Uebergibt die E-Mail an den pluggable complianceDigestSender -- in Produktion an die bestehende transaktionale E-Mail-Infra verdrahtet.
  6. Aktualisiert last_digest_sent_at auf der Kontaktzeile.
  7. Persistiert die Payload in city_compliance_reports mit der Audit-Zeile.

Wenn ein Schritt eine Exception wirft, wird der Kontakt geloggt, aber der Cron faehrt fort -- ein fehlgeschlagener Kontakt blockiert den Rest des Batches nicht.

Der pluggable Sender

src/lib/compliance/digest-email.ts exportiert setComplianceDigestSender(fn). Der Standard ist ein No-Op-Sender, der in die Konsole loggt; Produktion setzt ihn beim Start auf die Operator-App-E-Mail-Infra. Diese Indirektion erlaubt Tests, die Digest-Pipeline laufen zu lassen, ohne E-Mails zu senden.

E-Mail-Form

AbschnittInhalt
BetreffLevy Compliance Digest -- Boulder, CO -- Daily Report for 2026-05-17
KopfzeileBetreibername, Zustaendigkeitsname, Periode
Compliance-ScoreboardPass/Fail pro Bedingung mit aktuellem Wert vs Schwellwert
Heutige MomentaufnahmeFahrten, eingesetzte Fahrzeuge, geoeffnete/geschlossene Beschwerden
Enforcement-ZusammenfassungGesendete Geschwindigkeitsbefehle, ausgegebene Sperren, Stunden in Slow-Zonen
Offene PunkteFehlgeschlagene Bedingungen, offene Beschwerden ueber SLA
FussnoteLink zum Stadt-Portal, Support-Kontakt

Bei Monats-Digests haengen wir ein einseitiges PDF an. Taeglich und woechentlich sind nur HTML + Text.

HTML-Escaping

buildDigestEmail HTML-escaped jeden vom Nutzer kontrollierten String (Zustaendigkeitsname, Beschwerdetitel, Fahrzeugnummern). Getestet in digest-email.test.ts als sicher gegen eingeschleuste <script>-Tags in Policy-Namen -- selbst wenn eine Stadt eine Policy mit seltsamen Zeichen im Namen veroeffentlicht, rendert der Digest korrekt ohne Layout-Bruch.

Lokalisierung

Die E-Mail wird in city_contacts.locale gerendert (Standard en). Launch unterstuetzt en und es. Zusaetzliche Locales fallen mit einem Hinweis in der Fussnote auf Englisch zurueck ("Dieser Bericht ist nur auf Englisch verfuegbar").

Die Pass/Fail-Labels und Bedingungsnamen des Compliance-Scoreboards verwenden die Locale des Kontakts; Betreibername und Stadtname werden unveraendert durchgereicht.

last_digest_sent_at und Idempotenz

Wenn der Cron wiederholt (z.B. ein Deploy mitten im Zyklus, ein Vercel-Timeout), ist last_digest_sent_at der Schutz gegen Doppelsendungen. Der Cron aktualisiert ihn innerhalb derselben Transaktion wie den city_compliance_reports-Insert, sodass entweder beides oder keines passiert.

Wenn Sie jemals einen erneuten Versand erzwingen muessen (ein Kontakt sagt, er habe den Montags-Digest nicht erhalten), setzen Sie last_digest_sent_at ueber die "Digest erneut senden"-Aktion des Dashboards auf einen frueheren Wert. Der naechste Cron-Lauf erkennt den Kontakt als faellig und liefert ihn erneut aus.

Digests vorübergehend deaktivieren

Um Digests fuer einen Kontakt zu pausieren, ohne ihn zu entfernen:

  • Nur Portal-Zugang: portal_access = true und digest_frequency = null setzen. Der Kontakt kann sich weiterhin am Portal anmelden, erhaelt aber keine E-Mail.
  • Beides pausiert: portal_access = false setzen. Magic-Link-Auth schlaegt fehl; keine Digests gesendet.

Persistierte Berichte

Jeder Digest-Lauf schreibt eine Zeile in city_compliance_reports:

SpalteInhalt
idUUID
jurisdiction_idFK
report_typedaily / weekly / monthly
period_start / period_endAbgedecktes Zeitfenster
computed_atWann der Bericht gebaut wurde
payloadVoller JSON-Snapshot
pdf_urlFuer Monatsberichte
delivered_to_contact_idDie city_contacts-Zeile, die ihn erhielt

Diese Tabelle ist der Audit-Trail. Wenn ein Stadt-Auditor fragt "haben Sie am 14. April einen Digest gesendet?", ist die Zeile in city_compliance_reports die Antwort. Berichte werden unbegrenzt aufbewahrt.

Haeufige Fragen

"Mein Kontakt sagt, er habe die E-Mail nicht bekommen." Spam pruefen, dann city_compliance_reports auf die erwartete Zeile. Wenn die Zeile existiert und delivered_to_contact_id passt, wurde die E-Mail versandt -- beim E-Mail-Anbieter pruefen. Wenn die Zeile fehlt, hat der Cron den Kontakt entweder uebersprungen (Kadenzfenster noch nicht abgelaufen) oder einen Fehler bekommen (Sentry).

"Kann derselbe Kontakt sowohl taeglich als auch monatlich erhalten?" Nein -- digest_frequency ist ein einzelner Wert. Erstellen Sie zwei Kontaktzeilen fuer dieselbe E-Mail, wenn Sie beide Kadenzen wollen.

"Kann ich jemanden im Digest CCen?" Nicht ueber die Kontaktzeile. Erstellen Sie eine separate city_contacts-Zeile fuer den CC-Empfaenger. Jeder erhaelt eigenen Magic-Link-Zugang und eigenes last_digest_sent_at.

Was kommt als naechstes?