203 lines
7.3 KiB
Go
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
|
|
}
|