Files
paliad/internal/services/reminder_service.go
m 5fb55164b3 feat: settings page — profile, email preferences, CalDAV as tabs (t-paliad-022)
Unified /einstellungen page replaces the standalone CalDAV screen. Three
tabs today (Profil / Benachrichtigungen / CalDAV); adding more is additive
(one <a> in the tab nav, one <section> panel, one loader). Tab switching
is client-side from ?tab=<name> — default tab is Profil.

Profil tab lets users fix onboarding data without admin intervention:
display name, office, role, Dezernat, language. Email is read-only (the
source of truth is auth.users and an account-level change is out of
scope for the settings page).

Benachrichtigungen tab exposes deadline reminder preferences as a master
toggle plus three per-kind sub-toggles (overdue / tomorrow / weekly).
Preferences land in paliad.users.email_preferences (JSONB); missing keys
are treated as opt-in so existing users keep the behaviour they had
before the page shipped.

CalDAV tab is the old /einstellungen/caldav screen ported inline.
/einstellungen/caldav now 301-redirects to /einstellungen?tab=caldav so
bookmarks keep working.

Backend:
- PATCH /api/me (handlers/users.go) mutates the caller's paliad.users
  row. Attempts to include "email" in the body return 400 — the field is
  always server-authoritative.
- UserService.UpdateProfile builds a dynamic UPDATE from the pointer
  fields supplied; omitted keys are left untouched. Re-uses the
  admin-bootstrap guard for role changes.
- GetByID SELECT now includes lang + email_preferences so /api/me
  returns the data the settings page needs without a second round-trip.
- ReminderService consults email_preferences before sending — the helper
  reminderEnabled covers the master switch and per-kind overrides; corrupt
  JSON falls back to on so a bad row can't silence reminders.
- Migration 017 adds email_preferences jsonb NOT NULL DEFAULT '{}' and
  upgrades lang from nullable (from 016) to NOT NULL DEFAULT 'de' with a
  one-shot backfill. Down restores the nullable lang and drops
  email_preferences.

Model change: User.Lang moved from *string to string — it's NOT NULL in
the DB now, so the indirection was carrying no information. Inviter.Lang
and reminder row structs followed suit; the templates and callers used
""/"en" comparisons that translate 1:1.

Sidebar: the "Einstellungen" group now links to /einstellungen (instead
of just /einstellungen/caldav); the CalDAV sub-item is folded into the
tab nav on the page itself.

Tests: reminderEnabled has table-driven coverage (master switch,
per-kind, corrupt JSON, non-bool values). DB-backed user tests still
skip without TEST_DATABASE_URL as before.

Verified: go build ./..., go vet ./..., go test ./..., bun run build —
all clean.
2026-04-20 13:17:24 +02:00

438 lines
14 KiB
Go

// Package services — ReminderService — hourly deadline-reminder emails.
//
// Runs one goroutine for the process lifetime. Every hour it scans
// paliad.fristen for Fristen that need a reminder, then issues the mail
// through MailService and records the delivery in paliad.reminder_log so the
// next tick doesn't double-send.
//
// Three reminder kinds:
// * overdue — due_date = today, status = pending.
// Heavier alert tone (red header).
// * tomorrow — due_date = today+1, status = pending.
// Pre-deadline nudge.
// * weekly — Monday only: due_date BETWEEN today AND today+7.
// Summary table of the week's Fristen. One email per user
// aggregating every Frist they created.
//
// Dedup window: 24h. The service refuses to resend the same (user,
// reminder_type, frist_id) pair if a row was inserted in the last 24 hours.
// This means at most one overdue / tomorrow email per Frist per day, and
// at most one weekly email per user per Monday.
//
// Recipient selection: the Frist.CreatedBy user — that is, whoever set up
// the deadline. Collaborators on the Akte are not notified (avoids spam when
// five people share an Akte). A future refinement could add an opt-in
// preference table; for now, Frist owner only.
package services
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// reminderTickInterval controls how often the service checks for due Fristen.
// Hourly is enough given the 24h dedup window and the "tomorrow / today"
// granularity — we don't need minute-precision.
const reminderTickInterval = time.Hour
// reminderDedupWindow is the minimum gap between identical reminders (same
// user, same type, same Frist). 24h matches the "one per day" policy.
const reminderDedupWindow = 24 * time.Hour
// ReminderService wires the hourly reminder job. Construct with NewReminderService,
// start with Start(ctx), stop by cancelling the parent context.
type ReminderService struct {
db *sqlx.DB
mail *MailService
users *UserService
// baseURL is the frontend origin used in email links. Defaults to
// https://paliad.de; override via PALIAD_BASE_URL for staging/preview.
baseURL string
// clock returns the current time. Exposed for tests so they can pin
// "today" without having to freeze time globally.
clock func() time.Time
}
// NewReminderService wires the service. The MailService may be disabled
// (Enabled() == false) — in that case Start still runs so logs show what
// would have gone out, but every Send is a no-op.
func NewReminderService(db *sqlx.DB, mail *MailService, users *UserService, baseURL string) *ReminderService {
if baseURL == "" {
baseURL = "https://paliad.de"
}
return &ReminderService{
db: db,
mail: mail,
users: users,
baseURL: baseURL,
clock: func() time.Time { return time.Now() },
}
}
// Start spawns the hourly ticker goroutine. Returns immediately; the loop
// exits when ctx is cancelled.
func (s *ReminderService) Start(ctx context.Context) {
go s.loop(ctx)
}
func (s *ReminderService) loop(ctx context.Context) {
slog.Info("reminder: starting hourly scanner",
"interval", reminderTickInterval,
"mail_enabled", s.mail.Enabled())
// Run once immediately so a fresh deploy catches up without waiting an
// hour — paired with the 24h dedup, this is safe.
s.RunOnce(ctx)
t := time.NewTicker(reminderTickInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
slog.Info("reminder: shutdown")
return
case <-t.C:
s.RunOnce(ctx)
}
}
}
// RunOnce performs one scan+send pass. Exposed so tests (and, later, an
// admin trigger endpoint) can exercise the path without waiting for the
// ticker. Errors on individual Fristen are logged and swallowed so one bad
// row doesn't block the rest of the scan.
func (s *ReminderService) RunOnce(ctx context.Context) {
now := s.clock()
today := now.UTC().Truncate(24 * time.Hour)
if err := s.sendPerFrist(ctx, today, "overdue"); err != nil {
slog.Warn("reminder: overdue scan failed", "error", err)
}
if err := s.sendPerFrist(ctx, today, "tomorrow"); err != nil {
slog.Warn("reminder: tomorrow scan failed", "error", err)
}
// Weekly runs only on Mondays. time.Weekday returns time.Monday = 1.
if now.Weekday() == time.Monday {
if err := s.sendWeekly(ctx, today); err != nil {
slog.Warn("reminder: weekly scan failed", "error", err)
}
}
}
// fristReminderRow is the projection needed to render a per-Frist email.
// We join the parent Akte for its Aktenzeichen / title and the user row for
// the preferred language and notification preferences.
type fristReminderRow struct {
FristID uuid.UUID `db:"frist_id"`
FristTitle string `db:"frist_title"`
DueDate time.Time `db:"due_date"`
AkteAktenzeichen string `db:"akte_aktenzeichen"`
AkteTitle string `db:"akte_title"`
UserID uuid.UUID `db:"user_id"`
UserEmail string `db:"user_email"`
UserDisplayName string `db:"user_display_name"`
UserLang string `db:"user_lang"`
UserEmailPreferences json.RawMessage `db:"user_email_preferences"`
}
// sendPerFrist covers the two per-Frist reminder kinds. The query filters on
// due_date and the dedup table in a single round-trip so concurrent workers
// can't both decide to send (though we only run one reminder process).
func (s *ReminderService) sendPerFrist(ctx context.Context, today time.Time, kind string) error {
var dueDate time.Time
switch kind {
case "overdue":
dueDate = today
case "tomorrow":
dueDate = today.AddDate(0, 0, 1)
default:
return fmt.Errorf("unknown kind %q", kind)
}
// Overdue is "<= today" — include older still-pending Fristen. Tomorrow is
// an exact match.
var cond string
if kind == "overdue" {
cond = "f.due_date <= $1"
} else {
cond = "f.due_date = $1"
}
query := `
SELECT f.id AS frist_id,
f.title AS frist_title,
f.due_date AS due_date,
a.aktenzeichen AS akte_aktenzeichen,
a.title AS akte_title,
u.id AS user_id,
u.email AS user_email,
u.display_name AS user_display_name,
u.lang AS user_lang,
u.email_preferences AS user_email_preferences
FROM paliad.fristen f
JOIN paliad.akten a ON a.id = f.akte_id
JOIN paliad.users u ON u.id = f.created_by
WHERE f.status = 'pending'
AND ` + cond + `
AND NOT EXISTS (
SELECT 1 FROM paliad.reminder_log r
WHERE r.user_id = u.id
AND r.reminder_type = $2
AND r.frist_id = f.id
AND r.sent_at >= $3
)`
rows := []fristReminderRow{}
if err := s.db.SelectContext(ctx, &rows, query,
dueDate, kind, s.clock().Add(-reminderDedupWindow),
); err != nil {
return fmt.Errorf("select fristen for %s: %w", kind, err)
}
for _, r := range rows {
if !reminderEnabled(r.UserEmailPreferences, kind) {
continue
}
if err := s.deliverFristReminder(ctx, kind, r); err != nil {
slog.Warn("reminder: deliver failed",
"kind", kind, "frist_id", r.FristID, "user_id", r.UserID, "error", err)
continue
}
}
return nil
}
func (s *ReminderService) deliverFristReminder(ctx context.Context, kind string, r fristReminderRow) error {
lang := "de"
if r.UserLang == "en" {
lang = "en"
}
subject := buildSubject(kind, lang, r.FristTitle, 0)
data := map[string]any{
"Kind": kind,
"Title": r.FristTitle,
"DueDate": r.DueDate.Format("2006-01-02"),
"AkteAktenzeichen": r.AkteAktenzeichen,
"AkteTitle": r.AkteTitle,
"FristURL": fmt.Sprintf("%s/fristen/%s", s.baseURL, r.FristID),
}
if err := s.mail.SendTemplate(TemplateData{
To: r.UserEmail,
Subject: subject,
Lang: lang,
Name: "deadline_reminder",
Data: data,
}); err != nil {
return fmt.Errorf("send: %w", err)
}
return s.logSend(ctx, r.UserID, &r.FristID, kind)
}
// weeklyRow captures one user's batch of upcoming Fristen plus their
// preferred language. We hold rows per-user in memory and emit one email
// per user with the aggregated table.
type weeklyRow struct {
UserID uuid.UUID `db:"user_id"`
UserEmail string `db:"user_email"`
UserDisplayName string `db:"user_display_name"`
UserLang string `db:"user_lang"`
UserEmailPreferences json.RawMessage `db:"user_email_preferences"`
FristID uuid.UUID `db:"frist_id"`
FristTitle string `db:"frist_title"`
DueDate time.Time `db:"due_date"`
AkteAktenzeichen string `db:"akte_aktenzeichen"`
}
// reminderEnabled reports whether the user's email_preferences allow a given
// reminder kind. A missing key or empty object means on (opt-out) — that
// preserves the behaviour users had before the settings page shipped.
//
// Two gates: the master toggle "deadline_reminders" and the per-kind key
// "deadline_reminders.<kind>". Either being explicitly false skips the send.
func reminderEnabled(raw json.RawMessage, kind string) bool {
if len(raw) == 0 {
return true
}
prefs := map[string]any{}
if err := json.Unmarshal(raw, &prefs); err != nil {
// Corrupt JSON in the DB shouldn't silence reminders — err on the
// side of sending so users aren't dropped because of bad data.
return true
}
if v, ok := prefs["deadline_reminders"]; ok {
if b, ok := v.(bool); ok && !b {
return false
}
}
if v, ok := prefs["deadline_reminders."+kind]; ok {
if b, ok := v.(bool); ok && !b {
return false
}
}
return true
}
func (s *ReminderService) sendWeekly(ctx context.Context, today time.Time) error {
end := today.AddDate(0, 0, 7)
query := `
SELECT u.id AS user_id,
u.email AS user_email,
u.display_name AS user_display_name,
u.lang AS user_lang,
u.email_preferences AS user_email_preferences,
f.id AS frist_id,
f.title AS frist_title,
f.due_date AS due_date,
a.aktenzeichen AS akte_aktenzeichen
FROM paliad.fristen f
JOIN paliad.akten a ON a.id = f.akte_id
JOIN paliad.users u ON u.id = f.created_by
WHERE f.status = 'pending'
AND f.due_date >= $1
AND f.due_date < $2
ORDER BY u.id, f.due_date ASC, f.id ASC`
rows := []weeklyRow{}
if err := s.db.SelectContext(ctx, &rows, query, today, end); err != nil {
return fmt.Errorf("select weekly rows: %w", err)
}
// Group by user and drop users we already emailed within the dedup window.
byUser := map[uuid.UUID][]weeklyRow{}
order := []uuid.UUID{}
for _, r := range rows {
if _, seen := byUser[r.UserID]; !seen {
order = append(order, r.UserID)
}
byUser[r.UserID] = append(byUser[r.UserID], r)
}
for _, uid := range order {
userRows := byUser[uid]
if len(userRows) == 0 {
continue
}
if !reminderEnabled(userRows[0].UserEmailPreferences, "weekly") {
continue
}
alreadySent, err := s.hasWeeklySentSince(ctx, uid, s.clock().Add(-reminderDedupWindow))
if err != nil {
slog.Warn("reminder: weekly dedup check failed", "user_id", uid, "error", err)
continue
}
if alreadySent {
continue
}
if err := s.deliverWeekly(ctx, today, userRows); err != nil {
slog.Warn("reminder: weekly deliver failed", "user_id", uid, "error", err)
continue
}
}
return nil
}
func (s *ReminderService) hasWeeklySentSince(ctx context.Context, userID uuid.UUID, since time.Time) (bool, error) {
var exists bool
err := s.db.GetContext(ctx, &exists,
`SELECT EXISTS (
SELECT 1 FROM paliad.reminder_log
WHERE user_id = $1 AND reminder_type = 'weekly' AND sent_at >= $2
)`, userID, since)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return exists, err
}
func (s *ReminderService) deliverWeekly(ctx context.Context, today time.Time, rows []weeklyRow) error {
if len(rows) == 0 {
return nil
}
first := rows[0]
lang := "de"
if first.UserLang == "en" {
lang = "en"
}
items := make([]map[string]any, 0, len(rows))
for _, r := range rows {
items = append(items, map[string]any{
"DueDate": r.DueDate.Format("2006-01-02"),
"Title": r.FristTitle,
"AkteAktenzeichen": r.AkteAktenzeichen,
"URL": fmt.Sprintf("%s/fristen/%s", s.baseURL, r.FristID),
"Overdue": r.DueDate.Before(today),
})
}
subject := buildSubject("weekly", lang, "", len(rows))
if err := s.mail.SendTemplate(TemplateData{
To: first.UserEmail,
Subject: subject,
Lang: lang,
Name: "deadline_weekly",
Data: map[string]any{
"Count": len(rows),
"Items": items,
"FristenURL": fmt.Sprintf("%s/fristen", s.baseURL),
},
}); err != nil {
return fmt.Errorf("send weekly: %w", err)
}
return s.logSend(ctx, first.UserID, nil, "weekly")
}
func (s *ReminderService) logSend(ctx context.Context, userID uuid.UUID, fristID *uuid.UUID, kind string) error {
if _, err := s.db.ExecContext(ctx,
`INSERT INTO paliad.reminder_log (user_id, reminder_type, frist_id)
VALUES ($1, $2, $3)`,
userID, kind, fristID,
); err != nil {
return fmt.Errorf("insert reminder_log: %w", err)
}
return nil
}
// buildSubject shapes the email subject per kind+lang. Kept here (not in the
// template) so the caller can log the subject without rendering HTML.
func buildSubject(kind, lang, title string, count int) string {
if lang == "en" {
switch kind {
case "overdue":
return "[Paliad] Deadline overdue: " + title
case "tomorrow":
return "[Paliad] Deadline tomorrow: " + title
case "weekly":
return fmt.Sprintf("[Paliad] Weekly summary: %d deadline%s", count, pluralS(count))
}
}
switch kind {
case "overdue":
return "[Paliad] Frist überfällig: " + title
case "tomorrow":
return "[Paliad] Frist morgen: " + title
case "weekly":
return fmt.Sprintf("[Paliad] Wochenübersicht: %d Fristen", count)
}
return "[Paliad] Erinnerung"
}
func pluralS(n int) string {
if n == 1 {
return ""
}
return "s"
}