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 } // 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 } // GetEnabled returns whether the user wants sādhana reminders. func (r *ReminderRepo) GetEnabled(ctx context.Context, userID string) (bool, error) { var enabled bool err := r.pg.Pool.QueryRow(ctx, `SELECT sadhna_reminders_enabled FROM users WHERE id = $1`, userID).Scan(&enabled) if err != nil { return false, fmt.Errorf("get reminder pref: %w", err) } return enabled, nil } // SetEnabled flips the user's reminder opt-in. func (r *ReminderRepo) SetEnabled(ctx context.Context, userID string, enabled bool) error { _, err := r.pg.Pool.Exec(ctx, `UPDATE users SET sadhna_reminders_enabled = $1 WHERE id = $2`, enabled, userID) if err != nil { return fmt.Errorf("set reminder pref: %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). 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) FROM users u WHERE u.sadhna_reminders_enabled = TRUE 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 )`) 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, ); 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 }