From 51e0213e922ac40e4f26acbd968ab8c7d84da402 Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Fri, 9 Oct 2026 16:42:56 -0400 Subject: [PATCH] Wire plan_ready, list_ready, new_suggestions and Send to Google Chat (Feature 19) (#167) Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> --- cmd/server/main.go | 44 ++-- docs/ARCHITECTURE.md | 48 +++- 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/models.go | 19 +- internal/db/querier.go | 11 + internal/db/shopping.sql.go | 74 +++++- 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 | 75 +++++- internal/server/shopping_test.go | 141 +++++++++- internal/server/suggestions_test.go | 3 +- internal/shopping/chat.go | 106 ++++++++ internal/shopping/notify_test.go | 242 ++++++++++++++++++ internal/shopping/shopping.go | 62 ++++- internal/shopping/shopping_test.go | 24 +- internal/store/migrate_test.go | 41 ++- internal/suggest/batch.go | 46 +++- internal/suggest/suggest.go | 13 +- internal/suggest/suggest_test.go | 157 +++++++++++- ...17_shopping_list_chat_send_unconfirmed.sql | 10 + migrations/queries/shopping.sql | 21 ++ migrations/queries/suggestions.sql | 5 + web/src/App.test.tsx | 1 + web/src/plan/PlanPage.module.css | 65 ----- web/src/plan/PlanPage.test.tsx | 1 + web/src/plan/PlanPage.tsx | 38 +-- web/src/shopping/SupplyArchivePage.test.tsx | 1 + web/src/shopping/SupplyRunPage.module.css | 4 + web/src/shopping/SupplyRunPage.test.tsx | 117 ++++++++- web/src/shopping/SupplyRunPage.tsx | 56 +++- web/src/shopping/api.ts | 37 ++- web/src/shopping/offlineQueue.test.tsx | 1 + 45 files changed, 1911 insertions(+), 275 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 create mode 100644 migrations/0017_shopping_list_chat_send_unconfirmed.sql 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..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). | @@ -656,6 +656,28 @@ 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 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 + 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. @@ -870,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, @@ -880,6 +903,27 @@ 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 `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). - `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/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 e7d9a8c..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) @@ -143,6 +149,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 +241,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, 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. MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error diff --git a/internal/db/shopping.sql.go b/internal/db/shopping.sql.go index 55b060a..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 } @@ -362,6 +408,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 = ?, chat_send_unconfirmed_at = NULL, 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, 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 { + 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..c5bb173 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 { @@ -25,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"` } @@ -59,6 +66,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 +133,53 @@ 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, 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) + 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, shopping.ErrSendUnconfirmed): + logger.WarnContext(r.Context(), "send Supply run to Google Chat", "error", err) + 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) + 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 { @@ -232,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 1698821..7128449 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" @@ -82,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"` @@ -184,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) } @@ -787,3 +789,124 @@ 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) + } + var l supplyRunJSON + callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l) + 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) + 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..93c4c84 --- /dev/null +++ b/internal/shopping/chat.go @@ -0,0 +1,106 @@ +package shopping + +import ( + "context" + "database/sql" + "errors" + "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, 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() + l, err := s.Get(ctx, weekStart) + if err != nil { + return List{}, err + } + if l.Status != StatusCurrent { + return List{}, ErrFrozen + } + if (l.SentToChatAt != nil || l.ChatSendUnconfirmedAt != 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) + } + } + 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) + } + 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 = q.MarkShoppingListSentToChat(write, db.MarkShoppingListSentToChatParams{ + SentToChatAt: sql.NullString{String: now, Valid: true}, + UpdatedAt: now, + ID: l.ID, + }) + if err != nil { + return List{}, fmt.Errorf("%w: record sent to Google Chat: %w", ErrSendUnconfirmed, 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..eb23d45 --- /dev/null +++ b/internal/shopping/notify_test.go @@ -0,0 +1,242 @@ +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, 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"}) + 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) + } + 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 { + 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") + } +} + +// 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 5a5451b..595d321 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" ) @@ -80,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") @@ -95,6 +103,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 +114,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 +132,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 @@ -145,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 @@ -272,6 +295,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 +317,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 +338,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 +363,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 } @@ -538,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/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/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 51ee427..00f26b0 100644 --- a/internal/suggest/batch.go +++ b/internal/suggest/batch.go @@ -10,8 +10,10 @@ 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/recipes" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" @@ -54,7 +56,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 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 { + 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,7 +78,7 @@ 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) + res, err := s.fill(ctx, p.BatchID, batch.Trigger != TriggerReplenish, publish) if err == nil { if err := s.finishBatch(ctx, p.BatchID, BatchDone); err != nil { return batchResult{}, err @@ -86,8 +99,9 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe } // 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) { +// 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 batchResult{}, err @@ -108,6 +122,11 @@ func (s *Service) fill(ctx context.Context, batchID string, publish func(batchRe if err := publish(res); err != nil { return batchResult{}, err } + var week *store.LocalDate + if notifies { + w := household.WeekStart(in.Context.Today) + week = &w + } left := out.Suggestions for len(res.SuggestionIDs) < BatchSize && len(left) > 0 { round := left[:min(BatchSize-len(res.SuggestionIDs), len(left))] @@ -117,7 +136,7 @@ func (s *Service) fill(ctx context.Context, batchID string, publish func(batchRe return batchResult{}, err } res.Dropped += len(round) - len(grounded) - ids, err := s.store(ctx, batchID, grounded) + ids, err := s.store(ctx, batchID, week, grounded) if err != nil { return batchResult{}, err } @@ -204,8 +223,16 @@ func (s *Service) ground(ctx context.Context, quests []skills.SideQuest) ([]grou // 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) { +// +// 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 { q := db.New(tx) existing, err := q.ListUnarchivedDinners(ctx) @@ -269,12 +296,19 @@ 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 week == nil || len(stored) == 0 { + return nil + } + return s.notifier.EmitTx(ctx, tx, chat.NewSuggestions(batchID, *week, stored)) }) if err != nil { return nil, err } + if week != nil && len(stored) > 0 { + s.notifier.Wake() + } return ids, nil } diff --git a/internal/suggest/suggest.go b/internal/suggest/suggest.go index 287ad59..8042a5f 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 diff --git a/internal/suggest/suggest_test.go b/internal/suggest/suggest_test.go index cac527e..4a0eea7 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,151 @@ 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) + } + }) + } +} + +// 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) @@ -929,7 +1076,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 +1298,10 @@ 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) } + // 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) + } } func TestBatchRecoversWhenRecordingItsEndAndItsJobsEndFail(t *testing.T) { @@ -1284,7 +1435,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/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 56bf1c3..b4ade22 100644 --- a/migrations/queries/shopping.sql +++ b/migrations/queries/shopping.sql @@ -89,6 +89,27 @@ UPDATE shopping_lists SET title = ?, updated_at = ? WHERE id = ? AND title IS NULL AND status = 'current' AND frozen_at IS NULL; +-- 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 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/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/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.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.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/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/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 d48f353..64a0f70 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 } @@ -52,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, @@ -372,19 +374,122 @@ 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('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 f9ae536..839e8e2 100644 --- a/web/src/shopping/SupplyRunPage.tsx +++ b/web/src/shopping/SupplyRunPage.tsx @@ -1,10 +1,11 @@ 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 { 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' import buttonStyles from '../ui/Button.module.css' import { Checkbox } from '../ui/Checkbox.tsx' import { EmptyState } from '../ui/EmptyState.tsx' @@ -14,6 +15,7 @@ import { Skeleton } from '../ui/Skeleton.tsx' import { Tag } from '../ui/Tag.tsx' import { useGenerateSupplyRun, + useSendToChat, useSupplyRun, useToggles, WEEK_PARAM, @@ -27,10 +29,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 +102,29 @@ 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, 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 ( + + ) +} + // ArchiveLink opens the Supply archive: an outline action from tablet up, a // grape link on mobile. function ArchiveLink({ layout }: { layout: Breakpoint }) { @@ -120,13 +141,22 @@ 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, and one whose outcome is not known +// offers no retry and leaves the status line saying so. 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 + // A send whose outcome is not known is posted again only on request. + const resend = sent || run.chat_send_unconfirmed_at !== null + const unconfirmed = !send.isPending && run.chat_send_unconfirmed_at !== null && ( + + ) + const sendUnconfirmed = send.error instanceof ApiError && send.error.code === 'chat_unconfirmed' const viewOnly = readOnly || run.status === 'archived' const wide = layout !== 'mobile' const failed = toggles.failed @@ -155,12 +185,7 @@ function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) { {wide && (
- {!viewOnly && ( - - - Send to Google Chat - - )} + {!viewOnly && send.mutate(resend)} />}
)} @@ -170,7 +195,13 @@ function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) { {failed.error.message} )} + {send.isError && !viewOnly && ( + send.mutate(resend)}> + Couldn't send the shopping list to Google Chat. {send.error.message} + + )} {wide && run.sent_to_chat_at !== null && } + {wide && unconfirmed} {run.groups.length === 0 ? ( None of this week's dinners has a recipe with ingredients yet. ) : ( @@ -183,6 +214,7 @@ function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) { {!wide && (
{run.sent_to_chat_at !== null && } + {unconfirmed}
)} diff --git a/web/src/shopping/api.ts b/web/src/shopping/api.ts index 09fc0a3..a103fa3 100644 --- a/web/src/shopping/api.ts +++ b/web/src/shopping/api.ts @@ -72,14 +72,17 @@ export type SupplyRunGroup = { dinner: { id: string; name: string }; date: strin export type Counts = { needed_count: number; total_count: number } // SupplyRun is GET /api/v1/shopping/{weekStart}: label is the week label, -// "Week 41 · Oct 5 – 11", title the AI's title once written, and -// sent_to_chat_at when it was sent to Google Chat, if it was. +// "Week 41 · Oct 5 – 11", title the AI's title once written, +// sent_to_chat_at when it was sent to Google Chat, if it was, and +// chat_send_unconfirmed_at when a send whose outcome is not known began, +// if one did: it may or may not have reached Google Chat. export type SupplyRun = Counts & { weekStart: string label: string title: string | null status: SupplyRunStatus sent_to_chat_at: Date | null + chat_send_unconfirmed_at: Date | null groups: SupplyRunGroup[] } @@ -125,12 +128,12 @@ function parseGroup(where: string, body: unknown): SupplyRunGroup { } } -// sentAt reads sent_to_chat_at, an RFC 3339 instant or null. -function sentAt(where: string, value: unknown): Date | null { +// instant reads field, an RFC 3339 instant or null. +function instant(where: string, field: string, value: unknown): Date | null { if (value === null) return null const at = typeof value === 'string' ? new Date(value) : null if (at === null || Number.isNaN(at.getTime())) { - throw new Error(`${where}: sent_to_chat_at ${JSON.stringify(value)} is not an instant`) + throw new Error(`${where}: ${field} ${JSON.stringify(value)} is not an instant`) } return at } @@ -150,7 +153,8 @@ export function parseSupplyRun(where: string, weekStart: string, body: unknown): label: body.label, title: body.title, status: oneOf(where, 'status', SUPPLY_RUN_STATUSES, body.status), - sent_to_chat_at: sentAt(where, body.sent_to_chat_at), + sent_to_chat_at: instant(where, 'sent_to_chat_at', body.sent_to_chat_at), + chat_send_unconfirmed_at: instant(where, 'chat_send_unconfirmed_at', body.chat_send_unconfirmed_at), ...parseCounts(where, body), groups: body.groups.map((group) => parseGroup(where, group)), } @@ -206,6 +210,27 @@ 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, or with +// a send unconfirmed, is posted again only with resend: without it the +// server answers with the Supply run as it is. A send that fails re-reads +// the Supply run, so a send left unconfirmed shows as such. +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) + }, + onError: () => queryClient.invalidateQueries({ queryKey: supplyRunKey(weekStart) }), + }) +} + // Toggle is one item marked to buy (needed) or "have it" (not needed). export type Toggle = { id: string; needed: boolean } diff --git a/web/src/shopping/offlineQueue.test.tsx b/web/src/shopping/offlineQueue.test.tsx index b398645..36afc80 100644 --- a/web/src/shopping/offlineQueue.test.tsx +++ b/web/src/shopping/offlineQueue.test.tsx @@ -93,6 +93,7 @@ function stubServer( title: null, status: week === '2026-10-05' ? status : 'current', sent_to_chat_at: null, + chat_send_unconfirmed_at: null, ...counts(week), groups: [ {