229 lines
8.1 KiB
Go
229 lines
8.1 KiB
Go
// 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 {
|
||
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 {
|
||
return err
|
||
}
|
||
return s.prefs.SyncLegacyColumns(ctx, userID)
|
||
}
|
||
|
||
// 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))
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|