reminders: add recurring reminder support
Add cron expression support for recurring reminders using robfig/cron/v3. Changes: - Migration #2: ALTER TABLE reminders ADD COLUMN cron TEXT + next_fire_ts INTEGER - Reminder struct: add Cron and NextFireTs fields - scanReminder helper extracts full row including nullable cron - CreateReminder: accept optional cron param, store next_fire_ts = fire_ts - DueReminders: query on next_fire_ts instead of fire_ts - RescheduleReminder: new method — parse cron, compute next fire, update next_fire_ts or mark fired if no more valid times - Dispatcher: call RescheduleReminder for cron reminders, MarkReminder for one-shots (preserving existing behavior for ID=0 digest skip) - ReminderCompleter interface: add RescheduleReminder method - storeAPI adapter: forward RescheduleReminder - All callers updated: CreateReminder signature includes cron param - Tests: TestRecurringReminder (store), TestDispatchRecurringReminderReschedules - Existing tests updated for new signature
This commit is contained in:
@@ -20,11 +20,13 @@ type NudgeRecorder interface {
|
||||
RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error)
|
||||
}
|
||||
|
||||
// ReminderCompleter — the seam the store implements. a reminder fires once:
|
||||
// pending → fired after successful delivery. a failed send does NOT mark the
|
||||
// reminder fired (it stays pending; the next tick re-delivers).
|
||||
// ReminderCompleter — the seam the store implements. For one-shot reminders:
|
||||
// pending → fired after successful delivery. For recurring reminders (with
|
||||
// cron): reschedule after successful delivery. A failed send does NOT mark or
|
||||
// reschedule it (it stays pending; the next tick re-delivers).
|
||||
type ReminderCompleter interface {
|
||||
MarkReminder(ctx context.Context, id int64, status string) error
|
||||
RescheduleReminder(ctx context.Context, id int64, now time.Time) error
|
||||
}
|
||||
|
||||
// PhrasedNudge — the phraser module's output for a nudge. the phraser (LFM
|
||||
@@ -184,11 +186,17 @@ func (d *Dispatcher) DispatchReminder(ctx context.Context, pr PhrasedReminder, n
|
||||
}
|
||||
if d.cfg.Reminders != nil && len(out) > 0 {
|
||||
// ID=0 is a synthetic digest reminder; it's not in the DB so
|
||||
// MarkReminder would fail with ErrReminderNotFound. The originals
|
||||
// MarkReminder/RescheduleReminder would fail. The originals
|
||||
// were already marked fired by collapseReminders in gather.go.
|
||||
if rd.Reminder.ID != 0 {
|
||||
if err := d.cfg.Reminders.MarkReminder(ctx, rd.Reminder.ID, "fired"); err != nil {
|
||||
return out, fmt.Errorf("mark reminder fired: %w", err)
|
||||
if rd.Reminder.Cron != "" {
|
||||
if err := d.cfg.Reminders.RescheduleReminder(ctx, rd.Reminder.ID, now); err != nil {
|
||||
return out, fmt.Errorf("reschedule reminder %d: %w", rd.Reminder.ID, err)
|
||||
}
|
||||
} else {
|
||||
if err := d.cfg.Reminders.MarkReminder(ctx, rd.Reminder.ID, "fired"); err != nil {
|
||||
return out, fmt.Errorf("mark reminder fired: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,8 @@ type fakeReminderCompleter struct {
|
||||
id int64
|
||||
status string
|
||||
}
|
||||
err error
|
||||
rescheduled []int64
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeReminderCompleter) MarkReminder(_ context.Context, id int64, status string) error {
|
||||
@@ -74,6 +75,14 @@ func (f *fakeReminderCompleter) MarkReminder(_ context.Context, id int64, status
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeReminderCompleter) RescheduleReminder(_ context.Context, id int64, _ time.Time) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
f.rescheduled = append(f.rescheduled, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeAck struct {
|
||||
acked map[string]bool
|
||||
lastSent map[string]time.Time
|
||||
@@ -599,3 +608,30 @@ func TestRepeatUnackedNilTelegramOrAckIsNoOp(t *testing.T) {
|
||||
t.Fatalf("nil telegram/ack: want nil, got %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchRecurringReminderReschedules(t *testing.T) {
|
||||
voice := &fakeSink{}
|
||||
rc := &fakeReminderCompleter{}
|
||||
d := NewDispatcher(Config{Voice: voice, Reminders: rc})
|
||||
|
||||
rd := loop.ReminderDecision{
|
||||
Reminder: store.Reminder{ID: 7, Cron: "0 9 * * *", Status: "pending"},
|
||||
State: loop.State{Now: refNow(), Presence: store.Present},
|
||||
}
|
||||
out, err := d.DispatchReminder(context.Background(), PhrasedReminder{
|
||||
Decision: rd, Body: "daily standup", Summary: "standup",
|
||||
}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch: %v", err)
|
||||
}
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("want 1 dispatch, got %d", len(out))
|
||||
}
|
||||
// Should RescheduleReminder, not MarkReminder
|
||||
if len(rc.rescheduled) != 1 || rc.rescheduled[0] != 7 {
|
||||
t.Fatalf("want rescheduled 7, got %+v", rc.rescheduled)
|
||||
}
|
||||
if len(rc.marked) != 0 {
|
||||
t.Fatalf("recurring reminder should not be marked fired: %+v", rc.marked)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user