// 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" "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 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 notif *NotificationService logger *zap.Logger sadhnaURL string } func NewReminderService(repo *repository.ReminderRepo, notif *NotificationService, logger *zap.Logger, frontendURL string) *ReminderService { return &ReminderService{ repo: repo, notif: notif, logger: logger, sadhnaURL: strings.TrimRight(frontendURL, "/") + "/sadhna-streak", } } // Enabled / SetEnabled / SetTimezone back the settings + tz-capture endpoints. func (s *ReminderService) Enabled(ctx context.Context, userID string) (bool, error) { return s.repo.GetEnabled(ctx, userID) } func (s *ReminderService) SetEnabled(ctx context.Context, userID string, enabled bool) error { return s.repo.SetEnabled(ctx, userID, enabled) } // SetTimezone validates the IANA zone before storing so a bad value can never // break the (timezone-using) candidate scan. func (s *ReminderService) SetTimezone(ctx context.Context, userID, tz string) error { tz = 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)) } } } // 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 } } // 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 }