Files
spsays-bff/internal/service/reminder_service.go
T

203 lines
7.3 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
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))
}
}
}
// 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
}