Files
breakpilot-lehrer/school-service/internal/notifications/templates.go
T
Benjamin Admin 8311b33fb3
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 1m10s
CI / test-go-edu-search (push) Successful in 43s
CI / test-python-klausur (push) Failing after 4m4s
CI / test-python-agent-core (push) Successful in 44s
CI / test-nodejs-website (push) Successful in 51s
Phase 9d: Notification cron + multilingual templates + status badges
Backend (school-service):
  - notification_log table with UNIQUE(event_id, lead_days, audience,
    channel) for idempotent re-runs. Status enum sent/failed/skipped.
  - internal/notifications/templates.go: per-event-type × audience ×
    lead-day-bucket × language templates in 8 languages (de/en/tr/ar/
    uk/ru/pl/fr). Fallback chain (lang→de, eventType→andere) so we
    never miss a render.
  - service.go scans cal_school_event for events whose
    (start_date - runDate) appears in notification_lead_days. For each
    due (audience, channel) tuple it dispatches via POST to the
    Matrix/Email upstreams owned by the colleague's services.
    Empty URL → status='skipped', logged for visibility.
  - dispatcher.go handles the POST, parent-recipient lookup (joins
    parent_account + parent_child + cal_school_event.affected_class_ids),
    and writeLog with the unique constraint dropping duplicate runs.
  - main.go runs a 1-hour ticker; when time.Hour()==6 it invokes the
    scanner for today. Idempotent so transient restarts don't double-
    send.
  - POST /calendar/notifications/run-now for manual trigger + backfill
    (?date=YYYY-MM-DD).
  - GET /calendar/events/:id/notifications returns notification_log
    rows scoped to the owning teacher.
  - MATRIX_SERVICE_URL + EMAIL_SERVICE_URL env vars added (default
    empty = stub mode).

Frontend (studio-v2):
  - NotificationStatus component fetches /events/:id/notifications and
    renders coloured badges per (lead, audience, channel, status).
  - DayDetail mounts NotificationStatus inside each event card when
    notify_parents or notify_students is set.

Tests:
  - 6 new Go unit tests for bucketFor + Render (de/tr/fallback paths)
    + substitute(class_suffix). 89 subtests gesamt.
  - 2 new Playwright tests: badge render with mocked log, hidden when
    notifications are off.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:12:39 +02:00

255 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package notifications
import (
"fmt"
"strings"
)
// Vars are the placeholder values rendered into a template string.
// The fields here must match the {{…}} markers in templates below.
type Vars struct {
Title string
Date string // YYYY-MM-DD
DatePretty string // e.g. "Donnerstag, 15. Oktober"
ClassName string // empty = whole school
TeacherName string
}
// Render picks the right template based on event type, audience, lead-day
// bucket and language, then substitutes the {{var}} placeholders.
//
// lead is grouped into three buckets:
// 0 → "today"
// 1 → "tomorrow"
// >=2 → "in X days" with X = lead value
//
// Falls back through (lang → de) and (event_type → "andere") so we never
// fail to render even with custom rule combos.
func Render(eventType, audience string, lead int, lang string, v Vars) (subject, body string) {
bucket := bucketFor(lead)
lc := strings.ToLower(lang)
if _, ok := templates[lc]; !ok {
lc = "de"
}
t := lookup(lc, eventType, audience, bucket)
subject = substitute(t.Subject, lead, v)
body = substitute(t.Body, lead, v)
return subject, body
}
func bucketFor(lead int) string {
switch {
case lead <= 0:
return "today"
case lead == 1:
return "tomorrow"
default:
return "days"
}
}
// lookup walks the templates map applying the (lang → de) and
// (event_type → andere) fallbacks. The bucket is guaranteed to exist for
// every audience because we always define today/tomorrow/days in the de
// baseline.
func lookup(lang, eventType, audience, bucket string) tmpl {
byEvent, ok := templates[lang][eventType]
if !ok {
byEvent = templates[lang]["andere"]
}
byAudience, ok := byEvent[audience]
if !ok {
byAudience = byEvent["parents"]
}
t, ok := byAudience[bucket]
if !ok {
t = byAudience["days"]
}
return t
}
func substitute(s string, lead int, v Vars) string {
classSuffix := ""
if v.ClassName != "" {
classSuffix = " (" + v.ClassName + ")"
}
repl := strings.NewReplacer(
"{{title}}", v.Title,
"{{date}}", v.Date,
"{{date_pretty}}", v.DatePretty,
"{{class_name}}", v.ClassName,
"{{class_suffix}}", classSuffix,
"{{teacher_name}}", v.TeacherName,
"{{lead}}", fmt.Sprintf("%d", lead),
)
return repl.Replace(s)
}
type tmpl struct {
Subject string
Body string
}
// templates[lang][eventType][audience][bucket] → tmpl
//
// Only the eight parent languages we ship subject-i18n.ts for are covered.
// Custom event_types fall back to the "andere" branch. Custom languages
// fall back to "de". This keeps the file under 500 LOC; if more locales
// are needed, split into one file per language.
var templates = map[string]map[string]map[string]map[string]tmpl{
"de": deTemplates(),
"en": enTemplates(),
"tr": trTemplates(),
"ar": arTemplates(),
"uk": ukTemplates(),
"ru": ruTemplates(),
"pl": plTemplates(),
"fr": frTemplates(),
}
func deTemplates() map[string]map[string]map[string]tmpl {
// All event types share the same template family; we only vary by audience
// and bucket. Specialise where wording really differs (e.g. fortbildung
// is school-free, schulfeier invites attendance).
parentToday := tmpl{
Subject: "Heute: {{title}}{{class_suffix}}",
Body: "Liebe Eltern, heute findet {{title}} statt{{class_suffix}}. Datum: {{date_pretty}}.",
}
parentTomorrow := tmpl{
Subject: "Morgen: {{title}}{{class_suffix}}",
Body: "Liebe Eltern, morgen ({{date_pretty}}) findet {{title}} statt{{class_suffix}}.",
}
parentDays := tmpl{
Subject: "In {{lead}} Tagen: {{title}}{{class_suffix}}",
Body: "Liebe Eltern, in {{lead}} Tagen ({{date_pretty}}) findet {{title}} statt{{class_suffix}}.",
}
studentToday := tmpl{
Subject: "Heute: {{title}}",
Body: "Heute ist {{title}}{{class_suffix}}.",
}
studentTomorrow := tmpl{
Subject: "Morgen: {{title}}",
Body: "Morgen ({{date_pretty}}) ist {{title}}{{class_suffix}}.",
}
studentDays := tmpl{
Subject: "In {{lead}} Tagen: {{title}}",
Body: "In {{lead}} Tagen ({{date_pretty}}) ist {{title}}{{class_suffix}}.",
}
bucket := func(today, tomorrow, days tmpl) map[string]tmpl {
return map[string]tmpl{"today": today, "tomorrow": tomorrow, "days": days}
}
universal := map[string]map[string]tmpl{
"parents": bucket(parentToday, parentTomorrow, parentDays),
"students": bucket(studentToday, studentTomorrow, studentDays),
}
return map[string]map[string]map[string]tmpl{
"fortbildung": universal,
"schulfeier": universal,
"klassenfahrt": universal,
"projekttag": universal,
"eltern_info": universal,
"andere": universal,
}
}
// The non-DE templates use the same structure; only the strings change.
// Defined in a single helper that takes the translation map and reuses the
// DE family glue.
func makeFamily(
pT, pTm, pD, sT, sTm, sD tmpl,
) map[string]map[string]map[string]tmpl {
bucket := func(today, tomorrow, days tmpl) map[string]tmpl {
return map[string]tmpl{"today": today, "tomorrow": tomorrow, "days": days}
}
universal := map[string]map[string]tmpl{
"parents": bucket(pT, pTm, pD),
"students": bucket(sT, sTm, sD),
}
return map[string]map[string]map[string]tmpl{
"fortbildung": universal,
"schulfeier": universal,
"klassenfahrt": universal,
"projekttag": universal,
"eltern_info": universal,
"andere": universal,
}
}
func enTemplates() map[string]map[string]map[string]tmpl {
return makeFamily(
tmpl{"Today: {{title}}{{class_suffix}}", "Dear parents, today {{title}} takes place{{class_suffix}}. Date: {{date_pretty}}."},
tmpl{"Tomorrow: {{title}}{{class_suffix}}", "Dear parents, tomorrow ({{date_pretty}}) {{title}} takes place{{class_suffix}}."},
tmpl{"In {{lead}} days: {{title}}{{class_suffix}}", "Dear parents, in {{lead}} days ({{date_pretty}}) {{title}} takes place{{class_suffix}}."},
tmpl{"Today: {{title}}", "Today is {{title}}{{class_suffix}}."},
tmpl{"Tomorrow: {{title}}", "Tomorrow ({{date_pretty}}) is {{title}}{{class_suffix}}."},
tmpl{"In {{lead}} days: {{title}}", "In {{lead}} days ({{date_pretty}}) is {{title}}{{class_suffix}}."},
)
}
func trTemplates() map[string]map[string]map[string]tmpl {
return makeFamily(
tmpl{"Bugün: {{title}}{{class_suffix}}", "Sayın veliler, bugün {{title}} gerçekleşiyor{{class_suffix}}. Tarih: {{date_pretty}}."},
tmpl{"Yarın: {{title}}{{class_suffix}}", "Sayın veliler, yarın ({{date_pretty}}) {{title}} gerçekleşiyor{{class_suffix}}."},
tmpl{"{{lead}} gün sonra: {{title}}{{class_suffix}}", "Sayın veliler, {{lead}} gün sonra ({{date_pretty}}) {{title}} gerçekleşiyor{{class_suffix}}."},
tmpl{"Bugün: {{title}}", "Bugün {{title}}{{class_suffix}}."},
tmpl{"Yarın: {{title}}", "Yarın ({{date_pretty}}) {{title}}{{class_suffix}}."},
tmpl{"{{lead}} gün sonra: {{title}}", "{{lead}} gün sonra ({{date_pretty}}) {{title}}{{class_suffix}}."},
)
}
func arTemplates() map[string]map[string]map[string]tmpl {
return makeFamily(
tmpl{"اليوم: {{title}}{{class_suffix}}", "أعزائي أولياء الأمور، اليوم يقام {{title}}{{class_suffix}}. التاريخ: {{date_pretty}}."},
tmpl{"غدًا: {{title}}{{class_suffix}}", "أعزائي أولياء الأمور، غدًا ({{date_pretty}}) يقام {{title}}{{class_suffix}}."},
tmpl{"بعد {{lead}} أيام: {{title}}{{class_suffix}}", "أعزائي أولياء الأمور، بعد {{lead}} أيام ({{date_pretty}}) يقام {{title}}{{class_suffix}}."},
tmpl{"اليوم: {{title}}", "اليوم {{title}}{{class_suffix}}."},
tmpl{"غدًا: {{title}}", "غدًا ({{date_pretty}}) {{title}}{{class_suffix}}."},
tmpl{"بعد {{lead}} أيام: {{title}}", "بعد {{lead}} أيام ({{date_pretty}}) {{title}}{{class_suffix}}."},
)
}
func ukTemplates() map[string]map[string]map[string]tmpl {
return makeFamily(
tmpl{"Сьогодні: {{title}}{{class_suffix}}", "Шановні батьки, сьогодні відбудеться {{title}}{{class_suffix}}. Дата: {{date_pretty}}."},
tmpl{"Завтра: {{title}}{{class_suffix}}", "Шановні батьки, завтра ({{date_pretty}}) відбудеться {{title}}{{class_suffix}}."},
tmpl{"Через {{lead}} днів: {{title}}{{class_suffix}}", "Шановні батьки, через {{lead}} днів ({{date_pretty}}) відбудеться {{title}}{{class_suffix}}."},
tmpl{"Сьогодні: {{title}}", "Сьогодні {{title}}{{class_suffix}}."},
tmpl{"Завтра: {{title}}", "Завтра ({{date_pretty}}) {{title}}{{class_suffix}}."},
tmpl{"Через {{lead}} днів: {{title}}", "Через {{lead}} днів ({{date_pretty}}) {{title}}{{class_suffix}}."},
)
}
func ruTemplates() map[string]map[string]map[string]tmpl {
return makeFamily(
tmpl{"Сегодня: {{title}}{{class_suffix}}", "Уважаемые родители, сегодня состоится {{title}}{{class_suffix}}. Дата: {{date_pretty}}."},
tmpl{"Завтра: {{title}}{{class_suffix}}", "Уважаемые родители, завтра ({{date_pretty}}) состоится {{title}}{{class_suffix}}."},
tmpl{"Через {{lead}} дней: {{title}}{{class_suffix}}", "Уважаемые родители, через {{lead}} дней ({{date_pretty}}) состоится {{title}}{{class_suffix}}."},
tmpl{"Сегодня: {{title}}", "Сегодня {{title}}{{class_suffix}}."},
tmpl{"Завтра: {{title}}", "Завтра ({{date_pretty}}) {{title}}{{class_suffix}}."},
tmpl{"Через {{lead}} дней: {{title}}", "Через {{lead}} дней ({{date_pretty}}) {{title}}{{class_suffix}}."},
)
}
func plTemplates() map[string]map[string]map[string]tmpl {
return makeFamily(
tmpl{"Dzisiaj: {{title}}{{class_suffix}}", "Drodzy rodzice, dzisiaj odbywa się {{title}}{{class_suffix}}. Data: {{date_pretty}}."},
tmpl{"Jutro: {{title}}{{class_suffix}}", "Drodzy rodzice, jutro ({{date_pretty}}) odbywa się {{title}}{{class_suffix}}."},
tmpl{"Za {{lead}} dni: {{title}}{{class_suffix}}", "Drodzy rodzice, za {{lead}} dni ({{date_pretty}}) odbywa się {{title}}{{class_suffix}}."},
tmpl{"Dzisiaj: {{title}}", "Dzisiaj {{title}}{{class_suffix}}."},
tmpl{"Jutro: {{title}}", "Jutro ({{date_pretty}}) {{title}}{{class_suffix}}."},
tmpl{"Za {{lead}} dni: {{title}}", "Za {{lead}} dni ({{date_pretty}}) {{title}}{{class_suffix}}."},
)
}
func frTemplates() map[string]map[string]map[string]tmpl {
return makeFamily(
tmpl{"Aujourd'hui : {{title}}{{class_suffix}}", "Chers parents, aujourd'hui a lieu {{title}}{{class_suffix}}. Date : {{date_pretty}}."},
tmpl{"Demain : {{title}}{{class_suffix}}", "Chers parents, demain ({{date_pretty}}) a lieu {{title}}{{class_suffix}}."},
tmpl{"Dans {{lead}} jours : {{title}}{{class_suffix}}", "Chers parents, dans {{lead}} jours ({{date_pretty}}) a lieu {{title}}{{class_suffix}}."},
tmpl{"Aujourd'hui : {{title}}", "Aujourd'hui c'est {{title}}{{class_suffix}}."},
tmpl{"Demain : {{title}}", "Demain ({{date_pretty}}) c'est {{title}}{{class_suffix}}."},
tmpl{"Dans {{lead}} jours : {{title}}", "Dans {{lead}} jours ({{date_pretty}}) c'est {{title}}{{class_suffix}}."},
)
}