package store import ( "context" "database/sql" "errors" "fmt" "time" "github.com/robfig/cron/v3" ) // Reminder — user-stated future intent. fires once or recurring (if cron set). type Reminder struct { ID int64 CreatedTs time.Time FireTs time.Time NextFireTs time.Time // computed next fire (for recurring) or same as FireTs Payload string // raw json Status string // pending | fired | cancelled Cron string // cron expression, empty for one-shot // Collapsed — set only on a synthetic digest reminder (ID=0): the original // due reminders it stands in for. Not persisted. The dispatcher completes // (marks fired / reschedules) each of these after the digest delivers. Collapsed []Reminder } // Reminder lifecycle states. Named for the same reason DigestStatus is: a // caller filtering on the string literal "pending" is one typo away from a // filter that silently matches nothing. const ( ReminderPending = "pending" ReminderFired = "fired" ReminderCancelled = "cancelled" ) var ( ErrReminderNotFound = errors.New("store: reminder not found") ErrReminderState = errors.New("store: reminder not in a mutable state") ) func scanReminder(sc scanner) (Reminder, error) { var r Reminder var created, fire, nextFire int64 var cron *string if err := sc.Scan(&r.ID, &created, &fire, &nextFire, &r.Payload, &r.Status, &cron); err != nil { return Reminder{}, err } r.CreatedTs = time.UnixMilli(created).UTC() r.FireTs = time.UnixMilli(fire).UTC() r.NextFireTs = time.UnixMilli(nextFire).UTC() if cron != nil { r.Cron = *cron } return r, nil } // CreateReminder persists a reminder with a resolved absolute fire time. // The caller (router/capture path) MUST have already converted "in 4h" → now+4h. // We do not accept strings here. cron is a cron expression for recurring // reminders; empty for one-shot. func (s *Store) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) { now := time.Now().UTC() var cronPtr *string if cron != "" { cronPtr = &cron } res, err := s.db.ExecContext(ctx, `INSERT INTO reminders (created_ts, fire_ts, next_fire_ts, payload, status, cron) VALUES (?,?,?,?, 'pending', ?)`, now.UnixMilli(), fire.UnixMilli(), fire.UnixMilli(), payload, cronPtr) if err != nil { return 0, fmt.Errorf("create reminder: %w", err) } id, err := res.LastInsertId() if err != nil { return 0, fmt.Errorf("create reminder: last insert id: %w", err) } return id, nil } // DueReminders returns pending reminders with next_fire_ts <= now, oldest first. // This is the predicate input from the loop side: `next_fire_ts <= now AND status='pending'`. func (s *Store) DueReminders(ctx context.Context, now time.Time) ([]Reminder, error) { rows, err := s.db.QueryContext(ctx, ` SELECT id, created_ts, fire_ts, next_fire_ts, payload, status, cron FROM reminders WHERE status = 'pending' AND next_fire_ts <= ? ORDER BY next_fire_ts ASC`, now.UnixMilli()) if err != nil { return nil, fmt.Errorf("due reminders: %w", err) } defer rows.Close() var out []Reminder for rows.Next() { r, err := scanReminder(rows) if err != nil { return nil, err } out = append(out, r) } return out, rows.Err() } // PendingReminders returns the pending reminders whose next fire time falls in // [from, to), earliest first. // // The day plan used to take the newest 500 rows out of ListReminders, which // orders by creation, and then filter them by day. A reminder stated long ago // for today fell off the end of that scan while a reminder stated this morning // for next year stayed on it. Bounding by fire time drops what is out of range // instead of what is old. func (s *Store) PendingReminders(ctx context.Context, from, to time.Time) ([]Reminder, error) { rows, err := s.db.QueryContext(ctx, ` SELECT id, created_ts, fire_ts, next_fire_ts, payload, status, cron FROM reminders WHERE status = ? AND next_fire_ts >= ? AND next_fire_ts < ? ORDER BY next_fire_ts ASC, id ASC`, ReminderPending, from.UnixMilli(), to.UnixMilli()) if err != nil { return nil, fmt.Errorf("pending reminders: %w", err) } defer rows.Close() var out []Reminder for rows.Next() { r, err := scanReminder(rows) if err != nil { return nil, err } out = append(out, r) } return out, rows.Err() } // MarkReminder sets a reminder's status. Only valid transitions: pending→fired, // pending→cancelled. Anything else is a programming error. func (s *Store) MarkReminder(ctx context.Context, id int64, status string) error { if status != "fired" && status != "cancelled" { return fmt.Errorf("%w: %s", ErrReminderState, status) } // pending → fired|cancelled only. var current string err := s.db.QueryRowContext(ctx, "SELECT status FROM reminders WHERE id = ?", id).Scan(¤t) if errors.Is(err, sql.ErrNoRows) { return ErrReminderNotFound } if err != nil { return err } if current != "pending" { return fmt.Errorf("%w: currently %s", ErrReminderState, current) } _, err = s.db.ExecContext(ctx, "UPDATE reminders SET status = ? WHERE id = ?", status, id) return err } // ListReminders returns the n most recent reminders, newest first. func (s *Store) ListReminders(ctx context.Context, n int) ([]Reminder, error) { rows, err := s.db.QueryContext(ctx, ` SELECT id, created_ts, fire_ts, next_fire_ts, payload, status, cron FROM reminders ORDER BY created_ts DESC, id DESC LIMIT ?`, n) if err != nil { return nil, fmt.Errorf("list reminders: %w", err) } defer rows.Close() var out []Reminder for rows.Next() { r, err := scanReminder(rows) if err != nil { return nil, err } out = append(out, r) } return out, rows.Err() } // RescheduleReminder computes the next fire time for a recurring reminder and // updates next_fire_ts. Returns ErrReminderState if the reminder is not // recurring or not pending. If no more valid fire times exist, marks it fired. func (s *Store) RescheduleReminder(ctx context.Context, id int64, now time.Time) error { row := s.db.QueryRowContext(ctx, ` SELECT id, created_ts, fire_ts, next_fire_ts, payload, status, cron FROM reminders WHERE id = ?`, id) r, err := scanReminder(row) if err != nil { return err } if r.Cron == "" { return fmt.Errorf("%w: not a recurring reminder", ErrReminderState) } if r.Status != "pending" { return fmt.Errorf("%w: currently %s", ErrReminderState, r.Status) } sched, err := cron.ParseStandard(r.Cron) if err != nil { return fmt.Errorf("parse cron %q: %w", r.Cron, err) } next := sched.Next(r.NextFireTs.Add(time.Minute)) if next.IsZero() || next.Before(now) { _, err = s.db.ExecContext(ctx, "UPDATE reminders SET status = 'fired' WHERE id = ?", id) return err } _, err = s.db.ExecContext(ctx, "UPDATE reminders SET next_fire_ts = ? WHERE id = ?", next.UnixMilli(), id) return err }