package store import ( "context" "database/sql" "errors" "fmt" "time" ) // Reminder — user-stated future intent. fires once. relative→absolute happens // at capture ("in 4h" → store now+4h, never the string). type Reminder struct { ID int64 CreatedTs time.Time FireTs time.Time Payload string // raw json Status string // pending | fired | cancelled } var ( ErrReminderNotFound = errors.New("store: reminder not found") ErrReminderState = errors.New("store: reminder not in a mutable state") ) // 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. func (s *Store) CreateReminder(ctx context.Context, fire time.Time, payload string) (int64, error) { now := time.Now().UTC() res, err := s.db.ExecContext(ctx, `INSERT INTO reminders (created_ts, fire_ts, payload, status) VALUES (?,?,?, 'pending')`, now.UnixMilli(), fire.UnixMilli(), payload) if err != nil { return 0, fmt.Errorf("create reminder: %w", err) } id, _ := res.LastInsertId() return id, nil } // DueReminders returns pending reminders with fire_ts <= now, oldest first. // This is the predicate input from the loop side: `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, payload, status FROM reminders WHERE status = 'pending' AND fire_ts <= ? ORDER BY 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() { var r Reminder var created, fire int64 if err := rows.Scan(&r.ID, &created, &fire, &r.Payload, &r.Status); err != nil { return nil, err } r.CreatedTs = time.UnixMilli(created).UTC() r.FireTs = time.UnixMilli(fire).UTC() 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 }