// 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 users *repository.UserRepo notif *NotificationService logger *zap.Logger sadhnaURL string } func NewReminderService(repo *repository.ReminderRepo, prefs *repository.NotificationPrefsRepo, users *repository.UserRepo, notif *NotificationService, logger *zap.Logger, frontendURL string) *ReminderService { return &ReminderService{ repo: repo, prefs: prefs, users: users, 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 } if err := s.repo.SetTimezone(ctx, userID, tz); err != nil { return err } // Best-effort: backfill the account country from the timezone when it's // still unset (geo-IP at login is the primary source; SetCountryIfEmpty // never overwrites it). A no-op when the zone isn't in the table. if s.users != nil { if cc := tzCountry[tz]; cc != "" { if err := s.users.SetCountryIfEmpty(ctx, userID, cc); err != nil { s.logger.Warn("backfill country from timezone failed", zap.String("user", userID), zap.String("country", cc), zap.Error(err)) } } } return nil } // 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 }