130 lines
5.1 KiB
Go
130 lines
5.1 KiB
Go
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
|
||
}
|