reminder_service.go: BuildDigest audience predicate switches the
"project lead anywhere on the path" branch from `pt.role = 'lead'` to
`pt.responsibility = 'lead'`. Two SQL sites + comment updated.
deadline_service.go: assertCanAdminProject (Reopen permission) switches
from `pt.role IN ('admin','lead')` to `pt.responsibility = 'lead'`.
The legacy 'admin' was already dead since t-paliad-051 — never present
in project_teams.role to begin with — so this also drops a slow leak.
Doc comments + error message updated.
derivation_service.go: ListDescendantStaffed SELECTs both `pt.role` and
`pt.responsibility`, returns the new column to the team-tab "from
descendants" subsection (so the firm-tier badge + responsibility pill
both render). ORDER BY switches to responsibility.
Build + vet clean. Pure-Go tests pass.
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.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,
|
|
})
|
|
}
|