Commit 7 of 8. Outbound surfaces honour the pending-approval state instead of going silent on it. CalDAV (caldav_ical.go formatAppointment): when an appointment is approval_status='pending', the iCal SUMMARY line is prefixed with "[PENDING] ". External clients (Outlook, Apple Calendar, etc.) thus display the unverified state honestly. Approved entries sync clean. Email reminder digest (reminder_service.go): - digestRow gains ApprovalStatus, sourced from f.approval_status in the SELECT. - Each pending row's Title is rewritten to "[PENDING] <title>" before it lands in the template — visible in every email-rendered list. - Template data carries PendingCount (count of pending rows in this digest) + InboxURL so future template revisions can render a banner like "Hinweis: N Frist(en) wartet auf 4-Augen-Genehmigung — /inbox" without further code changes. Existing templates unchanged for backwards compat; the prefix on row titles already conveys the signal. - IsPending flag on each item map for future per-row template conditionals. Rationale: silence on a pending change is the worst outcome for a 4-eye system. The user's external calendar and reminder mail must reflect "this exists but isn't verified" so they can act before the deadline lapses.
663 lines
25 KiB
Go
663 lines
25 KiB
Go
// 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.role = '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.role = '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.role = '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,
|
|
})
|
|
}
|