From e68ff5b434ede743bea172180a7f3dce6222641d Mon Sep 17 00:00:00 2001 From: m Date: Mon, 27 Apr 2026 11:47:10 +0200 Subject: [PATCH] feat(reminders): per-user send times + due-today evening sweep (t-paliad-048) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reminders used to fire whenever the hourly ticker happened to scan after a user's first eligible event — m got mail at 02:28. We now gate delivery to a user-chosen hour-of-day in their local timezone. * Migration 022 adds reminder_morning_time / reminder_evening_time / reminder_timezone (defaults 09:00, 16:00, Europe/Berlin). * New "due_today_evening" reminder kind with its own template — fires only for due_date = today AND status = pending, in the evening slot. * Reminder service computes user-local hour each tick and skips users outside their slot. SQL widens to a 3-day band; in-process filter narrows to per-user local date. * Settings → Notifications gains time inputs and a timezone field. * Tests: pure (inSlot, slotForKind, matchesLocalDueDate) plus a live-DB TestReminderSlots covering morning, evening, outside-slot, and the completed-deadline case. --- frontend/src/client/i18n.ts | 20 ++ frontend/src/client/settings.ts | 31 +- frontend/src/settings.tsx | 41 +++ .../022_user_reminder_times.down.sql | 4 + .../migrations/022_user_reminder_times.up.sql | 19 ++ internal/models/models.go | 10 +- internal/services/reminder_service.go | 319 +++++++++++++----- internal/services/reminder_service_test.go | 232 +++++++++++++ internal/services/user_service.go | 65 +++- .../templates/email/deadline_due_today.html | 32 ++ 10 files changed, 684 insertions(+), 89 deletions(-) create mode 100644 internal/db/migrations/022_user_reminder_times.down.sql create mode 100644 internal/db/migrations/022_user_reminder_times.up.sql create mode 100644 internal/templates/email/deadline_due_today.html diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index 2395242..b764daf 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -989,7 +989,17 @@ const translations: Record> = { "einstellungen.prefs.reminders.master": "Frist-Erinnerungen aktiv", "einstellungen.prefs.reminders.overdue": "\u00dcberf\u00e4llige Fristen", "einstellungen.prefs.reminders.tomorrow": "Fristen morgen f\u00e4llig", + "einstellungen.prefs.reminders.due_today_evening": "Heute f\u00e4llig (Abend-Erinnerung)", "einstellungen.prefs.reminders.weekly": "Wochen\u00fcbersicht (montags)", + "einstellungen.prefs.times.heading": "Zeitpunkte", + "einstellungen.prefs.times.hint": "Erinnerungen werden zur gew\u00e4hlten Uhrzeit in Ihrer Zeitzone versendet.", + "einstellungen.prefs.times.morning": "Morgendliche Erinnerung", + "einstellungen.prefs.times.morning.hint": "F\u00fcr \u00fcberf\u00e4llige Fristen, Fristen morgen und Wochen\u00fcbersicht.", + "einstellungen.prefs.times.evening": "Abend-Erinnerung (heute f\u00e4llig)", + "einstellungen.prefs.times.evening.hint": "Erinnert an heute f\u00e4llige, noch offene Fristen.", + "einstellungen.prefs.times.timezone": "Zeitzone", + "einstellungen.prefs.times.timezone.hint": "IANA-Zeitzonen-Name (z.B. Europe/Berlin, Europe/London).", + "einstellungen.prefs.times.error.required": "Bitte beide Uhrzeiten ausw\u00e4hlen.", // Invitation modal (sidebar) "invite.button": "Kolleg:in einladen", @@ -2152,7 +2162,17 @@ const translations: Record> = { "einstellungen.prefs.reminders.master": "Deadline reminders enabled", "einstellungen.prefs.reminders.overdue": "Overdue deadlines", "einstellungen.prefs.reminders.tomorrow": "Due tomorrow", + "einstellungen.prefs.reminders.due_today_evening": "Due today (evening reminder)", "einstellungen.prefs.reminders.weekly": "Weekly summary (Monday)", + "einstellungen.prefs.times.heading": "Send times", + "einstellungen.prefs.times.hint": "Reminders are sent at the chosen time in your timezone.", + "einstellungen.prefs.times.morning": "Morning reminder", + "einstellungen.prefs.times.morning.hint": "Used for overdue, tomorrow and weekly summary.", + "einstellungen.prefs.times.evening": "Evening reminder (due today)", + "einstellungen.prefs.times.evening.hint": "Reminds you of deadlines that are due today and still open.", + "einstellungen.prefs.times.timezone": "Timezone", + "einstellungen.prefs.times.timezone.hint": "IANA timezone name (e.g. Europe/Berlin, Europe/London).", + "einstellungen.prefs.times.error.required": "Please choose both reminder times.", // Invitation modal (sidebar) "invite.button": "Invite a colleague", diff --git a/frontend/src/client/settings.ts b/frontend/src/client/settings.ts index 15cb697..a8be172 100644 --- a/frontend/src/client/settings.ts +++ b/frontend/src/client/settings.ts @@ -20,6 +20,9 @@ interface Me { dezernat?: string; lang: Lang; email_preferences: Record; + reminder_morning_time: string; + reminder_evening_time: string; + reminder_timezone: string; } interface CalDAVConfig { @@ -269,13 +272,23 @@ function fillPrefsForm() { const master = document.getElementById("prefs-reminders-master") as HTMLInputElement; const overdue = document.getElementById("prefs-reminders-overdue") as HTMLInputElement; const tomorrow = document.getElementById("prefs-reminders-tomorrow") as HTMLInputElement; + const dueTodayEvening = document.getElementById("prefs-reminders-due-today-evening") as HTMLInputElement; const weekly = document.getElementById("prefs-reminders-weekly") as HTMLInputElement; master.checked = readPrefBool("deadline_reminders", true); overdue.checked = readPrefBool("deadline_reminders.overdue", true); tomorrow.checked = readPrefBool("deadline_reminders.tomorrow", true); + dueTodayEvening.checked = readPrefBool("deadline_reminders.due_today_evening", true); weekly.checked = readPrefBool("deadline_reminders.weekly", true); + // The model returns "HH:MM:SS" but wants "HH:MM". + (document.getElementById("prefs-reminder-morning") as HTMLInputElement).value = + (me?.reminder_morning_time ?? "09:00:00").slice(0, 5); + (document.getElementById("prefs-reminder-evening") as HTMLInputElement).value = + (me?.reminder_evening_time ?? "16:00:00").slice(0, 5); + (document.getElementById("prefs-reminder-timezone") as HTMLInputElement).value = + me?.reminder_timezone ?? "Europe/Berlin"; + updatePrefsSubGroup(master.checked); master.addEventListener("change", () => updatePrefsSubGroup(master.checked)); } @@ -300,15 +313,31 @@ async function savePrefs(ev: Event) { next["deadline_reminders"] = (document.getElementById("prefs-reminders-master") as HTMLInputElement).checked; next["deadline_reminders.overdue"] = (document.getElementById("prefs-reminders-overdue") as HTMLInputElement).checked; next["deadline_reminders.tomorrow"] = (document.getElementById("prefs-reminders-tomorrow") as HTMLInputElement).checked; + next["deadline_reminders.due_today_evening"] = (document.getElementById("prefs-reminders-due-today-evening") as HTMLInputElement).checked; next["deadline_reminders.weekly"] = (document.getElementById("prefs-reminders-weekly") as HTMLInputElement).checked; + const morning = (document.getElementById("prefs-reminder-morning") as HTMLInputElement).value; + const evening = (document.getElementById("prefs-reminder-evening") as HTMLInputElement).value; + const timezone = (document.getElementById("prefs-reminder-timezone") as HTMLInputElement).value.trim(); + + if (!morning || !evening) { + msg.textContent = t("einstellungen.prefs.times.error.required"); + msg.className = "form-msg form-msg-error"; + return; + } + const submitBtn = (ev.target as HTMLFormElement).querySelector("button[type=submit]")!; submitBtn.disabled = true; try { const resp = await fetch("/api/me", { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email_preferences: next }), + body: JSON.stringify({ + email_preferences: next, + reminder_morning_time: morning, + reminder_evening_time: evening, + reminder_timezone: timezone || "Europe/Berlin", + }), }); if (resp.ok) { me = await resp.json(); diff --git a/frontend/src/settings.tsx b/frontend/src/settings.tsx index bdb2f3a..9abe303 100644 --- a/frontend/src/settings.tsx +++ b/frontend/src/settings.tsx @@ -157,6 +157,12 @@ export function renderSettings(): string { Fristen morgen fällig +
+ +
+

Zeitpunkte

+

+ Erinnerungen werden zur gewählten Uhrzeit in Ihrer Zeitzone versendet. +

+ +
+
+ + +

+ Für überfällige Fristen, Fristen morgen und Wochenübersicht. +

+
+
+ + +

+ Erinnert an heute fällige, noch offene Fristen. +

+
+
+ +
+ + +

+ IANA-Zeitzonen-Name (z.B. Europe/Berlin, Europe/London). +

+
+

diff --git a/internal/db/migrations/022_user_reminder_times.down.sql b/internal/db/migrations/022_user_reminder_times.down.sql new file mode 100644 index 0000000..4686c84 --- /dev/null +++ b/internal/db/migrations/022_user_reminder_times.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE paliad.users + DROP COLUMN IF EXISTS reminder_timezone, + DROP COLUMN IF EXISTS reminder_evening_time, + DROP COLUMN IF EXISTS reminder_morning_time; diff --git a/internal/db/migrations/022_user_reminder_times.up.sql b/internal/db/migrations/022_user_reminder_times.up.sql new file mode 100644 index 0000000..7b0b971 --- /dev/null +++ b/internal/db/migrations/022_user_reminder_times.up.sql @@ -0,0 +1,19 @@ +-- Per-user reminder send times (t-paliad-048). +-- +-- Background: the hourly ReminderService used to send mail whenever the +-- ticker found a matching due_date — m got reminders at 02:28. We now gate +-- delivery to a user-chosen hour-of-day in the user's local timezone. +-- +-- Three knobs per user: +-- * reminder_morning_time — when to send overdue / tomorrow / weekly +-- * reminder_evening_time — when to send the new "due-today, still pending" +-- evening sweep +-- * reminder_timezone — IANA timezone the two times are interpreted in +-- +-- Defaults target the HLC-Munich audience; existing rows pick them up via +-- the column DEFAULT, so no explicit backfill is needed. + +ALTER TABLE paliad.users + ADD COLUMN IF NOT EXISTS reminder_morning_time TIME NOT NULL DEFAULT '09:00:00', + ADD COLUMN IF NOT EXISTS reminder_evening_time TIME NOT NULL DEFAULT '16:00:00', + ADD COLUMN IF NOT EXISTS reminder_timezone TEXT NOT NULL DEFAULT 'Europe/Berlin'; diff --git a/internal/models/models.go b/internal/models/models.go index 615f38d..0dc5d74 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -27,8 +27,14 @@ type User struct { Dezernat *string `db:"dezernat" json:"dezernat,omitempty"` Lang string `db:"lang" json:"lang"` EmailPreferences json.RawMessage `db:"email_preferences" json:"email_preferences"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + // ReminderMorningTime / ReminderEveningTime are stored as Postgres TIME and + // scanned as strings in HH:MM:SS form so we don't need a separate type and + // the JSON shape stays trivially editable from the settings page. + ReminderMorningTime string `db:"reminder_morning_time" json:"reminder_morning_time"` + ReminderEveningTime string `db:"reminder_evening_time" json:"reminder_evening_time"` + ReminderTimezone string `db:"reminder_timezone" json:"reminder_timezone"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } // Project is one node in the paliad.projects tree. Visibility is team-based diff --git a/internal/services/reminder_service.go b/internal/services/reminder_service.go index f18e34f..2d1550e 100644 --- a/internal/services/reminder_service.go +++ b/internal/services/reminder_service.go @@ -5,19 +5,24 @@ // 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 Deadlines. One email per user -// aggregating every Deadline they created. +// Four reminder kinds: +// * overdue — due_date <= today, status = pending. Heavier alert +// tone (red header). Sent in the morning slot. +// * tomorrow — due_date = today+1, status = pending. Pre-deadline +// nudge. Sent in the morning slot. +// * weekly — Monday only: due_date BETWEEN today AND today+7. +// Summary table of the week's Deadlines. One email per +// user. Sent in the morning slot. +// * due_today_evening — due_date = today, status = pending. Late-day nudge +// for whatever the user didn't get to. Sent in the +// evening slot. // -// Dedup window: 24h. The service refuses to resend the same (user, -// reminder_type, deadline_id) pair if a row was inserted in the last 24 hours. -// This means at most one overdue / tomorrow email per Deadline per day, and -// at most one weekly email per user per Monday. +// Send-time gating (t-paliad-048): each user has reminder_morning_time and +// reminder_evening_time (in their reminder_timezone). The hourly tick fires +// the morning batch only when the current hour in the user's timezone matches +// reminder_morning_time, and the evening batch only when it matches +// reminder_evening_time. Combined with the 24h dedup window this guarantees +// at most one morning batch and one evening batch per user per day. // // Recipient selection: the Deadline.CreatedBy user — that is, whoever set up // the deadline. Collaborators on the Akte are not notified (avoids spam when @@ -111,98 +116,176 @@ func (s *ReminderService) loop(ctx context.Context) { // admin trigger endpoint) can exercise the path without waiting for the // ticker. Errors on individual Deadlines are logged and swallowed so one bad // row doesn't block the rest of the scan. +// +// Each per-Deadline scan computes "is this user inside their morning/evening +// slot right now?" by parsing the user's reminder_timezone and comparing +// hour-of-day. Users outside their slot are skipped this tick. 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 { + if err := s.sendPerFrist(ctx, now, "overdue"); err != nil { slog.Warn("reminder: overdue scan failed", "error", err) } - if err := s.sendPerFrist(ctx, today, "tomorrow"); err != nil { + if err := s.sendPerFrist(ctx, now, "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) - } + if err := s.sendPerFrist(ctx, now, "due_today_evening"); err != nil { + slog.Warn("reminder: due_today_evening scan failed", "error", err) + } + if err := s.sendWeekly(ctx, now); err != nil { + slog.Warn("reminder: weekly scan failed", "error", err) } } // fristReminderRow is the projection needed to render a per-Deadline email. // We join the parent Project for its reference / title and the user row for -// the preferred language and notification preferences. +// the preferred language, notification preferences, and send-time slots. type fristReminderRow struct { - DeadlineID uuid.UUID `db:"deadline_id"` - DeadlineTitle string `db:"deadline_title"` - DueDate time.Time `db:"due_date"` - ProjectReference string `db:"project_reference"` - ProjectTitle string `db:"project_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"` + DeadlineID uuid.UUID `db:"deadline_id"` + DeadlineTitle string `db:"deadline_title"` + DueDate time.Time `db:"due_date"` + ProjectReference string `db:"project_reference"` + ProjectTitle string `db:"project_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"` + UserMorningTime string `db:"user_morning_time"` + UserEveningTime string `db:"user_evening_time"` + UserTimezone string `db:"user_timezone"` } -// sendPerFrist covers the two per-Deadline 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 +// slotForKind reports which time-of-day slot a reminder kind belongs to. +// Morning kinds fire at the user's reminder_morning_time; evening kinds fire +// at reminder_evening_time. Centralising the mapping keeps sendPerFrist / +// sendWeekly from open-coding it. +func slotForKind(kind string) string { + switch kind { + case "due_today_evening": + return "evening" + default: + return "morning" + } +} + +// inSlot reports whether the user's local hour-of-day matches the configured +// hour for the given slot. The minute is ignored — the ticker fires hourly, +// so we only compare hour granularity. A bad timezone or unparseable time +// falls back to UTC and the column default ("09:00:00"/"16:00:00") so a +// corrupt row never silently suppresses every reminder. +func inSlot(now time.Time, tz, morning, evening, slot string) bool { + loc, err := time.LoadLocation(tz) + if err != nil { + loc = time.UTC + } + local := now.In(loc) + + target := morning + if slot == "evening" { + target = evening + } + hour, ok := parseHour(target) + if !ok { + // Fall back to defaults rather than dropping the user entirely. + if slot == "evening" { + hour = 16 + } else { + hour = 9 + } + } + return local.Hour() == hour +} + +// parseHour pulls the hour out of an "HH:MM" or "HH:MM:SS" string. Returns +// (0, false) on malformed input — callers fall back to the column defaults. +func parseHour(s string) (int, bool) { + for _, layout := range []string{"15:04:05", "15:04"} { + if t, err := time.Parse(layout, s); err == nil { + return t.Hour(), true + } + } + return 0, false +} + +// sendPerFrist covers the per-Deadline reminder kinds (overdue, tomorrow, +// due_today_evening). The query fans out across users and lets the in-process +// slot filter decide which rows to actually send — that's simpler than +// pushing timezone math into SQL and keeps the dedup table the only state. +func (s *ReminderService) sendPerFrist(ctx context.Context, now time.Time, kind string) error { + // Each user lives in their own timezone; "today" for the SELECT must + // therefore be a wide enough window to catch every possible local date. + // We scan a 3-day band (yesterday-UTC..tomorrow-UTC+1) and let the + // per-user filter narrow down. That's cheap (small table, indexed by + // due_date / status). + utcToday := now.UTC().Truncate(24 * time.Hour) + bandStart := utcToday.AddDate(0, 0, -1) + bandEnd := utcToday.AddDate(0, 0, 2) + + var cond string switch kind { case "overdue": - dueDate = today + // Anything still pending up to today (inclusive) — old overdues + // keep getting nudged. + cond = "f.due_date <= $2" case "tomorrow": - dueDate = today.AddDate(0, 0, 1) + cond = "f.due_date BETWEEN $1 AND $2" + case "due_today_evening": + cond = "f.due_date BETWEEN $1 AND $2" default: return fmt.Errorf("unknown kind %q", kind) } - // Overdue is "<= today" — include older still-pending Deadlines. 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 deadline_id, - f.title AS deadline_title, - f.due_date AS due_date, - COALESCE(p.reference, '') AS project_reference, - p.title AS project_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 + SELECT f.id AS deadline_id, + f.title AS deadline_title, + f.due_date AS due_date, + COALESCE(p.reference, '') AS project_reference, + p.title AS project_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, + u.reminder_morning_time::text AS user_morning_time, + u.reminder_evening_time::text AS user_evening_time, + u.reminder_timezone AS user_timezone FROM paliad.deadlines f JOIN paliad.projects p ON p.id = f.project_id JOIN paliad.users u ON u.id = f.created_by WHERE f.status = 'pending' + AND f.due_date >= $1 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.reminder_type = $3 AND r.deadline_id = f.id - AND r.sent_at >= $3 + AND r.sent_at >= $4 )` rows := []fristReminderRow{} if err := s.db.SelectContext(ctx, &rows, query, - dueDate, kind, s.clock().Add(-reminderDedupWindow), + bandStart, bandEnd, kind, now.Add(-reminderDedupWindow), ); err != nil { return fmt.Errorf("select deadlines for %s: %w", kind, err) } + slot := slotForKind(kind) for _, r := range rows { if !reminderEnabled(r.UserEmailPreferences, kind) { continue } + if !inSlot(now, r.UserTimezone, r.UserMorningTime, r.UserEveningTime, slot) { + continue + } + // Per-user "is today the right local date?" check. Without it the 3-day + // band would email tomorrow's deadline today (or yesterday's overdue + // twice) for users whose timezone offset puts them on a different date + // than UTC. + if !matchesLocalDueDate(now, r.UserTimezone, r.DueDate, kind) { + continue + } if err := s.deliverFristReminder(ctx, kind, r); err != nil { slog.Warn("reminder: deliver failed", "kind", kind, "deadline_id", r.DeadlineID, "user_id", r.UserID, "error", err) @@ -212,6 +295,33 @@ func (s *ReminderService) sendPerFrist(ctx context.Context, today time.Time, kin return nil } +// matchesLocalDueDate decides whether a given Deadline qualifies for the +// given kind in the user's local timezone. Mirrors the SQL band but at +// per-user date precision. +func matchesLocalDueDate(now time.Time, tz string, dueDate time.Time, kind string) bool { + loc, err := time.LoadLocation(tz) + if err != nil { + loc = time.UTC + } + local := now.In(loc) + today := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, loc) + tomorrow := today.AddDate(0, 0, 1) + + // Postgres DATE columns scan as time.Time at UTC midnight. Compare in UTC + // to avoid double-conversion artefacts. + due := time.Date(dueDate.Year(), dueDate.Month(), dueDate.Day(), 0, 0, 0, 0, loc) + + switch kind { + case "overdue": + return !due.After(today) + case "tomorrow": + return due.Equal(tomorrow) + case "due_today_evening": + return due.Equal(today) + } + return false +} + func (s *ReminderService) deliverFristReminder(ctx context.Context, kind string, r fristReminderRow) error { lang := "de" if r.UserLang == "en" { @@ -219,6 +329,10 @@ func (s *ReminderService) deliverFristReminder(ctx context.Context, kind string, } subject := buildSubject(kind, lang, r.DeadlineTitle, 0) + templateName := "deadline_reminder" + if kind == "due_today_evening" { + templateName = "deadline_due_today" + } data := map[string]any{ "Kind": kind, "Title": r.DeadlineTitle, @@ -231,7 +345,7 @@ func (s *ReminderService) deliverFristReminder(ctx context.Context, kind string, To: r.UserEmail, Subject: subject, Lang: lang, - Name: "deadline_reminder", + Name: templateName, Data: data, }); err != nil { return fmt.Errorf("send: %w", err) @@ -248,6 +362,9 @@ type weeklyRow struct { UserDisplayName string `db:"user_display_name"` UserLang string `db:"user_lang"` UserEmailPreferences json.RawMessage `db:"user_email_preferences"` + UserMorningTime string `db:"user_morning_time"` + UserEveningTime string `db:"user_evening_time"` + UserTimezone string `db:"user_timezone"` DeadlineID uuid.UUID `db:"deadline_id"` DeadlineTitle string `db:"deadline_title"` @@ -284,19 +401,29 @@ func reminderEnabled(raw json.RawMessage, kind string) bool { return true } -func (s *ReminderService) sendWeekly(ctx context.Context, today time.Time) error { - end := today.AddDate(0, 0, 7) +// sendWeekly fans out the Monday digest. Like the per-Deadline scans, it +// runs every tick but the in-process filter narrows down to "Monday in the +// user's tz, inside their morning slot". +func (s *ReminderService) sendWeekly(ctx context.Context, now time.Time) error { + // Pull a band wide enough that any user's local "this week" window fits. + // One day of slack on either side covers every IANA offset. + utcToday := now.UTC().Truncate(24 * time.Hour) + bandStart := utcToday.AddDate(0, 0, -1) + bandEnd := utcToday.AddDate(0, 0, 9) 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 deadline_id, - f.title AS deadline_title, - f.due_date AS due_date, - COALESCE(p.reference, '') AS project_reference + 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, + u.reminder_morning_time::text AS user_morning_time, + u.reminder_evening_time::text AS user_evening_time, + u.reminder_timezone AS user_timezone, + f.id AS deadline_id, + f.title AS deadline_title, + f.due_date AS due_date, + COALESCE(p.reference, '') AS project_reference FROM paliad.deadlines f JOIN paliad.projects p ON p.id = f.project_id JOIN paliad.users u ON u.id = f.created_by @@ -306,11 +433,13 @@ func (s *ReminderService) sendWeekly(ctx context.Context, today time.Time) error 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 { + if err := s.db.SelectContext(ctx, &rows, query, bandStart, bandEnd); err != nil { return fmt.Errorf("select weekly rows: %w", err) } - // Group by user and drop users we already emailed within the dedup window. + // Group by user, then per user filter to "Monday + morning slot + week + // window". Doing the filter in Go keeps the SQL portable across + // timezones. byUser := map[uuid.UUID][]weeklyRow{} order := []uuid.UUID{} for _, r := range rows { @@ -325,10 +454,38 @@ func (s *ReminderService) sendWeekly(ctx context.Context, today time.Time) error if len(userRows) == 0 { continue } - if !reminderEnabled(userRows[0].UserEmailPreferences, "weekly") { + first := userRows[0] + if !reminderEnabled(first.UserEmailPreferences, "weekly") { continue } - alreadySent, err := s.hasWeeklySentSince(ctx, uid, s.clock().Add(-reminderDedupWindow)) + + loc, err := time.LoadLocation(first.UserTimezone) + if err != nil { + loc = time.UTC + } + local := now.In(loc) + if local.Weekday() != time.Monday { + continue + } + if !inSlot(now, first.UserTimezone, first.UserMorningTime, first.UserEveningTime, "morning") { + continue + } + + // Now restrict the rows to the user's actual local week. + today := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, loc) + end := today.AddDate(0, 0, 7) + filtered := userRows[:0] + for _, r := range userRows { + due := time.Date(r.DueDate.Year(), r.DueDate.Month(), r.DueDate.Day(), 0, 0, 0, 0, loc) + if !due.Before(today) && due.Before(end) { + filtered = append(filtered, r) + } + } + if len(filtered) == 0 { + continue + } + + alreadySent, err := s.hasWeeklySentSince(ctx, uid, now.Add(-reminderDedupWindow)) if err != nil { slog.Warn("reminder: weekly dedup check failed", "user_id", uid, "error", err) continue @@ -336,7 +493,7 @@ func (s *ReminderService) sendWeekly(ctx context.Context, today time.Time) error if alreadySent { continue } - if err := s.deliverWeekly(ctx, today, userRows); err != nil { + if err := s.deliverWeekly(ctx, today, filtered); err != nil { slog.Warn("reminder: weekly deliver failed", "user_id", uid, "error", err) continue } @@ -384,8 +541,8 @@ func (s *ReminderService) deliverWeekly(ctx context.Context, today time.Time, ro Lang: lang, Name: "deadline_weekly", Data: map[string]any{ - "Count": len(rows), - "Items": items, + "Count": len(rows), + "Items": items, "DeadlinesURL": fmt.Sprintf("%s/deadlines", s.baseURL), }, }); err != nil { @@ -414,6 +571,8 @@ func buildSubject(kind, lang, title string, count int) string { return "[Paliad] Deadline overdue: " + title case "tomorrow": return "[Paliad] Deadline tomorrow: " + title + case "due_today_evening": + return "[Paliad] Due today: " + title case "weekly": return fmt.Sprintf("[Paliad] Weekly summary: %d deadline%s", count, pluralS(count)) } @@ -423,6 +582,8 @@ func buildSubject(kind, lang, title string, count int) string { return "[Paliad] Deadline überfällig: " + title case "tomorrow": return "[Paliad] Deadline morgen: " + title + case "due_today_evening": + return "[Paliad] Heute fällig: " + title case "weekly": return fmt.Sprintf("[Paliad] Wochenübersicht: %d Deadlines", count) } diff --git a/internal/services/reminder_service_test.go b/internal/services/reminder_service_test.go index cb951e9..3415d3f 100644 --- a/internal/services/reminder_service_test.go +++ b/internal/services/reminder_service_test.go @@ -58,6 +58,238 @@ func TestReminderEnabled(t *testing.T) { } } +// TestInSlot covers the per-user time-of-day gate. The hourly ticker scans +// every user every hour; inSlot is what keeps it from firing outside the +// user's chosen morning/evening hour. +func TestInSlot(t *testing.T) { + // 2026-04-27 09:30 Europe/Berlin == 07:30 UTC. + utc0930Berlin := time.Date(2026, 4, 27, 7, 30, 0, 0, time.UTC) + utc1030Berlin := time.Date(2026, 4, 27, 8, 30, 0, 0, time.UTC) + utc1605Berlin := time.Date(2026, 4, 27, 14, 5, 0, 0, time.UTC) + + tests := []struct { + name string + now time.Time + tz string + morning string + evening string + slot string + want bool + }{ + {"morning slot matches", utc0930Berlin, "Europe/Berlin", "09:00:00", "16:00:00", "morning", true}, + {"morning slot misses one hour later", utc1030Berlin, "Europe/Berlin", "09:00:00", "16:00:00", "morning", false}, + {"evening slot matches at 16:05", utc1605Berlin, "Europe/Berlin", "09:00:00", "16:00:00", "evening", true}, + {"evening slot doesn't fire in morning", utc0930Berlin, "Europe/Berlin", "09:00:00", "16:00:00", "evening", false}, + {"morning custom 11:00", time.Date(2026, 4, 27, 9, 30, 0, 0, time.UTC), "Europe/Berlin", "11:00", "16:00", "morning", true}, + {"unknown tz falls back to UTC default 09:00", time.Date(2026, 4, 27, 9, 0, 0, 0, time.UTC), "Mars/Olympus", "", "", "morning", true}, + {"empty morning string falls back to default 09:00", time.Date(2026, 4, 27, 7, 0, 0, 0, time.UTC), "Europe/Berlin", "", "", "morning", true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := inSlot(tc.now, tc.tz, tc.morning, tc.evening, tc.slot) + if got != tc.want { + t.Errorf("inSlot(now=%s, tz=%q, m=%q, e=%q, slot=%q) = %v, want %v", + tc.now.Format(time.RFC3339), tc.tz, tc.morning, tc.evening, tc.slot, got, tc.want) + } + }) + } +} + +// TestSlotForKind locks the kind→slot mapping. The new evening kind goes to +// the evening slot; everything else stays in the morning slot. +func TestSlotForKind(t *testing.T) { + cases := map[string]string{ + "overdue": "morning", + "tomorrow": "morning", + "weekly": "morning", + "due_today_evening": "evening", + "random": "morning", + } + for kind, want := range cases { + if got := slotForKind(kind); got != want { + t.Errorf("slotForKind(%q) = %q, want %q", kind, got, want) + } + } +} + +// TestMatchesLocalDueDate is the per-user date filter — without it, the +// 3-day SELECT band would email tomorrow's deadline today (or yesterday's) +// for users whose timezone offset puts them on a different date than UTC. +func TestMatchesLocalDueDate(t *testing.T) { + // Anchor: Monday 2026-04-27 09:30 in Europe/Berlin == 07:30 UTC. + now := time.Date(2026, 4, 27, 7, 30, 0, 0, time.UTC) + today := time.Date(2026, 4, 27, 0, 0, 0, 0, time.UTC) + yesterday := today.AddDate(0, 0, -1) + tomorrow := today.AddDate(0, 0, 1) + + tests := []struct { + name string + dueDate time.Time + kind string + want bool + }{ + {"overdue today", today, "overdue", true}, + {"overdue yesterday", yesterday, "overdue", true}, + {"overdue not tomorrow", tomorrow, "overdue", false}, + {"tomorrow exact", tomorrow, "tomorrow", true}, + {"tomorrow not today", today, "tomorrow", false}, + {"due_today exact", today, "due_today_evening", true}, + {"due_today not tomorrow", tomorrow, "due_today_evening", false}, + {"unknown kind never matches", today, "garbage", false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := matchesLocalDueDate(now, "Europe/Berlin", tc.dueDate, tc.kind) + if got != tc.want { + t.Errorf("matchesLocalDueDate(due=%s, kind=%q) = %v, want %v", + tc.dueDate.Format("2006-01-02"), tc.kind, got, tc.want) + } + }) + } +} + +// TestSendsAtMorningSlot / TestSendsAtEveningSlot / TestSkipsOutsideSlot / +// TestEveningSkipsCompleted exercise the live DB path. Each seeds a user +// with a known reminder_morning_time and reminder_evening_time, plus one +// pending deadline due today, then runs the scan with a clock pinned to a +// chosen hour and asserts the reminder_log got (or didn't get) a row. +func TestReminderSlots(t *testing.T) { + url := os.Getenv("TEST_DATABASE_URL") + if url == "" { + t.Skip("TEST_DATABASE_URL not set — skipping live DB test") + } + if err := db.ApplyMigrations(url); err != nil { + t.Fatalf("apply migrations: %v", err) + } + pool, err := sqlx.Connect("postgres", url) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer pool.Close() + + ctx := context.Background() + seed := func(t *testing.T, status string) (uuid.UUID, uuid.UUID, uuid.UUID, func()) { + t.Helper() + userID := uuid.New() + projectID := uuid.New() + deadlineID := uuid.New() + cleanup := func() { + pool.ExecContext(ctx, `DELETE FROM paliad.reminder_log WHERE user_id = $1`, userID) + pool.ExecContext(ctx, `DELETE FROM paliad.deadlines WHERE id = $1`, deadlineID) + pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, projectID) + pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, projectID) + pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID) + pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID) + } + cleanup() + if _, err := pool.ExecContext(ctx, + `INSERT INTO auth.users (id, email) VALUES ($1, $2)`, + userID, "slot-test-"+userID.String()+"@hlc.com"); err != nil { + t.Fatalf("seed auth.users: %v", err) + } + if _, err := pool.ExecContext(ctx, + `INSERT INTO paliad.users + (id, email, display_name, office, role, lang, email_preferences, + reminder_morning_time, reminder_evening_time, reminder_timezone) + VALUES ($1, $2, 'Slot Test', 'munich', 'associate', 'de', '{}'::jsonb, + '09:00:00', '16:00:00', 'Europe/Berlin')`, + userID, "slot-test-"+userID.String()+"@hlc.com"); err != nil { + t.Fatalf("seed paliad.users: %v", err) + } + if _, err := pool.ExecContext(ctx, + `INSERT INTO paliad.projects (id, type, path, title, reference, status, created_by) + VALUES ($1, 'project', $1::text, 'Slot Test', '2026/8888', 'active', $2)`, + projectID, userID); err != nil { + t.Fatalf("seed paliad.projects: %v", err) + } + // Today's date in Europe/Berlin — pick a fixed wall-clock day that + // doesn't straddle DST. The clock will be aligned to this date below. + due := time.Date(2026, 4, 27, 0, 0, 0, 0, time.UTC) + if _, err := pool.ExecContext(ctx, + `INSERT INTO paliad.deadlines (id, project_id, title, due_date, source, status, created_by) + VALUES ($1, $2, 'Schriftsatz einreichen', $3, 'manual', $4, $5)`, + deadlineID, projectID, due, status, userID); err != nil { + t.Fatalf("seed paliad.deadlines: %v", err) + } + return userID, projectID, deadlineID, cleanup + } + + mail, err := NewMailService() + if err != nil { + t.Fatalf("NewMailService: %v", err) + } + + countLog := func(t *testing.T, userID uuid.UUID, kind string) int { + t.Helper() + var n int + if err := pool.GetContext(ctx, &n, + `SELECT count(*) FROM paliad.reminder_log + WHERE user_id = $1 AND reminder_type = $2`, userID, kind); err != nil { + t.Fatalf("count log: %v", err) + } + return n + } + + t.Run("SendsAtMorningSlot", func(t *testing.T) { + userID, _, _, cleanup := seed(t, "pending") + defer cleanup() + // 09:30 Europe/Berlin == 07:30 UTC. Inside morning slot for hour=9. + clock := time.Date(2026, 4, 27, 7, 30, 0, 0, time.UTC) + svc := NewReminderService(pool, mail, nil, "https://paliad.test") + svc.clock = func() time.Time { return clock } + svc.RunOnce(ctx) + if got := countLog(t, userID, "overdue"); got != 1 { + t.Errorf("overdue log rows = %d, want 1", got) + } + if got := countLog(t, userID, "due_today_evening"); got != 0 { + t.Errorf("due_today_evening log rows = %d, want 0 in morning slot", got) + } + }) + + t.Run("SendsAtEveningSlot", func(t *testing.T) { + userID, _, _, cleanup := seed(t, "pending") + defer cleanup() + // 16:30 Europe/Berlin == 14:30 UTC. Inside evening slot for hour=16. + clock := time.Date(2026, 4, 27, 14, 30, 0, 0, time.UTC) + svc := NewReminderService(pool, mail, nil, "https://paliad.test") + svc.clock = func() time.Time { return clock } + svc.RunOnce(ctx) + if got := countLog(t, userID, "due_today_evening"); got != 1 { + t.Errorf("due_today_evening log rows = %d, want 1", got) + } + if got := countLog(t, userID, "overdue"); got != 0 { + t.Errorf("overdue log rows = %d, want 0 in evening slot", got) + } + }) + + t.Run("SkipsOutsideSlot", func(t *testing.T) { + userID, _, _, cleanup := seed(t, "pending") + defer cleanup() + // 02:28 Europe/Berlin == 00:28 UTC. The exact bug m hit. No slot fires. + clock := time.Date(2026, 4, 27, 0, 28, 0, 0, time.UTC) + svc := NewReminderService(pool, mail, nil, "https://paliad.test") + svc.clock = func() time.Time { return clock } + svc.RunOnce(ctx) + for _, kind := range []string{"overdue", "tomorrow", "due_today_evening", "weekly"} { + if got := countLog(t, userID, kind); got != 0 { + t.Errorf("%s log rows = %d, want 0 outside any slot", kind, got) + } + } + }) + + t.Run("EveningSkipsCompleted", func(t *testing.T) { + userID, _, _, cleanup := seed(t, "completed") + defer cleanup() + clock := time.Date(2026, 4, 27, 14, 30, 0, 0, time.UTC) + svc := NewReminderService(pool, mail, nil, "https://paliad.test") + svc.clock = func() time.Time { return clock } + svc.RunOnce(ctx) + if got := countLog(t, userID, "due_today_evening"); got != 0 { + t.Errorf("completed deadline triggered evening reminder (rows=%d)", got) + } + }) +} + // TestSendPerFrist_ScansCleanly is the regression guard for the German→English // rename: the prior `frist_title` / `akte_aktenzeichen` / `akte_title` SQL // aliases didn't match the renamed struct tags, so sqlx.SelectContext returned diff --git a/internal/services/user_service.go b/internal/services/user_service.go index 3ecc127..ec2bc49 100644 --- a/internal/services/user_service.go +++ b/internal/services/user_service.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "strings" + "time" "github.com/google/uuid" "github.com/jmoiron/sqlx" @@ -15,6 +16,22 @@ import ( "mgit.msbls.de/m/patholo/internal/offices" ) +// normaliseTimeOfDay accepts "HH:MM" or "HH:MM:SS" (the two shapes the HTML +// time input and Postgres TIME column produce) and returns "HH:MM:SS" for +// consistent storage. Empty / out-of-range inputs are rejected. +func normaliseTimeOfDay(raw string) (string, error) { + s := strings.TrimSpace(raw) + if s == "" { + return "", fmt.Errorf("required (HH:MM or HH:MM:SS)") + } + for _, layout := range []string{"15:04", "15:04:05"} { + if t, err := time.Parse(layout, s); err == nil { + return t.Format("15:04:05"), nil + } + } + return "", fmt.Errorf("invalid time %q (want HH:MM)", raw) +} + // Sentinel errors returned by UserService. var ( // ErrUserAlreadyOnboarded is returned when POST /api/onboarding is called @@ -42,7 +59,11 @@ func NewUserService(db *sqlx.DB) *UserService { } const userColumns = `id, email, display_name, office, additional_offices, practice_group, role, dezernat, - lang, email_preferences, created_at, updated_at` + lang, email_preferences, + reminder_morning_time::text AS reminder_morning_time, + reminder_evening_time::text AS reminder_evening_time, + reminder_timezone, + created_at, updated_at` // GetByID returns the user row, or (nil, nil) if the user hasn't completed // onboarding yet. Real errors bubble up. @@ -163,12 +184,15 @@ func (s *UserService) Create(ctx context.Context, id uuid.UUID, email string, in // auth.users.email is the source of truth and the handler rejects any attempt // to mutate it via this endpoint. type UpdateProfileInput struct { - DisplayName *string `json:"display_name,omitempty"` - Office *string `json:"office,omitempty"` - Role *string `json:"role,omitempty"` - Dezernat *string `json:"dezernat,omitempty"` - Lang *string `json:"lang,omitempty"` - EmailPreferences *json.RawMessage `json:"email_preferences,omitempty"` + DisplayName *string `json:"display_name,omitempty"` + Office *string `json:"office,omitempty"` + Role *string `json:"role,omitempty"` + Dezernat *string `json:"dezernat,omitempty"` + Lang *string `json:"lang,omitempty"` + EmailPreferences *json.RawMessage `json:"email_preferences,omitempty"` + ReminderMorningTime *string `json:"reminder_morning_time,omitempty"` + ReminderEveningTime *string `json:"reminder_evening_time,omitempty"` + ReminderTimezone *string `json:"reminder_timezone,omitempty"` } // UpdateProfile mutates the paliad.users row for the authenticated user. @@ -244,6 +268,33 @@ func (s *UserService) UpdateProfile(ctx context.Context, id uuid.UUID, input Upd args = append(args, []byte(raw)) i++ } + if input.ReminderMorningTime != nil { + t, err := normaliseTimeOfDay(*input.ReminderMorningTime) + if err != nil { + return nil, fmt.Errorf("reminder_morning_time: %w", err) + } + sets = append(sets, fmt.Sprintf("reminder_morning_time = $%d", i)) + args = append(args, t) + i++ + } + if input.ReminderEveningTime != nil { + t, err := normaliseTimeOfDay(*input.ReminderEveningTime) + if err != nil { + return nil, fmt.Errorf("reminder_evening_time: %w", err) + } + sets = append(sets, fmt.Sprintf("reminder_evening_time = $%d", i)) + args = append(args, t) + i++ + } + if input.ReminderTimezone != nil { + tz := strings.TrimSpace(*input.ReminderTimezone) + if _, err := time.LoadLocation(tz); err != nil { + return nil, fmt.Errorf("invalid reminder_timezone %q", tz) + } + sets = append(sets, fmt.Sprintf("reminder_timezone = $%d", i)) + args = append(args, tz) + i++ + } if len(sets) == 0 { // No-op PATCH is legal — just return the current row. diff --git a/internal/templates/email/deadline_due_today.html b/internal/templates/email/deadline_due_today.html new file mode 100644 index 0000000..69e7e53 --- /dev/null +++ b/internal/templates/email/deadline_due_today.html @@ -0,0 +1,32 @@ +{{define "content"}} +{{if eq .Lang "en"}} +

Still due today

+

The following deadline is due today and is still open:

+{{else}} +

Heute noch offen

+

Die folgende Frist ist heute fällig und noch nicht erledigt:

+{{end}} + + + + + +
+
{{.Title}}
+
+ {{if eq .Lang "en"}}Due:{{else}}Fällig am:{{end}} + {{.DueDate}} +
+
+ {{if eq .Lang "en"}}Matter:{{else}}Akte:{{end}} + {{.ProjectReference}} + {{if .ProjectTitle}} — {{.ProjectTitle}}{{end}} +
+
+ +

+ + {{if eq .Lang "en"}}Open in Paliad{{else}}In Paliad öffnen{{end}} + +

+{{end}}