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
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>
255 lines
11 KiB
Go
255 lines
11 KiB
Go
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}}."},
|
||
)
|
||
}
|