package repository import ( "context" "fmt" "time" ) // ReminderRepo backs the sādhana reminder scheduler: per-user preferences, // the timezone used to fire at local time, and the candidate query the // worker scans each tick. type ReminderRepo struct{ pg *Postgres } func NewReminderRepo(pg *Postgres) *ReminderRepo { return &ReminderRepo{pg: pg} } // ReminderCandidate is one user the worker evaluates per tick. The time-of-day // windowing + per-day dedupe decision happens in Go (using TZ); the SQL just // supplies the inputs, including whether today's disciplines are already done // (bucketed by UTC day, matching the streak logic). type ReminderCandidate struct { UserID string Email *string TZ string ChantTime string // "HH:MM" local QuoteTime string // "HH:MM" local LastChantOn *time.Time LastQuoteOn *time.Time LastChantDate *time.Time // most recent completed chant day (UTC), nil if never ChantDoneToday bool QuoteDoneToday bool // For the evening "incomplete chanting" reminder. RoundsToday int // rounds chanted so far today TargetRounds int // the user's daily target (16 if unset) LastChantIncompleteOn *time.Time // UTC date the evening reminder last fired } // SetTimezone records the user's IANA timezone (validated by the caller). func (r *ReminderRepo) SetTimezone(ctx context.Context, userID, tz string) error { _, err := r.pg.Pool.Exec(ctx, `UPDATE users SET timezone = $1 WHERE id = $2`, tz, userID) if err != nil { return fmt.Errorf("set timezone: %w", err) } return nil } // DueCandidates returns reminder-enabled users who haven't already had BOTH // reminders today, with the data the worker needs to decide. Defaults are // folded in (06:00 chant, 21:30 quote, UTC tz). // // "Reminder-enabled" now reads notification_prefs: a user drops out of the // scan only when every reminder kind × channel cell (3 × 3) is opted out. // Finer-grained opt-outs (e.g. email off, push on) still match here — // SendReminder enforces the per-channel prefs on delivery. func (r *ReminderRepo) DueCandidates(ctx context.Context) ([]ReminderCandidate, error) { rows, err := r.pg.Pool.Query(ctx, ` SELECT u.id, u.email, COALESCE(NULLIF(u.timezone, ''), 'UTC'), COALESCE(NULLIF(u.preferred_chanting_time, ''), '06:00'), COALESCE(NULLIF(u.peak_active_time, ''), '21:30'), u.last_chant_reminder_on, u.last_quote_reminder_on, (SELECT max(l.log_date) FROM sadhna_logs l WHERE l.user_id = u.id AND l.chant_done), EXISTS(SELECT 1 FROM sadhna_logs l WHERE l.user_id = u.id AND l.chant_done AND l.log_date = (now() AT TIME ZONE 'UTC')::date), EXISTS(SELECT 1 FROM posts p WHERE p.user_id = u.id AND (p.created_at AT TIME ZONE 'UTC')::date = (now() AT TIME ZONE 'UTC')::date), COALESCE((SELECT l.rounds_completed FROM sadhna_logs l WHERE l.user_id = u.id AND l.log_date = (now() AT TIME ZONE 'UTC')::date), 0), COALESCE(u.daily_chanting_rounds, 16), u.last_chant_incomplete_reminder_on FROM users u WHERE (SELECT count(*) FROM notification_prefs np WHERE np.user_id = u.id AND np.kind IN ('chant_reminder', 'quote_reminder', 'chant_incomplete_reminder') AND np.enabled = FALSE) < 9 AND ( u.last_chant_reminder_on IS NULL OR u.last_chant_reminder_on < (now() AT TIME ZONE 'UTC')::date OR u.last_quote_reminder_on IS NULL OR u.last_quote_reminder_on < (now() AT TIME ZONE 'UTC')::date OR u.last_chant_incomplete_reminder_on IS NULL OR u.last_chant_incomplete_reminder_on < (now() AT TIME ZONE 'UTC')::date )`) if err != nil { return nil, fmt.Errorf("due candidates: %w", err) } defer rows.Close() var out []ReminderCandidate for rows.Next() { var c ReminderCandidate if err := rows.Scan( &c.UserID, &c.Email, &c.TZ, &c.ChantTime, &c.QuoteTime, &c.LastChantOn, &c.LastQuoteOn, &c.LastChantDate, &c.ChantDoneToday, &c.QuoteDoneToday, &c.RoundsToday, &c.TargetRounds, &c.LastChantIncompleteOn, ); err != nil { return nil, fmt.Errorf("scan candidate: %w", err) } out = append(out, c) } return out, rows.Err() } // MarkChantReminded / MarkQuoteReminded stamp the UTC date a reminder went // out, so the same kind won't fire again that day. func (r *ReminderRepo) MarkChantReminded(ctx context.Context, userID string, utcDate time.Time) error { _, err := r.pg.Pool.Exec(ctx, `UPDATE users SET last_chant_reminder_on = $1 WHERE id = $2`, utcDate, userID) return err } func (r *ReminderRepo) MarkQuoteReminded(ctx context.Context, userID string, utcDate time.Time) error { _, err := r.pg.Pool.Exec(ctx, `UPDATE users SET last_quote_reminder_on = $1 WHERE id = $2`, utcDate, userID) return err } // MarkChantIncompleteReminded stamps the UTC date the evening "finish your // rounds" reminder went out, so it fires at most once per day. func (r *ReminderRepo) MarkChantIncompleteReminded(ctx context.Context, userID string, utcDate time.Time) error { _, err := r.pg.Pool.Exec(ctx, `UPDATE users SET last_chant_incomplete_reminder_on = $1 WHERE id = $2`, utcDate, userID) return err }