Chat autosend, week rollover and ai_runs purge schedules (Feature 20)
shopping_chat_autosend fires at chat_autosend_dow/time for the coming
week and posts its Supply run through SendToChat when a webhook is
stored, list_ready's Chat channel is on and the list exists unsent;
otherwise it logs why (key list_ready:chat:{weekStart}). week_rollover
freezes the previous week at Monday 00:05 through Freeze, judging the
week ended by the fire time. ai_runs_purge calls harness.Purge daily at
03:00 with the fire time less RunRetention. Fake-clock tests cover each.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -204,6 +204,19 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := sched.Register(shopSvc.ChatAutosendSchedule()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := sched.Register(shopSvc.RolloverSchedule()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
purge, err := harness.PurgeSchedule(db, logger)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := sched.Register(purge); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, chatSvc, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, ideasSvc, runner, photos, frontend.Assets())
|
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, chatSvc, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, ideasSvc, runner, photos, frontend.Assets())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+22
-7
@@ -537,8 +537,8 @@ automatic** (D-021).
|
|||||||
received.
|
received.
|
||||||
`harness.Purge` deletes rows older than `harness.RunRetention` (30 days),
|
`harness.Purge` deletes rows older than `harness.RunRetention` (30 days),
|
||||||
clearing the `suggestion_batches` and `week_plans` references first. The
|
clearing the `suggestion_batches` and `week_plans` references first. The
|
||||||
harness does not call it: the scheduler's daily `ai_runs` purge
|
daily `ai_runs_purge` schedule (`harness.PurgeSchedule`) calls it with
|
||||||
(Feature 20) must call `harness.Purge` with `now - RunRetention`.
|
its fire time less `RunRetention`.
|
||||||
- **Concurrency guards** (`harness.Guard`, one per process): `harness.Run`
|
- **Concurrency guards** (`harness.Guard`, one per process): `harness.Run`
|
||||||
takes a scope key, and concurrent runs of one skill with one scope share a
|
takes a scope key, and concurrent runs of one skill with one scope share a
|
||||||
single in-flight run and its result, error included (single-flight); runs
|
single in-flight run and its result, error included (single-flight); runs
|
||||||
@@ -653,7 +653,8 @@ job failed, the freeze finds the list still untitled and makes its own
|
|||||||
`archive_title` attempt before freezing. A job that starts during a freeze
|
`archive_title` attempt before freezing. A job that starts during a freeze
|
||||||
waits for it, then finds the list titled (that title stands) or frozen
|
waits for it, then finds the list titled (that title stands) or frozen
|
||||||
untitled (the job fails `title_failed`), and stores nothing. The
|
untitled (the job fails `title_failed`), and stores nothing. The
|
||||||
`week_rollover` schedule is to call `Freeze` for last week.
|
`week_rollover` schedule (`shopping.Service.RolloverSchedule`) calls
|
||||||
|
`Freeze` for last week, judging the week ended by its fire time.
|
||||||
|
|
||||||
## Recipe grounding
|
## Recipe grounding
|
||||||
|
|
||||||
@@ -781,11 +782,11 @@ purge are fixed and not configurable.
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `suggestions_weekly` | Sunday 08:00 | New batch of Side quests (`suggest.Service.WeeklySchedule`). |
|
| `suggestions_weekly` | Sunday 08:00 | New batch of Side quests (`suggest.Service.WeeklySchedule`). |
|
||||||
| `plan_draft` | Friday 09:00 | AI drafts next week's plan (`plan.Service.DraftSchedule`). |
|
| `plan_draft` | Friday 09:00 | AI drafts next week's plan (`plan.Service.DraftSchedule`). |
|
||||||
| `shopping_chat_autosend` | Saturday 08:00 | Sends the Supply run to Google Chat. |
|
| `shopping_chat_autosend` | Saturday 08:00 | Sends the coming week's Supply run to Google Chat (`shopping.Service.ChatAutosendSchedule`). |
|
||||||
| `day_of_reminder` | Daily 16:30, and 15 minutes before a crock-pot start_by earlier than that | Tonight's dinner reminder and the crock-pot start reminder (`plan.Service.ReminderSchedules`). |
|
| `day_of_reminder` | Daily 16:30, and 15 minutes before a crock-pot start_by earlier than that | Tonight's dinner reminder and the crock-pot start reminder (`plan.Service.ReminderSchedules`). |
|
||||||
| `rotation_check` | Daily 09:00 | Asks "Keep it in rotation?" for tried dinners. |
|
| `rotation_check` | Daily 09:00 | Asks "Keep it in rotation?" for tried dinners. |
|
||||||
| `week_rollover` | Monday 00:05 (fixed) | Freezes last week's Supply run into the Supply archive. |
|
| `week_rollover` | Monday 00:05 (fixed) | Freezes last week's Supply run into the Supply archive (`shopping.Service.RolloverSchedule`). |
|
||||||
| `ai_runs` purge | Daily (fixed) | Calls `harness.Purge` to delete `ai_runs` rows past 30-day retention (Feature 20). |
|
| `ai_runs_purge` | Daily 03:00 (fixed) | Calls `harness.Purge` to delete `ai_runs` rows past 30-day retention (`harness.PurgeSchedule`). |
|
||||||
|
|
||||||
Runs are recorded in `schedule_runs` and executed through `jobs`.
|
Runs are recorded in `schedule_runs` and executed through `jobs`.
|
||||||
|
|
||||||
@@ -840,6 +841,20 @@ comes at once, so a crock-pot dinner swapped in for tonight is still
|
|||||||
reminded 15 minutes before its `start_by`, not at the next hourly timer
|
reminded 15 minutes before its `start_by`, not at the next hourly timer
|
||||||
(`maxWait`); a lookup that fails is logged and repeated every
|
(`maxWait`); a lookup that fails is logged and repeated every
|
||||||
`ClaimRetry`.
|
`ClaimRetry`.
|
||||||
|
`shopping.Service.ChatAutosendSchedule()` is `shopping_chat_autosend`,
|
||||||
|
`WeeklyForNextWeek(chat_autosend_dow, chat_autosend_time)`, so its period
|
||||||
|
names the coming week. Its run calls `Autosend(period.Week)`, which posts
|
||||||
|
with `SendToChat(week, false)`, the household's own send, only when a
|
||||||
|
webhook is stored, `list_ready`'s Chat channel is on
|
||||||
|
(`notify.Service.ChannelOn`) and the week has a Supply run neither sent
|
||||||
|
nor with a send unconfirmed; otherwise it posts nothing and logs why,
|
||||||
|
with the key `list_ready:chat:{weekStart}`. It never resends; a send that
|
||||||
|
fails is the period's error. `shopping.Service.RolloverSchedule()` is
|
||||||
|
`week_rollover`, `Weekly(Monday, 00:05)`; its run freezes the week before
|
||||||
|
the fire date's, and a week with no Supply run freezes nothing and logs
|
||||||
|
it. `harness.PurgeSchedule(store, logger)` is `ai_runs_purge`,
|
||||||
|
`Daily(03:00)`, deleting rows created more than 30 days before the fire
|
||||||
|
time.
|
||||||
Fake-clock tests of a schedule live in `internal/scheduler`, where
|
Fake-clock tests of a schedule live in `internal/scheduler`, where
|
||||||
`SetClock` is visible to tests.
|
`SetClock` is visible to tests.
|
||||||
|
|
||||||
@@ -1140,7 +1155,7 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
|
|||||||
request. 404 when the week has no Supply run, 409
|
request. 404 when the week has no Supply run, 409
|
||||||
`archived` when it is in the Supply archive, 400 `invalid_request` for a
|
`archived` when it is in the Supply archive, 400 `invalid_request` for a
|
||||||
bad body. `shopping.SendToChat(weekStart, resend)` is what
|
bad body. `shopping.SendToChat(weekStart, resend)` is what
|
||||||
`shopping_chat_autosend` calls (Feature 20).
|
`shopping_chat_autosend` calls, through `Autosend`.
|
||||||
- `PATCH /api/v1/shopping/items/{id}` takes `{needed}` (a required
|
- `PATCH /api/v1/shopping/items/{id}` takes `{needed}` (a required
|
||||||
boolean; it sets the flag, it does not flip it) and answers `{id,
|
boolean; it sets the flag, it does not flip it) and answers `{id,
|
||||||
needed, needed_count, total_count}` with the list's counts after the
|
needed, needed_count, total_count}` with the list's counts after the
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ const (
|
|||||||
StatusProviderError RunStatus = "provider_error"
|
StatusProviderError RunStatus = "provider_error"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RunRetention is how long ai_runs rows are kept. The daily purge calls
|
// RunRetention is how long ai_runs rows are kept. The daily purge,
|
||||||
// Purge with time.Now().Add(-RunRetention).
|
// PurgeSchedule, calls Purge with its fire time less RunRetention.
|
||||||
const RunRetention = 30 * 24 * time.Hour
|
const RunRetention = 30 * 24 * time.Hour
|
||||||
|
|
||||||
// runSaveTimeout bounds writing the ai_runs row of a run that has failed,
|
// runSaveTimeout bounds writing the ai_runs row of a run that has failed,
|
||||||
@@ -46,9 +46,6 @@ var runSaveTimeout = 5 * time.Second
|
|||||||
// Purge deletes the ai_runs rows created before olderThan and returns how
|
// Purge deletes the ai_runs rows created before olderThan and returns how
|
||||||
// many it deleted. The suggestion_batches and week_plans references to
|
// many it deleted. The suggestion_batches and week_plans references to
|
||||||
// those rows are cleared first, in the same transaction.
|
// those rows are cleared first, in the same transaction.
|
||||||
//
|
|
||||||
// Nothing in this package calls Purge: the scheduler's daily ai_runs purge
|
|
||||||
// (Feature 20) must call it with time.Now().Add(-RunRetention).
|
|
||||||
func Purge(ctx context.Context, st *store.Store, olderThan time.Time) (int64, error) {
|
func Purge(ctx context.Context, st *store.Store, olderThan time.Time) (int64, error) {
|
||||||
if olderThan.IsZero() {
|
if olderThan.IsZero() {
|
||||||
return 0, errors.New("harness: purge ai_runs: zero cutoff")
|
return 0, errors.New("harness: purge ai_runs: zero cutoff")
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package harness
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PurgeTime is when, every day in the household zone, ai_runs_purge runs.
|
||||||
|
// It is fixed, not a setting.
|
||||||
|
const PurgeTime = "03:00"
|
||||||
|
|
||||||
|
// PurgeSchedule is the ai_runs_purge schedule over st: every day at
|
||||||
|
// PurgeTime it deletes the ai_runs rows created more than RunRetention
|
||||||
|
// before the fire time with Purge, its period scheduler.Daily keyed
|
||||||
|
// ai_runs_purge:YYYY-MM-DD. A purge caught up late deletes no row younger
|
||||||
|
// than RunRetention; one that fails is the period's error and is not
|
||||||
|
// retried, and the next day's purge deletes what it left.
|
||||||
|
func PurgeSchedule(st *store.Store, logger *slog.Logger) (scheduler.Schedule, error) {
|
||||||
|
if st == nil {
|
||||||
|
return scheduler.Schedule{}, errors.New("harness: purge schedule: nil store")
|
||||||
|
}
|
||||||
|
if logger == nil {
|
||||||
|
return scheduler.Schedule{}, errors.New("harness: purge schedule: nil logger")
|
||||||
|
}
|
||||||
|
return scheduler.Schedule{
|
||||||
|
Name: scheduler.AIRunsPurge,
|
||||||
|
Rule: func(household.Settings) scheduler.Rule {
|
||||||
|
return scheduler.Daily(PurgeTime)
|
||||||
|
},
|
||||||
|
Run: func(ctx context.Context, p scheduler.Period) error {
|
||||||
|
cutoff := p.FireAt.Add(-RunRetention)
|
||||||
|
n, err := Purge(ctx, st, cutoff)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
logger.InfoContext(ctx, "ai_runs purged", "period", p.Key, "deleted", n, "created_before", cutoff.UTC().Format(time.RFC3339))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -238,6 +239,19 @@ func (s *Service) emitTx(ctx context.Context, tx *sql.Tx, ev Event, channels fun
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ChannelOn reports whether the preference of event type ev switches
|
||||||
|
// channel ch on. A type with no preference row is an error.
|
||||||
|
func (s *Service) ChannelOn(ctx context.Context, ev EventType, ch Channel) (bool, error) {
|
||||||
|
pref, err := db.New(s.st.Reader).GetNotificationPref(ctx, string(ev))
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return false, fmt.Errorf("notify: no notification preference for %s", ev)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("notify: read notification preference for %s: %w", ev, err)
|
||||||
|
}
|
||||||
|
return slices.Contains(enabledChannels(pref), ch), nil
|
||||||
|
}
|
||||||
|
|
||||||
// enabledChannels returns the channels pref switches on.
|
// enabledChannels returns the channels pref switches on.
|
||||||
func enabledChannels(pref db.NotificationPref) []Channel {
|
func enabledChannels(pref db.NotificationPref) []Channel {
|
||||||
var chs []Channel
|
var chs []Channel
|
||||||
|
|||||||
@@ -0,0 +1,420 @@
|
|||||||
|
package scheduler_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat/chattest"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The tests in this file run shopping's shopping_chat_autosend and
|
||||||
|
// week_rollover schedules, and harness's ai_runs_purge, on the fake clock
|
||||||
|
// against the real shopping service, Google Chat client (posting to a fake
|
||||||
|
// webhook), notification preferences and ai_runs purge. The seeded
|
||||||
|
// autosend is Saturday 08:00 in Chicago.
|
||||||
|
|
||||||
|
// w41 is the Monday of 2026-W41.
|
||||||
|
var w41 = mustLocalDate("2026-10-05")
|
||||||
|
|
||||||
|
// shopEnv is a shopping service over a scheduler env's store. Its job
|
||||||
|
// runner never starts, so Generate's title jobs stay queued and every
|
||||||
|
// Supply run stays untitled until Freeze titles it, which fails.
|
||||||
|
type shopEnv struct {
|
||||||
|
e *env
|
||||||
|
svc *shopping.Service
|
||||||
|
chat *chattest.Chat
|
||||||
|
cs *chat.Service
|
||||||
|
plans *planEnv
|
||||||
|
logs *syncBuffer
|
||||||
|
}
|
||||||
|
|
||||||
|
// shopping builds a shopping service over e, with Regulars to plan from.
|
||||||
|
func (e *env) shopping() *shopEnv {
|
||||||
|
e.t.Helper()
|
||||||
|
s := &shopEnv{e: e, chat: chattest.New(e.t), plans: e.plans(e.pool()), logs: &syncBuffer{}}
|
||||||
|
key := make([]byte, secretbox.KeySize)
|
||||||
|
rand.Read(key)
|
||||||
|
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
|
||||||
|
if err != nil {
|
||||||
|
e.t.Fatal(err)
|
||||||
|
}
|
||||||
|
s.cs, err = chat.NewService(e.st, box, publicURL, s.chat.Transport())
|
||||||
|
if err != nil {
|
||||||
|
e.t.Fatal(err)
|
||||||
|
}
|
||||||
|
logger := slog.New(slog.NewTextHandler(s.logs, nil))
|
||||||
|
runner, err := jobs.NewRunner(e.st, logger, 1)
|
||||||
|
if err != nil {
|
||||||
|
e.t.Fatal(err)
|
||||||
|
}
|
||||||
|
noTitle := func(context.Context, string, skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
|
||||||
|
return skills.ArchiveTitle{}, errors.New("no AI in this test")
|
||||||
|
}
|
||||||
|
s.svc, err = shopping.NewService(e.st, noTitle, runner, notifytest.New(e.t, e.st), s.cs, logger)
|
||||||
|
if err != nil {
|
||||||
|
e.t.Fatal(err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// list plans the week starting weekStart and generates its Supply run.
|
||||||
|
func (s *shopEnv) list(weekStart store.LocalDate) {
|
||||||
|
s.e.t.Helper()
|
||||||
|
s.plans.planWeek(weekStart)
|
||||||
|
if _, err := s.svc.Generate(context.Background(), weekStart); err != nil {
|
||||||
|
s.e.t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// webhook stores the fake Google Chat webhook.
|
||||||
|
func (s *shopEnv) webhook() {
|
||||||
|
s.e.t.Helper()
|
||||||
|
if _, err := s.cs.Put(context.Background(), chattest.Webhook, "Family"); err != nil {
|
||||||
|
s.e.t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// get returns the Supply run of the week starting weekStart.
|
||||||
|
func (s *shopEnv) get(weekStart store.LocalDate) shopping.List {
|
||||||
|
s.e.t.Helper()
|
||||||
|
l, err := s.svc.Get(context.Background(), weekStart)
|
||||||
|
if err != nil {
|
||||||
|
s.e.t.Fatal(err)
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantLogged fails unless the service logged want and logged no error.
|
||||||
|
func (s *shopEnv) wantLogged(want string) {
|
||||||
|
s.e.t.Helper()
|
||||||
|
if logs := s.logs.String(); !strings.Contains(logs, want) || strings.Contains(logs, "level=ERROR") {
|
||||||
|
s.e.t.Errorf("logs = %s, want %q and no error", logs, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChatAutosendSendsComingWeek: at the seeded Saturday 08:00 the
|
||||||
|
// schedule posts the coming week's Supply run, W43's, to Google Chat
|
||||||
|
// through SendToChat, in that week's thread, and records sent_to_chat_at;
|
||||||
|
// its period is shopping_chat_autosend:2026-W43. It posts once.
|
||||||
|
func TestChatAutosendSendsComingWeek(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
s := e.shopping()
|
||||||
|
s.list(w43)
|
||||||
|
s.webhook()
|
||||||
|
|
||||||
|
// Saturday 07:00: the previous Saturday's period is past the
|
||||||
|
// catch-up window, so it is recorded as missed.
|
||||||
|
r := e.start(at(2026, 10, 17, 7, 0), s.svc.ChatAutosendSchedule())
|
||||||
|
r.runUntil(at(2026, 10, 17, 7, 59))
|
||||||
|
if n := len(s.chat.Posts()); n != 0 || s.get(w43).SentToChatAt != nil {
|
||||||
|
t.Fatalf("before 08:00: %d posts, sent_to_chat_at %v, want none", n, s.get(w43).SentToChatAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.runUntil(at(2026, 10, 17, 8, 0))
|
||||||
|
posts := s.chat.Posts()
|
||||||
|
if len(posts) != 1 {
|
||||||
|
t.Fatalf("at 08:00: %d posts, want 1", len(posts))
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(posts[0].Text(), "Supply run · Week 43 · Oct 19 – 25") {
|
||||||
|
t.Errorf("posted %q, want the W43 Supply run", posts[0].Text())
|
||||||
|
}
|
||||||
|
if got := posts[0].Query.Get("threadKey"); got != chat.ThreadKey(w43) {
|
||||||
|
t.Errorf("posted in thread %q, want %q", got, chat.ThreadKey(w43))
|
||||||
|
}
|
||||||
|
if s.get(w43).SentToChatAt == nil {
|
||||||
|
t.Error("sent_to_chat_at not recorded")
|
||||||
|
}
|
||||||
|
s.wantLogged("Supply run sent to Google Chat")
|
||||||
|
s.wantLogged("key=list_ready:chat:2026-10-19")
|
||||||
|
|
||||||
|
r.runUntil(at(2026, 10, 24, 7, 0))
|
||||||
|
if n := len(s.chat.Posts()); n != 1 {
|
||||||
|
t.Errorf("a week on: %d posts, want 1", n)
|
||||||
|
}
|
||||||
|
wantKeys(t, "schedule_runs", e.claimed(scheduler.ShoppingChatAutosend), []string{"2026-W42", "2026-W43"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChatAutosendFollowsSettings: chat_autosend_dow and
|
||||||
|
// chat_autosend_time move the send without a restart, here to Friday
|
||||||
|
// 18:00, still for the coming week.
|
||||||
|
func TestChatAutosendFollowsSettings(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
s := e.shopping()
|
||||||
|
s.list(w43)
|
||||||
|
s.webhook()
|
||||||
|
|
||||||
|
r := e.start(at(2026, 10, 16, 12, 0), s.svc.ChatAutosendSchedule())
|
||||||
|
e.update(household.SettingsPatch{ChatAutosendDow: ptr(5), ChatAutosendTime: ptr("18:00")})
|
||||||
|
r.changed()
|
||||||
|
r.runUntil(at(2026, 10, 16, 17, 59))
|
||||||
|
if n := len(s.chat.Posts()); n != 0 {
|
||||||
|
t.Fatalf("before Friday 18:00: %d posts, want 0", n)
|
||||||
|
}
|
||||||
|
r.runUntil(at(2026, 10, 16, 18, 0))
|
||||||
|
if n := len(s.chat.Posts()); n != 1 || s.get(w43).SentToChatAt == nil {
|
||||||
|
t.Fatalf("at Friday 18:00: %d posts, sent_to_chat_at %v, want 1 and set", n, s.get(w43).SentToChatAt)
|
||||||
|
}
|
||||||
|
r.runUntil(at(2026, 10, 17, 9, 0))
|
||||||
|
if n := len(s.chat.Posts()); n != 1 {
|
||||||
|
t.Errorf("Saturday: %d posts, want still 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChatAutosendSkips: the schedule posts nothing, records no send and
|
||||||
|
// fails nothing when the coming week's Supply run was already sent, when
|
||||||
|
// the week has no Supply run, when list_ready is switched off for Google
|
||||||
|
// Chat, and when no webhook is stored. Each period is still claimed and
|
||||||
|
// the reason logged.
|
||||||
|
func TestChatAutosendSkips(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
setup func(t *testing.T, s *shopEnv)
|
||||||
|
// posts is how many posts the setup itself made.
|
||||||
|
posts int
|
||||||
|
reason string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "already sent",
|
||||||
|
setup: func(t *testing.T, s *shopEnv) {
|
||||||
|
s.list(w43)
|
||||||
|
s.webhook()
|
||||||
|
if _, err := s.svc.SendToChat(context.Background(), w43, false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
posts: 1,
|
||||||
|
reason: shopping.AutosendAlreadySent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no list",
|
||||||
|
setup: func(t *testing.T, s *shopEnv) {
|
||||||
|
s.list(w42)
|
||||||
|
s.webhook()
|
||||||
|
},
|
||||||
|
reason: shopping.AutosendNoList,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "chat off for list_ready",
|
||||||
|
setup: func(t *testing.T, s *shopEnv) {
|
||||||
|
s.list(w43)
|
||||||
|
s.webhook()
|
||||||
|
if _, err := s.e.st.Writer.Exec(`UPDATE notification_prefs SET chat = 0 WHERE event = 'list_ready'`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
reason: shopping.AutosendChatOff,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no webhook",
|
||||||
|
setup: func(t *testing.T, s *shopEnv) {
|
||||||
|
s.list(w43)
|
||||||
|
},
|
||||||
|
reason: shopping.AutosendNotConfigured,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
s := e.shopping()
|
||||||
|
c.setup(t, s)
|
||||||
|
var sentBefore *string
|
||||||
|
if l, err := s.svc.Get(context.Background(), w43); err == nil {
|
||||||
|
sentBefore = l.SentToChatAt
|
||||||
|
}
|
||||||
|
|
||||||
|
r := e.start(at(2026, 10, 17, 7, 0), s.svc.ChatAutosendSchedule())
|
||||||
|
r.runUntil(at(2026, 10, 17, 9, 0))
|
||||||
|
if n := len(s.chat.Posts()); n != c.posts {
|
||||||
|
t.Errorf("posts = %d, want %d", n, c.posts)
|
||||||
|
}
|
||||||
|
if l, err := s.svc.Get(context.Background(), w43); err == nil && !equalPtr(l.SentToChatAt, sentBefore) {
|
||||||
|
t.Errorf("sent_to_chat_at = %v, want %v", l.SentToChatAt, sentBefore)
|
||||||
|
}
|
||||||
|
s.wantLogged("Supply run not sent to Google Chat: " + c.reason)
|
||||||
|
wantKeys(t, "schedule_runs", e.claimed(scheduler.ShoppingChatAutosend), []string{"2026-W42", "2026-W43"})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChatAutosendFailureSurfaces: a send Google Chat refuses is the
|
||||||
|
// period's error, logged as such; the period stays claimed and is not
|
||||||
|
// retried, and sent_to_chat_at is not recorded.
|
||||||
|
func TestChatAutosendFailureSurfaces(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
s := e.shopping()
|
||||||
|
s.list(w43)
|
||||||
|
s.webhook()
|
||||||
|
s.chat.SetStatus(500)
|
||||||
|
|
||||||
|
var logs syncBuffer
|
||||||
|
sched, err := scheduler.New(e.st, e.settings, slog.New(slog.NewTextHandler(&logs, nil)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
clock := newFakeClock(at(2026, 10, 17, 7, 0))
|
||||||
|
scheduler.SetClock(sched, clock.Now, clock.After)
|
||||||
|
if err := sched.Register(s.svc.ChatAutosendSchedule()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cancel()
|
||||||
|
sched.Wait()
|
||||||
|
})
|
||||||
|
if err := sched.Start(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r := &running{t: t, clock: clock, stop: cancel}
|
||||||
|
r.idle()
|
||||||
|
r.runUntil(at(2026, 10, 17, 9, 0))
|
||||||
|
|
||||||
|
if n := len(s.chat.Posts()); n != 1 {
|
||||||
|
t.Errorf("posts = %d, want the one refused", n)
|
||||||
|
}
|
||||||
|
if s.get(w43).SentToChatAt != nil {
|
||||||
|
t.Error("sent_to_chat_at recorded for a refused send")
|
||||||
|
}
|
||||||
|
if got := logs.String(); !strings.Contains(got, "level=ERROR") || !strings.Contains(got, "scheduler: run failed") || !strings.Contains(got, "schedule=shopping_chat_autosend") {
|
||||||
|
t.Errorf("scheduler logs = %s, want the refused send logged as a failed run", got)
|
||||||
|
}
|
||||||
|
wantKeys(t, "schedule_runs", e.claimed(scheduler.ShoppingChatAutosend), []string{"2026-W42", "2026-W43"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func equalPtr(a, b *string) bool {
|
||||||
|
return (a == nil && b == nil) || (a != nil && b != nil && *a == *b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWeekRolloverFreezesPreviousWeek: at Monday 00:05 the schedule
|
||||||
|
// freezes exactly the week that has just ended into the Supply archive,
|
||||||
|
// not the week before it nor the new week; the next Monday freezes the
|
||||||
|
// next week; a Monday whose previous week has no Supply run freezes
|
||||||
|
// nothing and fails nothing. Its periods are week_rollover:YYYY-Www of the
|
||||||
|
// Monday.
|
||||||
|
func TestWeekRolloverFreezesPreviousWeek(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
s := e.shopping()
|
||||||
|
s.list(w41)
|
||||||
|
s.list(w42)
|
||||||
|
s.list(w43)
|
||||||
|
|
||||||
|
status := func() []string {
|
||||||
|
return []string{s.get(w41).Status, s.get(w42).Status, s.get(w43).Status}
|
||||||
|
}
|
||||||
|
cur, arc := shopping.StatusCurrent, shopping.StatusArchived
|
||||||
|
|
||||||
|
// Sunday 23:00: last Monday's rollover is past the catch-up window and
|
||||||
|
// recorded as missed, so W41 is never frozen by it.
|
||||||
|
r := e.start(at(2026, 10, 18, 23, 0), s.svc.RolloverSchedule())
|
||||||
|
r.runUntil(at(2026, 10, 19, 0, 4))
|
||||||
|
wantKeys(t, "before Monday 00:05", status(), []string{cur, cur, cur})
|
||||||
|
|
||||||
|
r.runUntil(at(2026, 10, 19, 0, 5))
|
||||||
|
wantKeys(t, "at Monday 00:05", status(), []string{cur, arc, cur})
|
||||||
|
if s.get(w42).Title != nil {
|
||||||
|
t.Errorf("W42 title = %q, want frozen untitled", *s.get(w42).Title)
|
||||||
|
}
|
||||||
|
if _, err := s.svc.Generate(context.Background(), w42); !errors.Is(err, shopping.ErrFrozen) {
|
||||||
|
t.Errorf("generate W42 after the rollover: %v, want ErrFrozen", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.runUntil(at(2026, 10, 26, 0, 5))
|
||||||
|
wantKeys(t, "next Monday", status(), []string{cur, arc, arc})
|
||||||
|
|
||||||
|
r.runUntil(at(2026, 11, 2, 0, 5))
|
||||||
|
s.wantLogged("week rollover froze nothing: the week has no Supply run")
|
||||||
|
wantKeys(t, "schedule_runs", e.claimed(scheduler.WeekRollover), []string{"2026-W42", "2026-W43", "2026-W44", "2026-W45"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// aiRun stores an ai_runs row created at created and returns its id.
|
||||||
|
func (e *env) aiRun(created time.Time) string {
|
||||||
|
e.t.Helper()
|
||||||
|
id := store.NewID()
|
||||||
|
ts := store.Timestamp{Time: created.UTC()}.String()
|
||||||
|
if err := db.New(e.st.Writer).CreateAIRun(context.Background(), db.CreateAIRunParams{
|
||||||
|
ID: id, Skill: "draft_week", SkillVersion: "1", Provider: "anthropic", Model: "claude-test",
|
||||||
|
Temperature: 0.7, Status: "ok", RequestRedacted: "{}", ResponseRedacted: sql.NullString{String: "{}", Valid: true},
|
||||||
|
CreatedAt: ts, UpdatedAt: ts,
|
||||||
|
}); err != nil {
|
||||||
|
e.t.Fatal(err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// aiRuns returns the ids of the ai_runs rows, oldest first.
|
||||||
|
func (e *env) aiRuns() []string {
|
||||||
|
e.t.Helper()
|
||||||
|
rows, err := e.st.Reader.Query(`SELECT id FROM ai_runs ORDER BY created_at`)
|
||||||
|
if err != nil {
|
||||||
|
e.t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var ids []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
e.t.Fatal(err)
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
e.t.Fatal(err)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAIRunsPurgeDaily: every day at 03:00 the schedule deletes the
|
||||||
|
// ai_runs rows created more than 30 days before, and no younger row; its
|
||||||
|
// periods are ai_runs_purge:YYYY-MM-DD.
|
||||||
|
func TestAIRunsPurgeDaily(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.aiRun(at(2026, 9, 10, 12, 0)) // older than Sep 11 03:00
|
||||||
|
edge := e.aiRun(at(2026, 9, 12, 2, 59))
|
||||||
|
young := e.aiRun(at(2026, 9, 12, 3, 1))
|
||||||
|
recent := e.aiRun(at(2026, 10, 11, 12, 0))
|
||||||
|
|
||||||
|
sch, err := harness.PurgeSchedule(e.st, slog.New(slog.NewTextHandler(&syncBuffer{}, nil)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Monday 02:00: the catch-up of Sunday 03:00 purges before Sep 11 03:00.
|
||||||
|
r := e.start(at(2026, 10, 12, 2, 0), sch)
|
||||||
|
wantKeys(t, "after the catch-up", e.aiRuns(), []string{edge, young, recent})
|
||||||
|
|
||||||
|
r.runUntil(at(2026, 10, 12, 2, 59))
|
||||||
|
wantKeys(t, "before 03:00", e.aiRuns(), []string{edge, young, recent})
|
||||||
|
r.runUntil(at(2026, 10, 12, 3, 0))
|
||||||
|
wantKeys(t, "at 03:00", e.aiRuns(), []string{young, recent})
|
||||||
|
r.runUntil(at(2026, 10, 13, 3, 0))
|
||||||
|
wantKeys(t, "next day", e.aiRuns(), []string{recent})
|
||||||
|
wantKeys(t, "schedule_runs", e.claimed(scheduler.AIRunsPurge), []string{"2026-10-11", "2026-10-12", "2026-10-13"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPurgeScheduleRejectsNil: the purge schedule needs a store and a
|
||||||
|
// logger.
|
||||||
|
func TestPurgeScheduleRejectsNil(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
if _, err := harness.PurgeSchedule(nil, slog.Default()); err == nil {
|
||||||
|
t.Error("nil store accepted")
|
||||||
|
}
|
||||||
|
if _, err := harness.PurgeSchedule(e.st, nil); err == nil {
|
||||||
|
t.Error("nil logger accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||||
@@ -39,11 +40,17 @@ const (
|
|||||||
// only when no job is running it. A job that runs after the freeze finds
|
// only when no job is running it. A job that runs after the freeze finds
|
||||||
// the list titled, or frozen untitled, and stores nothing.
|
// the list titled, or frozen untitled, and stores nothing.
|
||||||
func (s *Service) Freeze(ctx context.Context, weekStart store.LocalDate) error {
|
func (s *Service) Freeze(ctx context.Context, weekStart store.LocalDate) error {
|
||||||
|
return s.freeze(ctx, weekStart, s.now())
|
||||||
|
}
|
||||||
|
|
||||||
|
// freeze is Freeze with the week ended by instant now rather than by the
|
||||||
|
// service's clock.
|
||||||
|
func (s *Service) freeze(ctx context.Context, weekStart store.LocalDate, now time.Time) error {
|
||||||
if _, err := household.WeekDays(weekStart); err != nil {
|
if _, err := household.WeekDays(weekStart); err != nil {
|
||||||
return fmt.Errorf("shopping: freeze: %w", err)
|
return fmt.Errorf("shopping: freeze: %w", err)
|
||||||
}
|
}
|
||||||
q := db.New(s.st.Writer)
|
q := db.New(s.st.Writer)
|
||||||
thisWeek, err := s.thisWeek(ctx, q)
|
thisWeek, err := weekAt(ctx, q, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -73,9 +80,9 @@ func (s *Service) Freeze(ctx context.Context, weekStart store.LocalDate) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A list frozen meanwhile is left as it is: the freeze is done.
|
// A list frozen meanwhile is left as it is: the freeze is done.
|
||||||
now := store.Now().String()
|
stamp := store.Now().String()
|
||||||
if _, err := q.FreezeShoppingList(ctx, db.FreezeShoppingListParams{
|
if _, err := q.FreezeShoppingList(ctx, db.FreezeShoppingListParams{
|
||||||
FrozenAt: sql.NullString{String: now, Valid: true}, UpdatedAt: now, ID: list.ID,
|
FrozenAt: sql.NullString{String: stamp, Valid: true}, UpdatedAt: stamp, ID: list.ID,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("shopping: freeze %s: %w", weekStart, err)
|
return fmt.Errorf("shopping: freeze %s: %w", weekStart, err)
|
||||||
}
|
}
|
||||||
@@ -103,6 +110,12 @@ func freezeTitleJob(ctx context.Context, q *db.Queries, list db.ShoppingList) (T
|
|||||||
// thisWeek returns the Monday of the household's current week, in its
|
// thisWeek returns the Monday of the household's current week, in its
|
||||||
// time zone, by the service's clock.
|
// time zone, by the service's clock.
|
||||||
func (s *Service) thisWeek(ctx context.Context, q *db.Queries) (store.LocalDate, error) {
|
func (s *Service) thisWeek(ctx context.Context, q *db.Queries) (store.LocalDate, error) {
|
||||||
|
return weekAt(ctx, q, s.now())
|
||||||
|
}
|
||||||
|
|
||||||
|
// weekAt returns the Monday of the household's week at instant t, in its
|
||||||
|
// time zone.
|
||||||
|
func weekAt(ctx context.Context, q *db.Queries, t time.Time) (store.LocalDate, error) {
|
||||||
settings, err := q.GetHouseholdSettings(ctx)
|
settings, err := q.GetHouseholdSettings(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return store.LocalDate{}, fmt.Errorf("shopping: read household settings: %w", err)
|
return store.LocalDate{}, fmt.Errorf("shopping: read household settings: %w", err)
|
||||||
@@ -111,7 +124,7 @@ func (s *Service) thisWeek(ctx context.Context, q *db.Queries) (store.LocalDate,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return store.LocalDate{}, fmt.Errorf("shopping: household settings: %w", err)
|
return store.LocalDate{}, fmt.Errorf("shopping: household settings: %w", err)
|
||||||
}
|
}
|
||||||
return household.WeekStart(household.Today(s.now(), zone)), nil
|
return household.WeekStart(household.Today(t, zone)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Period is one heading of the Supply archive and its posts, newest first.
|
// Period is one heading of the Supply archive and its posts, newest first.
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package shopping
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RolloverTime is when, every Monday in the household zone, week_rollover
|
||||||
|
// freezes the week that has just ended. It is fixed, not a setting.
|
||||||
|
const RolloverTime = "00:05"
|
||||||
|
|
||||||
|
// Reasons Autosend posts nothing.
|
||||||
|
const (
|
||||||
|
AutosendNotConfigured = "no Google Chat webhook is configured"
|
||||||
|
AutosendChatOff = "list_ready is switched off for Google Chat"
|
||||||
|
AutosendNoList = "the week has no Supply run"
|
||||||
|
AutosendAlreadySent = "the Supply run was already sent to Google Chat"
|
||||||
|
AutosendUnconfirmed = "a send of the Supply run to Google Chat is unconfirmed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AutosendKey identifies the automatic send of the Supply run of the week
|
||||||
|
// starting weekStart, list_ready:chat:YYYY-MM-DD, in the schedule's logs.
|
||||||
|
// The send itself is recorded in the list's sent_to_chat_at.
|
||||||
|
func AutosendKey(weekStart store.LocalDate) string {
|
||||||
|
return string(notify.ListReady) + ":" + string(notify.ChannelChat) + ":" + weekStart.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatAutosendSchedule is the shopping_chat_autosend schedule: at the
|
||||||
|
// household's chat_autosend_dow and chat_autosend_time it sends the Supply
|
||||||
|
// run of the coming week, the week after the fire date's, to Google Chat
|
||||||
|
// with Autosend. Its period scheduler.WeeklyForNextWeek keys
|
||||||
|
// shopping_chat_autosend:YYYY-Www for that week. A send that fails is the
|
||||||
|
// period's error and is not retried; the household can still send it from
|
||||||
|
// the Supply run.
|
||||||
|
func (s *Service) ChatAutosendSchedule() scheduler.Schedule {
|
||||||
|
return scheduler.Schedule{
|
||||||
|
Name: scheduler.ShoppingChatAutosend,
|
||||||
|
Rule: func(settings household.Settings) scheduler.Rule {
|
||||||
|
return scheduler.WeeklyForNextWeek(time.Weekday(settings.ChatAutosendDow), settings.ChatAutosendTime)
|
||||||
|
},
|
||||||
|
Run: func(ctx context.Context, p scheduler.Period) error {
|
||||||
|
skipped, err := s.Autosend(ctx, p.Week)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log := s.logger.With("period", p.Key, "key", AutosendKey(p.Week), "week_start", p.Week.String())
|
||||||
|
if skipped != "" {
|
||||||
|
log.InfoContext(ctx, "Supply run not sent to Google Chat: "+skipped)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
log.InfoContext(ctx, "Supply run sent to Google Chat")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Autosend sends the Supply run of the week starting weekStart to Google
|
||||||
|
// Chat with SendToChat, as the household's send does, when a webhook is
|
||||||
|
// stored, list_ready's Google Chat channel is switched on, and the week has
|
||||||
|
// a Supply run not yet sent and with no send unconfirmed. Otherwise it
|
||||||
|
// posts nothing and returns which of those reasons stopped it; it never
|
||||||
|
// resends. A send that fails is SendToChat's error.
|
||||||
|
func (s *Service) Autosend(ctx context.Context, weekStart store.LocalDate) (skipped string, err error) {
|
||||||
|
settings, err := s.chat.Get(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("shopping: autosend: %w", err)
|
||||||
|
}
|
||||||
|
if settings.URLMasked == nil {
|
||||||
|
return AutosendNotConfigured, nil
|
||||||
|
}
|
||||||
|
on, err := s.notifier.ChannelOn(ctx, notify.ListReady, notify.ChannelChat)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("shopping: autosend: %w", err)
|
||||||
|
}
|
||||||
|
if !on {
|
||||||
|
return AutosendChatOff, nil
|
||||||
|
}
|
||||||
|
l, err := s.Get(ctx, weekStart)
|
||||||
|
if errors.Is(err, ErrListNotFound) {
|
||||||
|
return AutosendNoList, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case l.SentToChatAt != nil:
|
||||||
|
return AutosendAlreadySent, nil
|
||||||
|
case l.ChatSendUnconfirmedAt != nil:
|
||||||
|
return AutosendUnconfirmed, nil
|
||||||
|
}
|
||||||
|
if _, err := s.SendToChat(ctx, weekStart, false); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RolloverSchedule is the week_rollover schedule: every Monday at
|
||||||
|
// RolloverTime it freezes the Supply run of the week before the fire
|
||||||
|
// date's into the Supply archive with Freeze, the week ending by the fire
|
||||||
|
// time. Its period scheduler.Weekly keys week_rollover:YYYY-Www for the
|
||||||
|
// fire date's week. A week with no Supply run has nothing to freeze, and
|
||||||
|
// the period says so; a freeze that fails is the period's error and is not
|
||||||
|
// retried.
|
||||||
|
func (s *Service) RolloverSchedule() scheduler.Schedule {
|
||||||
|
return scheduler.Schedule{
|
||||||
|
Name: scheduler.WeekRollover,
|
||||||
|
Rule: func(household.Settings) scheduler.Rule {
|
||||||
|
return scheduler.Weekly(time.Monday, RolloverTime)
|
||||||
|
},
|
||||||
|
Run: func(ctx context.Context, p scheduler.Period) error {
|
||||||
|
y, m, d := p.Week.Date()
|
||||||
|
last := store.LocalDateOf(time.Date(y, m, d-7, 0, 0, 0, 0, time.UTC))
|
||||||
|
err := s.freeze(ctx, last, p.FireAt)
|
||||||
|
if errors.Is(err, ErrListNotFound) {
|
||||||
|
s.logger.InfoContext(ctx, "week rollover froze nothing: the week has no Supply run",
|
||||||
|
"period", p.Key, "week_start", last.String())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user