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.
438 lines
14 KiB
Go
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"
|
|
}
|