// Package services — ReminderService — hourly bundled-digest reminder mail. // // Runs one goroutine for the process lifetime. Every hour it walks the user // list, and for each user inside their morning or evening slot it builds a // single bundled email summarising every pending deadline that matters to // that user, grouped by category. One email per (user, slot, local-date) // — no per-deadline mail, no Mondays-only digest. // // Three deadline categories drive the email layout (computed in the user's // local timezone on each tick): // // * overdue — due_date < today (status=pending). System-failure // framing — we engineer this away. Audience: owner + // escalation channel. The escalation channel is the // owner's user.escalation_contact_id when set, else the // firm's global_admins (fallback). Settings → Notifications // exposes the override (t-paliad-066). // * due_today — due_date == today (status=pending). Day-of awareness in // the morning slot; DRINGEND escalation framing in the // evening slot. Audience: owner + project leads (+ // escalation channel on the evening slot — same // contact-or-admins fallback as overdue). // * due_warning — due_date == today + user.reminder_warning_offset_days // (default 7), status=pending. Heads-up section. Audience: // owner + project leads. Morning slot only. // // Per-(user,slot,local-date) dedup uses paliad.reminder_log.slot/slot_date // (migration 025). Legacy rows (slot IS NULL) coexist and are ignored. // // t-paliad-064 redesign — replaces the prior per-deadline kinds (overdue / // tomorrow / due_today_evening / weekly) and per-mail templates with one // bundled deadline_digest.html. package services import ( "context" "encoding/json" "fmt" "log/slog" "time" // Embed Go's IANA tz database. Mirrors the import in cmd/server/main.go // so the services test binary also has tz data available — without this, // `go test ./internal/services` would pass on a dev host (which has OS // tzdata) but the prod alpine binary would still be broken, and the tz // regression test in this package would be vacuous. _ "time/tzdata" "github.com/google/uuid" "github.com/jmoiron/sqlx" "mgit.msbls.de/m/paliad/internal/models" ) // 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 boundary-aligned scanner goroutine. Returns immediately; // the loop exits when ctx is cancelled. func (s *ReminderService) Start(ctx context.Context) { go s.loop(ctx) } // nextTopOfHour returns the duration from now until the next HH:00:00 in // absolute time. Used to align the scanner's wake-up to natural hour // boundaries instead of the container-start offset (t-paliad-069). // // Pre-fix the loop used `time.NewTicker(time.Hour)` directly: a deploy at // 13:27:50 produced ticks at HH:27:50 forever, drifting the user-visible // arrival of a 09:00-Berlin digest anywhere in the 09:xx hour and — worse — // completely missing slots when redeploys clustered inside the slot hour. // time.Truncate operates on absolute time, so the boundary is HH:00:00 UTC; // for whole-hour-offset zones (e.g. Europe/Berlin = UTC±N) that's also // HH:00:00 wall-clock locally, which is what users care about. func nextTopOfHour(now time.Time) time.Duration { next := now.Truncate(time.Hour).Add(time.Hour) return next.Sub(now) } func (s *ReminderService) loop(ctx context.Context) { slog.Info("reminder: starting boundary-aligned scanner", "mail_enabled", s.mail.Enabled()) // Startup catch-up: fire any user/slot whose configured hour has already // arrived today but no log row exists yet. Covers redeploys during or // after a slot hour — without this, a single mistimed deploy can lose a // day for affected users (the regular tick filter requires // local.Hour() == slot_hour, which is only true for one hour per day). // The slot_date dedup makes re-firing safe: if the previous container // already logged the slot, this is a no-op. s.runStartupCatchUp(ctx) // Aligned wait loop. nextTopOfHour is recomputed every iteration so any // clock skew or RunOnce duration self-corrects rather than accumulating. for { timer := time.NewTimer(nextTopOfHour(s.clock())) select { case <-ctx.Done(): timer.Stop() slog.Info("reminder: shutdown") return case <-timer.C: } s.RunOnce(ctx) } } // RunOnce performs one scan+send pass for the regular hourly tick — fires // only the slots whose configured hour matches the current local hour for // each user. Exposed so tests (and, later, an admin trigger endpoint) can // exercise the path without waiting for the ticker. Errors on individual // users are logged and swallowed so one bad row doesn't block the scan. func (s *ReminderService) RunOnce(ctx context.Context) { s.scanForSlots(ctx, "tick", func(now time.Time, u models.User, slot string) bool { return inSlot(now, u.ReminderTimezone, u.ReminderMorningTime, u.ReminderEveningTime, slot) }) } // runStartupCatchUp fires any user/slot whose configured hour has already // arrived today (regardless of the current hour) but has no log row yet — // see loop() for the rationale. Goes through the same runSlotForUser path // as RunOnce, so the slot_date dedup, audience filter, and email shape all // match the regular tick. func (s *ReminderService) runStartupCatchUp(ctx context.Context) { s.scanForSlots(ctx, "startup-catchup", func(now time.Time, u models.User, slot string) bool { return slotPastDueToday(now, u.ReminderTimezone, u.ReminderMorningTime, u.ReminderEveningTime, slot) }) } // scanForSlots is the shared scan body: load all users, walk morning+evening, // and call runSlotForUser for each that passes filterFn. label distinguishes // the two callers in logs. func (s *ReminderService) scanForSlots( ctx context.Context, label string, filterFn func(now time.Time, u models.User, slot string) bool, ) { now := s.clock() if s.users == nil { slog.Warn("reminder: UserService not wired, skipping scan", "label", label) return } users, err := s.users.List(ctx) if err != nil { slog.Warn("reminder: list users failed", "label", label, "error", err) return } for _, u := range users { for _, slot := range []string{"morning", "evening"} { if !filterFn(now, u, slot) { continue } if !reminderEnabled(u.EmailPreferences, "deadline_reminders") { continue } if err := s.runSlotForUser(ctx, now, u, slot); err != nil { slog.Warn("reminder: slot run failed", "label", label, "user_id", u.ID, "slot", slot, "error", err) } } } } // runSlotForUser emits at most one digest email for (user, slot, local-date). // Returns nil on send-skipped (empty digest, dedup hit) and on success. // Errors signal infrastructure problems (DB / mail) the caller can log. func (s *ReminderService) runSlotForUser(ctx context.Context, now time.Time, u models.User, slot string) error { loc, err := time.LoadLocation(u.ReminderTimezone) if err != nil { // Defense in depth — inSlot already screens this, but if we get here // don't try to send with a bogus tz. return fmt.Errorf("load tz %q: %w", u.ReminderTimezone, err) } local := now.In(loc) today := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, loc) already, err := s.hasDigestSent(ctx, u.ID, slot, today) if err != nil { return fmt.Errorf("dedup check: %w", err) } if already { return nil } rows, err := s.fetchSlotDeadlines(ctx, u, today, slot) if err != nil { return fmt.Errorf("fetch deadlines: %w", err) } if len(rows) == 0 { // Nothing for this user in this slot — no email, no log row. The // next slot will check again. Per the design we deliberately do // NOT send "everything is quiet" ack mail. return nil } if err := s.deliverDigest(u, slot, rows); err != nil { return fmt.Errorf("deliver: %w", err) } return s.logDigestSend(ctx, u.ID, slot, today) } // digestRow is one deadline as it will appear in a digest email. Categories // are computed in Go from due_date so we don't have to encode the offset // twice (SQL + template). type digestRow struct { DeadlineID uuid.UUID `db:"deadline_id"` Title string `db:"title"` DueDate time.Time `db:"due_date"` OwnerID uuid.UUID `db:"owner_id"` OwnerName string `db:"owner_name"` ProjectReference string `db:"project_reference"` ProjectTitle string `db:"project_title"` IsLead bool `db:"is_lead"` // ApprovalStatus (t-paliad-138). When 'pending', the digest renders // the row with a "[PENDING] " title prefix so the user can't miss // that the deadline is unverified — silence on a pending change is // the worst outcome. ApprovalStatus string `db:"approval_status"` // OwnerEscalationContactID is the owner's optional escalation override: // non-NULL diverts overdue/DRINGEND escalation away from global_admins // to the named user. Used by visibleForCategory to decide whether the // global-admin fallback applies for this row. OwnerEscalationContactID *uuid.UUID `db:"owner_escalation_contact_id"` // Filled in Go after the SELECT. Category string } // fetchSlotDeadlines pulls every pending deadline that matters to u in this // slot, applies the per-category audience filter, and returns the rows the // digest should render. // // SQL fans out across the three audience predicates (owner, project lead // along path, global admin). The per-category recipient rules (e.g. leads // don't get overdue) are applied in Go after the rows return — keeps the // SQL portable and the rule-table readable. func (s *ReminderService) fetchSlotDeadlines(ctx context.Context, u models.User, today time.Time, slot string) ([]digestRow, error) { isGlobalAdmin := u.GlobalRole == "global_admin" offset := u.ReminderWarningOffsetDays if offset < 1 { offset = 7 } // Build the date predicate per slot. Positional placeholders only — // sqlx.Named can't be used because the query body contains PostgreSQL // `::TYPE` cast operators and sqlx eats the second `:` as a named-arg // prefix. // $1 = today // $2 = userid // $3 = is_global_admin // `offset` is interpolated as a literal int (clamped ≥1 above) — keeping // it as a parameter would force every slot's query to declare it even // when unused (evening), and Postgres can't infer the type of an // unreferenced parameter. // morning: overdue OR due_today OR due_warning(today+offset) // evening: overdue OR due_today (no +offset heads-up in the evening) var dateCond string if slot == "evening" { dateCond = `(f.due_date < $1 OR f.due_date = $1)` } else { dateCond = fmt.Sprintf(`(f.due_date < $1 OR f.due_date = $1 OR f.due_date = ($1::date + '%d days'::interval)::date)`, offset) } // Audience predicates: // * owner of the deadline — f.created_by = U // * project lead anywhere on the path — pt.responsibility = 'lead' // * owner's escalation contact (override) — own.escalation_contact_id = U // * global admin AND owner has no override — fallback channel // Per-category recipient rules (e.g. leads don't get overdue) are applied // in Go by visibleForCategory so the rule table stays readable. query := ` SELECT f.id AS deadline_id, f.title AS title, f.due_date AS due_date, f.approval_status AS approval_status, f.created_by AS owner_id, COALESCE(own.display_name, '') AS owner_name, own.escalation_contact_id AS owner_escalation_contact_id, COALESCE(p.reference, '') AS project_reference, p.title AS project_title, EXISTS ( SELECT 1 FROM paliad.project_teams pt WHERE pt.user_id = $2 AND pt.responsibility = 'lead' AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[]) ) AS is_lead FROM paliad.deadlines f JOIN paliad.projects p ON p.id = f.project_id LEFT JOIN paliad.users own ON own.id = f.created_by WHERE f.status = 'pending' AND ` + dateCond + ` AND ( f.created_by = $2 OR EXISTS ( SELECT 1 FROM paliad.project_teams pt WHERE pt.user_id = $2 AND pt.responsibility = 'lead' AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[]) ) OR own.escalation_contact_id = $2 OR ($3 = TRUE AND own.escalation_contact_id IS NULL) ) ORDER BY f.due_date ASC, f.id ASC` rows := []digestRow{} if err := s.db.SelectContext(ctx, &rows, query, today, u.ID, isGlobalAdmin); err != nil { return nil, fmt.Errorf("select deadlines: %w", err) } out := make([]digestRow, 0, len(rows)) for _, r := range rows { cat := categorize(r.DueDate, today, offset) if cat == "" { continue } isOwner := r.OwnerID == u.ID ownerHasOverride := r.OwnerEscalationContactID != nil isEscalationContact := ownerHasOverride && *r.OwnerEscalationContactID == u.ID if !visibleForCategory(cat, slot, isOwner, r.IsLead, isGlobalAdmin, isEscalationContact, ownerHasOverride) { continue } // Honour the per-category email_preferences toggle if the user // opted out of that specific kind. if !reminderEnabled(u.EmailPreferences, "deadline_reminders."+cat) { continue } r.Category = cat out = append(out, r) } return out, nil } // categorize buckets a deadline into "overdue" / "due_today" / "due_warning" // based on how its due_date relates to today + the user's warning offset, // all interpreted in the user's local timezone. Anything that isn't one of // the three returns "" (skip). // // Postgres DATE columns scan as time.Time at UTC midnight. We compare on // y/m/d only to avoid offset artefacts. func categorize(dueDate, today time.Time, warningOffsetDays int) string { d := time.Date(dueDate.Year(), dueDate.Month(), dueDate.Day(), 0, 0, 0, 0, today.Location()) t := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, today.Location()) switch { case d.Before(t): return "overdue" case d.Equal(t): return "due_today" case d.Equal(t.AddDate(0, 0, warningOffsetDays)): return "due_warning" } return "" } // visibleForCategory enforces the per-category recipient rules from the // design doc: // // * due_warning: owner OR lead (morning only — caller already filtered // by slot in the SQL date predicate). // * due_today, morning: owner OR lead. // * due_today, evening (DRINGEND): owner OR lead OR escalation channel. // * overdue: owner OR escalation channel (NOT lead — system failure, // escalate past the team). // // The "escalation channel" is the owner's escalation_contact_id when // non-NULL, else the firm's global_admins (fallback). When a contact is // set, global_admins are *deliberately excluded* so escalation does not // fan out to the whole admin team — that's the whole point of the // override (t-paliad-066). func visibleForCategory( category, slot string, isOwner, isLead, isGlobalAdmin, isEscalationContact, ownerHasEscalationOverride bool, ) bool { // When the owner set a specific escalation contact, the global-admin // fallback is suppressed — only the named contact (and the owner / // leads, who get notified for their own reasons) are on the escalation // channel. escalationVisible := func() bool { if ownerHasEscalationOverride { return isEscalationContact } return isGlobalAdmin } switch category { case "due_warning": return isOwner || isLead case "due_today": if slot == "evening" { return isOwner || isLead || escalationVisible() } return isOwner || isLead case "overdue": return isOwner || escalationVisible() } return false } // reminderEnabled reports whether the user's email_preferences allow a given // reminder key. A missing key or empty object means on (opt-out) — that // preserves the behaviour users had before the settings page shipped. // // Two paths share this helper: // * the master switch ("deadline_reminders") — if explicitly false, no // digest email goes out at all. // * per-category keys ("deadline_reminders.overdue", "…due_today", // "…due_warning") — if explicitly false, that category's rows are // dropped from the digest. The legacy keys ("…tomorrow", "…weekly") // are no longer queried. func reminderEnabled(raw json.RawMessage, key 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 } // Master gate: an explicit false on "deadline_reminders" wins. if v, ok := prefs["deadline_reminders"]; ok { if b, ok := v.(bool); ok && !b { return false } } // Caller-specific gate. if key != "deadline_reminders" { if v, ok := prefs[key]; ok { if b, ok := v.(bool); ok && !b { return false } } } return true } // 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/empty timezone returns false (skip the user this tick) and logs an // error — historically we silently fell back to UTC, which masked the // alpine-container tzdata bug (t-paliad-064): in production // time.LoadLocation("Europe/Berlin") errored because the runtime image // shipped no /usr/share/zoneinfo, the silent UTC fallback fired, and // reminder_morning_time=09:00 matched at 09:00 UTC = 11:00 Berlin. The // `_ "time/tzdata"` import in cmd/server/main.go makes lookups work without // OS tzdata; this function now refuses to send rather than guessing. // // An unparseable HH:MM string still falls back to the column default // (09:00 / 16:00) — that's a separate, recoverable input and dropping the // user for it would be punitive. func inSlot(now time.Time, tz, morning, evening, slot string) bool { loc, err := time.LoadLocation(tz) if err != nil { slog.Error("reminder: cannot load timezone, skipping user this tick", "tz", tz, "slot", slot, "error", err) return false } 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 } // slotPastDueToday reports whether the user's slot hour for today has // already arrived (or is currently in progress) in the user's timezone. // It's the relaxed sibling of inSlot: inSlot uses ==, slotPastDueToday // uses >=. Used by runStartupCatchUp to redeliver slots that the regular // tick missed because a redeploy moved the tick out of the slot hour. // // The slot_date dedup (paliad.reminder_log.slot/slot_date with partial // UNIQUE INDEX) prevents this from double-firing if the slot already ran // earlier today, so it's safe to run this opportunistically on every boot. // // A bad/empty timezone returns false (skip this user) — same defensive // stance inSlot took for the same reason (t-paliad-064: alpine tzdata). func slotPastDueToday(now time.Time, tz, morning, evening, slot string) bool { loc, err := time.LoadLocation(tz) if err != nil { slog.Error("reminder: catch-up cannot load timezone, skipping user", "tz", tz, "slot", slot, "error", err) return false } local := now.In(loc) target := morning if slot == "evening" { target = evening } hour, ok := parseHour(target) if !ok { 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 } // hasDigestSent checks the slot-level dedup row (migration 025). The // partial unique index on (user_id, slot, slot_date) WHERE slot IS NOT NULL // makes this a single index lookup. func (s *ReminderService) hasDigestSent(ctx context.Context, userID uuid.UUID, slot string, slotDate 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 slot = $2 AND slot_date = $3 )`, userID, slot, slotDate) return exists, err } // logDigestSend writes one row per (user, slot, local-date). The unique // index serialises concurrent ticks for the same user/slot — if a stray // double-tick raced past the dedup check, the second insert errors and the // caller drops the second send. func (s *ReminderService) logDigestSend(ctx context.Context, userID uuid.UUID, slot string, slotDate time.Time) error { reminderType := slot + "_digest" if _, err := s.db.ExecContext(ctx, `INSERT INTO paliad.reminder_log (user_id, reminder_type, slot, slot_date) VALUES ($1, $2, $3, $4)`, userID, reminderType, slot, slotDate, ); err != nil { return fmt.Errorf("insert reminder_log: %w", err) } return nil } // deliverDigest renders deadline_digest.html for u and sends one email. // Caller is responsible for the dedup row (we want it to land only on // successful send). func (s *ReminderService) deliverDigest(u models.User, slot string, rows []digestRow) error { lang := "de" if u.Lang == "en" { lang = "en" } // Bucket rows by category for the template. Within a category, rows // arrive sorted by due_date already (SQL ORDER BY). // // Pending-approval rows (t-paliad-138) get a "[PENDING] " title prefix // so the recipient can't miss that the deadline is unverified — silence // on a pending change is the worst outcome for a 4-eye system. var overdue, dueToday, dueWarning []map[string]any pendingCount := 0 for _, r := range rows { title := r.Title isPending := r.ApprovalStatus == ApprovalStatusPending if isPending { title = "[PENDING] " + title pendingCount++ } item := map[string]any{ "DueDate": r.DueDate.Format("2006-01-02"), "Title": title, "IsPending": isPending, "ProjectReference": r.ProjectReference, "ProjectTitle": r.ProjectTitle, "OwnerName": r.OwnerName, "IsOtherOwner": r.OwnerID != u.ID, "URL": fmt.Sprintf("%s/deadlines/%s", s.baseURL, r.DeadlineID), } switch r.Category { case "overdue": overdue = append(overdue, item) case "due_today": dueToday = append(dueToday, item) case "due_warning": dueWarning = append(dueWarning, item) } } // Subject is rendered from the (deadline_digest, lang) template row by // MailService — see internal/services/email_template_service.go for the // SYSTEMAUSFALL/SYSTEM FAILURE conditional logic and the rationale for // keeping that framing in place. OpenTotal is precomputed for the // "Frist-Erinnerung: N offen" pluralisation in the subject template. data := map[string]any{ "Slot": slot, "IsEvening": slot == "evening", "Overdue": overdue, "DueToday": dueToday, "DueWarning": dueWarning, "OverdueCount": len(overdue), "DueTodayCount": len(dueToday), "DueWarningCount": len(dueWarning), "OpenTotal": len(dueToday) + len(dueWarning), "DeadlinesURL": fmt.Sprintf("%s/deadlines", s.baseURL), // PendingCount > 0 → templates can render a banner like // "Hinweis: N Frist(en) wartet auf 4-Augen-Genehmigung — // /inbox" above the digest body. Available even when the // template doesn't currently use it (forward-compat, no // existing-template breakage). "PendingCount": pendingCount, "InboxURL": fmt.Sprintf("%s/inbox", s.baseURL), } return s.mail.SendTemplate(TemplateData{ To: u.Email, Lang: lang, Name: "deadline_digest", Data: data, }) }