6bab68e96d
Store layer: ListReminders returns the n most recent reminders (newest first). IPC: new MethodListReminders wired through server, client, and lockedAPI. Web: /reminders page with table of created time, fire time, status badge, and payload text; empty state with prompt to ask maven for a reminder. Sidebar entry under Automation.
168 lines
5.4 KiB
Go
168 lines
5.4 KiB
Go
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
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
// 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
|
|
}
|