Files
spsays-bff/internal/service/reminder_service.go
T
2026-06-11 01:11:10 +05:30

236 lines
8.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package service — ReminderService drives the daily sādhana reminders.
//
// A worker calls RunOnce on a fixed tick. For each reminder-enabled user it
// computes the user's *local* clock time (from their IANA timezone) and, if
// it falls in the send window for that reminder AND they haven't done the
// discipline today AND they haven't already been reminded today, sends the
// nudge through every channel (email + push + websocket toast).
//
// "Done today" and the once-per-day dedupe are bucketed by UTC calendar day,
// matching the streak logic; only the *time of day* the reminder fires is
// local. The recency-aware chant message is chosen from the user's most
// recent completed chant day.
package service
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
// Embed the IANA tz database so time.LoadLocation works regardless of
// whether the host image ships zoneinfo.
_ "time/tzdata"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"go.uber.org/zap"
)
// ErrInvalidTimezone is returned when a timezone-set request carries a value
// that isn't a loadable IANA zone.
var ErrInvalidTimezone = errors.New("invalid timezone")
const (
// reminderWindowMin must be >= the worker tick so no target time is
// missed between ticks; the per-day dedupe prevents a double-send within
// the overlap.
reminderWindowMin = 20
// The evening "finish your rounds" reminder fires at 21:00 local. Kept
// before the 21:30 quote reminder so the two don't collide in one tick.
chantIncompleteTime = "21:00"
chantMsgStart = "Chant with Prabhupāda — start building your sādhana streak."
chantMsgLapsed = "Just one hour of sādhana can save you from this duḥkhālayam aśāśvatam. Come and build your streak again."
chantMsgYesterday = "Your sādhana was wonderful yesterday! Let's continue your sādhana today as well."
quoteMsg = "Share a verse from the Vāṇī and complete today's sādhana."
)
type ReminderService struct {
repo *repository.ReminderRepo
prefs *repository.NotificationPrefsRepo
notif *NotificationService
logger *zap.Logger
sadhnaURL string
}
func NewReminderService(repo *repository.ReminderRepo, prefs *repository.NotificationPrefsRepo, notif *NotificationService, logger *zap.Logger, frontendURL string) *ReminderService {
return &ReminderService{
repo: repo,
prefs: prefs,
notif: notif,
logger: logger,
sadhnaURL: strings.TrimRight(frontendURL, "/") + "/sadhna-streak",
}
}
// Enabled / SetEnabled back the legacy all-or-nothing reminder toggle — now a
// shim over the per-kind/channel prefs (dual-writing the legacy users column)
// so old clients and the new matrix stay consistent. "Enabled" means at least
// one reminder kind × channel cell is still on.
func (s *ReminderService) Enabled(ctx context.Context, userID string) (bool, error) {
off, err := s.prefs.DisabledSet(ctx, userID)
if err != nil {
// Handler sanitizes to a generic 500 — log the cause here.
s.logger.Error("reminder prefs: load failed", zap.String("user", userID), zap.Error(err))
return false, err
}
for _, kind := range PrefCategories["sadhna_reminders"] {
for _, ch := range PrefChannels {
if _, disabled := off[repository.PrefKey(kind, ch)]; !disabled {
return true, nil
}
}
}
return false, nil
}
func (s *ReminderService) SetEnabled(ctx context.Context, userID string, enabled bool) error {
kinds := PrefCategories["sadhna_reminders"]
var err error
if enabled {
err = s.prefs.Enable(ctx, userID, kinds, PrefChannels)
} else {
err = s.prefs.Disable(ctx, userID, kinds, PrefChannels)
}
if err == nil {
err = s.prefs.SyncLegacyColumns(ctx, userID)
}
if err != nil {
s.logger.Error("reminder prefs: write failed", zap.String("user", userID), zap.Error(err))
}
return err
}
// SetTimezone validates the IANA zone before storing so a bad value can never
// break the (timezone-using) candidate scan. Legacy aliases are canonicalized
// first (browsers report e.g. "Asia/Calcutta"): Go resolves them but a
// Postgres without the tzdata backward links rejects them in AT TIME ZONE.
func (s *ReminderService) SetTimezone(ctx context.Context, userID, tz string) error {
tz = canonicalTZ(strings.TrimSpace(tz))
if tz == "" {
tz = "UTC"
}
if _, err := time.LoadLocation(tz); err != nil {
return ErrInvalidTimezone
}
return s.repo.SetTimezone(ctx, userID, tz)
}
// RunOnce evaluates every due candidate against `now` and fires the reminders
// that are due. Errors are logged, never returned — a bad row shouldn't stop
// the rest.
func (s *ReminderService) RunOnce(ctx context.Context, now time.Time) {
candidates, err := s.repo.DueCandidates(ctx)
if err != nil {
s.logger.Warn("reminders: load candidates failed", zap.Error(err))
return
}
utc := now.UTC()
utcToday := time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC)
for _, c := range candidates {
loc, err := time.LoadLocation(c.TZ)
if err != nil {
loc = time.UTC
}
local := now.In(loc)
localMin := local.Hour()*60 + local.Minute()
// Chant reminder.
if !c.ChantDoneToday && notRemindedToday(c.LastChantOn, utcToday) {
if t := parseHHMM(c.ChantTime); t >= 0 && withinWindow(localMin, t) {
s.notif.SendReminder(ctx, c.UserID, NotifChantReminder,
"Time for your sādhana 🙏", chantMessage(c.LastChantDate, utcToday), s.sadhnaURL)
if err := s.repo.MarkChantReminded(ctx, c.UserID, utcToday); err != nil {
s.logger.Warn("reminders: mark chant failed", zap.String("user", c.UserID), zap.Error(err))
}
}
}
// Evening "incomplete chanting" reminder — the devotee started their
// rounds today (RoundsToday > 0) but hasn't reached the target by
// 21:00 local. Only nudges those who began, so non-starters get just
// the morning reminder, not a second one. Independent of the morning
// chant reminder (own time + own per-day dedupe).
if c.RoundsToday > 0 && !c.ChantDoneToday && notRemindedToday(c.LastChantIncompleteOn, utcToday) {
if t := parseHHMM(chantIncompleteTime); t >= 0 && withinWindow(localMin, t) {
s.notif.SendReminder(ctx, c.UserID, NotifChantIncomplete,
"Finish your japa 🙏", incompleteChantMessage(c.RoundsToday, c.TargetRounds), s.sadhnaURL)
if err := s.repo.MarkChantIncompleteReminded(ctx, c.UserID, utcToday); err != nil {
s.logger.Warn("reminders: mark chant-incomplete failed", zap.String("user", c.UserID), zap.Error(err))
}
}
}
// Quote reminder.
if !c.QuoteDoneToday && notRemindedToday(c.LastQuoteOn, utcToday) {
if t := parseHHMM(c.QuoteTime); t >= 0 && withinWindow(localMin, t) {
s.notif.SendReminder(ctx, c.UserID, NotifQuoteReminder,
"Share a verse from the Vāṇī", quoteMsg, s.sadhnaURL)
if err := s.repo.MarkQuoteReminded(ctx, c.UserID, utcToday); err != nil {
s.logger.Warn("reminders: mark quote failed", zap.String("user", c.UserID), zap.Error(err))
}
}
}
}
}
// chantMessage picks the recency-aware nudge from the most recent completed
// chant day (both args are UTC midnight). The candidate is, by construction,
// not done today, so the gap is >= 1 day.
func chantMessage(lastChant *time.Time, utcToday time.Time) string {
if lastChant == nil {
return chantMsgStart
}
days := int(utcToday.Sub(*lastChant).Hours() / 24)
switch {
case days <= 1:
return chantMsgYesterday // chanted yesterday
case days <= 7:
return chantMsgLapsed // within the last week, but not yesterday
default:
return chantMsgStart // nothing in over a week
}
}
// incompleteChantMessage nudges a devotee who began their rounds today to
// finish them (only sent when rounds > 0).
func incompleteChantMessage(rounds, target int) string {
if target <= 0 {
target = 16
}
return fmt.Sprintf(
"You've chanted %d of %d rounds today — finish your japa before the day ends. 🙏",
rounds, target)
}
// notRemindedToday is true when no reminder of this kind has gone out on the
// current UTC day.
func notRemindedToday(last *time.Time, utcToday time.Time) bool {
return last == nil || last.Before(utcToday)
}
// withinWindow reports whether localMin falls in [target, target+window),
// handling midnight wrap.
func withinWindow(localMin, target int) bool {
diff := ((localMin-target)%1440 + 1440) % 1440
return diff < reminderWindowMin
}
// parseHHMM turns "HH:MM" into minutes-of-day, or -1 if malformed.
func parseHHMM(s string) int {
parts := strings.SplitN(strings.TrimSpace(s), ":", 2)
if len(parts) != 2 {
return -1
}
h, err1 := strconv.Atoi(parts[0])
m, err2 := strconv.Atoi(parts[1])
if err1 != nil || err2 != nil || h < 0 || h > 23 || m < 0 || m > 59 {
return -1
}
return h*60 + m
}