Files
spsays-bff/internal/repository/reminder_repo.go

130 lines
5.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}