From 45c4f8a803b26847d4266d371cfaf89d69643aa3 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Fri, 9 Oct 2026 15:42:16 -0400 Subject: [PATCH 1/2] Wire plan_ready, list_ready, new_suggestions and Send to Google Chat Connect the features to the notification outbox so real events notify, each stored by the new notify.EmitTx in the transaction of the change it is about (Wake after commit): - plan_ready:{weekStart} (/plan) when a draft job stores the AI's draft; a redraft shares the key and a manual swap never emits. The title reads "Next week's plan is ready" for the coming week. - list_ready:{weekStart} (/plan/shopping) when a week's Supply run is first created, by generate or after Push to Calendar. - new_suggestions:{batchId} (/discover) when a scheduled or manual batch ends done with cards; replenish batches never notify. - plan.Service.EmitDayOfReminder(date) emits day_of_reminder:{date} for the Feature 20 scheduler. Add POST /api/v1/shopping/{weekStart}/send-chat: shopping.SendToChat posts the dinners with needed items and a link through chat.PostSupplyRun, records sent_to_chat_at, and is idempotent per week unless {"resend": true}. The Supply run's Send to Google Chat button calls it ("Send again" once sent) and shows failures in an ErrorBanner; the unused Unavailable placeholder is removed. Task: f0cd9d50-9c11-41be-a945-7bae9f5f28a4 Co-Authored-By: Claude Opus 5.5 --- cmd/server/main.go | 44 +++--- docs/ARCHITECTURE.md | 35 +++++ internal/chat/chat.go | 80 +++++++++-- internal/chat/chat_test.go | 64 ++++++++- internal/chat/chattest/chattest.go | 97 +++++++++++++ internal/chat/messages.go | 78 ++++++++++- internal/chat/messages_test.go | 41 +++++- internal/db/querier.go | 5 + internal/db/shopping.sql.go | 22 +++ internal/db/suggestions.sql.go | 20 +++ internal/notify/notify.go | 74 +++++----- internal/notify/notify_test.go | 31 +++++ internal/notify/notifytest/notifytest.go | 65 +++++++++ internal/notify/worker.go | 5 +- internal/plan/draft.go | 16 ++- internal/plan/plan.go | 36 ++++- internal/plan/plan_test.go | 84 ++++++++++- internal/server/plans_test.go | 21 ++- internal/server/server.go | 1 + internal/server/server_test.go | 10 +- internal/server/shopping.go | 55 ++++++++ internal/server/shopping_test.go | 118 ++++++++++++++++ internal/server/suggestions_test.go | 3 +- internal/shopping/chat.go | 76 ++++++++++ internal/shopping/notify_test.go | 169 +++++++++++++++++++++++ internal/shopping/shopping.go | 35 ++++- internal/shopping/shopping_test.go | 24 +++- internal/suggest/batch.go | 70 +++++++--- internal/suggest/suggest.go | 49 ++++--- internal/suggest/suggest_test.go | 62 ++++++++- migrations/queries/shopping.sql | 7 + migrations/queries/suggestions.sql | 5 + web/src/plan/PlanPage.module.css | 65 --------- web/src/plan/PlanPage.tsx | 38 +---- web/src/shopping/SupplyRunPage.test.tsx | 73 +++++++++- web/src/shopping/SupplyRunPage.tsx | 32 +++-- web/src/shopping/api.ts | 19 +++ 37 files changed, 1477 insertions(+), 252 deletions(-) create mode 100644 internal/chat/chattest/chattest.go create mode 100644 internal/notify/notifytest/notifytest.go create mode 100644 internal/shopping/chat.go create mode 100644 internal/shopping/notify_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 20f434e..536cfdd 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -109,11 +109,30 @@ func run(cfg config.Config, logger *slog.Logger) error { if err != nil { return err } + notifier, err := notify.NewService(db, logger) + if err != nil { + return err + } + pushSender, err := push.NewSender(db, logger, cfg.VAPID.PublicKey, cfg.VAPID.PrivateKey.Reveal(), cfg.VAPID.Subject) + if err != nil { + return err + } + if err := notifier.Register(notify.ChannelPush, pushSender); err != nil { + return err + } + chatSvc, err := chat.NewService(db, box, cfg.PublicURL, http.DefaultTransport) + if err != nil { + return err + } + if err := notifier.Register(notify.ChannelChat, chatSvc); err != nil { + return err + } + dinnerSvc, err := dinners.NewService(db, grounder, runner, logger) if err != nil { return err } - suggestSvc, err := suggest.NewService(db, grounder, sideQuests(ai, guard), runner, logger) + suggestSvc, err := suggest.NewService(db, grounder, sideQuests(ai, guard), runner, notifier, logger) if err != nil { return err } @@ -131,12 +150,12 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } - planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, googleSvc, cfg.PublicURL, logger) + planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, googleSvc, notifier, cfg.PublicURL, logger) if err != nil { return err } - shopSvc, err := shopping.NewService(db, archiveTitler(ai, guard), runner, logger) + shopSvc, err := shopping.NewService(db, archiveTitler(ai, guard), runner, notifier, chatSvc, logger) if err != nil { return err } @@ -146,25 +165,6 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } - notifier, err := notify.NewService(db, logger) - if err != nil { - return err - } - pushSender, err := push.NewSender(db, logger, cfg.VAPID.PublicKey, cfg.VAPID.PrivateKey.Reveal(), cfg.VAPID.Subject) - if err != nil { - return err - } - if err := notifier.Register(notify.ChannelPush, pushSender); err != nil { - return err - } - chatSvc, err := chat.NewService(db, box, cfg.PublicURL, http.DefaultTransport) - if err != nil { - return err - } - if err := notifier.Register(notify.ChannelChat, chatSvc); err != nil { - return err - } - sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax) if err != nil { return err diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 54ebbbd..7c004aa 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -656,6 +656,25 @@ Runs are recorded in `schedule_runs` and executed through `jobs`. - Events: `plan_ready`, `list_ready`, `day_of_reminder`, `new_suggestions`, `rotation_review`; each can go to Web Push and/or Google Chat per `notification_prefs`. +- Where each event is emitted, with its idempotency key. Each is stored by + `notify.EmitTx` in the transaction of the change it is about, so the + change and its notification commit together or not at all: + - `plan_ready:{weekStart}` (URL `/plan`) when a draft job stores the + AI's draft of a week. Only the first draft of a week notifies: a + redraft (Rebuild week) has the same key, and a manual swap never + drafts. Its title is "Next week's plan is ready" (or "This week's", + or "The plan for the week of Oct 26") and its body the drafted + dinners Monday to Sunday. + - `list_ready:{weekStart}` (URL `/plan/shopping`) when a week's Supply + run is first created, by `POST /shopping/{weekStart}/generate` or + after Push to Calendar; remaking it does not notify. + - `new_suggestions:{batchId}` (URL `/discover`) when a `scheduled` or + `manual` suggestion batch ends done having added Side quests, in the + transaction recording it done. A `replenish` batch never notifies. + - `day_of_reminder:{date}` (URL `/dinner/{id}`) through + `plan.Service.EmitDayOfReminder(date)`, which names the dinner planned + on the date and emits nothing for a week with no plan. The scheduler + calls it at `reminder_time` (Feature 20). - Delivery goes through `notification_outbox`. - Web Push uses VAPID (`webpush-go`). Google Chat uses the stored incoming webhook. @@ -880,6 +899,22 @@ Runs are recorded in `schedule_runs` and executed through `jobs`. through `shopping.Generate` and answers with the same shape plus `skipped: [{date, dinner_id, dinner_name, reason}]`. 404 when the week has no plan, 409 `archived` when its list is in the Supply archive. +- `POST /api/v1/shopping/{weekStart}/send-chat` (CSRF-checked) takes + `{}` or `{"resend": true}` and posts the Supply run to Google Chat + through `chat.PostSupplyRun`, in the week's thread: "Supply run · " and + its title (or week label), each dinner with items still needed and those + items, and a card whose "Open" button links to + `/plan/shopping?week={weekStart}` under `MEALQUEST_PUBLIC_URL`. It + records `sent_to_chat_at` and answers with the Supply run as `GET` gives + it. A week already sent is answered as it is, with no second post, + unless `resend` is true. No webhook stored is 409 `not_configured`; + Google Chat not answering is 502 `chat_unreachable` (retryable) and + answering with a status other than 2xx 502 `chat_refused` (retryable + only for 429 or 5xx), and neither + records `sent_to_chat_at`. 404 when the week has no Supply run, 409 + `archived` when it is in the Supply archive, 400 `invalid_request` for a + bad body. `shopping.SendToChat(weekStart, resend)` is what + `shopping_chat_autosend` calls (Feature 20). - `PATCH /api/v1/shopping/items/{id}` takes `{needed}` (a required 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 diff --git a/internal/chat/chat.go b/internal/chat/chat.go index ca617da..309f8eb 100644 --- a/internal/chat/chat.go +++ b/internal/chat/chat.go @@ -53,6 +53,9 @@ var ( // ErrUnreachable is wrapped by the error of a post Google Chat did not // answer. ErrUnreachable = errors.New("chat: Google Chat did not answer") + // ErrRefused is matched by the *RefusedError of a post Google Chat + // answered with a status other than 2xx. + ErrRefused = errors.New("chat: Google Chat refused the message") // ErrWebhookChanged is returned by Test when the webhook was replaced // while the test message was being sent; nothing is recorded. ErrWebhookChanged = errors.New("chat: the Google Chat webhook changed during the test") @@ -78,6 +81,24 @@ type TestResult struct { Settings } +// RefusedError is a post Google Chat answered with Status, not 2xx. It +// matches ErrRefused. +type RefusedError struct { + Status int +} + +func (e *RefusedError) Error() string { + return fmt.Sprintf("%v with status %d", ErrRefused, e.Status) +} + +func (e *RefusedError) Unwrap() error { return ErrRefused } + +// Retryable reports whether posting again may succeed: Google Chat was +// busy (429) or failing (5xx), rather than refusing the message. +func (e *RefusedError) Retryable() bool { + return e.Status == http.StatusTooManyRequests || e.Status >= 500 +} + // Problem is one invalid field of a webhook update, named by its JSON key. type Problem struct { Field string @@ -362,22 +383,65 @@ func (s *Service) messageOf(p notify.Payload) (message, error) { if !carded[p.Event] { return msg, nil } - ref, err := url.Parse(p.URL) - if err != nil || ref.Scheme != "" || ref.Host != "" || !strings.HasPrefix(p.URL, "/") { - return message{}, fmt.Errorf("payload url %q is not an app path", p.URL) + c, err := s.openCard(string(p.Event), p.Title, p.Body, p.URL) + if err != nil { + return message{}, err + } + msg.CardsV2 = []cardV2{c} + return msg, nil +} + +// openCard is a card headed title and subtitle whose "Open" button links +// to the app path under the public URL. +func (s *Service) openCard(id, title, subtitle, path string) (cardV2, error) { + ref, err := url.Parse(path) + if err != nil || ref.Scheme != "" || ref.Host != "" || !strings.HasPrefix(path, "/") { + return cardV2{}, fmt.Errorf("payload url %q is not an app path", path) } link := s.publicURL.ResolveReference(ref) - msg.CardsV2 = []cardV2{{ - CardID: string(p.Event), + return cardV2{ + CardID: id, Card: card{ - Header: cardHeader{Title: p.Title, Subtitle: p.Body}, + Header: cardHeader{Title: title, Subtitle: subtitle}, Sections: []cardSection{{Widgets: []cardWidget{{ButtonList: buttonList{Buttons: []button{{ Text: "Open", OnClick: onClick{OpenLink: openLink{URL: link.String()}}, }}}}}}}, }, - }} - return msg, nil + }, nil +} + +// PostSupplyRun posts the Supply run of the week starting on week, named +// name, with groups, to the stored webhook in the week's thread: its +// SupplyRunText and a card whose "Open" button links to that week's Supply +// run. No webhook stored is ErrNotConfigured; a post Google Chat does not +// answer wraps ErrUnreachable, and one it answers with a status other than +// 2xx is a *RefusedError. +func (s *Service) PostSupplyRun(ctx context.Context, week store.LocalDate, name string, groups []SupplyRunGroup) error { + row, err := db.New(s.st.Reader).GetChatSettings(ctx) + if errors.Is(err, sql.ErrNoRows) { + return ErrNotConfigured + } + if err != nil { + return fmt.Errorf("chat: read chat settings: %w", err) + } + hook, err := s.open(row) + if err != nil { + return err + } + path := "/plan/shopping?" + url.Values{"week": {week.String()}}.Encode() + c, err := s.openCard("supply_run", "Supply run · "+name, "", path) + if err != nil { + return err + } + status, err := s.post(ctx, hook, message{Text: SupplyRunText(name, groups), CardsV2: []cardV2{c}}, ThreadKey(week)) + if err != nil { + return err + } + if status < 200 || status >= 300 { + return &RefusedError{Status: status} + } + return nil } // post sends msg to hook, in thread when it is not empty, and returns the diff --git a/internal/chat/chat_test.go b/internal/chat/chat_test.go index 6af9988..e06d9e7 100644 --- a/internal/chat/chat_test.go +++ b/internal/chat/chat_test.go @@ -304,7 +304,7 @@ func TestSendThreadsByWeek(t *testing.T) { } week := mustDate(t, "2026-10-12") events := []notify.Event{ - PlanReady(week, []string{"Tacos", "Bolognese", "Tikka masala", "A", "B", "C", "D"}), + PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala", "A", "B", "C", "D"}), ListReady(week, 19, 7), DayOfReminder(mustDate(t, "2026-10-18"), "01J9DINNER", "Tacos"), NewSuggestions("01J9BATCH", week, []string{"Pho"}), @@ -383,7 +383,7 @@ func TestSendClassifiesStatus(t *testing.T) { if _, err := svc.Put(ctx, testHook, "Family"); err != nil { t.Fatal(err) } - m := notify.Message{Payload: payloadOf(PlanReady(mustDate(t, "2026-10-12"), []string{"Tacos"}))} + m := notify.Message{Payload: payloadOf(PlanReady(mustDate(t, "2026-10-12"), mustDate(t, "2026-10-12"), []string{"Tacos"}))} for _, tc := range []struct { status int ok, permanent bool @@ -464,7 +464,7 @@ func TestOutboxRowsFollowClassification(t *testing.T) { week := mustDate(t, "2026-10-12") f.setStatus(http.StatusBadRequest) - planned := PlanReady(week, []string{"Tacos"}) + planned := PlanReady(week, week, []string{"Tacos"}) if err := notifier.Emit(ctx, planned); err != nil { t.Fatal(err) } @@ -528,3 +528,61 @@ func (b *syncBuffer) String() string { defer b.mu.Unlock() return b.buf.String() } + +func TestPostSupplyRun(t *testing.T) { + t.Parallel() + ctx := context.Background() + f := newFakeChat(t) + svc, _ := newTestService(t, f) + week := mustDate(t, "2026-10-12") + groups := []SupplyRunGroup{{Dinner: "Tacos", Items: []SupplyRunItem{{Name: "tortillas"}}}} + + if err := svc.PostSupplyRun(ctx, week, "Week 42 · Oct 12 – 18", groups); !errors.Is(err, ErrNotConfigured) { + t.Fatalf("PostSupplyRun without a webhook = %v, want ErrNotConfigured", err) + } + if _, err := svc.Put(ctx, testHook, "Family"); err != nil { + t.Fatal(err) + } + if err := svc.PostSupplyRun(ctx, week, "Week 42 · Oct 12 – 18", groups); err != nil { + t.Fatal(err) + } + reqs := f.requests() + if len(reqs) != 1 { + t.Fatalf("Chat got %d requests, want 1", len(reqs)) + } + r := reqs[0] + if r.Query.Get("threadKey") != "mealquest-week-2026-W42" || r.Query.Get("messageReplyOption") != replyOption { + t.Errorf("threading = %v", r.Query) + } + if want := SupplyRunText("Week 42 · Oct 12 – 18", groups); r.Body["text"] != want { + t.Errorf("text = %q, want %q", r.Body["text"], want) + } + data, _ := json.Marshal(r.Body["cardsV2"]) + var cards []cardV2 + if err := json.Unmarshal(data, &cards); err != nil || len(cards) != 1 { + t.Fatalf("cardsV2 = %s, %v", data, err) + } + buttons := cards[0].Card.Sections[0].Widgets[0].ButtonList.Buttons + if cards[0].Card.Header.Title != "Supply run · Week 42 · Oct 12 – 18" || len(buttons) != 1 || + buttons[0].OnClick.OpenLink.URL != "https://mealquest.example/plan/shopping?week=2026-10-12" { + t.Errorf("card = %s, want an Open button to the week's Supply run", data) + } + + f.setStatus(http.StatusForbidden) + err := svc.PostSupplyRun(ctx, week, "Week 42 · Oct 12 – 18", groups) + if !errors.Is(err, ErrRefused) || !strings.Contains(err.Error(), "403") { + t.Errorf("PostSupplyRun answered 403 = %v, want ErrRefused with the status", err) + } + if err != nil { + assertNoSecret(t, "refused error", err.Error()) + } + + f.srv.Close() + err = svc.PostSupplyRun(ctx, week, "Week 42 · Oct 12 – 18", groups) + if !errors.Is(err, ErrUnreachable) { + t.Errorf("PostSupplyRun unanswered = %v, want ErrUnreachable", err) + } + if err != nil { + assertNoSecret(t, "unreachable error", err.Error()) + } +} diff --git a/internal/chat/chattest/chattest.go b/internal/chat/chattest/chattest.go new file mode 100644 index 0000000..8bbfc88 --- /dev/null +++ b/internal/chat/chattest/chattest.go @@ -0,0 +1,97 @@ +// Package chattest provides a fake Google Chat incoming webhook for tests +// of the features that post to Google Chat: an httptest server that +// records each post and answers a status the test sets, reached through a +// transport that sends chat.googleapis.com requests to it. +package chattest + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "sync" + "testing" +) + +// Webhook is a valid Google Chat incoming webhook URL to store. +const Webhook = "https://chat.googleapis.com/v1/spaces/AAQA1b2C3d4/messages?key=AIzaSyDdI0hCZtE6vySjMm-WEfRq3CPzqKqqsHI&token=q0Lz3kV9Yw8pXcRtN2bHsJdFgA7uEmKiOlP1nZxQ%3D" + +// Post is one message the fake received. +type Post struct { + // Query is the request's query: key, token and any threading. + Query url.Values + // Body is the decoded JSON message. + Body map[string]any +} + +// Text is the post's text. +func (p Post) Text() string { + s, _ := p.Body["text"].(string) + return s +} + +// Chat is a fake Google Chat endpoint. It answers 200 until SetStatus. +type Chat struct { + srv *httptest.Server + mu sync.Mutex + status int + posts []Post +} + +// New starts a fake Google Chat endpoint, closed when the test ends. +func New(t *testing.T) *Chat { + t.Helper() + c := &Chat{status: http.StatusOK} + c.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data, _ := io.ReadAll(r.Body) + var body map[string]any + if err := json.Unmarshal(data, &body); err != nil { + t.Errorf("Google Chat post body %q: %v", data, err) + } + c.mu.Lock() + c.posts = append(c.posts, Post{Query: r.URL.Query(), Body: body}) + status := c.status + c.mu.Unlock() + w.WriteHeader(status) + })) + t.Cleanup(c.srv.Close) + return c +} + +// Transport sends every request to the fake, as if it were +// chat.googleapis.com. +func (c *Chat) Transport() http.RoundTripper { + return transport{c} +} + +type transport struct{ c *Chat } + +func (tr transport) RoundTrip(req *http.Request) (*http.Response, error) { + target, err := url.Parse(tr.c.srv.URL) + if err != nil { + return nil, err + } + out := req.Clone(req.Context()) + out.URL.Scheme, out.URL.Host = target.Scheme, target.Host + return http.DefaultTransport.RoundTrip(out) +} + +// SetStatus makes the fake answer status from now on. +func (c *Chat) SetStatus(status int) { + c.mu.Lock() + defer c.mu.Unlock() + c.status = status +} + +// Close stops the fake, so posts get no answer. +func (c *Chat) Close() { + c.srv.Close() +} + +// Posts returns the messages received so far, in order. +func (c *Chat) Posts() []Post { + c.mu.Lock() + defer c.mu.Unlock() + return append([]Post(nil), c.posts...) +} diff --git a/internal/chat/messages.go b/internal/chat/messages.go index 592a70d..eadf111 100644 --- a/internal/chat/messages.go +++ b/internal/chat/messages.go @@ -3,6 +3,7 @@ package chat import ( "fmt" "strings" + "time" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify" @@ -34,13 +35,16 @@ var actions = map[notify.EventType]string{ // button. var carded = map[notify.EventType]bool{notify.PlanReady: true, notify.ListReady: true} -// PlanReady is the plan_ready event of the week starting on week, naming -// its dinners in plan order. -func PlanReady(week store.LocalDate, dinners []string) notify.Event { +// PlanReady is the plan_ready event of the week starting on week, drafted +// in the week starting on thisWeek, naming its dinners in plan order. Its +// title names the week as seen from thisWeek: "Next week's plan is ready", +// "This week's plan is ready", or "The plan for the week of Oct 26 is +// ready" for any other week. +func PlanReady(week, thisWeek store.LocalDate, dinners []string) notify.Event { return notify.Event{ Type: notify.PlanReady, Key: string(notify.PlanReady) + ":" + week.String(), - Title: "This week's plan is ready", + Title: planTitle(week, thisWeek), Body: nameList(dinners), URL: "/plan", Week: week, @@ -100,6 +104,72 @@ func RotationReview(dinnerID, name string, week store.LocalDate) notify.Event { } } +// SupplyRunItem is a Supply run item still needed, as sent to Google Chat; +// Qty is empty when the recipe gives none. +type SupplyRunItem struct { + Name string + Qty string +} + +// SupplyRunGroup is one dinner of a Supply run sent to Google Chat, with +// its items still needed. +type SupplyRunGroup struct { + Dinner string + Items []SupplyRunItem +} + +// SupplyRunText is the Google Chat message of the Supply run named name +// (its title, or its week label until it has one): each dinner with items +// still needed in bold, its items one per line, and the call to action. +// +// Supply run · Week 42 · Oct 12 – 18 +// +// *Tacos* +// • ground beef — 1 lb +// • tortillas +// +// Open the Supply run → +// +// A Supply run with nothing still needed says so. +func SupplyRunText(name string, groups []SupplyRunGroup) string { + var b strings.Builder + b.WriteString("Supply run · " + name) + listed := false + for _, g := range groups { + if len(g.Items) == 0 { + continue + } + listed = true + b.WriteString("\n\n*" + g.Dinner + "*") + for _, it := range g.Items { + b.WriteString("\n• " + it.Name) + if it.Qty != "" { + b.WriteString(" — " + it.Qty) + } + } + } + if !listed { + b.WriteString("\n\nNothing left to buy.") + } + b.WriteString("\n\n" + actions[notify.ListReady] + " →") + return b.String() +} + +// planTitle is the plan_ready title of the week starting on week, seen +// from the week starting on thisWeek. +func planTitle(week, thisWeek store.LocalDate) string { + y, m, d := thisWeek.Date() + next := store.LocalDateOf(time.Date(y, m, d+7, 0, 0, 0, 0, time.UTC)) + switch week { + case thisWeek: + return "This week's plan is ready" + case next: + return "Next week's plan is ready" + } + _, m, d = week.Date() + return fmt.Sprintf("The plan for the week of %s %d is ready", m.String()[:3], d) +} + // Text is the Google Chat message of p: its title, its body after a colon // and the event's call to action with an arrow. func Text(p notify.Payload) string { diff --git a/internal/chat/messages_test.go b/internal/chat/messages_test.go index 4386606..5a9f6a3 100644 --- a/internal/chat/messages_test.go +++ b/internal/chat/messages_test.go @@ -36,7 +36,7 @@ func TestMessages(t *testing.T) { }{ { name: "plan ready", - ev: PlanReady(week, []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo", "Bibimbap", "Shakshuka", "Chili"}), + ev: PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo", "Bibimbap", "Shakshuka", "Chili"}), key: "plan_ready:2026-10-12", url: "/plan", week: "2026-10-12", @@ -46,7 +46,7 @@ func TestMessages(t *testing.T) { }, { name: "plan ready with three dinners", - ev: PlanReady(week, []string{"Tacos", "Bolognese", "Tikka masala"}), + ev: PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala"}), key: "plan_ready:2026-10-12", url: "/plan", week: "2026-10-12", @@ -54,6 +54,26 @@ func TestMessages(t *testing.T) { body: "Tacos, Bolognese, Tikka masala", chatT: "This week's plan is ready: Tacos, Bolognese, Tikka masala. Open the plan →", }, + { + name: "plan ready for next week", + ev: PlanReady(week, mustDate(t, "2026-10-05"), []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo"}), + key: "plan_ready:2026-10-12", + url: "/plan", + week: "2026-10-12", + title: "Next week's plan is ready", + body: "Tacos, Bolognese, Tikka masala + 1 more", + chatT: "Next week's plan is ready: Tacos, Bolognese, Tikka masala + 1 more. Open the plan →", + }, + { + name: "plan ready for a later week", + ev: PlanReady(mustDate(t, "2026-10-26"), mustDate(t, "2026-10-05"), []string{"Tacos"}), + key: "plan_ready:2026-10-26", + url: "/plan", + week: "2026-10-26", + title: "The plan for the week of Oct 26 is ready", + body: "Tacos", + chatT: "The plan for the week of Oct 26 is ready: Tacos. Open the plan →", + }, { name: "list ready", ev: ListReady(week, 19, 7), @@ -115,6 +135,23 @@ func TestMessages(t *testing.T) { } } +func TestSupplyRunText(t *testing.T) { + t.Parallel() + got := SupplyRunText("Week 42 · Oct 12 – 18", []SupplyRunGroup{ + {Dinner: "Tacos", Items: []SupplyRunItem{{Name: "ground beef", Qty: "1 lb"}, {Name: "tortillas"}}}, + {Dinner: "Bolognese"}, + {Dinner: "Pho", Items: []SupplyRunItem{{Name: "rice noodles", Qty: "200 g"}}}, + }) + want := "Supply run · Week 42 · Oct 12 – 18\n\n*Tacos*\n• ground beef — 1 lb\n• tortillas\n\n*Pho*\n• rice noodles — 200 g\n\nOpen the Supply run →" + if got != want { + t.Errorf("SupplyRunText =\n%s\nwant\n%s", got, want) + } + got = SupplyRunText("Cozy week", []SupplyRunGroup{{Dinner: "Tacos"}}) + if want := "Supply run · Cozy week\n\nNothing left to buy.\n\nOpen the Supply run →"; got != want { + t.Errorf("SupplyRunText with nothing needed = %q, want %q", got, want) + } +} + func TestTextWithoutBody(t *testing.T) { t.Parallel() got := Text(notify.Payload{Event: notify.PlanReady, Title: "This week's plan is ready", URL: "/plan"}) diff --git a/internal/db/querier.go b/internal/db/querier.go index e7d9a8c..fbbab33 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -143,6 +143,8 @@ type Querier interface { GetShoppingList(ctx context.Context, weekStart string) (ShoppingList, error) // A suggestion with its dinner. Returns sql.ErrNoRows for an unknown id. GetSuggestion(ctx context.Context, id string) (GetSuggestionRow, error) + // Batch id. Returns sql.ErrNoRows when there is none. + GetSuggestionBatch(ctx context.Context, id string) (SuggestionBatch, error) // The id of the job that runs batch batch_id. GetSuggestionBatchJobID(ctx context.Context, arg GetSuggestionBatchJobIDParams) (string, error) // The plan of the week starting week_start. Returns sql.ErrNoRows when @@ -233,6 +235,9 @@ type Querier interface { // Records a delivered notification; updated_at is when it was sent. The // lease must still be the claim's. MarkNotificationSent(ctx context.Context, arg MarkNotificationSentParams) (int64, error) + // Records list id sent to Google Chat at sent_to_chat_at, unless it is + // frozen into the Supply archive. + MarkShoppingListSentToChat(ctx context.Context, arg MarkShoppingListSentToChatParams) (int64, error) // Records an edit of plan id, raising its revision: a synced plan becomes // changed, any other keeps its status. MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error diff --git a/internal/db/shopping.sql.go b/internal/db/shopping.sql.go index 55b060a..eb8e921 100644 --- a/internal/db/shopping.sql.go +++ b/internal/db/shopping.sql.go @@ -362,6 +362,28 @@ func (q *Queries) ListShoppingListsThrough(ctx context.Context, weekStart string return items, nil } +const markShoppingListSentToChat = `-- name: MarkShoppingListSentToChat :execrows +UPDATE shopping_lists +SET sent_to_chat_at = ?, updated_at = ? +WHERE id = ? AND status = 'current' AND frozen_at IS NULL +` + +type MarkShoppingListSentToChatParams struct { + SentToChatAt sql.NullString + UpdatedAt string + ID string +} + +// Records list id sent to Google Chat at sent_to_chat_at, unless it is +// frozen into the Supply archive. +func (q *Queries) MarkShoppingListSentToChat(ctx context.Context, arg MarkShoppingListSentToChatParams) (int64, error) { + result, err := q.db.ExecContext(ctx, markShoppingListSentToChat, arg.SentToChatAt, arg.UpdatedAt, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const moveShoppingGroup = `-- name: MoveShoppingGroup :exec UPDATE shopping_groups SET position = ?, updated_at = ? diff --git a/internal/db/suggestions.sql.go b/internal/db/suggestions.sql.go index e7f12d7..74bded5 100644 --- a/internal/db/suggestions.sql.go +++ b/internal/db/suggestions.sql.go @@ -259,6 +259,26 @@ func (q *Queries) GetSuggestion(ctx context.Context, id string) (GetSuggestionRo return i, err } +const getSuggestionBatch = `-- name: GetSuggestionBatch :one +SELECT id, "trigger", status, ai_run_id, created_at, updated_at FROM suggestion_batches +WHERE id = ? +` + +// Batch id. Returns sql.ErrNoRows when there is none. +func (q *Queries) GetSuggestionBatch(ctx context.Context, id string) (SuggestionBatch, error) { + row := q.db.QueryRowContext(ctx, getSuggestionBatch, id) + var i SuggestionBatch + err := row.Scan( + &i.ID, + &i.Trigger, + &i.Status, + &i.AiRunID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const getSuggestionBatchJobID = `-- name: GetSuggestionBatchJobID :one SELECT id FROM jobs WHERE kind = ?1 diff --git a/internal/notify/notify.go b/internal/notify/notify.go index 31017b5..d3b61ab 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -6,7 +6,8 @@ // row in notification_outbox keyed by (event key, channel). The event key is // the caller's idempotency key, so emitting the same event twice stores // nothing the second time and never touches a row already stored, whatever -// its status. +// its status. EmitTx does the same inside the caller's transaction, so an +// event is stored with the change it is about; the caller then calls Wake. // // The worker started by Start delivers due rows through the Sender // registered for their channel. A delivered row is marked sent. A failed @@ -142,6 +143,20 @@ func NewService(st *store.Store, logger *slog.Logger) (*Service, error) { // row with ev.Key is left as it is. An invalid event is ErrInvalidEvent and // stores nothing; a type with no preference row is an error. func (s *Service) Emit(ctx context.Context, ev Event) error { + err := s.st.WithTx(ctx, func(tx *sql.Tx) error { + return s.EmitTx(ctx, tx, ev) + }) + if err != nil { + return err + } + s.Wake() + return nil +} + +// EmitTx stores ev's notifications as Emit does, in tx, so they are +// committed with the change the event is about or not at all. The caller +// calls Wake once tx has committed. +func (s *Service) EmitTx(ctx context.Context, tx *sql.Tx, ev Event) error { if err := validate(ev); err != nil { return err } @@ -154,42 +169,33 @@ func (s *Service) Emit(ctx context.Context, ev Event) error { return fmt.Errorf("notify: encode payload of %s: %w", ev.Key, err) } - var queued []Channel - err = s.st.WithTx(ctx, func(tx *sql.Tx) error { - q := db.New(tx) - pref, err := q.GetNotificationPref(ctx, string(ev.Type)) - if errors.Is(err, sql.ErrNoRows) { - return fmt.Errorf("notify: no notification preference for %s", ev.Type) - } - if err != nil { - return fmt.Errorf("notify: read notification preference for %s: %w", ev.Type, err) - } - now := timestamp(s.now()) - for _, ch := range enabledChannels(pref) { - n, err := q.InsertNotification(ctx, db.InsertNotificationParams{ - ID: store.NewID(), - EventKey: ev.Key, - Channel: string(ch), - Payload: string(data), - NextAttemptAt: sql.NullString{String: now, Valid: true}, - Now: now, - }) - if err != nil { - return fmt.Errorf("notify: store %s notification %s: %w", ch, ev.Key, err) - } - if n == 1 { - queued = append(queued, ch) - } - } - return nil - }) + q := db.New(tx) + pref, err := q.GetNotificationPref(ctx, string(ev.Type)) + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("notify: no notification preference for %s", ev.Type) + } if err != nil { - return err + return fmt.Errorf("notify: read notification preference for %s: %w", ev.Type, err) } - s.logger.InfoContext(ctx, "notification emitted", "event_key", ev.Key, "queued", queued) - if len(queued) > 0 { - s.wakeWorker() + now := timestamp(s.now()) + var queued []Channel + for _, ch := range enabledChannels(pref) { + n, err := q.InsertNotification(ctx, db.InsertNotificationParams{ + ID: store.NewID(), + EventKey: ev.Key, + Channel: string(ch), + Payload: string(data), + NextAttemptAt: sql.NullString{String: now, Valid: true}, + Now: now, + }) + if err != nil { + return fmt.Errorf("notify: store %s notification %s: %w", ch, ev.Key, err) + } + if n == 1 { + queued = append(queued, ch) + } } + s.logger.InfoContext(ctx, "notification stored in transaction", "event_key", ev.Key, "queued", queued) return nil } diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go index cb124e5..f5b99a7 100644 --- a/internal/notify/notify_test.go +++ b/internal/notify/notify_test.go @@ -753,3 +753,34 @@ func TestWorkerDeliversEmittedEvent(t *testing.T) { t.Fatalf("%d deliveries, want 1", n) } } + +// TestEmitTxCommitsWithTheCallersTransaction: EmitTx's rows are stored only +// when the caller's transaction commits. +func TestEmitTxCommitsWithTheCallersTransaction(t *testing.T) { + ctx := context.Background() + svc, st, _ := newTestService(t) + setPref(t, st, PlanReady, true, false) + const key = "plan_ready:2026-10-12" + rollback := errors.New("roll back") + err := st.WithTx(ctx, func(tx *sql.Tx) error { + if err := svc.EmitTx(ctx, tx, planReady(key)); err != nil { + return err + } + return rollback + }) + if !errors.Is(err, rollback) { + t.Fatalf("WithTx = %v", err) + } + if rows := outbox(t, st, key); len(rows) != 0 { + t.Fatalf("rows after rollback = %v, want none", rows) + } + if err := st.WithTx(ctx, func(tx *sql.Tx) error { return svc.EmitTx(ctx, tx, planReady(key)) }); err != nil { + t.Fatal(err) + } + if r := onlyRow(t, st, key, ChannelPush); r.Status != "pending" { + t.Errorf("row after commit = %+v, want pending", r) + } + if err := st.WithTx(ctx, func(tx *sql.Tx) error { return svc.EmitTx(ctx, tx, Event{Type: PlanReady, Key: key}) }); !errors.Is(err, ErrInvalidEvent) { + t.Errorf("EmitTx of an invalid event = %v, want ErrInvalidEvent", err) + } +} diff --git a/internal/notify/notifytest/notifytest.go b/internal/notify/notifytest/notifytest.go new file mode 100644 index 0000000..1d74f95 --- /dev/null +++ b/internal/notify/notifytest/notifytest.go @@ -0,0 +1,65 @@ +// Package notifytest builds notify services for tests of the features that +// emit notifications, and reads back what they stored. +package notifytest + +import ( + "encoding/json" + "io" + "log/slog" + "testing" + + "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/store" +) + +// New returns a notify service over st, with st's notification preferences +// seeded, so every event type goes to push and plan_ready and list_ready to +// Google Chat too. Its worker is not started: what is emitted stays pending. +func New(t *testing.T, st *store.Store) *notify.Service { + t.Helper() + if err := household.SeedNotificationPrefs(t.Context(), st); err != nil { + t.Fatal(err) + } + svc, err := notify.NewService(st, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatal(err) + } + return svc +} + +// Row is one stored notification. +type Row struct { + Key string + Channel notify.Channel + Payload notify.Payload +} + +// Rows returns every notification stored in st whose event is of type ev, +// oldest first. +func Rows(t *testing.T, st *store.Store, ev notify.EventType) []Row { + t.Helper() + rs, err := st.Reader.QueryContext(t.Context(), + `SELECT event_key, channel, payload FROM notification_outbox WHERE event_key LIKE ? ORDER BY created_at, channel`, + string(ev)+":%") + if err != nil { + t.Fatal(err) + } + defer rs.Close() + var out []Row + for rs.Next() { + var r Row + var payload string + if err := rs.Scan(&r.Key, &r.Channel, &payload); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(payload), &r.Payload); err != nil { + t.Fatal(err) + } + out = append(out, r) + } + if err := rs.Err(); err != nil { + t.Fatal(err) + } + return out +} diff --git a/internal/notify/worker.go b/internal/notify/worker.go index c188556..63a1755 100644 --- a/internal/notify/worker.go +++ b/internal/notify/worker.go @@ -104,8 +104,9 @@ func (s *Service) Wait() { s.wg.Wait() } -// wakeWorker gives the worker a token to look for due rows. -func (s *Service) wakeWorker() { +// Wake tells the worker to look for due rows, as after EmitTx's +// transaction commits. +func (s *Service) Wake() { select { case s.wake <- struct{}{}: default: // the worker already has a token and will look again diff --git a/internal/plan/draft.go b/internal/plan/draft.go index b1cc157..4b9bfbd 100644 --- a/internal/plan/draft.go +++ b/internal/plan/draft.go @@ -10,6 +10,7 @@ import ( "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/db" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" @@ -147,6 +148,12 @@ func (s *Service) request(ctx context.Context, weekStart store.LocalDate) (skill // draft_week was given: a day whose dinner is no longer in it, or is in it // as a Regular rather than the Quest log or the other way round, leaves // everything as it is, failing with ReasonPoolChanged. +// +// A stored draft emits plan_ready, keyed by the week, in the same +// transaction, naming the drafted dinners Monday to Sunday. The key is the week's, +// so only the AI's first draft of a week notifies: a redraft (Rebuild week) +// stores no second notification. A manual swap never drafts, so never +// notifies. func (s *Service) storeDraft(ctx context.Context, weekStart, thisWeek store.LocalDate, confirm bool, base int64, week skills.Week) (string, error) { var planID string err := s.st.WithTx(ctx, func(tx *sql.Tx) error { @@ -156,8 +163,10 @@ func (s *Service) storeDraft(ctx context.Context, weekStart, thisWeek store.Loca return fmt.Errorf("plan: list draft pool: %w", err) } pool := make(map[string]string, len(rows)) + names := make(map[string]string, len(rows)) for _, d := range rows { pool[d.ID] = d.Status + names[d.ID] = d.Name } for _, d := range week.Days { status, ok := pool[d.DinnerID] @@ -215,11 +224,16 @@ func (s *Service) storeDraft(ctx context.Context, weekStart, thisWeek store.Loca return fmt.Errorf("plan: put %s of %s: %w", d.Date, planID, err) } } - return nil + planned := make([]string, len(week.Days)) + for i, d := range week.Days { + planned[i] = names[d.DinnerID] + } + return s.notifier.EmitTx(ctx, tx, chat.PlanReady(weekStart, thisWeek, planned)) }) if err != nil { return "", err } + s.notifier.Wake() return planID, nil } diff --git a/internal/plan/plan.go b/internal/plan/plan.go index c016212..e43b9af 100644 --- a/internal/plan/plan.go +++ b/internal/plan/plan.go @@ -22,10 +22,12 @@ import ( "time" "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/db" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" "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" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" ) @@ -138,6 +140,7 @@ type Service struct { st *store.Store draft Drafter cal Calendar + notifier *notify.Service logger *slog.Logger draftJob *jobs.Kind[DraftJob] // publicURL is MEALQUEST_PUBLIC_URL, under which a pushed event links @@ -146,11 +149,12 @@ type Service struct { } // NewService returns a service over st that drafts with draft, pushes to -// cal with links under publicURL, and registers the KindDraftWeek job on +// cal with links under publicURL, tells the household through notifier, +// which must be over st, and registers the KindDraftWeek job on // runner, which must not have started yet. The kind is service-only: // StartDraft, which keeps one draft in flight per week, is the only way to // enqueue it. -func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, publicURL *url.URL, logger *slog.Logger) (*Service, error) { +func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, notifier *notify.Service, publicURL *url.URL, logger *slog.Logger) (*Service, error) { switch { case st == nil: return nil, errors.New("plan: nil store") @@ -160,12 +164,14 @@ func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calenda return nil, errors.New("plan: nil job runner") case cal == nil: return nil, errors.New("plan: nil calendar") + case notifier == nil: + return nil, errors.New("plan: nil notifier") case publicURL == nil: return nil, errors.New("plan: nil public URL") case logger == nil: return nil, errors.New("plan: nil logger") } - s := &Service{st: st, draft: draft, cal: cal, logger: logger, publicURL: publicURL} + s := &Service{st: st, draft: draft, cal: cal, notifier: notifier, logger: logger, publicURL: publicURL} kind, err := jobs.Register(runner, KindDraftWeek, s.runDraft, Reasons...) if err != nil { return nil, err @@ -226,6 +232,30 @@ func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (p Plan, e return readPlan(ctx, db.New(tx), weekStart) } +// EmitDayOfReminder emits day_of_reminder for date, keyed by the date, +// naming the dinner planned on it, and reports whether it did: a date whose +// week has no plan emits nothing. The scheduler calls it at the +// household's reminder_time (Feature 20); emitting a date again stores no +// second notification. +func (s *Service) EmitDayOfReminder(ctx context.Context, date store.LocalDate) (bool, error) { + p, err := s.Get(ctx, household.WeekStart(date)) + if errors.Is(err, ErrNotFound) { + return false, nil + } + if err != nil { + return false, err + } + for _, d := range p.Days { + if d.Date == date.String() { + if err := s.notifier.Emit(ctx, chat.DayOfReminder(date, d.Dinner.ID, d.Dinner.Name)); err != nil { + return false, fmt.Errorf("plan: %w", err) + } + return true, nil + } + } + return false, fmt.Errorf("plan: week %s has no day %s", p.WeekStart, date) +} + // readPlan reads the plan of the week starting weekStart through q, with // each day's start_by from the household's dinner time and zone. func readPlan(ctx context.Context, q *db.Queries, weekStart store.LocalDate) (Plan, error) { diff --git a/internal/plan/plan_test.go b/internal/plan/plan_test.go index dd48ada..f4166d0 100644 --- a/internal/plan/plan_test.go +++ b/internal/plan/plan_test.go @@ -20,11 +20,14 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" "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/db" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest" "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" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" @@ -143,7 +146,7 @@ func newEnv(t *testing.T) *env { if err != nil { t.Fatal(err) } - e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, publicURL, logger) + e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, notifytest.New(t, st), publicURL, logger) if err != nil { t.Fatal(err) } @@ -352,6 +355,85 @@ func TestDraftCreatesPlan(t *testing.T) { } } +// TestDraftEmitsPlanReady: the AI's first draft of a week stores plan_ready +// keyed by the week, on push and Google Chat, naming the dinners Monday to +// Sunday; a redraft (Rebuild week) and a manual swap store nothing more. +func TestDraftEmitsPlanReady(t *testing.T) { + e := newEnv(t) + e.start(t) + e.reply("", fullWeek...) + e.mustDraft(t, false) + + zone, err := time.LoadLocation("America/New_York") + if err != nil { + t.Fatal(err) + } + want := chat.PlanReady(week, household.WeekStart(household.Today(time.Now(), zone)), fullWeek) + rows := notifytest.Rows(t, e.st, notify.PlanReady) + if len(rows) != 2 || rows[0].Channel != notify.ChannelChat || rows[1].Channel != notify.ChannelPush { + t.Fatalf("plan_ready rows = %+v, want one on chat and one on push", rows) + } + for _, r := range rows { + p := r.Payload + if r.Key != "plan_ready:2026-10-12" || p.Tag != r.Key || p.URL != "/plan" || p.Week != "2026-10-12" || + p.Title != want.Title || p.Body != "Beef Chili, Pot Roast, Pad Thai + 4 more" { + t.Errorf("%s row = %+v, want key plan_ready:2026-10-12, title %q, the drafted dinners and /plan", r.Channel, r, want.Title) + } + } + + reversed := slices.Clone(fullWeek) + slices.Reverse(reversed) + e.reply("", reversed...) + e.mustDraft(t, false) + e.ids["Fish Tacos"] = e.dinner(t, "Fish Tacos", "regular", "stovetop", 25) + if _, err := e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Fish Tacos"]); err != nil { + t.Fatal(err) + } + if again := notifytest.Rows(t, e.st, notify.PlanReady); !slices.Equal(again, rows) { + t.Errorf("plan_ready rows after a redraft and a swap = %+v, want the first draft's only", again) + } +} + +// TestFailedDraftEmitsNothing: a draft that stores no plan notifies no one. +func TestFailedDraftEmitsNothing(t *testing.T) { + e := newEnv(t) + e.start(t) + e.model.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")}) + if job := e.draft(t, false); job.Status != jobs.StatusFailed { + t.Fatalf("job %s, want failed", job.Status) + } + if rows := notifytest.Rows(t, e.st, notify.PlanReady); len(rows) != 0 { + t.Errorf("plan_ready rows = %+v, want none", rows) + } +} + +// TestEmitDayOfReminder: the reminder of a planned date names its dinner, +// keyed by the date; a date with no plan emits nothing, and a date emitted +// again stores nothing more. +func TestEmitDayOfReminder(t *testing.T) { + e := newEnv(t) + e.start(t) + if ok, err := e.svc.EmitDayOfReminder(t.Context(), mustDate("2026-10-14")); ok || err != nil { + t.Fatalf("EmitDayOfReminder with no plan = %v, %v, want false", ok, err) + } + e.reply("", fullWeek...) + e.mustDraft(t, false) + for range 2 { + if ok, err := e.svc.EmitDayOfReminder(t.Context(), mustDate("2026-10-14")); !ok || err != nil { + t.Fatalf("EmitDayOfReminder = %v, %v, want true", ok, err) + } + } + rows := notifytest.Rows(t, e.st, notify.DayOfReminder) + if len(rows) != 1 { + t.Fatalf("day_of_reminder rows = %+v, want one on push", rows) + } + r := rows[0] + if r.Key != "day_of_reminder:2026-10-14" || r.Channel != notify.ChannelPush || r.Payload.Title != "Tonight's dinner" || + r.Payload.Body != "Pad Thai" || r.Payload.URL != "/dinner/"+e.ids["Pad Thai"] || r.Payload.Week != "2026-10-12" { + t.Errorf("day_of_reminder row = %+v", r) + } +} + func TestStartByIsDinnerTimeMinusMinutesForCrockPot(t *testing.T) { e := newEnv(t) e.start(t) diff --git a/internal/server/plans_test.go b/internal/server/plans_test.go index 8d79900..ae542fd 100644 --- a/internal/server/plans_test.go +++ b/internal/server/plans_test.go @@ -17,11 +17,14 @@ import ( "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/gcal" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest" "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/plan" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" @@ -44,6 +47,10 @@ type plansEnv struct { // unconnected. google *gcaltest.Google gcal *gcal.Service + // chat is the fake Google Chat endpoint chatSvc posts to; no webhook + // is stored until a test stores one. + chat *chattest.Chat + chatSvc *chat.Service // logs holds every log line the router writes. logs *bytes.Buffer } @@ -85,15 +92,21 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv { if err != nil { t.Fatal(err) } - svc, err := plan.NewService(st, fail, runner, googleSvc, pub, logger) + notifier := notifytest.New(t, st) + svc, err := plan.NewService(st, fail, runner, googleSvc, notifier, pub, logger) if err != nil { t.Fatal(err) } - shop, err := shopping.NewService(st, title, runner, logger) + fakeChat := chattest.New(t) + chatSvc, err := chat.NewService(st, newTestBox(t), pub, fakeChat.Transport()) if err != nil { t.Fatal(err) } - h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), + shop, err := shopping.NewService(st, title, runner, notifier, chatSvc, logger) + if err != nil { + t.Fatal(err) + } + h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), chatSvc, newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) @@ -129,7 +142,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv { } } return plansEnv{h: h, st: st, svc: svc, shop: shop, runner: runner, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35), - google: google, gcal: googleSvc, logs: logs} + google: google, gcal: googleSvc, chat: fakeChat, chatSvc: chatSvc, logs: logs} } func (e plansEnv) setStatus(t *testing.T, status string) { diff --git a/internal/server/server.go b/internal/server/server.go index 1464b69..af7a20c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -163,6 +163,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a apiV1.Get("/shopping/archive", getSupplyArchive(logger, shopSvc)) apiV1.Get("/shopping/{weekStart}", getSupplyRun(logger, shopSvc)) apiV1.Post("/shopping/{weekStart}/generate", postSupplyRunGenerate(logger, shopSvc)) + apiV1.Post("/shopping/{weekStart}/send-chat", postSupplyRunSendChat(logger, shopSvc)) apiV1.Patch("/shopping/items/{id}", patchShoppingItem(logger, shopSvc)) apiV1.Get("/rotation-reviews", getRotationReviews(logger, planSvc)) apiV1.Post("/dinners/{id}/rotation-review", postRotationReview(logger, planSvc)) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index a67a081..9b68035 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -26,6 +26,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" "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/plan" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest" @@ -186,7 +187,7 @@ func newTestSuggest(t *testing.T) *suggest.Service { fail := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) { return skills.SideQuests{}, errors.New("no model in this test") } - svc, err := suggest.NewService(st, g, fail, runner, logger) + svc, err := suggest.NewService(st, g, fail, runner, notifytest.New(t, st), logger) if err != nil { t.Fatal(err) } @@ -206,7 +207,7 @@ func newTestPlan(t *testing.T) *plan.Service { fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) { return skills.Week{}, errors.New("no model in this test") } - svc, err := plan.NewService(st, fail, runner, newTestGoogle(t), &url.URL{Scheme: "https", Host: "mealquest.example"}, logger) + svc, err := plan.NewService(st, fail, runner, newTestGoogle(t), notifytest.New(t, st), &url.URL{Scheme: "https", Host: "mealquest.example"}, logger) if err != nil { t.Fatal(err) } @@ -214,7 +215,8 @@ func newTestPlan(t *testing.T) *plan.Service { } // newTestShopping returns a shopping service over a migrated temp -// database, whose Supply runs stay untitled. +// database, whose Supply runs stay untitled and whose posts to Google Chat +// all fail. func newTestShopping(t *testing.T) *shopping.Service { t.Helper() st := newTestStore(t) @@ -223,7 +225,7 @@ func newTestShopping(t *testing.T) *shopping.Service { if err != nil { t.Fatal(err) } - svc, err := shopping.NewService(st, untitled, runner, logger) + svc, err := shopping.NewService(st, untitled, runner, notifytest.New(t, st), newTestChat(t), logger) if err != nil { t.Fatal(err) } diff --git a/internal/server/shopping.go b/internal/server/shopping.go index e62ab53..565be55 100644 --- a/internal/server/shopping.go +++ b/internal/server/shopping.go @@ -9,6 +9,7 @@ import ( "github.com/go-chi/chi/v5" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" @@ -17,6 +18,9 @@ import ( // maxShoppingItemBody bounds a PATCH /shopping/items/{id} body. const maxShoppingItemBody = 1 << 10 +// maxSendChatBody bounds a POST /shopping/{weekStart}/send-chat body. +const maxSendChatBody = 1 << 10 + // supplyRun is a Supply run as the API shows it. label is the week label, // which stands in for title until the AI writes one. type supplyRun struct { @@ -59,6 +63,12 @@ type generatedSupplyRun struct { Skipped []shopping.SkippedDay `json:"skipped"` } +// sendChatRequest is the POST /shopping/{weekStart}/send-chat body: +// resend posts a week already sent again. +type sendChatRequest struct { + Resend bool `json:"resend"` +} + // shoppingItemPatch is the PATCH /shopping/items/{id} body. type shoppingItemPatch struct { Needed *bool `json:"needed"` @@ -120,6 +130,51 @@ func postSupplyRunGenerate(logger *slog.Logger, svc *shopping.Service) http.Hand } } +// postSupplyRunSendChat posts the week's Supply run to Google Chat and +// answers with it, sent_to_chat_at recorded. Its body is a JSON object, +// {} or {"resend": true}: a week already sent is answered as it is, +// without a second post, unless resend is true. No webhook stored is 409 +// not_configured; Google Chat not answering is 502 chat_unreachable, +// retryable, and refusing the message 502 chat_refused, retryable only for +// 429 or 5xx. +func postSupplyRunSendChat(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + week, ok := weekStartOrFail(w, r, logger) + if !ok { + return + } + body, err := decodePatch[sendChatRequest](http.MaxBytesReader(w, r.Body, maxSendChatBody)) + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + l, err := svc.SendToChat(r.Context(), week, body.Resend) + var refused *chat.RefusedError + switch { + case errors.Is(err, chat.ErrNotConfigured): + writeError(w, r, logger, http.StatusConflict, "not_configured", "no Google Chat webhook is configured; add one in Settings") + return + case errors.Is(err, chat.ErrUnreachable): + logger.WarnContext(r.Context(), "send Supply run to Google Chat", "error", err) + writeAPIError(w, r, logger, http.StatusBadGateway, apiError{Code: "chat_unreachable", Message: "Google Chat did not answer", Retryable: true}) + return + case errors.As(err, &refused): + logger.WarnContext(r.Context(), "send Supply run to Google Chat", "error", err) + writeAPIError(w, r, logger, http.StatusBadGateway, apiError{Code: "chat_refused", Message: err.Error(), Retryable: refused.Retryable()}) + return + case err != nil: + writeShoppingError(w, r, logger, "send Supply run to Google Chat", err) + return + } + out, err := supplyRunOf(week, l) + if err != nil { + writeShoppingError(w, r, logger, "send Supply run to Google Chat", err) + return + } + writeJSON(w, r, logger, http.StatusOK, out) + } +} + // patchShoppingItem marks a Supply run item needed or "have it" and // answers with its Supply run's new counts. func patchShoppingItem(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc { diff --git a/internal/server/shopping_test.go b/internal/server/shopping_test.go index 1698821..2f1caf8 100644 --- a/internal/server/shopping_test.go +++ b/internal/server/shopping_test.go @@ -15,6 +15,7 @@ import ( "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/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/plan" @@ -787,3 +788,120 @@ func TestSupplyArchive(t *testing.T) { t.Errorf("frozen list = %s %d/%d %v, want archived 2/3 %v", frozen.Status, frozen.NeededCount, frozen.TotalCount, frozen.render(), wantLines) } } + +// TestSupplyRunSendChat posts the week's Supply run to Google Chat once, +// records sent_to_chat_at, answers a second send with the status as it +// is, and posts again only with resend. +func TestSupplyRunSendChat(t *testing.T) { + t.Parallel() + e := newPlansRouter(t) + e.groundWeek(t) + var l supplyRunJSON + callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &l) + callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+l.itemID(t, "onion"), `{"needed":false}`, nil) + send := shoppingPath + "2026-10-12/send-chat" + + if status, body := callShopping(t, e, http.MethodPost, send, `{}`, nil); status != http.StatusConflict || errorCode(body) != "not_configured" { + t.Fatalf("send with no webhook = %d %v, want 409 not_configured", status, body) + } + if _, err := e.chatSvc.Put(t.Context(), chattest.Webhook, "Family"); err != nil { + t.Fatal(err) + } + + var sent supplyRunJSON + if status, body := callShopping(t, e, http.MethodPost, send, `{}`, &sent); status != http.StatusOK { + t.Fatalf("send = %d %v", status, body) + } + if sent.SentToChatAt == nil || sent.WeekStart != "2026-10-12" || !reflect.DeepEqual(sent.render(), []string{ + "Monday 2026-10-12 Beef Chili: ground beef [1 lb] needed", + "Monday 2026-10-12 Beef Chili: kidney beans [2 cans] needed", + "Monday 2026-10-12 Beef Chili: onion [1] have it", + "Tuesday 2026-10-13 Regular 2026-10-13: spaghetti [500 g] needed", + "Tuesday 2026-10-13 Regular 2026-10-13: basil [null] needed", + }) { + t.Errorf("send answer = %+v, want the Supply run with sent_to_chat_at", sent) + } + posts := e.chat.Posts() + if len(posts) != 1 { + t.Fatalf("Google Chat got %d posts, want 1", len(posts)) + } + want := "Supply run · Week 42 · Oct 12 – 18\n\n*Beef Chili*\n• ground beef — 1 lb\n• kidney beans — 2 cans\n\n" + + "*Regular 2026-10-13*\n• spaghetti — 500 g\n• basil\n\nOpen the Supply run →" + if posts[0].Text() != want { + t.Errorf("posted text = %q, want %q", posts[0].Text(), want) + } + + var again supplyRunJSON + if status, body := callShopping(t, e, http.MethodPost, send, `{}`, &again); status != http.StatusOK || again.SentToChatAt == nil || *again.SentToChatAt != *sent.SentToChatAt { + t.Errorf("send again = %d %v, sent_to_chat_at %v, want 200 and %s", status, body, again.SentToChatAt, *sent.SentToChatAt) + } + if n := len(e.chat.Posts()); n != 1 { + t.Errorf("send again without resend posted: %d posts, want 1", n) + } + var got supplyRunJSON + callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &got) + if got.SentToChatAt == nil || *got.SentToChatAt != *sent.SentToChatAt { + t.Errorf("GET sent_to_chat_at = %v, want %s", got.SentToChatAt, *sent.SentToChatAt) + } + + var resent supplyRunJSON + if status, body := callShopping(t, e, http.MethodPost, send, `{"resend":true}`, &resent); status != http.StatusOK || resent.SentToChatAt == nil { + t.Fatalf("resend = %d %v", status, body) + } + if n := len(e.chat.Posts()); n != 2 { + t.Errorf("resend: %d posts, want 2", n) + } +} + +func TestSupplyRunSendChatErrors(t *testing.T) { + t.Parallel() + e := newPlansRouter(t) + e.groundWeek(t) + callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil) + if _, err := e.chatSvc.Put(t.Context(), chattest.Webhook, "Family"); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name, path, body string + status int + code string + }{ + {"no body", "2026-10-12/send-chat", "", http.StatusBadRequest, "invalid_request"}, + {"resend not a boolean", "2026-10-12/send-chat", `{"resend":"yes"}`, http.StatusBadRequest, "invalid_request"}, + {"unknown field", "2026-10-12/send-chat", `{"force":true}`, http.StatusBadRequest, "invalid_request"}, + {"not a Monday", "2026-10-13/send-chat", `{}`, http.StatusBadRequest, "invalid_week"}, + {"no list", "2026-10-19/send-chat", `{}`, http.StatusNotFound, "not_found"}, + } { + status, body := callShopping(t, e, http.MethodPost, shoppingPath+tc.path, tc.body, nil) + if status != tc.status || errorCode(body) != tc.code { + t.Errorf("%s = %d %v, want %d %s", tc.name, status, body, tc.status, tc.code) + } + } + + e.chat.SetStatus(http.StatusInternalServerError) + status, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/send-chat", `{}`, nil) + if status != http.StatusBadGateway || errorCode(body) != "chat_refused" || body["error"].(map[string]any)["retryable"] != true { + t.Errorf("send Google Chat refuses = %d %v, want 502 chat_refused, retryable", status, body) + } + e.chat.SetStatus(http.StatusNotFound) + status, body = callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/send-chat", `{}`, nil) + if status != http.StatusBadGateway || errorCode(body) != "chat_refused" || body["error"].(map[string]any)["retryable"] != false { + t.Errorf("send Google Chat answers 404 = %d %v, want 502 chat_refused, not retryable", status, body) + } + e.chat.Close() + status, body = callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/send-chat", `{}`, nil) + if status != http.StatusBadGateway || errorCode(body) != "chat_unreachable" { + t.Errorf("send Google Chat does not answer = %d %v, want 502 chat_unreachable", status, body) + } + var l supplyRunJSON + callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l) + if l.SentToChatAt != nil { + t.Errorf("sent_to_chat_at after failed sends = %s, want null", *l.SentToChatAt) + } + + e.archive(t) + status, body = callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/send-chat", `{"resend":true}`, nil) + if status != http.StatusConflict || errorCode(body) != "archived" { + t.Errorf("send an archived list = %d %v, want 409 archived", status, body) + } +} diff --git a/internal/server/suggestions_test.go b/internal/server/suggestions_test.go index e1b8938..ddc3cbf 100644 --- a/internal/server/suggestions_test.go +++ b/internal/server/suggestions_test.go @@ -17,6 +17,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" "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/recipes/recipestest" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest" @@ -40,7 +41,7 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S fail := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) { return skills.SideQuests{}, errors.New("no model in this test") } - svc, err := suggest.NewService(st, g, fail, runner, logger) + svc, err := suggest.NewService(st, g, fail, runner, notifytest.New(t, st), logger) if err != nil { t.Fatal(err) } diff --git a/internal/shopping/chat.go b/internal/shopping/chat.go new file mode 100644 index 0000000..f109926 --- /dev/null +++ b/internal/shopping/chat.go @@ -0,0 +1,76 @@ +package shopping + +import ( + "context" + "database/sql" + "fmt" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat" + "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/store" +) + +// SendToChat posts the Supply run of the week starting weekStart to Google +// Chat (chat.PostSupplyRun): each dinner with its items still needed, and a +// link to the Supply run. It records sent_to_chat_at and returns the list. +// +// A week is sent once: a Supply run already sent is returned as it is, +// without posting again, unless resend is true. A week with no Supply run +// is ErrListNotFound and one frozen into the Supply archive ErrFrozen. A +// post that fails returns chat.PostSupplyRun's error and records nothing. +func (s *Service) SendToChat(ctx context.Context, weekStart store.LocalDate, resend bool) (List, error) { + s.sending.Lock() + defer s.sending.Unlock() + l, err := s.Get(ctx, weekStart) + if err != nil { + return List{}, err + } + if l.Status != StatusCurrent { + return List{}, ErrFrozen + } + if l.SentToChatAt != nil && !resend { + return l, nil + } + name, err := household.WeekLabel(weekStart) + if err != nil { + return List{}, fmt.Errorf("shopping: send to Google Chat: %w", err) + } + if l.Title != nil { + name = *l.Title + } + groups := make([]chat.SupplyRunGroup, len(l.Groups)) + for i, g := range l.Groups { + groups[i].Dinner = g.DinnerName + for _, it := range g.Items { + if !it.Needed { + continue + } + item := chat.SupplyRunItem{Name: it.Name} + if it.Qty != nil { + item.Qty = *it.Qty + } + groups[i].Items = append(groups[i].Items, item) + } + } + if err := s.chat.PostSupplyRun(ctx, weekStart, name, groups); err != nil { + return List{}, err + } + // The post is made: record it even if the client has gone. + write, cancel := jobs.FinalWriteContext(ctx) + defer cancel() + now := store.Now().String() + n, err := db.New(s.st.Writer).MarkShoppingListSentToChat(write, db.MarkShoppingListSentToChatParams{ + SentToChatAt: sql.NullString{String: now, Valid: true}, + UpdatedAt: now, + ID: l.ID, + }) + if err != nil { + return List{}, fmt.Errorf("shopping: record sent to Google Chat: %w", err) + } + if n != 1 { + return List{}, ErrFrozen + } + return s.Get(write, weekStart) +} diff --git a/internal/shopping/notify_test.go b/internal/shopping/notify_test.go new file mode 100644 index 0000000..0615750 --- /dev/null +++ b/internal/shopping/notify_test.go @@ -0,0 +1,169 @@ +package shopping_test + +import ( + "errors" + "net/http" + "slices" + "testing" + + "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/notify" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping" +) + +// TestGenerateEmitsListReadyOnce: the week's first Supply run stores +// list_ready keyed by the week, on push and Google Chat; remaking it, and +// making it again after Push to Calendar, stores nothing more. +func TestGenerateEmitsListReadyOnce(t *testing.T) { + e := newEnv(t) + e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""}) + e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"}) + e.dinner(t, "Pot Roast") + e.plan(t, "Beef Chili", "Pad Thai", "Pot Roast") + + if _, err := e.svc.Generate(t.Context(), mustDate("2026-10-19")); !errors.Is(err, shopping.ErrPlanNotFound) { + t.Fatalf("Generate of a week with no plan = %v", err) + } + if rows := notifytest.Rows(t, e.st, notify.ListReady); len(rows) != 0 { + t.Fatalf("list_ready rows after a failed generation = %+v, want none", rows) + } + + e.generate(t) + rows := notifytest.Rows(t, e.st, notify.ListReady) + if len(rows) != 2 || rows[0].Channel != notify.ChannelChat || rows[1].Channel != notify.ChannelPush { + t.Fatalf("list_ready rows = %+v, want one on chat and one on push", rows) + } + for _, r := range rows { + p := r.Payload + if r.Key != "list_ready:2026-10-12" || p.URL != "/plan/shopping" || p.Week != "2026-10-12" || + p.Title != "This week's Supply run is ready" || p.Body != "3 items to buy for 2 dinners" { + t.Errorf("%s row = %+v, want list_ready:2026-10-12, 3 items for 2 dinners, /plan/shopping", r.Channel, r) + } + } + + e.swap(t, "2026-10-14", "Pad Thai") + e.swap(t, "2026-10-13", "Pot Roast") + e.generate(t) + if _, err := e.svc.GeneratePushed(t.Context(), week, planRevision(t, e)); err != nil { + t.Fatal(err) + } + if again := notifytest.Rows(t, e.st, notify.ListReady); !slices.Equal(again, rows) { + t.Errorf("list_ready rows after remaking = %+v, want the first list's only", again) + } +} + +// planRevision is the revision of the env's plan. +func planRevision(t *testing.T, e *env) int64 { + t.Helper() + var rev int64 + if err := e.st.Reader.QueryRow(`SELECT revision FROM week_plans WHERE id = ?`, e.planID).Scan(&rev); err != nil { + t.Fatal(err) + } + return rev +} + +// TestSendToChat posts the Supply run's needed items by dinner, records +// sent_to_chat_at, sends a week once, and again only with resend. +func TestSendToChat(t *testing.T) { + e := newEnv(t) + e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""}) + e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"}) + e.plan(t, "Beef Chili", "Pad Thai") + l := e.generate(t).List + e.haveIt(t, l, "Pad Thai", "rice noodles") + if _, err := e.chatSvc.Put(t.Context(), chattest.Webhook, "Family"); err != nil { + t.Fatal(err) + } + + sent, err := e.svc.SendToChat(t.Context(), week, false) + if err != nil { + t.Fatal(err) + } + if sent.SentToChatAt == nil { + t.Fatal("sent_to_chat_at not recorded") + } + posts := e.chat.Posts() + if len(posts) != 1 { + t.Fatalf("Google Chat got %d posts, want 1", len(posts)) + } + want := chat.SupplyRunText("Week 42 · Oct 12 – 18", []chat.SupplyRunGroup{ + {Dinner: "Beef Chili", Items: []chat.SupplyRunItem{{Name: "ground beef", Qty: "1 lb"}, {Name: "onion", Qty: "1"}}}, + {Dinner: "Pad Thai"}, + }) + if posts[0].Text() != want { + t.Errorf("posted text = %q, want %q", posts[0].Text(), want) + } + if posts[0].Query.Get("threadKey") != chat.ThreadKey(week) { + t.Errorf("posted in thread %q, want %q", posts[0].Query.Get("threadKey"), chat.ThreadKey(week)) + } + if got, err := e.svc.Get(t.Context(), week); err != nil || got.SentToChatAt == nil || *got.SentToChatAt != *sent.SentToChatAt { + t.Errorf("stored sent_to_chat_at = %v, %v, want %s", got.SentToChatAt, err, *sent.SentToChatAt) + } + + again, err := e.svc.SendToChat(t.Context(), week, false) + if err != nil { + t.Fatal(err) + } + if n := len(e.chat.Posts()); n != 1 || *again.SentToChatAt != *sent.SentToChatAt { + t.Errorf("send again without resend: %d posts, sent_to_chat_at %s, want 1 post and %s", n, *again.SentToChatAt, *sent.SentToChatAt) + } + + if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET title = 'Chili and noodles'`); err != nil { + t.Fatal(err) + } + resent, err := e.svc.SendToChat(t.Context(), week, true) + if err != nil { + t.Fatal(err) + } + posts = e.chat.Posts() + if len(posts) != 2 || *resent.SentToChatAt < *sent.SentToChatAt { + t.Fatalf("resend: %d posts, sent_to_chat_at %s, want 2 posts and a later time than %s", len(posts), *resent.SentToChatAt, *sent.SentToChatAt) + } + if want := "Supply run · Chili and noodles"; posts[1].Text()[:len(want)] != want { + t.Errorf("resent text = %q, want it headed by the title", posts[1].Text()) + } +} + +// TestSendToChatFailuresRecordNothing: a send with no webhook, or one +// Google Chat refuses or does not answer, leaves sent_to_chat_at unset; a +// week with no list, or a frozen one, is refused without posting. +func TestSendToChatFailuresRecordNothing(t *testing.T) { + e := newEnv(t) + e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}) + e.plan(t, "Beef Chili") + + if _, err := e.svc.SendToChat(t.Context(), week, false); !errors.Is(err, shopping.ErrListNotFound) { + t.Errorf("SendToChat with no list = %v, want ErrListNotFound", err) + } + e.generate(t) + if _, err := e.svc.SendToChat(t.Context(), week, false); !errors.Is(err, chat.ErrNotConfigured) { + t.Errorf("SendToChat with no webhook = %v, want chat.ErrNotConfigured", err) + } + if _, err := e.chatSvc.Put(t.Context(), chattest.Webhook, "Family"); err != nil { + t.Fatal(err) + } + e.chat.SetStatus(http.StatusInternalServerError) + if _, err := e.svc.SendToChat(t.Context(), week, false); !errors.Is(err, chat.ErrRefused) { + t.Errorf("SendToChat answered 500 = %v, want chat.ErrRefused", err) + } + e.chat.Close() + if _, err := e.svc.SendToChat(t.Context(), week, false); !errors.Is(err, chat.ErrUnreachable) { + t.Errorf("SendToChat unanswered = %v, want chat.ErrUnreachable", err) + } + if l, err := e.svc.Get(t.Context(), week); err != nil || l.SentToChatAt != nil { + t.Errorf("sent_to_chat_at after failed sends = %v, %v, want unset", l.SentToChatAt, err) + } + + if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil { + t.Fatal(err) + } + posts := len(e.chat.Posts()) + if _, err := e.svc.SendToChat(t.Context(), week, true); !errors.Is(err, shopping.ErrFrozen) { + t.Errorf("SendToChat of a frozen list = %v, want ErrFrozen", err) + } + if n := len(e.chat.Posts()); n != posts { + t.Errorf("a frozen list was posted") + } +} diff --git a/internal/shopping/shopping.go b/internal/shopping/shopping.go index 5a5451b..3d26211 100644 --- a/internal/shopping/shopping.go +++ b/internal/shopping/shopping.go @@ -27,10 +27,12 @@ import ( "time" "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/db" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" "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" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" ) @@ -95,6 +97,8 @@ type Service struct { st *store.Store title Titler titleJob *jobs.Kind[TitleJob] + notifier *notify.Service + chat *chat.Service logger *slog.Logger // now is the clock weeks are ended and current on; tests replace it. now func() time.Time @@ -104,12 +108,17 @@ type Service struct { // waits for the title jobs in flight to store their titles or fail, // and no title job runs archive_title while a freeze is under way. titling sync.RWMutex + // sending serializes SendToChat, so a week is posted to Google Chat + // once however many sends race. + sending sync.Mutex } // NewService returns a service over st that titles Supply runs with title -// in KindTitleSupplyRun jobs, which it registers on runner, and logs to -// logger. runner must not have started yet. -func NewService(st *store.Store, title Titler, runner *jobs.Runner, logger *slog.Logger) (*Service, error) { +// in KindTitleSupplyRun jobs, which it registers on runner, tells the +// household through notifier, which must be over st, sends Supply runs to +// Google Chat through chatSvc, and logs to logger. runner must not have +// started yet. +func NewService(st *store.Store, title Titler, runner *jobs.Runner, notifier *notify.Service, chatSvc *chat.Service, logger *slog.Logger) (*Service, error) { switch { case st == nil: return nil, errors.New("shopping: nil store") @@ -117,10 +126,14 @@ func NewService(st *store.Store, title Titler, runner *jobs.Runner, logger *slog return nil, errors.New("shopping: nil titler") case runner == nil: return nil, errors.New("shopping: nil job runner") + case notifier == nil: + return nil, errors.New("shopping: nil notifier") + case chatSvc == nil: + return nil, errors.New("shopping: nil chat service") case logger == nil: return nil, errors.New("shopping: nil logger") } - s := &Service{st: st, title: title, logger: logger, now: time.Now} + s := &Service{st: st, title: title, notifier: notifier, chat: chatSvc, logger: logger, now: time.Now} kind, err := jobs.Register(runner, KindTitleSupplyRun, s.runTitleJob, ReasonTitleFailed) if err != nil { return nil, err @@ -272,6 +285,7 @@ func (s *Service) generate(ctx context.Context, weekStart store.LocalDate, check return Generated{}, fmt.Errorf("shopping: generate: %w", err) } var out Generated + notified := false err := s.st.WithTx(ctx, func(tx *sql.Tx) error { q := db.New(tx) plan, err := q.GetWeekPlan(ctx, weekStart.String()) @@ -293,8 +307,9 @@ func (s *Service) generate(ctx context.Context, weekStart store.LocalDate, check if err != nil && !errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("shopping: read list: %w", err) } + created := err != nil switch { - case err != nil: // no list yet + case created: // no list yet list = db.ShoppingList{ID: store.NewID(), WeekStart: weekStart.String(), PlanID: plan.ID, Status: StatusCurrent} err = q.CreateShoppingList(ctx, db.CreateShoppingListParams{ ID: list.ID, WeekStart: list.WeekStart, PlanID: list.PlanID, CreatedAt: now, UpdatedAt: now, @@ -313,6 +328,13 @@ func (s *Service) generate(ctx context.Context, weekStart store.LocalDate, check return err } out = Generated{List: listOf(list, groups), Skipped: skipped} + if created { + ev := chat.ListReady(weekStart, out.List.Counts().Needed, len(groups)) + if err := s.notifier.EmitTx(ctx, tx, ev); err != nil { + return fmt.Errorf("shopping: %w", err) + } + notified = true + } if list.Title.Valid { return nil } @@ -331,6 +353,9 @@ func (s *Service) generate(ctx context.Context, weekStart store.LocalDate, check if out.TitleJobID != nil { s.titleJob.Wake() } + if notified { + s.notifier.Wake() + } return out, nil } diff --git a/internal/shopping/shopping_test.go b/internal/shopping/shopping_test.go index a38f37e..64640ad 100644 --- a/internal/shopping/shopping_test.go +++ b/internal/shopping/shopping_test.go @@ -3,11 +3,14 @@ package shopping_test import ( "bytes" "context" + "crypto/rand" "database/sql" + "encoding/base64" "errors" "fmt" "io" "log/slog" + "net/url" "reflect" "strings" "sync" @@ -17,10 +20,14 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" "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/dinners" "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/recipes/recipestest" + "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" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" @@ -61,6 +68,10 @@ type env struct { beforeModel func(llm.Request) // log holds what the service logged. log *syncBuffer + // chat is the fake Google Chat endpoint chatSvc posts to; no webhook + // is stored until a test stores one. + chat *chattest.Chat + chatSvc *chat.Service } // syncBuffer is a bytes.Buffer safe for concurrent use. @@ -122,7 +133,18 @@ func newEnv(t *testing.T) *env { if err != nil { t.Fatal(err) } - e.svc, err = shopping.NewService(st, title, e.runner, logger) + key := make([]byte, secretbox.KeySize) + rand.Read(key) + box, err := secretbox.New(base64.StdEncoding.EncodeToString(key)) + if err != nil { + t.Fatal(err) + } + e.chat = chattest.New(t) + e.chatSvc, err = chat.NewService(st, box, &url.URL{Scheme: "https", Host: "meals.example.com"}, e.chat.Transport()) + if err != nil { + t.Fatal(err) + } + e.svc, err = shopping.NewService(st, title, e.runner, notifytest.New(t, st), e.chatSvc, logger) if err != nil { t.Fatal(err) } diff --git a/internal/suggest/batch.go b/internal/suggest/batch.go index 51ee427..826ae13 100644 --- a/internal/suggest/batch.go +++ b/internal/suggest/batch.go @@ -10,9 +10,12 @@ import ( "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/db" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" + "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" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" ) @@ -54,7 +57,18 @@ func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) } // recording the batch's end fails, so does the job; when it times out at // shutdown, the job is left running and the batch with it, for the next // start to fail. +// +// A scheduled or manual batch that ends done having added Side quests to +// the deck emits new_suggestions, keyed by the batch, in the transaction +// that records it done. A replenish batch, which tops up the deck as the +// household swipes, never notifies. A notification that cannot be stored +// fails the done write with it, as any failure recording the batch's end +// does: the job fails, and its Side quests stay in the deck. func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchResult) error) (batchResult, error) { + batch, err := db.New(s.st.Reader).GetSuggestionBatch(ctx, p.BatchID) + if err != nil { + return batchResult{}, fmt.Errorf("suggest: read batch %s: %w", p.BatchID, err) + } n, err := db.New(s.st.Writer).StartSuggestionBatch(ctx, db.StartSuggestionBatchParams{ UpdatedAt: store.Now().String(), ID: p.BatchID, @@ -65,14 +79,19 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe if n == 0 { return batchResult{}, fmt.Errorf("suggest: batch %s is not pending", p.BatchID) } - res, err := s.fill(ctx, p.BatchID, publish) + f, err := s.fill(ctx, p.BatchID, publish) if err == nil { - if err := s.finishBatch(ctx, p.BatchID, BatchDone); err != nil { + var ev *notify.Event + if batch.Trigger != TriggerReplenish && len(f.names) > 0 { + e := chat.NewSuggestions(p.BatchID, household.WeekStart(f.today), f.names) + ev = &e + } + if err := s.finishBatch(ctx, p.BatchID, BatchDone, ev); err != nil { return batchResult{}, err } - return res, nil + return f.res, nil } - finishErr := s.finishBatch(ctx, p.BatchID, BatchFailed) + finishErr := s.finishBatch(ctx, p.BatchID, BatchFailed, nil) if ctx.Err() != nil { return batchResult{}, errors.Join(ctx.Err(), finishErr) } @@ -85,50 +104,60 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe return batchResult{}, errors.Join(err, finishErr) } +// filled is what fill did: the batch's result, the names of the Side +// quests it added to the deck, and the household's date it ran on. +type filled struct { + res batchResult + names []string + today store.LocalDate +} + // fill does runBatch's work, returning a *jobs.Failure for a failure the // household may see. -func (s *Service) fill(ctx context.Context, batchID string, publish func(batchResult) error) (batchResult, error) { +func (s *Service) fill(ctx context.Context, batchID string, publish func(batchResult) error) (filled, error) { res := batchResult{BatchID: batchID, Stage: StageSuggesting, SuggestionIDs: []string{}} if err := publish(res); err != nil { - return batchResult{}, err + return filled{}, err } in, err := s.request(ctx) if err != nil { - return batchResult{}, err + return filled{}, err } out, err := s.suggest(ctx, batchID, in) if err != nil { if ctx.Err() != nil { - return batchResult{}, ctx.Err() + return filled{}, ctx.Err() } - return batchResult{}, &jobs.Failure{Code: ReasonAIFailed} + return filled{}, &jobs.Failure{Code: ReasonAIFailed} } res.Stage = StageGrounding res.Dropped = len(out.Dropped) if err := publish(res); err != nil { - return batchResult{}, err + return filled{}, err } + var names []string left := out.Suggestions for len(res.SuggestionIDs) < BatchSize && len(left) > 0 { round := left[:min(BatchSize-len(res.SuggestionIDs), len(left))] left = left[len(round):] grounded, err := s.ground(ctx, round) if err != nil { - return batchResult{}, err + return filled{}, err } res.Dropped += len(round) - len(grounded) - ids, err := s.store(ctx, batchID, grounded) + ids, stored, err := s.store(ctx, batchID, grounded) if err != nil { - return batchResult{}, err + return filled{}, err } res.Dropped += len(grounded) - len(ids) res.SuggestionIDs = append(res.SuggestionIDs, ids...) + names = append(names, stored...) if err := publish(res); err != nil { - return batchResult{}, err + return filled{}, err } } res.Stage = StageDone - return res, nil + return filled{res: res, names: names, today: in.Context.Today}, nil } // request reads what suggest_side_quests is given: the household context, @@ -203,9 +232,11 @@ func (s *Service) ground(ctx context.Context, quests []skills.SideQuest) ([]grou // suggest_side_quests ran, a dinner it repeats was added, a suggestion it // repeats passed or a Rotation check dropped, or a Regular it cites renamed // or archived. Passed names are read in the same transaction as the store, -// so a Pass or drop committed before it is never missed. -func (s *Service) store(ctx context.Context, batchID string, quests []groundedQuest) ([]string, error) { +// so a Pass or drop committed before it is never missed. The names stored +// are returned with the ids. +func (s *Service) store(ctx context.Context, batchID string, quests []groundedQuest) ([]string, []string, error) { ids := []string{} + var stored []string err := s.st.WithTx(ctx, func(tx *sql.Tx) error { q := db.New(tx) existing, err := q.ListUnarchivedDinners(ctx) @@ -269,13 +300,14 @@ func (s *Service) store(ctx context.Context, batchID string, quests []groundedQu } names = append(names, d.Name) ids = append(ids, id) + stored = append(stored, d.Name) } return nil }) if err != nil { - return nil, err + return nil, nil, err } - return ids, nil + return ids, stored, nil } // citedIDs returns the ids of the Regulars named cited, false when one diff --git a/internal/suggest/suggest.go b/internal/suggest/suggest.go index 287ad59..e4da046 100644 --- a/internal/suggest/suggest.go +++ b/internal/suggest/suggest.go @@ -20,6 +20,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" ) @@ -109,14 +110,16 @@ type Service struct { st *store.Store grounder *recipes.Grounder suggest Suggester + notifier *notify.Service logger *slog.Logger batchJob *jobs.Kind[BatchJob] } // NewService returns a service over st that suggests with suggest, grounds -// with grounder and registers the KindSuggestionBatch job on runner, which -// must not have started yet. -func NewService(st *store.Store, grounder *recipes.Grounder, suggest Suggester, runner *jobs.Runner, logger *slog.Logger) (*Service, error) { +// with grounder, tells the household through notifier, which must be over +// st, and registers the KindSuggestionBatch job on runner, which must not +// have started yet. +func NewService(st *store.Store, grounder *recipes.Grounder, suggest Suggester, runner *jobs.Runner, notifier *notify.Service, logger *slog.Logger) (*Service, error) { switch { case st == nil: return nil, errors.New("suggest: nil store") @@ -126,10 +129,12 @@ func NewService(st *store.Store, grounder *recipes.Grounder, suggest Suggester, return nil, errors.New("suggest: nil suggester") case runner == nil: return nil, errors.New("suggest: nil job runner") + case notifier == nil: + return nil, errors.New("suggest: nil notifier") case logger == nil: return nil, errors.New("suggest: nil logger") } - s := &Service{st: st, grounder: grounder, suggest: suggest, logger: logger} + s := &Service{st: st, grounder: grounder, suggest: suggest, notifier: notifier, logger: logger} kind, err := jobs.Register(runner, KindSuggestionBatch, s.runBatch, Reasons...) if err != nil { return nil, err @@ -252,27 +257,39 @@ func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (B return Batch{ID: row.ID, JobID: jobID, Trigger: row.Trigger, Status: row.Status, Created: true}, nil } -// finishBatch records running batch id as status, with -// jobs.FinalWriteContext: the write is made even when ctx has been -// cancelled, but a writer that stays busy cannot hold its worker past -// jobs.FinalWriteTimeout. Its job's handler returns any error, so the job +// finishBatch records running batch id as status, and emits ev with it +// when ev is not nil, with jobs.FinalWriteContext: the write is made even +// when ctx has been cancelled, but a writer that stays busy cannot hold its +// worker past jobs.FinalWriteTimeout. Its job's handler returns any error, so the job // never ends succeeded with the batch still running; the runner keeps only // a code, so the error is logged too. -func (s *Service) finishBatch(ctx context.Context, id, status string) error { +func (s *Service) finishBatch(ctx context.Context, id, status string, ev *notify.Event) error { write, cancel := jobs.FinalWriteContext(ctx) defer cancel() - n, err := db.New(s.st.Writer).FinishSuggestionBatch(write, db.FinishSuggestionBatchParams{ - Status: status, - UpdatedAt: store.Now().String(), - ID: id, + err := s.st.WithTx(write, func(tx *sql.Tx) error { + n, err := db.New(tx).FinishSuggestionBatch(write, db.FinishSuggestionBatchParams{ + Status: status, + UpdatedAt: store.Now().String(), + ID: id, + }) + if err != nil { + return err + } + if n != 1 { + return errors.New("batch is not running") + } + if ev == nil { + return nil + } + return s.notifier.EmitTx(write, tx, *ev) }) - if err == nil && n != 1 { - err = errors.New("batch is not running") - } if err != nil { s.logger.ErrorContext(ctx, "record suggestion batch status", "batch_id", id, "status", status, "error", err) return fmt.Errorf("suggest: record batch %s %s: %w", id, status, err) } + if ev != nil { + s.notifier.Wake() + } return nil } diff --git a/internal/suggest/suggest_test.go b/internal/suggest/suggest_test.go index cac527e..78f32a3 100644 --- a/internal/suggest/suggest_test.go +++ b/internal/suggest/suggest_test.go @@ -23,6 +23,8 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" "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" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest" @@ -121,7 +123,7 @@ func newEnv(t *testing.T) *env { if err != nil { t.Fatal(err) } - e.svc, err = suggest.NewService(st, g, suggester, e.runner, logger) + e.svc, err = suggest.NewService(st, g, suggester, e.runner, notifytest.New(t, st), logger) if err != nil { t.Fatal(err) } @@ -401,6 +403,57 @@ func TestBatchStoresGroundedSideQuests(t *testing.T) { } } +// TestBatchEmitsNewSuggestions: a scheduled or manual batch that adds Side +// quests stores new_suggestions keyed by the batch, naming them; a +// replenish batch, or one that adds none, stores nothing. +func TestBatchEmitsNewSuggestions(t *testing.T) { + for _, tc := range []struct { + trigger string + grounds bool + notify bool + }{ + {suggest.TriggerScheduled, true, true}, + {suggest.TriggerManual, true, true}, + {suggest.TriggerReplenish, true, false}, + {suggest.TriggerManual, false, false}, + } { + t.Run(fmt.Sprintf("%s grounds %v", tc.trigger, tc.grounds), func(t *testing.T) { + e := newEnv(t) + e.start(t) + if tc.grounds { + e.recipe(t, "Chicken Shawarma Bowls") + e.recipe(t, "Chili Verde") + } + e.reply( + sideQuest("Chicken Shawarma Bowls", "Spiced chicken, like your Chicken Fajitas.", "Chicken Fajitas"), + sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili"), + ) + b, err := e.svc.StartBatch(t.Context(), tc.trigger) + if err != nil { + t.Fatal(err) + } + if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusSucceeded { + t.Fatalf("batch job %s", job.Status) + } + rows := notifytest.Rows(t, e.st, notify.NewSuggestions) + if !tc.notify { + if len(rows) != 0 { + t.Errorf("new_suggestions rows = %+v, want none", rows) + } + return + } + if len(rows) != 1 { + t.Fatalf("new_suggestions rows = %+v, want one on push", rows) + } + r := rows[0] + if r.Key != "new_suggestions:"+b.ID || r.Channel != notify.ChannelPush || r.Payload.URL != "/discover" || + r.Payload.Title != "New Side quests are in" || r.Payload.Body != "Chicken Shawarma Bowls, Chili Verde" || r.Payload.Week == "" { + t.Errorf("new_suggestions row = %+v, want key new_suggestions:%s naming the Side quests, /discover", r, b.ID) + } + }) + } +} + func TestBatchKeepsAtMostBatchSize(t *testing.T) { e := newEnv(t) e.start(t) @@ -929,7 +982,7 @@ func TestLaterBatchNeverResuggestsRenamedDrop(t *testing.T) { fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) { return skills.Week{}, errors.New("no draft in this test") } - planSvc, err := plan.NewService(e.st, fail, e.runner, noCalendar{}, &url.URL{Scheme: "https", Host: "meals.example.com"}, logger) + planSvc, err := plan.NewService(e.st, fail, e.runner, noCalendar{}, notifytest.New(t, e.st), &url.URL{Scheme: "https", Host: "meals.example.com"}, logger) if err != nil { t.Fatal(err) } @@ -1151,6 +1204,9 @@ func TestBatchJobFailsWhenRecordingItsEndFails(t *testing.T) { if got := e.batchStatus(t, b.ID); got != suggest.BatchFailed { t.Errorf("batch status = %s with its job failed, want failed", got) } + if rows := notifytest.Rows(t, e.st, notify.NewSuggestions); len(rows) != 0 { + t.Errorf("new_suggestions rows of a batch whose end was not recorded = %+v, want none", rows) + } } func TestBatchRecoversWhenRecordingItsEndAndItsJobsEndFail(t *testing.T) { @@ -1284,7 +1340,7 @@ func TestBlockedBatchEndDoesNotHoldShutdown(t *testing.T) { refuse := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) { return skills.SideQuests{}, errors.New("not wanted") } - svc, err := suggest.NewService(e.st, e.grounder, refuse, next, logger) + svc, err := suggest.NewService(e.st, e.grounder, refuse, next, notifytest.New(t, e.st), logger) if err != nil { t.Fatal(err) } diff --git a/migrations/queries/shopping.sql b/migrations/queries/shopping.sql index 56bf1c3..99c6d68 100644 --- a/migrations/queries/shopping.sql +++ b/migrations/queries/shopping.sql @@ -89,6 +89,13 @@ UPDATE shopping_lists SET title = ?, updated_at = ? WHERE id = ? AND title IS NULL AND status = 'current' AND frozen_at IS NULL; +-- name: MarkShoppingListSentToChat :execrows +-- Records list id sent to Google Chat at sent_to_chat_at, unless it is +-- frozen into the Supply archive. +UPDATE shopping_lists +SET sent_to_chat_at = ?, updated_at = ? +WHERE id = ? AND status = 'current' AND frozen_at IS NULL; + -- name: FreezeShoppingList :execrows -- Freezes list id into the Supply archive at frozen_at, unless it is -- frozen already. diff --git a/migrations/queries/suggestions.sql b/migrations/queries/suggestions.sql index 1068622..5001325 100644 --- a/migrations/queries/suggestions.sql +++ b/migrations/queries/suggestions.sql @@ -26,6 +26,11 @@ WHERE kind = sqlc.arg(kind) ORDER BY id LIMIT 1; +-- name: GetSuggestionBatch :one +-- Batch id. Returns sql.ErrNoRows when there is none. +SELECT * FROM suggestion_batches +WHERE id = ?; + -- name: StartSuggestionBatch :execrows -- Marks a pending batch running; a batch in any other status is left as -- it is (0 rows). diff --git a/web/src/plan/PlanPage.module.css b/web/src/plan/PlanPage.module.css index 86298fd..03fcf98 100644 --- a/web/src/plan/PlanPage.module.css +++ b/web/src/plan/PlanPage.module.css @@ -258,75 +258,10 @@ color: var(--color-neutral-700); } -/* An action with nothing behind it yet (Send to Google Chat until Feature - 19): it keeps focus, so hovering, tapping or focusing it shows the - tooltip. */ -.unavailable { - position: relative; - display: flex; - flex-direction: column; -} - .pushButton { min-height: 50px; } -.unavailable [aria-disabled='true'] { - opacity: 0.45; - cursor: not-allowed; -} - -/* Unavailable, it neither darkens nor presses. */ -.unavailable .primary[aria-disabled='true']:hover, -.unavailable .primary[aria-disabled='true']:active { - background: var(--color-accent); - transform: none; -} - -.unavailable .outline[aria-disabled='true']:hover, -.unavailable .outline[aria-disabled='true']:active { - background: transparent; - transform: none; -} - -.hint { - position: absolute; - bottom: calc(100% + 8px); - left: 50%; - transform: translateX(-50%); - width: max-content; - max-width: 100%; - padding: 8px 12px; - border-radius: var(--radius-md); - background: var(--color-text); - color: var(--color-bg); - font-family: var(--font-body); - font-size: 13px; - line-height: 1.4; - text-align: center; - box-shadow: var(--shadow-md); - visibility: hidden; - pointer-events: none; -} - -/* In the plan manager's header the tooltip opens below, at the action's - right edge. */ -.below .hint { - top: calc(100% + 8px); - bottom: auto; - left: auto; - right: 0; - transform: none; - width: 240px; - max-width: none; - z-index: 1; -} - -.unavailable:hover .hint, -.unavailable [aria-disabled='true']:focus + .hint { - visibility: visible; -} - /* The plan manager's header: kicker and title beside the week buttons, over the sync status and the actions. */ .wideHeader { diff --git a/web/src/plan/PlanPage.tsx b/web/src/plan/PlanPage.tsx index 87124ba..63fb1c2 100644 --- a/web/src/plan/PlanPage.tsx +++ b/web/src/plan/PlanPage.tsx @@ -1,6 +1,6 @@ import { useQueryClient } from '@tanstack/react-query' import { CalendarDays, Check, ChevronLeft, ChevronRight, Plus, RefreshCw, ShoppingCart } from 'lucide-react' -import { useEffect, useId, useMemo, useState, type ReactNode } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Link } from 'react-router' import { ApiError, useJob } from '../api/index.ts' import { QUEST_LOG_KEY } from '../discover/api.ts' @@ -9,7 +9,7 @@ import { methodLabels } from '../settings/RegularsSettingsPage.tsx' import { formatClockTime, useHouseholdSettings } from '../settings/api.ts' import { supplyRunScreen, useGenerateSupplyRun, useSupplyRun } from '../shopping/api.ts' import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts' -import { Button, type ButtonVariant } from '../ui/Button.tsx' +import { Button } from '../ui/Button.tsx' import buttonStyles from '../ui/Button.module.css' import { EmptyState } from '../ui/EmptyState.tsx' import { ErrorBanner } from '../ui/ErrorBanner.tsx' @@ -672,40 +672,6 @@ function PushFailure({ error, cached, onRetry }: { error: Error; cached: boolean ) } -// Unavailable is an action with nothing behind it yet (Send to Google -// Chat): it stays focusable, so a tap or focus shows the tooltip saying -// why, above it or, at the top of the page, below. -export function Unavailable({ - hint, - variant = 'primary', - below = false, - className, - children, -}: { - hint: string - variant?: ButtonVariant - below?: boolean - className?: string - children: ReactNode -}) { - const hintId = useId() - return ( -
- - - {hint} - -
- ) -} - // PlanPage is the Draft board on the week chosen with the previous / next // week buttons: mock 1f on mobile; from tablet up the plan manager, with the // Quest log as a strip under the grid on tablet (2c) and the Quest log and diff --git a/web/src/shopping/SupplyRunPage.test.tsx b/web/src/shopping/SupplyRunPage.test.tsx index d48f353..773cd10 100644 --- a/web/src/shopping/SupplyRunPage.test.tsx +++ b/web/src/shopping/SupplyRunPage.test.tsx @@ -13,6 +13,7 @@ import { sentAt, SupplyRunPage } from './SupplyRunPage.tsx' const THIS_WEEK = '/api/v1/shopping/2026-10-05' const GENERATE = `${THIS_WEEK}/generate` +const SEND_CHAT = `${THIS_WEEK}/send-chat` const ITEM = (id: string) => `/api/v1/shopping/items/${id}` type Item = { id: string; name: string; qty: string | null; needed: boolean } @@ -372,19 +373,79 @@ describe('SupplyRunPage', () => { expect(archive).toHaveAttribute('href', '/plan/shopping/archive') expect(archive).toHaveClass(buttonStyles.outline!) - // Send to Google Chat waits for Feature 19: primary, unavailable, and - // its tooltip says so. Pressing it sends nothing. - const send = screen.getByRole('button', { name: 'Send to Google Chat' }) + // Already sent, the primary action sends it again. + const send = screen.getByRole('button', { name: 'Send again' }) expect(send).toHaveClass(buttonStyles.primary!) - expect(send).toHaveAttribute('aria-disabled', 'true') - expect(send).toHaveAccessibleDescription('Sending the shopping list to Google Chat arrives in a later update.') - fireEvent.click(send) + expect(send).toBeEnabled() expect(writes(fetchMock)).toEqual([]) expect(screen.getByText('Sent to Google Chat · Sat 8:00 am')).toBeInTheDocument() expect(screen.queryByRole('link', { name: 'Supply archive' })).not.toBeInTheDocument() }) + it('sends the list to Google Chat once and shows when it was sent', async () => { + mockViewport(1180) + const sent = deferred() + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()), + [`POST ${SEND_CHAT}`]: () => sent.response, + }) + await renderPage() + + await screen.findByText('4 / 6') + expect(screen.queryByText(/Sent to Google Chat ·/)).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Send to Google Chat' })) + + const sending = await screen.findByRole('button', { name: 'Sending…' }) + expect(sending).toBeDisabled() + expect(writes(fetchMock)).toEqual([`POST ${SEND_CHAT} {"resend":false}`]) + // Saturday Oct 10, 8:00 am in New York. + sent.send(jsonResponse(200, supplyRun({ sent_to_chat_at: '2026-10-10T12:00:00Z' }))) + + expect(await screen.findByText('Sent to Google Chat · Sat 8:00 am')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Send again' })).toBeEnabled() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('sends a list already sent again with resend', async () => { + mockViewport(1440) + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun({ sent_to_chat_at: '2026-10-10T12:00:00Z' })), + [`POST ${SEND_CHAT}`]: () => jsonResponse(200, supplyRun({ sent_to_chat_at: '2026-10-10T16:30:00Z' })), + }) + await renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Send again' })) + + expect(await screen.findByText('Sent to Google Chat · Sat 12:30 pm')).toBeInTheDocument() + expect(writes(fetchMock)).toEqual([`POST ${SEND_CHAT} {"resend":true}`]) + }) + + it('says why a send to Google Chat failed, and retries it', async () => { + mockViewport(1180) + const answers = [ + () => errorResponse(409, 'not_configured'), + () => jsonResponse(200, supplyRun({ sent_to_chat_at: '2026-10-10T12:00:00Z' })), + ] + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()), + [`POST ${SEND_CHAT}`]: () => answers.shift()!(), + }) + await renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Send to Google Chat' })) + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't send the shopping list to Google Chat. not_configured message") + expect(screen.queryByText(/Sent to Google Chat ·/)).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Send to Google Chat' })).toBeEnabled() + + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + expect(await screen.findByText('Sent to Google Chat · Sat 8:00 am')).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(writes(fetchMock)).toEqual([`POST ${SEND_CHAT} {"resend":false}`, `POST ${SEND_CHAT} {"resend":false}`]) + }) + it('renders an archived list read-only, tagged Archived', async () => { const fetchMock = stubServer({ 'GET /api/v1/shopping/2026-09-28': () => diff --git a/web/src/shopping/SupplyRunPage.tsx b/web/src/shopping/SupplyRunPage.tsx index f9ae536..a657db5 100644 --- a/web/src/shopping/SupplyRunPage.tsx +++ b/web/src/shopping/SupplyRunPage.tsx @@ -1,10 +1,10 @@ import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react' import { useState } from 'react' import { Link, useSearchParams } from 'react-router' -import { Unavailable } from '../plan/PlanPage.tsx' import { defaultWeek, localNow, weekdayKicker, weekLabel, weekStart as weekOf } from '../plan/week.ts' import { formatClockTime, useHouseholdSettings } from '../settings/api.ts' import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts' +import { Button } from '../ui/Button.tsx' import buttonStyles from '../ui/Button.module.css' import { Checkbox } from '../ui/Checkbox.tsx' import { EmptyState } from '../ui/EmptyState.tsx' @@ -14,6 +14,7 @@ import { Skeleton } from '../ui/Skeleton.tsx' import { Tag } from '../ui/Tag.tsx' import { useGenerateSupplyRun, + useSendToChat, useSupplyRun, useToggles, WEEK_PARAM, @@ -27,10 +28,6 @@ import styles from './SupplyRunPage.module.css' // SUPPLY_ARCHIVE is the Supply archive screen. export const SUPPLY_ARCHIVE = '/plan/shopping/archive' -// CHAT_HINT is why Send to Google Chat is unavailable. -// TODO(Feature 19): send the Supply run to Google Chat once it is built. -const CHAT_HINT = 'Sending the shopping list to Google Chat arrives in a later update.' - // sentAt is when the Supply run was sent to Google Chat as its status line // says it, in the household's zone: "Sat 8:00 am". export function sentAt(at: Date, timezone: string): string { @@ -104,6 +101,17 @@ function SentStatus({ at, timezone }: { at: Date; timezone: string }) { ) } +// SendToChat posts the Supply run to Google Chat: "Send to Google Chat" +// until it is sent, then "Send again", which posts it once more. +function SendToChat({ sent, pending, onSend }: { sent: boolean; pending: boolean; onSend: () => void }) { + return ( + + ) +} + // ArchiveLink opens the Supply archive: an outline action from tablet up, a // grape link on mobile. function ArchiveLink({ layout }: { layout: Breakpoint }) { @@ -120,13 +128,15 @@ function ArchiveLink({ layout }: { layout: Breakpoint }) { // the week's dates, the title with the count still to buy of all items, and // one card per dinner, stacked on mobile and in three masonry columns from // tablet up, where Archive and Send to Google Chat are actions in the -// header. On a current Supply run each item toggles at once (useToggles); a +// header; a send that fails says why. On a current Supply run each item toggles at once (useToggles); a // toggle refused puts the item back and says why. An archived one, or any // opened from the Supply archive (readOnly), is read-only and tagged // Archived, and a toggle it refused is not offered again. function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) { const toggles = useToggles(weekStart) + const send = useSendToChat(weekStart) const run = toggles.shown + const sent = run.sent_to_chat_at !== null const viewOnly = readOnly || run.status === 'archived' const wide = layout !== 'mobile' const failed = toggles.failed @@ -155,12 +165,7 @@ function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) { {wide && (
- {!viewOnly && ( - - - Send to Google Chat - - )} + {!viewOnly && send.mutate(sent)} />}
)} @@ -170,6 +175,9 @@ function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) { {failed.error.message} )} + {send.isError && !viewOnly && ( + send.mutate(sent)}>Couldn't send the shopping list to Google Chat. {send.error.message} + )} {wide && run.sent_to_chat_at !== null && } {run.groups.length === 0 ? ( None of this week's dinners has a recipe with ingredients yet. diff --git a/web/src/shopping/api.ts b/web/src/shopping/api.ts index 09fc0a3..f7ed4be 100644 --- a/web/src/shopping/api.ts +++ b/web/src/shopping/api.ts @@ -206,6 +206,25 @@ export function useGenerateSupplyRun(weekStart: string) { }) } +// useSendToChat posts the week's Supply run to Google Chat (POST +// /api/v1/shopping/{weekStart}/send-chat); the Supply run it answers with, +// sent_to_chat_at recorded, is the one shown. A week already sent is posted +// again only with resend: without it the server answers with the Supply +// run as it is. +export function useSendToChat(weekStart: string) { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (resend: boolean): Promise => { + const path = `${supplyRunPath(weekStart)}/send-chat` + return parseSupplyRun(`POST ${path}`, weekStart, await apiFetch(path, { method: 'POST', body: { resend } })) + }, + onSuccess: (run) => { + queryClient.setQueryData(supplyRunKey(weekStart), run) + }, + }) +} + // Toggle is one item marked to buy (needed) or "have it" (not needed). export type Toggle = { id: string; needed: boolean } -- 2.55.0 From 80a8dd0edf76805e112403f7c34372b92e1552ab Mon Sep 17 00:00:00 2001 From: dev_agent Date: Fri, 9 Oct 2026 16:26:19 -0400 Subject: [PATCH 2/2] Make new_suggestions and Send to Google Chat recoverable MQ-167-01: a scheduled or manual batch now emits new_suggestions:{batchId} in the transaction that first stores its Side quests, so the cards and their notification commit together. A later round that fails, or a failure recording the batch's end, no longer loses the event; a later round's emit is a no-op by key; a notification that cannot be stored fails that store, leaving no unannounced cards. Replenish batches still never notify. finishBatch goes back to recording status only. MQ-167-02: migration 0017 adds shopping_lists.chat_send_unconfirmed_at. SendToChat records the send as begun before posting, clears it with sent_to_chat_at once Google Chat accepts, and clears it alone when Chat refuses. A post Chat does not answer, or one it accepts that cannot be recorded, is ErrSendUnconfirmed (502 chat_unconfirmed, not retryable) and leaves the marker, as does a crash mid-send; a repeat without resend never reposts, and resend posts again. The Supply run screen re-reads the list after a failed send, shows an unconfirmed send, offers no Retry for it, and sends again only with resend. Task: f0cd9d50-9c11-41be-a945-7bae9f5f28a4 Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 31 +++--- internal/db/models.go | 19 ++-- internal/db/querier.go | 10 +- internal/db/shopping.sql.go | 58 +++++++++-- internal/server/shopping.go | 34 ++++--- internal/server/shopping_test.go | 37 ++++--- internal/shopping/chat.go | 50 ++++++++-- internal/shopping/notify_test.go | 89 +++++++++++++++-- internal/shopping/shopping.go | 27 +++-- internal/store/migrate_test.go | 41 +++++++- internal/suggest/batch.go | 90 ++++++++--------- internal/suggest/suggest.go | 36 +++---- internal/suggest/suggest_test.go | 99 ++++++++++++++++++- ...17_shopping_list_chat_send_unconfirmed.sql | 10 ++ migrations/queries/shopping.sql | 22 ++++- web/src/App.test.tsx | 1 + web/src/plan/PlanPage.test.tsx | 1 + web/src/shopping/SupplyArchivePage.test.tsx | 1 + web/src/shopping/SupplyRunPage.module.css | 4 + web/src/shopping/SupplyRunPage.test.tsx | 44 +++++++++ web/src/shopping/SupplyRunPage.tsx | 32 +++++- web/src/shopping/api.ts | 24 +++-- web/src/shopping/offlineQueue.test.tsx | 1 + 23 files changed, 586 insertions(+), 175 deletions(-) create mode 100644 migrations/0017_shopping_list_chat_send_unconfirmed.sql diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7c004aa..7ab3287 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -104,7 +104,7 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`). | `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at, revision (raised by every swap and stored draft). | | `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id (set while the chosen calendar shows the day as planned; cleared when its dinner changes, another calendar is chosen or Google Calendar is disconnected). | | `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), name (set only by a drop: the dinner's name when dropped, kept whatever it is later renamed, so a drop stays among the passed names), decided_at, updated_by. | -| `shopping_lists` | week_start (unique), plan_id, title (the AI-written post title; NULL until written or when the AI fails, when the week label is shown instead; never empty), status (`current` / `archived`), sent_to_chat_at, frozen_at. | +| `shopping_lists` | week_start (unique), plan_id, title (the AI-written post title; NULL until written or when the AI fails, when the week label is shown instead; never empty), status (`current` / `archived`), sent_to_chat_at, chat_send_unconfirmed_at (a send to Google Chat recorded as begun before posting and cleared once its outcome is known; left set when it may or may not have posted), frozen_at. | | `shopping_groups` / `shopping_items` | One group per plan day with ingredients: dinner_id, date, position (by date). Item name, qty (the display quantity, NULL when the recipe gives none), needed (bool), position. Made by `shopping.Generate` from the plan, with no model call: an item is a grounded recipe ingredient's normalized name with qty and unit joined ("2 cups"), or a manual recipe's line as typed with no qty. Regenerating keeps the id and needed flag of an item whose (dinner_id, name) is unchanged. | | `ai_settings` | One row: provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at. | | `recipe_search_settings` | One row: provider (`searxng` / `brave`), base_url (the SearXNG instance), api_key_ct and api_key_last4 (Brave). | @@ -669,8 +669,11 @@ Runs are recorded in `schedule_runs` and executed through `jobs`. run is first created, by `POST /shopping/{weekStart}/generate` or after Push to Calendar; remaking it does not notify. - `new_suggestions:{batchId}` (URL `/discover`) when a `scheduled` or - `manual` suggestion batch ends done having added Side quests, in the - transaction recording it done. A `replenish` batch never notifies. + `manual` suggestion batch first adds Side quests to the deck, in the + transaction storing them, so they are announced even when a later + round or recording the batch's end fails; it names that round's Side + quests, and a later round's emit stores nothing. A `replenish` batch + never notifies. - `day_of_reminder:{date}` (URL `/dinner/{id}`) through `plan.Service.EmitDayOfReminder(date)`, which names the dinner planned on the date and emits nothing for a week with no plan. The scheduler @@ -889,7 +892,8 @@ Runs are recorded in `schedule_runs` and executed through `jobs`. ## Supply run API (Feature 17) - `GET /api/v1/shopping/{weekStart}` returns `{weekStart, label, title, - status, sent_to_chat_at, needed_count, total_count, groups: [{dinner: + status, sent_to_chat_at, chat_send_unconfirmed_at, needed_count, + total_count, groups: [{dinner: {id, name}, date, day, items: [{id, name, qty, needed}]}]}`: `label` is the week label (shown while `title` is null), `day` the plan day's weekday ("Monday"), `qty` null when the recipe gives none, @@ -905,13 +909,18 @@ Runs are recorded in `schedule_runs` and executed through `jobs`. its title (or week label), each dinner with items still needed and those items, and a card whose "Open" button links to `/plan/shopping?week={weekStart}` under `MEALQUEST_PUBLIC_URL`. It - records `sent_to_chat_at` and answers with the Supply run as `GET` gives - it. A week already sent is answered as it is, with no second post, - unless `resend` is true. No webhook stored is 409 `not_configured`; - Google Chat not answering is 502 `chat_unreachable` (retryable) and - answering with a status other than 2xx 502 `chat_refused` (retryable - only for 429 or 5xx), and neither - records `sent_to_chat_at`. 404 when the week has no Supply run, 409 + records `chat_send_unconfirmed_at` before posting, then + `sent_to_chat_at` (clearing it) once Google Chat accepts, and answers + with the Supply run as `GET` gives it. A week already sent, or with a + send unconfirmed, is answered as it is, with no second post, unless + `resend` is true. No webhook stored is 409 `not_configured`; Google Chat + answering with a status other than 2xx is 502 `chat_refused` (retryable + only for 429 or 5xx), clearing the send begun; Google Chat not + answering, or accepting a post that cannot then be recorded, is 502 + `chat_unconfirmed` (not retryable: the post may have been made), which + leaves `chat_send_unconfirmed_at` set, as does the process stopping + mid-send. The screen shows an unconfirmed send and sends again only on + request. 404 when the week has no Supply run, 409 `archived` when it is in the Supply archive, 400 `invalid_request` for a bad body. `shopping.SendToChat(weekStart, resend)` is what `shopping_chat_autosend` calls (Feature 20). diff --git a/internal/db/models.go b/internal/db/models.go index 464b6b0..d451225 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -276,15 +276,16 @@ type ShoppingItem struct { } type ShoppingList struct { - ID string - WeekStart string - PlanID string - Title sql.NullString - Status string - SentToChatAt sql.NullString - FrozenAt sql.NullString - CreatedAt string - UpdatedAt string + ID string + WeekStart string + PlanID string + Title sql.NullString + Status string + SentToChatAt sql.NullString + FrozenAt sql.NullString + CreatedAt string + UpdatedAt string + ChatSendUnconfirmedAt sql.NullString } type Suggestion struct { diff --git a/internal/db/querier.go b/internal/db/querier.go index fbbab33..c02c42f 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -12,6 +12,10 @@ import ( type Querier interface { // An unknown id returns sql.ErrNoRows. ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (Dinner, error) + // Records a send of list id to Google Chat begun at + // chat_send_unconfirmed_at, before it is posted, unless the list is frozen + // into the Supply archive. + BeginShoppingListChatSend(ctx context.Context, arg BeginShoppingListChatSendParams) (int64, error) // Leases the pending notification due longest ago by moving its // next_attempt_at to lease_until, and returns it. A claimed row is not due // again until the lease ends, so no other claim takes it while it is being @@ -26,6 +30,8 @@ type Querier interface { // sets last_sync_error, keeping the chosen calendar. Affects no row when // nothing is connected. ClearIntegrationsGoogleToken(ctx context.Context, arg ClearIntegrationsGoogleTokenParams) (int64, error) + // Clears the send of list id begun, once Google Chat refused it. + ClearShoppingListChatSend(ctx context.Context, arg ClearShoppingListChatSendParams) (int64, error) // Drops references to the runs created before the cutoff, so the purge // can delete them. ClearSuggestionBatchAIRunsBefore(ctx context.Context, cutoff string) (int64, error) @@ -235,8 +241,8 @@ type Querier interface { // Records a delivered notification; updated_at is when it was sent. The // lease must still be the claim's. MarkNotificationSent(ctx context.Context, arg MarkNotificationSentParams) (int64, error) - // Records list id sent to Google Chat at sent_to_chat_at, unless it is - // frozen into the Supply archive. + // Records list id sent to Google Chat at sent_to_chat_at, its send + // confirmed, unless it is frozen into the Supply archive. MarkShoppingListSentToChat(ctx context.Context, arg MarkShoppingListSentToChatParams) (int64, error) // Records an edit of plan id, raising its revision: a synced plan becomes // changed, any other keeps its status. diff --git a/internal/db/shopping.sql.go b/internal/db/shopping.sql.go index eb8e921..91b1b9b 100644 --- a/internal/db/shopping.sql.go +++ b/internal/db/shopping.sql.go @@ -10,6 +10,49 @@ import ( "database/sql" ) +const beginShoppingListChatSend = `-- name: BeginShoppingListChatSend :execrows +UPDATE shopping_lists +SET chat_send_unconfirmed_at = ?, updated_at = ? +WHERE id = ? AND status = 'current' AND frozen_at IS NULL +` + +type BeginShoppingListChatSendParams struct { + ChatSendUnconfirmedAt sql.NullString + UpdatedAt string + ID string +} + +// Records a send of list id to Google Chat begun at +// chat_send_unconfirmed_at, before it is posted, unless the list is frozen +// into the Supply archive. +func (q *Queries) BeginShoppingListChatSend(ctx context.Context, arg BeginShoppingListChatSendParams) (int64, error) { + result, err := q.db.ExecContext(ctx, beginShoppingListChatSend, arg.ChatSendUnconfirmedAt, arg.UpdatedAt, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const clearShoppingListChatSend = `-- name: ClearShoppingListChatSend :execrows +UPDATE shopping_lists +SET chat_send_unconfirmed_at = NULL, updated_at = ? +WHERE id = ? +` + +type ClearShoppingListChatSendParams struct { + UpdatedAt string + ID string +} + +// Clears the send of list id begun, once Google Chat refused it. +func (q *Queries) ClearShoppingListChatSend(ctx context.Context, arg ClearShoppingListChatSendParams) (int64, error) { + result, err := q.db.ExecContext(ctx, clearShoppingListChatSend, arg.UpdatedAt, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const createShoppingGroup = `-- name: CreateShoppingGroup :exec INSERT INTO shopping_groups (id, list_id, dinner_id, date, position, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) @@ -135,7 +178,7 @@ func (q *Queries) FreezeShoppingList(ctx context.Context, arg FreezeShoppingList } const getShoppingItemList = `-- name: GetShoppingItemList :one -SELECT shopping_lists.id, shopping_lists.week_start, shopping_lists.plan_id, shopping_lists.title, shopping_lists.status, shopping_lists.sent_to_chat_at, shopping_lists.frozen_at, shopping_lists.created_at, shopping_lists.updated_at +SELECT shopping_lists.id, shopping_lists.week_start, shopping_lists.plan_id, shopping_lists.title, shopping_lists.status, shopping_lists.sent_to_chat_at, shopping_lists.frozen_at, shopping_lists.created_at, shopping_lists.updated_at, shopping_lists.chat_send_unconfirmed_at FROM shopping_items JOIN shopping_groups ON shopping_groups.id = shopping_items.group_id JOIN shopping_lists ON shopping_lists.id = shopping_groups.list_id @@ -161,12 +204,13 @@ func (q *Queries) GetShoppingItemList(ctx context.Context, id string) (GetShoppi &i.ShoppingList.FrozenAt, &i.ShoppingList.CreatedAt, &i.ShoppingList.UpdatedAt, + &i.ShoppingList.ChatSendUnconfirmedAt, ) return i, err } const getShoppingList = `-- name: GetShoppingList :one -SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at FROM shopping_lists +SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at, chat_send_unconfirmed_at FROM shopping_lists WHERE week_start = ? ` @@ -185,6 +229,7 @@ func (q *Queries) GetShoppingList(ctx context.Context, weekStart string) (Shoppi &i.FrozenAt, &i.CreatedAt, &i.UpdatedAt, + &i.ChatSendUnconfirmedAt, ) return i, err } @@ -322,7 +367,7 @@ func (q *Queries) ListShoppingItems(ctx context.Context, listID string) ([]ListS } const listShoppingListsThrough = `-- name: ListShoppingListsThrough :many -SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at FROM shopping_lists +SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at, chat_send_unconfirmed_at FROM shopping_lists WHERE week_start <= ? ORDER BY week_start DESC ` @@ -348,6 +393,7 @@ func (q *Queries) ListShoppingListsThrough(ctx context.Context, weekStart string &i.FrozenAt, &i.CreatedAt, &i.UpdatedAt, + &i.ChatSendUnconfirmedAt, ); err != nil { return nil, err } @@ -364,7 +410,7 @@ func (q *Queries) ListShoppingListsThrough(ctx context.Context, weekStart string const markShoppingListSentToChat = `-- name: MarkShoppingListSentToChat :execrows UPDATE shopping_lists -SET sent_to_chat_at = ?, updated_at = ? +SET sent_to_chat_at = ?, chat_send_unconfirmed_at = NULL, updated_at = ? WHERE id = ? AND status = 'current' AND frozen_at IS NULL ` @@ -374,8 +420,8 @@ type MarkShoppingListSentToChatParams struct { ID string } -// Records list id sent to Google Chat at sent_to_chat_at, unless it is -// frozen into the Supply archive. +// Records list id sent to Google Chat at sent_to_chat_at, its send +// confirmed, unless it is frozen into the Supply archive. func (q *Queries) MarkShoppingListSentToChat(ctx context.Context, arg MarkShoppingListSentToChatParams) (int64, error) { result, err := q.db.ExecContext(ctx, markShoppingListSentToChat, arg.SentToChatAt, arg.UpdatedAt, arg.ID) if err != nil { diff --git a/internal/server/shopping.go b/internal/server/shopping.go index 565be55..c5bb173 100644 --- a/internal/server/shopping.go +++ b/internal/server/shopping.go @@ -29,6 +29,9 @@ type supplyRun struct { Title *string `json:"title"` Status string `json:"status"` SentToChatAt *string `json:"sent_to_chat_at"` + // ChatSendUnconfirmedAt is when a send to Google Chat whose outcome is + // not known began. + ChatSendUnconfirmedAt *string `json:"chat_send_unconfirmed_at"` shopping.Counts Groups []supplyRunGroup `json:"groups"` } @@ -132,11 +135,13 @@ func postSupplyRunGenerate(logger *slog.Logger, svc *shopping.Service) http.Hand // postSupplyRunSendChat posts the week's Supply run to Google Chat and // answers with it, sent_to_chat_at recorded. Its body is a JSON object, -// {} or {"resend": true}: a week already sent is answered as it is, -// without a second post, unless resend is true. No webhook stored is 409 -// not_configured; Google Chat not answering is 502 chat_unreachable, -// retryable, and refusing the message 502 chat_refused, retryable only for -// 429 or 5xx. +// {} or {"resend": true}: a week already sent, or with a send +// unconfirmed, is answered as it is, without a second post, unless resend +// is true. No webhook stored is 409 not_configured; a send whose outcome is +// not known (Google Chat did not answer, or its acceptance could not be +// recorded) 502 chat_unconfirmed, not retryable, since retrying could post +// twice; and Google Chat refusing the message 502 chat_refused, retryable +// only for 429 or 5xx. func postSupplyRunSendChat(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { week, ok := weekStartOrFail(w, r, logger) @@ -154,9 +159,9 @@ func postSupplyRunSendChat(logger *slog.Logger, svc *shopping.Service) http.Hand case errors.Is(err, chat.ErrNotConfigured): writeError(w, r, logger, http.StatusConflict, "not_configured", "no Google Chat webhook is configured; add one in Settings") return - case errors.Is(err, chat.ErrUnreachable): + case errors.Is(err, shopping.ErrSendUnconfirmed): logger.WarnContext(r.Context(), "send Supply run to Google Chat", "error", err) - writeAPIError(w, r, logger, http.StatusBadGateway, apiError{Code: "chat_unreachable", Message: "Google Chat did not answer", Retryable: true}) + writeAPIError(w, r, logger, http.StatusBadGateway, apiError{Code: "chat_unconfirmed", Message: shopping.ErrSendUnconfirmed.Error()}) return case errors.As(err, &refused): logger.WarnContext(r.Context(), "send Supply run to Google Chat", "error", err) @@ -287,13 +292,14 @@ func supplyRunOf(week store.LocalDate, l shopping.List) (supplyRun, error) { } } return supplyRun{ - WeekStart: l.WeekStart, - Label: label, - Title: l.Title, - Status: l.Status, - SentToChatAt: l.SentToChatAt, - Counts: l.Counts(), - Groups: groups, + WeekStart: l.WeekStart, + Label: label, + Title: l.Title, + Status: l.Status, + SentToChatAt: l.SentToChatAt, + ChatSendUnconfirmedAt: l.ChatSendUnconfirmedAt, + Counts: l.Counts(), + Groups: groups, }, nil } diff --git a/internal/server/shopping_test.go b/internal/server/shopping_test.go index 2f1caf8..7128449 100644 --- a/internal/server/shopping_test.go +++ b/internal/server/shopping_test.go @@ -83,14 +83,15 @@ func (e plansEnv) archive(t *testing.T) { // supplyRunJSON is a Supply run answer decoded with every field the API // promises. type supplyRunJSON struct { - WeekStart string `json:"weekStart"` - Label string `json:"label"` - Title *string `json:"title"` - Status string `json:"status"` - SentToChatAt *string `json:"sent_to_chat_at"` - NeededCount int `json:"needed_count"` - TotalCount int `json:"total_count"` - Groups []struct { + WeekStart string `json:"weekStart"` + Label string `json:"label"` + Title *string `json:"title"` + Status string `json:"status"` + SentToChatAt *string `json:"sent_to_chat_at"` + ChatSendUnconfirmedAt *string `json:"chat_send_unconfirmed_at"` + NeededCount int `json:"needed_count"` + TotalCount int `json:"total_count"` + Groups []struct { Dinner struct { ID string `json:"id"` Name string `json:"name"` @@ -185,7 +186,7 @@ func TestSupplyRunShape(t *testing.T) { if status != http.StatusOK { t.Fatalf("generate = %d %v", status, raw) } - wantTop := []string{"groups", "label", "needed_count", "sent_to_chat_at", "skipped", "status", "title", "total_count", "weekStart"} + wantTop := []string{"chat_send_unconfirmed_at", "groups", "label", "needed_count", "sent_to_chat_at", "skipped", "status", "title", "total_count", "weekStart"} if got := keys(raw); !reflect.DeepEqual(got, wantTop) { t.Errorf("generate fields = %v, want %v", got, wantTop) } @@ -888,15 +889,19 @@ func TestSupplyRunSendChatErrors(t *testing.T) { if status != http.StatusBadGateway || errorCode(body) != "chat_refused" || body["error"].(map[string]any)["retryable"] != false { t.Errorf("send Google Chat answers 404 = %d %v, want 502 chat_refused, not retryable", status, body) } - e.chat.Close() - status, body = callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/send-chat", `{}`, nil) - if status != http.StatusBadGateway || errorCode(body) != "chat_unreachable" { - t.Errorf("send Google Chat does not answer = %d %v, want 502 chat_unreachable", status, body) - } var l supplyRunJSON callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l) - if l.SentToChatAt != nil { - t.Errorf("sent_to_chat_at after failed sends = %s, want null", *l.SentToChatAt) + if l.SentToChatAt != nil || l.ChatSendUnconfirmedAt != nil { + t.Errorf("after refused sends sent_to_chat_at = %v, chat_send_unconfirmed_at = %v, want both null", l.SentToChatAt, l.ChatSendUnconfirmedAt) + } + e.chat.Close() + status, body = callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/send-chat", `{}`, nil) + if status != http.StatusBadGateway || errorCode(body) != "chat_unconfirmed" || body["error"].(map[string]any)["retryable"] != false { + t.Errorf("send Google Chat does not answer = %d %v, want 502 chat_unconfirmed, not retryable", status, body) + } + callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l) + if l.SentToChatAt != nil || l.ChatSendUnconfirmedAt == nil { + t.Errorf("after an unanswered send sent_to_chat_at = %v, chat_send_unconfirmed_at = %v, want null and set", l.SentToChatAt, l.ChatSendUnconfirmedAt) } e.archive(t) diff --git a/internal/shopping/chat.go b/internal/shopping/chat.go index f109926..93c4c84 100644 --- a/internal/shopping/chat.go +++ b/internal/shopping/chat.go @@ -3,6 +3,7 @@ package shopping import ( "context" "database/sql" + "errors" "fmt" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat" @@ -16,10 +17,17 @@ import ( // Chat (chat.PostSupplyRun): each dinner with its items still needed, and a // link to the Supply run. It records sent_to_chat_at and returns the list. // -// A week is sent once: a Supply run already sent is returned as it is, -// without posting again, unless resend is true. A week with no Supply run -// is ErrListNotFound and one frozen into the Supply archive ErrFrozen. A -// post that fails returns chat.PostSupplyRun's error and records nothing. +// A week is sent once: a Supply run already sent, or with a send +// unconfirmed, is returned as it is, without posting again, unless resend +// is true. A week with no Supply run is ErrListNotFound and one frozen into +// the Supply archive ErrFrozen. +// +// The send is recorded as begun, in chat_send_unconfirmed_at, before it is +// posted, so a post whose outcome is lost is never taken for one never +// made. A post Google Chat refuses, or that is never made, returns +// chat.PostSupplyRun's error and clears it. A post Google Chat does not +// answer, or one it accepts that cannot then be recorded, is +// ErrSendUnconfirmed and leaves it, as does the process stopping mid-send. func (s *Service) SendToChat(ctx context.Context, weekStart store.LocalDate, resend bool) (List, error) { s.sending.Lock() defer s.sending.Unlock() @@ -30,7 +38,7 @@ func (s *Service) SendToChat(ctx context.Context, weekStart store.LocalDate, res if l.Status != StatusCurrent { return List{}, ErrFrozen } - if l.SentToChatAt != nil && !resend { + if (l.SentToChatAt != nil || l.ChatSendUnconfirmedAt != nil) && !resend { return l, nil } name, err := household.WeekLabel(weekStart) @@ -54,20 +62,42 @@ func (s *Service) SendToChat(ctx context.Context, weekStart store.LocalDate, res groups[i].Items = append(groups[i].Items, item) } } - if err := s.chat.PostSupplyRun(ctx, weekStart, name, groups); err != nil { - return List{}, err + q := db.New(s.st.Writer) + n, err := q.BeginShoppingListChatSend(ctx, db.BeginShoppingListChatSendParams{ + ChatSendUnconfirmedAt: sql.NullString{String: store.Now().String(), Valid: true}, + UpdatedAt: store.Now().String(), + ID: l.ID, + }) + if err != nil { + return List{}, fmt.Errorf("shopping: record send to Google Chat begun: %w", err) } - // The post is made: record it even if the client has gone. + if n != 1 { + return List{}, ErrFrozen + } + postErr := s.chat.PostSupplyRun(ctx, weekStart, name, groups) + // The post's outcome is known: record it even if the client has gone. write, cancel := jobs.FinalWriteContext(ctx) defer cancel() + if errors.Is(postErr, chat.ErrUnreachable) { + return List{}, fmt.Errorf("%w: %w", ErrSendUnconfirmed, postErr) + } + if postErr != nil { + if _, err := q.ClearShoppingListChatSend(write, db.ClearShoppingListChatSendParams{ + UpdatedAt: store.Now().String(), + ID: l.ID, + }); err != nil { + return List{}, errors.Join(postErr, fmt.Errorf("shopping: clear refused send to Google Chat: %w", err)) + } + return List{}, postErr + } now := store.Now().String() - n, err := db.New(s.st.Writer).MarkShoppingListSentToChat(write, db.MarkShoppingListSentToChatParams{ + n, err = q.MarkShoppingListSentToChat(write, db.MarkShoppingListSentToChatParams{ SentToChatAt: sql.NullString{String: now, Valid: true}, UpdatedAt: now, ID: l.ID, }) if err != nil { - return List{}, fmt.Errorf("shopping: record sent to Google Chat: %w", err) + return List{}, fmt.Errorf("%w: record sent to Google Chat: %w", ErrSendUnconfirmed, err) } if n != 1 { return List{}, ErrFrozen diff --git a/internal/shopping/notify_test.go b/internal/shopping/notify_test.go index 0615750..eb23d45 100644 --- a/internal/shopping/notify_test.go +++ b/internal/shopping/notify_test.go @@ -127,8 +127,8 @@ func TestSendToChat(t *testing.T) { } // TestSendToChatFailuresRecordNothing: a send with no webhook, or one -// Google Chat refuses or does not answer, leaves sent_to_chat_at unset; a -// week with no list, or a frozen one, is refused without posting. +// Google Chat refuses, leaves sent_to_chat_at and chat_send_unconfirmed_at +// unset; a week with no list, or a frozen one, is refused without posting. func TestSendToChatFailuresRecordNothing(t *testing.T) { e := newEnv(t) e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}) @@ -148,12 +148,8 @@ func TestSendToChatFailuresRecordNothing(t *testing.T) { if _, err := e.svc.SendToChat(t.Context(), week, false); !errors.Is(err, chat.ErrRefused) { t.Errorf("SendToChat answered 500 = %v, want chat.ErrRefused", err) } - e.chat.Close() - if _, err := e.svc.SendToChat(t.Context(), week, false); !errors.Is(err, chat.ErrUnreachable) { - t.Errorf("SendToChat unanswered = %v, want chat.ErrUnreachable", err) - } - if l, err := e.svc.Get(t.Context(), week); err != nil || l.SentToChatAt != nil { - t.Errorf("sent_to_chat_at after failed sends = %v, %v, want unset", l.SentToChatAt, err) + if l, err := e.svc.Get(t.Context(), week); err != nil || l.SentToChatAt != nil || l.ChatSendUnconfirmedAt != nil { + t.Errorf("after failed sends sent_to_chat_at = %v, chat_send_unconfirmed_at = %v, %v, want both unset", l.SentToChatAt, l.ChatSendUnconfirmedAt, err) } if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil { @@ -167,3 +163,80 @@ func TestSendToChatFailuresRecordNothing(t *testing.T) { t.Errorf("a frozen list was posted") } } + +// TestSendToChatUnconfirmed: a send whose outcome is not known, because +// Google Chat accepted the post but recording it failed, or did not answer, +// is ErrSendUnconfirmed and leaves the send unconfirmed rather than unsent: +// a send without resend does not post again, and one with resend does, +// confirming it. +func TestSendToChatUnconfirmed(t *testing.T) { + e := newEnv(t) + e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}) + e.plan(t, "Beef Chili") + e.generate(t) + if _, err := e.chatSvc.Put(t.Context(), chattest.Webhook, "Family"); err != nil { + t.Fatal(err) + } + if _, err := e.st.Writer.Exec(`CREATE TRIGGER refuse_sent BEFORE UPDATE OF sent_to_chat_at ON shopping_lists BEGIN SELECT RAISE(ABORT, 'sent refused'); END`); err != nil { + t.Fatal(err) + } + + if _, err := e.svc.SendToChat(t.Context(), week, false); !errors.Is(err, shopping.ErrSendUnconfirmed) { + t.Fatalf("SendToChat accepted but not recorded = %v, want ErrSendUnconfirmed", err) + } + if n := len(e.chat.Posts()); n != 1 { + t.Fatalf("Google Chat got %d posts, want 1", n) + } + l, err := e.svc.Get(t.Context(), week) + if err != nil || l.SentToChatAt != nil || l.ChatSendUnconfirmedAt == nil { + t.Fatalf("after an unrecorded send sent_to_chat_at = %v, chat_send_unconfirmed_at = %v, %v, want unset and set", l.SentToChatAt, l.ChatSendUnconfirmedAt, err) + } + if _, err := e.st.Writer.Exec(`DROP TRIGGER refuse_sent`); err != nil { + t.Fatal(err) + } + + again, err := e.svc.SendToChat(t.Context(), week, false) + if err != nil { + t.Fatal(err) + } + if n := len(e.chat.Posts()); n != 1 || again.ChatSendUnconfirmedAt == nil || *again.ChatSendUnconfirmedAt != *l.ChatSendUnconfirmedAt { + t.Errorf("send again without resend: %d posts, chat_send_unconfirmed_at %v, want 1 post and %s", n, again.ChatSendUnconfirmedAt, *l.ChatSendUnconfirmedAt) + } + + resent, err := e.svc.SendToChat(t.Context(), week, true) + if err != nil { + t.Fatal(err) + } + if n := len(e.chat.Posts()); n != 2 || resent.SentToChatAt == nil || resent.ChatSendUnconfirmedAt != nil { + t.Errorf("resend: %d posts, sent_to_chat_at %v, chat_send_unconfirmed_at %v, want 2 posts, set and unset", n, resent.SentToChatAt, resent.ChatSendUnconfirmedAt) + } + + e.chat.Close() + if _, err := e.svc.SendToChat(t.Context(), week, true); !errors.Is(err, shopping.ErrSendUnconfirmed) || !errors.Is(err, chat.ErrUnreachable) { + t.Errorf("SendToChat unanswered = %v, want ErrSendUnconfirmed and chat.ErrUnreachable", err) + } + if l, err := e.svc.Get(t.Context(), week); err != nil || l.SentToChatAt == nil || *l.SentToChatAt != *resent.SentToChatAt || l.ChatSendUnconfirmedAt == nil { + t.Errorf("after an unanswered resend sent_to_chat_at = %v, chat_send_unconfirmed_at = %v, %v, want %s and set", l.SentToChatAt, l.ChatSendUnconfirmedAt, err, *resent.SentToChatAt) + } +} + +// TestSendToChatPostsOnlyOnceBegun: a send that cannot be recorded as +// begun is not posted. +func TestSendToChatPostsOnlyOnceBegun(t *testing.T) { + e := newEnv(t) + e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}) + e.plan(t, "Beef Chili") + e.generate(t) + if _, err := e.chatSvc.Put(t.Context(), chattest.Webhook, "Family"); err != nil { + t.Fatal(err) + } + if _, err := e.st.Writer.Exec(`CREATE TRIGGER refuse_begin BEFORE UPDATE OF chat_send_unconfirmed_at ON shopping_lists WHEN NEW.chat_send_unconfirmed_at IS NOT NULL BEGIN SELECT RAISE(ABORT, 'begin refused'); END`); err != nil { + t.Fatal(err) + } + if _, err := e.svc.SendToChat(t.Context(), week, false); err == nil { + t.Fatal("SendToChat not recorded as begun succeeded, want an error") + } + if n := len(e.chat.Posts()); n != 0 { + t.Errorf("Google Chat got %d posts of a send not recorded as begun, want 0", n) + } +} diff --git a/internal/shopping/shopping.go b/internal/shopping/shopping.go index 3d26211..595d321 100644 --- a/internal/shopping/shopping.go +++ b/internal/shopping/shopping.go @@ -82,6 +82,12 @@ var ErrItemNotFound = errors.New("no Supply run has that item") // the household's time zone. var ErrWeekNotEnded = errors.New("the week has not ended") +// ErrSendUnconfirmed is a send to Google Chat whose outcome is not known: +// Google Chat did not answer, or accepted the post but recording it failed. +// The post may have been made, so the Supply run keeps the send as +// unconfirmed and is posted again only with resend. +var ErrSendUnconfirmed = errors.New("the shopping list may have reached Google Chat, but that could not be confirmed; check the space before sending again") + // ErrInvalidPeriod is an Archive grouping other than ByWeek, ByMonth and // ByYear. var ErrInvalidPeriod = errors.New("by must be week, month or year") @@ -158,7 +164,11 @@ type List struct { // SentToChatAt is when the list was sent to Google Chat; nil until it // is. SentToChatAt *string `json:"sent_to_chat_at"` - Groups []Group `json:"groups"` + // ChatSendUnconfirmedAt is when a send to Google Chat whose outcome is + // not known began: one under way, or one that may or may not have been + // posted. nil when there is none. + ChatSendUnconfirmedAt *string `json:"chat_send_unconfirmed_at"` + Groups []Group `json:"groups"` } // Counts are how many items of a Supply run are still needed and how many @@ -563,13 +573,14 @@ func frozen(list db.ShoppingList) bool { // listOf is list with groups. func listOf(list db.ShoppingList, groups []Group) List { return List{ - ID: list.ID, - WeekStart: list.WeekStart, - PlanID: list.PlanID, - Title: nullString(list.Title), - Status: list.Status, - SentToChatAt: nullString(list.SentToChatAt), - Groups: groups, + ID: list.ID, + WeekStart: list.WeekStart, + PlanID: list.PlanID, + Title: nullString(list.Title), + Status: list.Status, + SentToChatAt: nullString(list.SentToChatAt), + ChatSendUnconfirmedAt: nullString(list.ChatSendUnconfirmedAt), + Groups: groups, } } diff --git a/internal/store/migrate_test.go b/internal/store/migrate_test.go index f674930..bf09f0c 100644 --- a/internal/store/migrate_test.go +++ b/internal/store/migrate_test.go @@ -176,8 +176,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) { if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil { t.Fatal(err) } - if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled" { - t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled", got) + if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed" { + t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed", got) } } @@ -197,8 +197,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) { if err := Migrate(ctx, s.Writer, migrations.FS); err != nil { t.Fatal(err) } - if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled" { - t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled", got) + if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed" { + t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed", got) } if !tableExists(t, s, "dinners") { t.Error("table from 0001_init was not created") @@ -889,3 +889,36 @@ func TestMigrateShoppingListUntitledRefusesUndatedGroup(t *testing.T) { t.Error("shopping_groups_copy left behind") } } + +// TestMigrateShoppingListChatSendUnconfirmed applies 0017 over a list +// already sent: it keeps every value, and the list has no send +// unconfirmed. +func TestMigrateShoppingListChatSendUnconfirmed(t *testing.T) { + ctx := context.Background() + s := openTestStore(t) + migrateBefore(t, s, "0017") + for _, stmt := range []string{ + `INSERT INTO week_plans (id, week_start, status, created_at, updated_at) + VALUES ('plan-1', '2026-10-12', 'synced', 'c', 'u')`, + `INSERT INTO shopping_lists (id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at) + VALUES ('list-1', '2026-10-12', 'plan-1', 'Noodle week', 'current', 's', NULL, 'c1', 'u1')`, + } { + if _, err := s.Writer.Exec(stmt); err != nil { + t.Fatalf("seed: %v", err) + } + } + + if err := Migrate(ctx, s.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + + var got string + if err := s.Reader.QueryRowContext(ctx, `SELECT + id || '|' || title || '|' || status || '|' || sent_to_chat_at || '|' || COALESCE(chat_send_unconfirmed_at, 'NULL') || '|' || updated_at + FROM shopping_lists`).Scan(&got); err != nil { + t.Fatal(err) + } + if want := "list-1|Noodle week|current|s|NULL|u1"; got != want { + t.Errorf("shopping list after 0017 = %s, want %s", got, want) + } +} diff --git a/internal/suggest/batch.go b/internal/suggest/batch.go index 826ae13..00f26b0 100644 --- a/internal/suggest/batch.go +++ b/internal/suggest/batch.go @@ -15,7 +15,6 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" "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" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" ) @@ -58,12 +57,12 @@ func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) } // shutdown, the job is left running and the batch with it, for the next // start to fail. // -// A scheduled or manual batch that ends done having added Side quests to -// the deck emits new_suggestions, keyed by the batch, in the transaction -// that records it done. A replenish batch, which tops up the deck as the -// household swipes, never notifies. A notification that cannot be stored -// fails the done write with it, as any failure recording the batch's end -// does: the job fails, and its Side quests stay in the deck. +// A scheduled or manual batch emits new_suggestions, keyed by the batch, +// in the transaction that first adds Side quests to the deck, so the +// household is told of every Side quest stored, whether or not the batch +// then ends done: a later round, or recording the batch's end, may fail. +// A replenish batch, which tops up the deck as the household swipes, never +// notifies. See store. func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchResult) error) (batchResult, error) { batch, err := db.New(s.st.Reader).GetSuggestionBatch(ctx, p.BatchID) if err != nil { @@ -79,19 +78,14 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe if n == 0 { return batchResult{}, fmt.Errorf("suggest: batch %s is not pending", p.BatchID) } - f, err := s.fill(ctx, p.BatchID, publish) + res, err := s.fill(ctx, p.BatchID, batch.Trigger != TriggerReplenish, publish) if err == nil { - var ev *notify.Event - if batch.Trigger != TriggerReplenish && len(f.names) > 0 { - e := chat.NewSuggestions(p.BatchID, household.WeekStart(f.today), f.names) - ev = &e - } - if err := s.finishBatch(ctx, p.BatchID, BatchDone, ev); err != nil { + if err := s.finishBatch(ctx, p.BatchID, BatchDone); err != nil { return batchResult{}, err } - return f.res, nil + return res, nil } - finishErr := s.finishBatch(ctx, p.BatchID, BatchFailed, nil) + finishErr := s.finishBatch(ctx, p.BatchID, BatchFailed) if ctx.Err() != nil { return batchResult{}, errors.Join(ctx.Err(), finishErr) } @@ -104,60 +98,56 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe return batchResult{}, errors.Join(err, finishErr) } -// filled is what fill did: the batch's result, the names of the Side -// quests it added to the deck, and the household's date it ran on. -type filled struct { - res batchResult - names []string - today store.LocalDate -} - // fill does runBatch's work, returning a *jobs.Failure for a failure the -// household may see. -func (s *Service) fill(ctx context.Context, batchID string, publish func(batchResult) error) (filled, error) { +// household may see. When notifies is true, store emits new_suggestions +// with the Side quests it adds. +func (s *Service) fill(ctx context.Context, batchID string, notifies bool, publish func(batchResult) error) (batchResult, error) { res := batchResult{BatchID: batchID, Stage: StageSuggesting, SuggestionIDs: []string{}} if err := publish(res); err != nil { - return filled{}, err + return batchResult{}, err } in, err := s.request(ctx) if err != nil { - return filled{}, err + return batchResult{}, err } out, err := s.suggest(ctx, batchID, in) if err != nil { if ctx.Err() != nil { - return filled{}, ctx.Err() + return batchResult{}, ctx.Err() } - return filled{}, &jobs.Failure{Code: ReasonAIFailed} + return batchResult{}, &jobs.Failure{Code: ReasonAIFailed} } res.Stage = StageGrounding res.Dropped = len(out.Dropped) if err := publish(res); err != nil { - return filled{}, err + return batchResult{}, err + } + var week *store.LocalDate + if notifies { + w := household.WeekStart(in.Context.Today) + week = &w } - var names []string left := out.Suggestions for len(res.SuggestionIDs) < BatchSize && len(left) > 0 { round := left[:min(BatchSize-len(res.SuggestionIDs), len(left))] left = left[len(round):] grounded, err := s.ground(ctx, round) if err != nil { - return filled{}, err + return batchResult{}, err } res.Dropped += len(round) - len(grounded) - ids, stored, err := s.store(ctx, batchID, grounded) + ids, err := s.store(ctx, batchID, week, grounded) if err != nil { - return filled{}, err + return batchResult{}, err } res.Dropped += len(grounded) - len(ids) res.SuggestionIDs = append(res.SuggestionIDs, ids...) - names = append(names, stored...) if err := publish(res); err != nil { - return filled{}, err + return batchResult{}, err } } res.Stage = StageDone - return filled{res: res, names: names, today: in.Context.Today}, nil + return res, nil } // request reads what suggest_side_quests is given: the household context, @@ -232,9 +222,15 @@ func (s *Service) ground(ctx context.Context, quests []skills.SideQuest) ([]grou // suggest_side_quests ran, a dinner it repeats was added, a suggestion it // repeats passed or a Rotation check dropped, or a Regular it cites renamed // or archived. Passed names are read in the same transaction as the store, -// so a Pass or drop committed before it is never missed. The names stored -// are returned with the ids. -func (s *Service) store(ctx context.Context, batchID string, quests []groundedQuest) ([]string, []string, error) { +// so a Pass or drop committed before it is never missed. +// +// When week is not nil and a Side quest is stored, new_suggestions:{batchID} +// naming those stored is emitted in the same transaction, for the week +// starting week, so the Side quests and their notification commit together +// or not at all: a notification that cannot be stored fails the store, and +// the batch with it. The event's key makes the first round to store Side +// quests the one that notifies; a later round's emit stores nothing. +func (s *Service) store(ctx context.Context, batchID string, week *store.LocalDate, quests []groundedQuest) ([]string, error) { ids := []string{} var stored []string err := s.st.WithTx(ctx, func(tx *sql.Tx) error { @@ -302,12 +298,18 @@ func (s *Service) store(ctx context.Context, batchID string, quests []groundedQu ids = append(ids, id) stored = append(stored, d.Name) } - return nil + if week == nil || len(stored) == 0 { + return nil + } + return s.notifier.EmitTx(ctx, tx, chat.NewSuggestions(batchID, *week, stored)) }) if err != nil { - return nil, nil, err + return nil, err } - return ids, stored, nil + if week != nil && len(stored) > 0 { + s.notifier.Wake() + } + return ids, nil } // citedIDs returns the ids of the Regulars named cited, false when one diff --git a/internal/suggest/suggest.go b/internal/suggest/suggest.go index e4da046..8042a5f 100644 --- a/internal/suggest/suggest.go +++ b/internal/suggest/suggest.go @@ -257,39 +257,27 @@ func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (B return Batch{ID: row.ID, JobID: jobID, Trigger: row.Trigger, Status: row.Status, Created: true}, nil } -// finishBatch records running batch id as status, and emits ev with it -// when ev is not nil, with jobs.FinalWriteContext: the write is made even -// when ctx has been cancelled, but a writer that stays busy cannot hold its -// worker past jobs.FinalWriteTimeout. Its job's handler returns any error, so the job +// finishBatch records running batch id as status, with +// jobs.FinalWriteContext: the write is made even when ctx has been +// cancelled, but a writer that stays busy cannot hold its worker past +// jobs.FinalWriteTimeout. Its job's handler returns any error, so the job // never ends succeeded with the batch still running; the runner keeps only // a code, so the error is logged too. -func (s *Service) finishBatch(ctx context.Context, id, status string, ev *notify.Event) error { +func (s *Service) finishBatch(ctx context.Context, id, status string) error { write, cancel := jobs.FinalWriteContext(ctx) defer cancel() - err := s.st.WithTx(write, func(tx *sql.Tx) error { - n, err := db.New(tx).FinishSuggestionBatch(write, db.FinishSuggestionBatchParams{ - Status: status, - UpdatedAt: store.Now().String(), - ID: id, - }) - if err != nil { - return err - } - if n != 1 { - return errors.New("batch is not running") - } - if ev == nil { - return nil - } - return s.notifier.EmitTx(write, tx, *ev) + n, err := db.New(s.st.Writer).FinishSuggestionBatch(write, db.FinishSuggestionBatchParams{ + Status: status, + UpdatedAt: store.Now().String(), + ID: id, }) + if err == nil && n != 1 { + err = errors.New("batch is not running") + } if err != nil { s.logger.ErrorContext(ctx, "record suggestion batch status", "batch_id", id, "status", status, "error", err) return fmt.Errorf("suggest: record batch %s %s: %w", id, status, err) } - if ev != nil { - s.notifier.Wake() - } return nil } diff --git a/internal/suggest/suggest_test.go b/internal/suggest/suggest_test.go index 78f32a3..4a0eea7 100644 --- a/internal/suggest/suggest_test.go +++ b/internal/suggest/suggest_test.go @@ -454,6 +454,100 @@ func TestBatchEmitsNewSuggestions(t *testing.T) { } } +// TestBatchNotifiesAsSideQuestsAreStored: new_suggestions is stored with +// the first round to add Side quests, so a batch whose later round fails +// still tells the household of those in the deck, once; a later round that +// succeeds adds no second notification; a replenish batch notifies nothing. +func TestBatchNotifiesAsSideQuestsAreStored(t *testing.T) { + for _, tc := range []struct { + trigger string + lastFails bool + notify bool + }{ + {suggest.TriggerScheduled, true, true}, + {suggest.TriggerManual, true, true}, + {suggest.TriggerManual, false, true}, + {suggest.TriggerReplenish, true, false}, + } { + t.Run(fmt.Sprintf("%s last round fails %v", tc.trigger, tc.lastFails), func(t *testing.T) { + e := newEnv(t) + e.start(t) + // The first round grounds BatchSize Side quests but B; the + // second round grounds F. + var quests []string + for i := range suggest.BatchSize + 1 { + name := fmt.Sprintf("Fajita Dish %c", 'A'+i) + e.search.Results[name] = nil + if i != 1 { + e.recipe(t, name) + } + quests = append(quests, sideQuest(name, "Like your Chicken Fajitas.", "Chicken Fajitas")) + } + e.reply(quests...) + if tc.lastFails { + if _, err := e.st.Writer.ExecContext(t.Context(), `CREATE TRIGGER refuse_f BEFORE INSERT ON dinners WHEN NEW.name = 'Fajita Dish F' BEGIN SELECT RAISE(ABORT, 'F refused'); END`); err != nil { + t.Fatal(err) + } + } + + b, err := e.svc.StartBatch(t.Context(), tc.trigger) + if err != nil { + t.Fatal(err) + } + job := e.waitJob(t, b.JobID) + want, deck := jobs.StatusSucceeded, suggest.BatchSize + if tc.lastFails { + want, deck = jobs.StatusFailed, suggest.BatchSize-1 + } + if job.Status != want { + t.Fatalf("batch job %s, want %s", job.Status, want) + } + if got := e.deckNames(t); len(got) != deck { + t.Fatalf("deck = %v, want %d Side quests", got, deck) + } + rows := notifytest.Rows(t, e.st, notify.NewSuggestions) + if !tc.notify { + if len(rows) != 0 { + t.Errorf("new_suggestions rows = %+v, want none", rows) + } + return + } + if len(rows) != 1 || rows[0].Key != "new_suggestions:"+b.ID || + rows[0].Payload.Body != "Fajita Dish A, Fajita Dish C, Fajita Dish D + 1 more" { + t.Errorf("new_suggestions rows = %+v, want one keyed by batch %s naming the first round", rows, b.ID) + } + }) + } +} + +// TestBatchFailsWhenItsNotificationCannotBeStored: Side quests are stored +// only with their notification, so a notification that cannot be stored +// fails the batch with an empty deck rather than leaving Side quests the +// household is never told of. +func TestBatchFailsWhenItsNotificationCannotBeStored(t *testing.T) { + e := newEnv(t) + e.start(t) + e.recipe(t, "Chili Verde") + e.reply(sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili")) + if _, err := e.st.Writer.ExecContext(t.Context(), `CREATE TRIGGER refuse_outbox BEFORE INSERT ON notification_outbox BEGIN SELECT RAISE(ABORT, 'outbox refused'); END`); err != nil { + t.Fatal(err) + } + + b, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual) + if err != nil { + t.Fatal(err) + } + if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusFailed { + t.Fatalf("batch job %s, want failed", job.Status) + } + if got := e.batchStatus(t, b.ID); got != suggest.BatchFailed { + t.Errorf("batch status = %s, want failed", got) + } + if got := e.deckNames(t); len(got) != 0 { + t.Errorf("deck = %v with no notification stored, want empty", got) + } +} + func TestBatchKeepsAtMostBatchSize(t *testing.T) { e := newEnv(t) e.start(t) @@ -1204,8 +1298,9 @@ func TestBatchJobFailsWhenRecordingItsEndFails(t *testing.T) { if got := e.batchStatus(t, b.ID); got != suggest.BatchFailed { t.Errorf("batch status = %s with its job failed, want failed", got) } - if rows := notifytest.Rows(t, e.st, notify.NewSuggestions); len(rows) != 0 { - t.Errorf("new_suggestions rows of a batch whose end was not recorded = %+v, want none", rows) + // Its Side quests stay in the deck, and the household was told of them. + if rows := notifytest.Rows(t, e.st, notify.NewSuggestions); len(rows) != 1 || rows[0].Key != "new_suggestions:"+b.ID || rows[0].Payload.Body != "Chili Verde" { + t.Errorf("new_suggestions rows of a batch whose end was not recorded = %+v, want one naming Chili Verde", rows) } } diff --git a/migrations/0017_shopping_list_chat_send_unconfirmed.sql b/migrations/0017_shopping_list_chat_send_unconfirmed.sql new file mode 100644 index 0000000..bc63da5 --- /dev/null +++ b/migrations/0017_shopping_list_chat_send_unconfirmed.sql @@ -0,0 +1,10 @@ +-- 0017_shopping_list_chat_send_unconfirmed: a send of a Supply run to +-- Google Chat is recorded as begun, in chat_send_unconfirmed_at, before it +-- is posted, and cleared when Google Chat's answer is known: with +-- sent_to_chat_at when it accepted the post, alone when it refused it. A +-- value left behind (Google Chat did not answer, the process stopped, or +-- recording the answer failed) means the post may or may not have been +-- made, so it is never posted again without resend. Existing rows have no +-- send under way. + +ALTER TABLE shopping_lists ADD COLUMN chat_send_unconfirmed_at TEXT; diff --git a/migrations/queries/shopping.sql b/migrations/queries/shopping.sql index 99c6d68..b4ade22 100644 --- a/migrations/queries/shopping.sql +++ b/migrations/queries/shopping.sql @@ -89,13 +89,27 @@ UPDATE shopping_lists SET title = ?, updated_at = ? WHERE id = ? AND title IS NULL AND status = 'current' AND frozen_at IS NULL; --- name: MarkShoppingListSentToChat :execrows --- Records list id sent to Google Chat at sent_to_chat_at, unless it is --- frozen into the Supply archive. +-- name: BeginShoppingListChatSend :execrows +-- Records a send of list id to Google Chat begun at +-- chat_send_unconfirmed_at, before it is posted, unless the list is frozen +-- into the Supply archive. UPDATE shopping_lists -SET sent_to_chat_at = ?, updated_at = ? +SET chat_send_unconfirmed_at = ?, updated_at = ? WHERE id = ? AND status = 'current' AND frozen_at IS NULL; +-- name: MarkShoppingListSentToChat :execrows +-- Records list id sent to Google Chat at sent_to_chat_at, its send +-- confirmed, unless it is frozen into the Supply archive. +UPDATE shopping_lists +SET sent_to_chat_at = ?, chat_send_unconfirmed_at = NULL, updated_at = ? +WHERE id = ? AND status = 'current' AND frozen_at IS NULL; + +-- name: ClearShoppingListChatSend :execrows +-- Clears the send of list id begun, once Google Chat refused it. +UPDATE shopping_lists +SET chat_send_unconfirmed_at = NULL, updated_at = ? +WHERE id = ?; + -- name: FreezeShoppingList :execrows -- Freezes list id into the Supply archive at frozen_at, unless it is -- frozen already. diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 35fcc2c..4f395b1 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -120,6 +120,7 @@ describe('App routes', () => { title: null, status: 'current', sent_to_chat_at: null, + chat_send_unconfirmed_at: null, needed_count: 1, total_count: 1, groups: [{ dinner: { id: 'd1', name: 'Beef tacos' }, date: week, day: 'Monday', items: [{ id: 'i1', name: 'Ground beef', qty: '1 lb', needed: true }] }], diff --git a/web/src/plan/PlanPage.test.tsx b/web/src/plan/PlanPage.test.tsx index 1f027a9..fe90d5f 100644 --- a/web/src/plan/PlanPage.test.tsx +++ b/web/src/plan/PlanPage.test.tsx @@ -215,6 +215,7 @@ function supplyRun(needed: number) { title: null, status: 'current', sent_to_chat_at: null, + chat_send_unconfirmed_at: null, needed_count: needed, total_count: 32, groups: [], diff --git a/web/src/shopping/SupplyArchivePage.test.tsx b/web/src/shopping/SupplyArchivePage.test.tsx index 34fffc5..0ab4000 100644 --- a/web/src/shopping/SupplyArchivePage.test.tsx +++ b/web/src/shopping/SupplyArchivePage.test.tsx @@ -308,6 +308,7 @@ const FROZEN = { title: 'Pizza, pot roast & the great orzo trial', status: 'archived', sent_to_chat_at: null, + chat_send_unconfirmed_at: null, needed_count: 1, total_count: 2, groups: [ diff --git a/web/src/shopping/SupplyRunPage.module.css b/web/src/shopping/SupplyRunPage.module.css index b682c0c..b8aa5a1 100644 --- a/web/src/shopping/SupplyRunPage.module.css +++ b/web/src/shopping/SupplyRunPage.module.css @@ -238,6 +238,10 @@ color: var(--color-accent-2-800); } +.unconfirmed { + color: var(--color-accent-700); +} + .footer { display: flex; flex-direction: column; diff --git a/web/src/shopping/SupplyRunPage.test.tsx b/web/src/shopping/SupplyRunPage.test.tsx index 773cd10..64a0f70 100644 --- a/web/src/shopping/SupplyRunPage.test.tsx +++ b/web/src/shopping/SupplyRunPage.test.tsx @@ -53,6 +53,7 @@ function supplyRun(overrides: Record = {}, weekStart = '2026-10 title: null, status: 'current', sent_to_chat_at: null, + chat_send_unconfirmed_at: null, needed_count: items.filter((item) => item.needed).length, total_count: items.length, groups: GROUPS, @@ -446,6 +447,49 @@ describe('SupplyRunPage', () => { expect(writes(fetchMock)).toEqual([`POST ${SEND_CHAT} {"resend":false}`, `POST ${SEND_CHAT} {"resend":false}`]) }) + it('says a send whose outcome is not known was not confirmed, offers no retry, and sends again only with resend', async () => { + mockViewport(1180) + let run = supplyRun() + const answers = [ + () => { + // Saturday Oct 10, 8:00 am in New York. + run = supplyRun({ chat_send_unconfirmed_at: '2026-10-10T12:00:00Z' }) + return errorResponse(502, 'chat_unconfirmed') + }, + () => jsonResponse(200, supplyRun({ sent_to_chat_at: '2026-10-10T12:05:00Z' })), + ] + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, run), + [`POST ${SEND_CHAT}`]: () => answers.shift()!(), + }) + await renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Send to Google Chat' })) + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't send the shopping list to Google Chat. chat_unconfirmed message") + expect(within(alert).queryByRole('button')).not.toBeInTheDocument() + // The Supply run is read again, and says the send is unconfirmed. + expect( + await screen.findByText('Send to Google Chat not confirmed · Sat 8:00 am — check the space before sending again'), + ).toBeInTheDocument() + expect(screen.queryByText(/Sent to Google Chat ·/)).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Send again' })) + expect(await screen.findByText('Sent to Google Chat · Sat 8:05 am')).toBeInTheDocument() + expect(screen.queryByText(/not confirmed/)).not.toBeInTheDocument() + expect(writes(fetchMock)).toEqual([`POST ${SEND_CHAT} {"resend":false}`, `POST ${SEND_CHAT} {"resend":true}`]) + }) + + it('shows an unconfirmed send on mobile', async () => { + stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun({ chat_send_unconfirmed_at: '2026-10-10T12:00:00Z' })) }) + await renderPage() + + expect( + await screen.findByText('Send to Google Chat not confirmed · Sat 8:00 am — check the space before sending again'), + ).toBeInTheDocument() + }) + it('renders an archived list read-only, tagged Archived', async () => { const fetchMock = stubServer({ 'GET /api/v1/shopping/2026-09-28': () => diff --git a/web/src/shopping/SupplyRunPage.tsx b/web/src/shopping/SupplyRunPage.tsx index a657db5..839e8e2 100644 --- a/web/src/shopping/SupplyRunPage.tsx +++ b/web/src/shopping/SupplyRunPage.tsx @@ -2,6 +2,7 @@ import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react' import { useState } from 'react' import { Link, useSearchParams } from 'react-router' import { defaultWeek, localNow, weekdayKicker, weekLabel, weekStart as weekOf } from '../plan/week.ts' +import { ApiError } from '../api/errors.ts' import { formatClockTime, useHouseholdSettings } from '../settings/api.ts' import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts' import { Button } from '../ui/Button.tsx' @@ -101,8 +102,20 @@ function SentStatus({ at, timezone }: { at: Date; timezone: string }) { ) } +// UnconfirmedStatus is the status line while a send to Google Chat whose +// outcome is not known is recorded: it may or may not have been posted. +function UnconfirmedStatus({ at, timezone }: { at: Date; timezone: string }) { + return ( +

+ + Send to Google Chat not confirmed · {sentAt(at, timezone)} — check the space before sending again +

+ ) +} + // SendToChat posts the Supply run to Google Chat: "Send to Google Chat" -// until it is sent, then "Send again", which posts it once more. +// until it is sent, or a send is unconfirmed, then "Send again", which +// posts it once more. function SendToChat({ sent, pending, onSend }: { sent: boolean; pending: boolean; onSend: () => void }) { return (