Merge: customizable reminder send times + due-today evening sweep (t-paliad-048)

This commit is contained in:
m
2026-04-27 11:47:30 +02:00
10 changed files with 684 additions and 89 deletions

View File

@@ -989,7 +989,17 @@ const translations: Record<Lang, Record<string, string>> = {
"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<Lang, Record<string, string>> = {
"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",

View File

@@ -20,6 +20,9 @@ interface Me {
dezernat?: string;
lang: Lang;
email_preferences: Record<string, unknown>;
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 <input type="time"> 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<HTMLButtonElement>("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();

View File

@@ -157,6 +157,12 @@ export function renderSettings(): string {
<span data-i18n="einstellungen.prefs.reminders.tomorrow">Fristen morgen f&auml;llig</span>
</label>
</div>
<div className="form-field caldav-toggle-field">
<label className="caldav-toggle-label">
<input type="checkbox" id="prefs-reminders-due-today-evening" />
<span data-i18n="einstellungen.prefs.reminders.due_today_evening">Heute f&auml;llig (Abend-Erinnerung)</span>
</label>
</div>
<div className="form-field caldav-toggle-field">
<label className="caldav-toggle-label">
<input type="checkbox" id="prefs-reminders-weekly" />
@@ -165,6 +171,41 @@ export function renderSettings(): string {
</div>
</div>
<h2 className="settings-subhead" data-i18n="einstellungen.prefs.times.heading">Zeitpunkte</h2>
<p className="form-hint" data-i18n="einstellungen.prefs.times.hint">
Erinnerungen werden zur gew&auml;hlten Uhrzeit in Ihrer Zeitzone versendet.
</p>
<div className="form-field-row">
<div className="form-field">
<label htmlFor="prefs-reminder-morning" data-i18n="einstellungen.prefs.times.morning">Morgendliche Erinnerung</label>
<input type="time" id="prefs-reminder-morning" required />
<p className="form-hint" data-i18n="einstellungen.prefs.times.morning.hint">
F&uuml;r &uuml;berf&auml;llige Fristen, Fristen morgen und Wochen&uuml;bersicht.
</p>
</div>
<div className="form-field">
<label htmlFor="prefs-reminder-evening" data-i18n="einstellungen.prefs.times.evening">Abend-Erinnerung (heute f&auml;llig)</label>
<input type="time" id="prefs-reminder-evening" required />
<p className="form-hint" data-i18n="einstellungen.prefs.times.evening.hint">
Erinnert an heute f&auml;llige, noch offene Fristen.
</p>
</div>
</div>
<div className="form-field">
<label htmlFor="prefs-reminder-timezone" data-i18n="einstellungen.prefs.times.timezone">Zeitzone</label>
<input
type="text"
id="prefs-reminder-timezone"
placeholder="Europe/Berlin"
autocomplete="off"
/>
<p className="form-hint" data-i18n="einstellungen.prefs.times.timezone.hint">
IANA-Zeitzonen-Name (z.B. Europe/Berlin, Europe/London).
</p>
</div>
<p className="form-msg" id="prefs-msg" />
<div className="form-actions">

View File

@@ -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;

View File

@@ -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';

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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.

View File

@@ -0,0 +1,32 @@
{{define "content"}}
{{if eq .Lang "en"}}
<h1 style="margin:0 0 16px 0;font-size:20px;line-height:1.3;color:#b45309;">Still due today</h1>
<p style="margin:0 0 12px 0;">The following deadline is due <strong>today</strong> and is still open:</p>
{{else}}
<h1 style="margin:0 0 16px 0;font-size:20px;line-height:1.3;color:#b45309;">Heute noch offen</h1>
<p style="margin:0 0 12px 0;">Die folgende Frist ist <strong>heute</strong> f&auml;llig und noch nicht erledigt:</p>
{{end}}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:16px 0;border:1px solid #e7e5e4;border-radius:6px;">
<tr>
<td style="padding:16px;">
<div style="font-weight:600;font-size:16px;margin-bottom:6px;">{{.Title}}</div>
<div style="color:#57534e;font-size:13px;margin-bottom:10px;">
{{if eq .Lang "en"}}Due:{{else}}F&auml;llig am:{{end}}
<strong style="color:#1c1917;">{{.DueDate}}</strong>
</div>
<div style="color:#57534e;font-size:13px;">
{{if eq .Lang "en"}}Matter:{{else}}Akte:{{end}}
<strong style="color:#1c1917;">{{.ProjectReference}}</strong>
{{if .ProjectTitle}} &mdash; {{.ProjectTitle}}{{end}}
</div>
</td>
</tr>
</table>
<p style="margin:20px 0 0 0;">
<a href="{{.DeadlineURL}}" style="display:inline-block;background:#1c1917;color:#ffffff;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;font-size:14px;">
{{if eq .Lang "en"}}Open in Paliad{{else}}In Paliad &ouml;ffnen{{end}}
</a>
</p>
{{end}}