diff --git a/school-service/cmd/server/main.go b/school-service/cmd/server/main.go index 017786f..89cfdee 100644 --- a/school-service/cmd/server/main.go +++ b/school-service/cmd/server/main.go @@ -4,6 +4,7 @@ import ( "context" "log" "os" + "time" "github.com/breakpilot/school-service/internal/config" "github.com/breakpilot/school-service/internal/database" @@ -37,7 +38,28 @@ func main() { } // Create handler - handler := handlers.NewHandler(db.Pool, cfg.LLMGatewayURL, cfg.SolverServiceURL) + handler := handlers.NewHandler(db.Pool, cfg.LLMGatewayURL, cfg.SolverServiceURL, cfg.MatrixServiceURL, cfg.EmailServiceURL) + + // Phase 9d: daily notification cron. Ticks every hour and runs the + // scanner once when the current hour == 6. Idempotent via the + // notification_log UNIQUE constraint, so multiple ticks the same day + // are safe. + go func() { + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + for { + now := time.Now() + if now.Hour() == 6 { + res, err := handler.NotificationService().RunForDate(context.Background(), now) + if err != nil { + log.Printf("notification cron error: %v", err) + } else { + log.Printf("notification cron: %+v", res) + } + } + <-ticker.C + } + }() // Calendar seed — idempotent, runs every boot. Snapshot path is bundled // in the Docker image at /app/internal/seed/calendar_holidays.json. Failures @@ -263,6 +285,10 @@ func main() { api.GET("/calendar/parents", handler.ListParentInvites) api.POST("/calendar/parents/invite", handler.InviteParent) api.DELETE("/calendar/parents/children/:id", handler.DeleteParentInvite) + + // Phase 9d: notifications. + api.POST("/calendar/notifications/run-now", handler.RunNotificationsNow) + api.GET("/calendar/events/:id/notifications", handler.ListEventNotifications) } // Phase 9c: parent-side endpoints. Auth is the parent session cookie, diff --git a/school-service/internal/config/config.go b/school-service/internal/config/config.go index 338cbfa..4d90762 100644 --- a/school-service/internal/config/config.go +++ b/school-service/internal/config/config.go @@ -31,6 +31,10 @@ type Config struct { // Timetable solver service (Python/FastAPI, port 8095) SolverServiceURL string + + // Notification upstream services (Phase 9d). Empty → stub mode. + MatrixServiceURL string + EmailServiceURL string } // Load loads configuration from environment variables @@ -47,6 +51,8 @@ func Load() (*Config, error) { RateLimitWindow: getEnvInt("RATE_LIMIT_WINDOW", 60), LLMGatewayURL: getEnv("LLM_GATEWAY_URL", "http://backend:8000/llm"), SolverServiceURL: getEnv("SOLVER_SERVICE_URL", "http://timetable-solver-service:8095"), + MatrixServiceURL: getEnv("MATRIX_SERVICE_URL", ""), + EmailServiceURL: getEnv("EMAIL_SERVICE_URL", ""), } // Parse allowed origins diff --git a/school-service/internal/database/database.go b/school-service/internal/database/database.go index b934e19..636aa02 100644 --- a/school-service/internal/database/database.go +++ b/school-service/internal/database/database.go @@ -227,6 +227,9 @@ func Migrate(db *DB) error { // Append parent migrations (Phase 9c — see parent_migrations.go). migrations = append(migrations, ParentMigrations()...) + // Append notification log (Phase 9d — see notification_migrations.go). + migrations = append(migrations, NotificationMigrations()...) + for _, migration := range migrations { _, err := db.Pool.Exec(ctx, migration) if err != nil { diff --git a/school-service/internal/database/notification_migrations.go b/school-service/internal/database/notification_migrations.go new file mode 100644 index 0000000..54b3a27 --- /dev/null +++ b/school-service/internal/database/notification_migrations.go @@ -0,0 +1,31 @@ +package database + +// NotificationMigrations creates the one table Phase 9d needs: +// +// notification_log — one row per (event, lead_days, audience, channel) +// that the cron scanner has already attempted. The UNIQUE constraint +// makes the cron idempotent — running it twice on the same day does +// not re-send. +// +// channel ∈ {'matrix', 'email'} — set by the dispatcher. +// audience ∈ {'parents', 'students'}. +// status ∈ {'sent', 'failed', 'skipped'} — 'skipped' when the upstream +// service URL isn't configured, so we know not to count it as failure. +func NotificationMigrations() []string { + return []string{ + `CREATE TABLE IF NOT EXISTS notification_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID NOT NULL REFERENCES cal_school_event(id) ON DELETE CASCADE, + lead_days INT NOT NULL, + audience VARCHAR(20) NOT NULL CHECK (audience IN ('parents','students')), + channel VARCHAR(20) NOT NULL CHECK (channel IN ('matrix','email')), + status VARCHAR(20) NOT NULL CHECK (status IN ('sent','failed','skipped')), + error_message TEXT, + run_date DATE NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(event_id, lead_days, audience, channel) + )`, + `CREATE INDEX IF NOT EXISTS idx_notification_log_event ON notification_log(event_id)`, + `CREATE INDEX IF NOT EXISTS idx_notification_log_run_date ON notification_log(run_date)`, + } +} diff --git a/school-service/internal/handlers/handlers.go b/school-service/internal/handlers/handlers.go index 6bbbf4c..9ec900b 100644 --- a/school-service/internal/handlers/handlers.go +++ b/school-service/internal/handlers/handlers.go @@ -3,6 +3,7 @@ package handlers import ( "net/http" + "github.com/breakpilot/school-service/internal/notifications" "github.com/breakpilot/school-service/internal/services" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5/pgxpool" @@ -17,13 +18,14 @@ type Handler struct { certificateService *services.CertificateService aiService *services.AIService timetableService *services.TimetableService - calendarService *services.CalendarService - parentService *services.ParentService - solverServiceURL string + calendarService *services.CalendarService + parentService *services.ParentService + notificationService *notifications.Service + solverServiceURL string } // NewHandler creates a new Handler with all services -func NewHandler(db *pgxpool.Pool, llmGatewayURL, solverServiceURL string) *Handler { +func NewHandler(db *pgxpool.Pool, llmGatewayURL, solverServiceURL, matrixURL, emailURL string) *Handler { classService := services.NewClassService(db) examService := services.NewExamService(db) gradeService := services.NewGradeService(db) @@ -33,21 +35,29 @@ func NewHandler(db *pgxpool.Pool, llmGatewayURL, solverServiceURL string) *Handl timetableService := services.NewTimetableService(db) calendarService := services.NewCalendarService(db) parentService := services.NewParentService(db) + notificationService := notifications.NewService(db, matrixURL, emailURL) return &Handler{ - classService: classService, - examService: examService, - gradeService: gradeService, - gradebookService: gradebookService, - certificateService: certificateService, - aiService: aiService, - timetableService: timetableService, - calendarService: calendarService, - parentService: parentService, - solverServiceURL: solverServiceURL, + classService: classService, + examService: examService, + gradeService: gradeService, + gradebookService: gradebookService, + certificateService: certificateService, + aiService: aiService, + timetableService: timetableService, + calendarService: calendarService, + parentService: parentService, + notificationService: notificationService, + solverServiceURL: solverServiceURL, } } +// NotificationService exposes the underlying service so main.go can run +// the daily cron tick. +func (h *Handler) NotificationService() *notifications.Service { + return h.notificationService +} + // CalendarService exposes the underlying service so main.go can run the // one-off seed import after migrations. func (h *Handler) CalendarService() *services.CalendarService { diff --git a/school-service/internal/handlers/notification_handlers.go b/school-service/internal/handlers/notification_handlers.go new file mode 100644 index 0000000..06ca1d6 --- /dev/null +++ b/school-service/internal/handlers/notification_handlers.go @@ -0,0 +1,50 @@ +package handlers + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" +) + +// RunNotificationsNow triggers the scanner on demand (UI-test + backfill). +// Optional ?date=YYYY-MM-DD lets the teacher replay a past day's send. +// Idempotent — already-logged combos are skipped. +func (h *Handler) RunNotificationsNow(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + runDate := time.Now() + if param := c.Query("date"); param != "" { + d, err := time.Parse("2006-01-02", param) + if err != nil { + respondError(c, http.StatusBadRequest, "date must be YYYY-MM-DD") + return + } + runDate = d + } + res, err := h.notificationService.RunForDate(c.Request.Context(), runDate) + if err != nil { + respondError(c, http.StatusInternalServerError, "Run failed: "+err.Error()) + return + } + respondSuccess(c, res) +} + +// ListEventNotifications returns the notification_log rows for one event so +// the DayDetail UI can show "Erinnerung verschickt am …". +func (h *Handler) ListEventNotifications(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + rows, err := h.notificationService.ListLog(c.Request.Context(), c.Param("id"), uid) + if err != nil { + respondError(c, http.StatusInternalServerError, err.Error()) + return + } + respondSuccess(c, rows) +} diff --git a/school-service/internal/notifications/dispatcher.go b/school-service/internal/notifications/dispatcher.go new file mode 100644 index 0000000..2cd0e5d --- /dev/null +++ b/school-service/internal/notifications/dispatcher.go @@ -0,0 +1,151 @@ +package notifications + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "time" +) + +// dispatchOne builds the payload for one (event, audience, channel) tuple, +// posts it to the upstream Matrix/Email service, and writes a +// notification_log row. Returns one of "sent", "failed", "skipped", +// "already" so the caller can tally counters. +// +// "skipped" means the upstream URL is empty (dev/test mode) — we still log +// so the UI can render "will-send-when-configured". "already" means the +// (event, lead, audience, channel) combo is already logged from an earlier +// run today; we don't re-send. +func (s *Service) dispatchOne(ctx context.Context, e dueEvent, audience, channel string, runDate time.Time) (status string, err error) { + // Idempotency check. + var existing int + if err := s.db.QueryRow(ctx, ` + SELECT COUNT(*) FROM notification_log + WHERE event_id = $1 AND lead_days = $2 AND audience = $3 AND channel = $4 + `, e.ID, e.LeadDays, audience, channel).Scan(&existing); err != nil { + return "failed", err + } + if existing > 0 { + return "already", nil + } + + recipients, lang, err := s.recipientsFor(ctx, e, audience) + if err != nil { + return "failed", err + } + + url := s.urlFor(channel) + if url == "" { + // Stub mode: write a 'skipped' log row but report success so the cron + // counter isn't alarming when running locally without the upstream. + _ = s.writeLog(ctx, e, audience, channel, "skipped", "no upstream URL configured", runDate) + return "skipped", nil + } + + subject, body := Render(e.EventType, audience, e.LeadDays, lang, Vars{ + Title: e.Title, Date: e.StartDate.Format("2006-01-02"), + DatePretty: e.StartDate.Format("02.01.2006"), ClassName: e.ClassName, + }) + + for _, recipient := range recipients { + payload := DispatchPayload{ + Channel: channel, Recipient: recipient, Language: lang, + Subject: subject, Body: body, EventID: e.ID, LeadDays: e.LeadDays, + } + if err := s.postUpstream(ctx, url, payload); err != nil { + _ = s.writeLog(ctx, e, audience, channel, "failed", err.Error(), runDate) + return "failed", err + } + } + + if err := s.writeLog(ctx, e, audience, channel, "sent", "", runDate); err != nil { + log.Printf("notification_log insert failed (already counted as sent): %v", err) + } + return "sent", nil +} + +// recipientsFor returns the list of email addresses (parents) or Matrix +// handles (students — derived from … unimplemented for now; we just return +// the parent emails and let the bridge fan out). +// +// Per memory the Matrix/Email upstream services are owned by the colleague; +// our job here is to hand them a recipient identifier they can resolve. +// For parents that's the email; for students we have no contact identifier +// yet, so we fall back to the parent emails too (broadcast). +func (s *Service) recipientsFor(ctx context.Context, e dueEvent, audience string) ([]string, string, error) { + // Find the class IDs from the event row. If empty → all classes owned by + // the teacher. + rows, err := s.db.Query(ctx, ` + SELECT DISTINCT pa.email, pa.preferred_language + FROM parent_account pa + JOIN parent_child pc ON pc.parent_id = pa.id + WHERE pa.created_by_user_id = $1 + AND ( + (SELECT array_length(affected_class_ids, 1) FROM cal_school_event WHERE id = $2) IS NULL + OR pc.tt_class_id = ANY( + (SELECT affected_class_ids FROM cal_school_event WHERE id = $2) + ) + ) + `, e.OwnerUserID, e.ID) + if err != nil { + return nil, "de", err + } + defer rows.Close() + var emails []string + primaryLang := "de" + first := true + for rows.Next() { + var email, lang string + if err := rows.Scan(&email, &lang); err != nil { + return nil, "de", err + } + emails = append(emails, email) + if first { + primaryLang = lang + first = false + } + } + return emails, primaryLang, nil +} + +func (s *Service) urlFor(channel string) string { + switch channel { + case "matrix": + return s.matrixURL + case "email": + return s.emailURL + } + return "" +} + +func (s *Service) postUpstream(ctx context.Context, url string, payload DispatchPayload) error { + body, _ := json.Marshal(payload) + cctx, cancel := context.WithTimeout(ctx, s.httpTimeout) + defer cancel() + req, err := http.NewRequestWithContext(cctx, "POST", url, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return fmt.Errorf("upstream returned HTTP %d", resp.StatusCode) + } + return nil +} + +func (s *Service) writeLog(ctx context.Context, e dueEvent, audience, channel, status, errorMessage string, runDate time.Time) error { + _, err := s.db.Exec(ctx, ` + INSERT INTO notification_log (event_id, lead_days, audience, channel, status, error_message, run_date) + VALUES ($1::uuid, $2, $3, $4, $5, NULLIF($6, ''), $7::date) + ON CONFLICT (event_id, lead_days, audience, channel) DO NOTHING + `, e.ID, e.LeadDays, audience, channel, status, errorMessage, runDate.Format("2006-01-02")) + return err +} diff --git a/school-service/internal/notifications/service.go b/school-service/internal/notifications/service.go new file mode 100644 index 0000000..5ea1e86 --- /dev/null +++ b/school-service/internal/notifications/service.go @@ -0,0 +1,204 @@ +package notifications + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Service is the entry point for the daily notification scan. It reads +// cal_school_event + parent_account + tt_class, decides which (event, +// lead_days, audience) pairs are due today, dispatches via the configured +// upstream URLs, and writes a notification_log row for idempotency. +type Service struct { + db *pgxpool.Pool + matrixURL string // empty → status=skipped + emailURL string // empty → status=skipped + httpTimeout time.Duration +} + +// Dispatch is the contract our upstream services (Matrix bridge + Email +// gateway, owned by the colleague) must implement. We POST a body with +// these fields; they figure out delivery. Stub mode (URL == "") logs +// instead, useful for dev + tests. +type DispatchPayload struct { + Channel string `json:"channel"` // "matrix" | "email" + Recipient string `json:"recipient"` // email address; for Matrix the bridge maps it + Language string `json:"language"` + Subject string `json:"subject"` + Body string `json:"body"` + EventID string `json:"event_id"` + LeadDays int `json:"lead_days"` +} + +func NewService(db *pgxpool.Pool, matrixURL, emailURL string) *Service { + return &Service{ + db: db, + matrixURL: matrixURL, + emailURL: emailURL, + httpTimeout: 10 * time.Second, + } +} + +// LogRow is the read shape for the notification_log table. +type LogRow struct { + LeadDays int `json:"lead_days"` + Audience string `json:"audience"` + Channel string `json:"channel"` + Status string `json:"status"` + Error string `json:"error_message,omitempty"` + RunDate string `json:"run_date"` + CreatedAt time.Time `json:"created_at"` +} + +// ListLog returns the notification_log rows for one event, scoped to a +// teacher so the UI can't query someone else's log. +func (s *Service) ListLog(ctx context.Context, eventID, ownerUserID string) ([]LogRow, error) { + rows, err := s.db.Query(ctx, ` + SELECT nl.lead_days, nl.audience, nl.channel, nl.status, + COALESCE(nl.error_message, ''), nl.run_date::text, nl.created_at + FROM notification_log nl + JOIN cal_school_event ev ON ev.id = nl.event_id + WHERE ev.id = $1 AND ev.created_by_user_id = $2 + ORDER BY nl.lead_days DESC, nl.audience, nl.channel + `, eventID, ownerUserID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []LogRow{} + for rows.Next() { + var r LogRow + if err := rows.Scan(&r.LeadDays, &r.Audience, &r.Channel, &r.Status, &r.Error, &r.RunDate, &r.CreatedAt); err != nil { + return nil, err + } + out = append(out, r) + } + return out, nil +} + +// RunForDate scans every active cal_school_event with a lead_day equal to +// (event_start - runDate). For each due (audience, channel) pair it +// renders + dispatches + logs. Idempotent via the UNIQUE constraint on +// notification_log. +type RunResult struct { + Date string `json:"date"` + Sent int `json:"sent"` + Failed int `json:"failed"` + Skipped int `json:"skipped"` + AlreadyLogged int `json:"already_logged"` +} + +func (s *Service) RunForDate(ctx context.Context, runDate time.Time) (*RunResult, error) { + res := &RunResult{Date: runDate.Format("2006-01-02")} + + events, err := s.dueEvents(ctx, runDate) + if err != nil { + return nil, fmt.Errorf("query due events: %w", err) + } + + for _, e := range events { + // For each event we may notify parents and/or students depending on + // the row's flags. Channels are derived from the audience: + // parents → email + matrix + // students → matrix only (students don't always have email) + audiences := []string{} + if e.NotifyParents { + audiences = append(audiences, "parents") + } + if e.NotifyStudents { + audiences = append(audiences, "students") + } + for _, audience := range audiences { + channels := []string{"matrix"} + if audience == "parents" { + channels = append(channels, "email") + } + for _, channel := range channels { + status, err := s.dispatchOne(ctx, e, audience, channel, runDate) + switch status { + case "sent": + res.Sent++ + case "failed": + res.Failed++ + if err != nil { + log.Printf("notification dispatch failed: %v", err) + } + case "skipped": + res.Skipped++ + case "already": + res.AlreadyLogged++ + } + } + } + } + return res, nil +} + +// dueEvent holds the small slice of cal_school_event row we need plus the +// matched lead_day for this run. +type dueEvent struct { + ID string + Title string + EventType string + StartDate time.Time + ClassName string // optional, may be empty for "alle Klassen" + OwnerUserID string + NotifyParents bool + NotifyStudents bool + LeadDays int +} + +func (s *Service) dueEvents(ctx context.Context, runDate time.Time) ([]dueEvent, error) { + // A row is due when (start_date - runDate) appears in + // notification_lead_days. Lead=0 means "today the event starts". + rows, err := s.db.Query(ctx, ` + SELECT ev.id::text, ev.title, ev.event_type, ev.start_date, ev.created_by_user_id::text, + ev.notify_parents, ev.notify_students, ev.notification_lead_days, + COALESCE( + (SELECT string_agg(cl.name, ', ' ORDER BY cl.name) + FROM tt_class cl + WHERE cl.id = ANY(ev.affected_class_ids)), + '' + ) AS class_names + FROM cal_school_event ev + WHERE ev.start_date >= $1::date + AND (ev.notify_parents OR ev.notify_students) + `, runDate.Format("2006-01-02")) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []dueEvent + for rows.Next() { + var ( + id, title, eventType, ownerUserID, classNames string + notifyParents, notifyStudents bool + startDate time.Time + leadDays []int32 + ) + if err := rows.Scan(&id, &title, &eventType, &startDate, &ownerUserID, + ¬ifyParents, ¬ifyStudents, &leadDays, &classNames); err != nil { + return nil, err + } + distanceDays := int(startDate.Sub(runDate).Hours() / 24) + for _, lead := range leadDays { + if int(lead) == distanceDays { + out = append(out, dueEvent{ + ID: id, Title: title, EventType: eventType, + StartDate: startDate, ClassName: classNames, + OwnerUserID: ownerUserID, + NotifyParents: notifyParents, + NotifyStudents: notifyStudents, + LeadDays: int(lead), + }) + break + } + } + } + return out, nil +} diff --git a/school-service/internal/notifications/templates.go b/school-service/internal/notifications/templates.go new file mode 100644 index 0000000..039438d --- /dev/null +++ b/school-service/internal/notifications/templates.go @@ -0,0 +1,254 @@ +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}}."}, + ) +} diff --git a/school-service/internal/notifications/templates_test.go b/school-service/internal/notifications/templates_test.go new file mode 100644 index 0000000..e1f0811 --- /dev/null +++ b/school-service/internal/notifications/templates_test.go @@ -0,0 +1,75 @@ +package notifications + +import ( + "strings" + "testing" +) + +func TestBucketFor(t *testing.T) { + cases := []struct { + lead int + want string + }{ + {-1, "today"}, {0, "today"}, {1, "tomorrow"}, {2, "days"}, {7, "days"}, + } + for _, c := range cases { + if got := bucketFor(c.lead); got != c.want { + t.Errorf("bucketFor(%d) = %q, want %q", c.lead, got, c.want) + } + } +} + +func TestRender_GermanParentsToday(t *testing.T) { + subject, body := Render("fortbildung", "parents", 0, "de", Vars{ + Title: "SCHILF", DatePretty: "10.10.2026", ClassName: "5a", + }) + if !strings.Contains(subject, "Heute") || !strings.Contains(subject, "SCHILF") { + t.Errorf("expected today + title in subject, got %q", subject) + } + if !strings.Contains(body, "Liebe Eltern") { + t.Errorf("expected greeting in body, got %q", body) + } + if !strings.Contains(body, "(5a)") { + t.Errorf("expected class suffix, got %q", body) + } +} + +func TestRender_TurkishStudentTomorrow(t *testing.T) { + subject, _ := Render("schulfeier", "students", 1, "tr", Vars{ + Title: "Yaz şenliği", DatePretty: "12.06.2026", + }) + if !strings.Contains(subject, "Yarın") || !strings.Contains(subject, "Yaz şenliği") { + t.Errorf("expected Turkish 'tomorrow' subject, got %q", subject) + } +} + +func TestRender_FallbackLanguage(t *testing.T) { + // 'xx' isn't supported → falls back to de. + subject, _ := Render("klassenfahrt", "parents", 7, "xx", Vars{ + Title: "Wattenmeer", DatePretty: "15.05.2026", ClassName: "6b", + }) + if !strings.Contains(subject, "In 7 Tagen") { + t.Errorf("expected German fallback, got %q", subject) + } +} + +func TestRender_FallbackEventType(t *testing.T) { + // 'unknown_type' falls back to 'andere'. + subject, _ := Render("unknown_type", "parents", 0, "de", Vars{ + Title: "Sondertermin", + }) + if !strings.Contains(subject, "Heute") || !strings.Contains(subject, "Sondertermin") { + t.Errorf("expected today subject after fallback, got %q", subject) + } +} + +func TestSubstitute_DropsClassSuffixWhenEmpty(t *testing.T) { + out := substitute("X{{class_suffix}}Y", 0, Vars{ClassName: ""}) + if out != "XY" { + t.Errorf("expected XY (no suffix), got %q", out) + } + out = substitute("X{{class_suffix}}Y", 0, Vars{ClassName: "5a"}) + if out != "X (5a)Y" { + t.Errorf("expected ' (5a)' suffix, got %q", out) + } +} diff --git a/studio-v2/app/schulkalender/_components/DayDetail.tsx b/studio-v2/app/schulkalender/_components/DayDetail.tsx index 365430f..71c4d85 100644 --- a/studio-v2/app/schulkalender/_components/DayDetail.tsx +++ b/studio-v2/app/schulkalender/_components/DayDetail.tsx @@ -4,6 +4,7 @@ import { useTheme } from '@/lib/ThemeContext' import { calendarApi } from '@/lib/schulkalender/api' import type { PublicEvent, SchoolEvent } from '@/app/schulkalender/types' import { EVENT_TYPE_COLOR, EVENT_TYPE_LABEL } from '@/app/schulkalender/types' +import { NotificationStatus } from './NotificationStatus' interface DayDetailProps { iso: string @@ -84,6 +85,9 @@ export function DayDetail({ iso, holidays, events, onClose, onDeleted }: DayDeta {e.notify_parents && ' · 📧 Eltern erinnern'} {e.notify_students && ' · 💬 Schueler erinnern'} + {(e.notify_parents || e.notify_students) && ( + + )}