Wire plan_ready, list_ready, new_suggestions and Send to Google Chat
CI / ci (push) Failing after 5m48s
CI / ci (pull_request) Successful in 6m58s

Connect the features to the notification outbox so real events notify,
each stored by the new notify.EmitTx in the transaction of the change it
is about (Wake after commit):

- plan_ready:{weekStart} (/plan) when a draft job stores the AI's draft;
  a redraft shares the key and a manual swap never emits. The title reads
  "Next week's plan is ready" for the coming week.
- list_ready:{weekStart} (/plan/shopping) when a week's Supply run is
  first created, by generate or after Push to Calendar.
- new_suggestions:{batchId} (/discover) when a scheduled or manual batch
  ends done with cards; replenish batches never notify.
- plan.Service.EmitDayOfReminder(date) emits day_of_reminder:{date} for
  the Feature 20 scheduler.

Add POST /api/v1/shopping/{weekStart}/send-chat: shopping.SendToChat posts
the dinners with needed items and a link through chat.PostSupplyRun,
records sent_to_chat_at, and is idempotent per week unless
{"resend": true}. The Supply run's Send to Google Chat button calls it
("Send again" once sent) and shows failures in an ErrorBanner; the unused
Unavailable placeholder is removed.

Task: f0cd9d50-9c11-41be-a945-7bae9f5f28a4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 15:42:16 -04:00
co-authored by Claude Opus 5.5
parent bbe031ec8d
commit 45c4f8a803
37 changed files with 1477 additions and 252 deletions
+76
View File
@@ -0,0 +1,76 @@
package shopping
import (
"context"
"database/sql"
"fmt"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// SendToChat posts the Supply run of the week starting weekStart to Google
// Chat (chat.PostSupplyRun): each dinner with its items still needed, and a
// link to the Supply run. It records sent_to_chat_at and returns the list.
//
// A week is sent once: a Supply run already sent is returned as it is,
// without posting again, unless resend is true. A week with no Supply run
// is ErrListNotFound and one frozen into the Supply archive ErrFrozen. A
// post that fails returns chat.PostSupplyRun's error and records nothing.
func (s *Service) SendToChat(ctx context.Context, weekStart store.LocalDate, resend bool) (List, error) {
s.sending.Lock()
defer s.sending.Unlock()
l, err := s.Get(ctx, weekStart)
if err != nil {
return List{}, err
}
if l.Status != StatusCurrent {
return List{}, ErrFrozen
}
if l.SentToChatAt != nil && !resend {
return l, nil
}
name, err := household.WeekLabel(weekStart)
if err != nil {
return List{}, fmt.Errorf("shopping: send to Google Chat: %w", err)
}
if l.Title != nil {
name = *l.Title
}
groups := make([]chat.SupplyRunGroup, len(l.Groups))
for i, g := range l.Groups {
groups[i].Dinner = g.DinnerName
for _, it := range g.Items {
if !it.Needed {
continue
}
item := chat.SupplyRunItem{Name: it.Name}
if it.Qty != nil {
item.Qty = *it.Qty
}
groups[i].Items = append(groups[i].Items, item)
}
}
if err := s.chat.PostSupplyRun(ctx, weekStart, name, groups); err != nil {
return List{}, err
}
// The post is made: record it even if the client has gone.
write, cancel := jobs.FinalWriteContext(ctx)
defer cancel()
now := store.Now().String()
n, err := db.New(s.st.Writer).MarkShoppingListSentToChat(write, db.MarkShoppingListSentToChatParams{
SentToChatAt: sql.NullString{String: now, Valid: true},
UpdatedAt: now,
ID: l.ID,
})
if err != nil {
return List{}, fmt.Errorf("shopping: record sent to Google Chat: %w", err)
}
if n != 1 {
return List{}, ErrFrozen
}
return s.Get(write, weekStart)
}
+169
View File
@@ -0,0 +1,169 @@
package shopping_test
import (
"errors"
"net/http"
"slices"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat/chattest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
)
// TestGenerateEmitsListReadyOnce: the week's first Supply run stores
// list_ready keyed by the week, on push and Google Chat; remaking it, and
// making it again after Push to Calendar, stores nothing more.
func TestGenerateEmitsListReadyOnce(t *testing.T) {
e := newEnv(t)
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""})
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"})
e.dinner(t, "Pot Roast")
e.plan(t, "Beef Chili", "Pad Thai", "Pot Roast")
if _, err := e.svc.Generate(t.Context(), mustDate("2026-10-19")); !errors.Is(err, shopping.ErrPlanNotFound) {
t.Fatalf("Generate of a week with no plan = %v", err)
}
if rows := notifytest.Rows(t, e.st, notify.ListReady); len(rows) != 0 {
t.Fatalf("list_ready rows after a failed generation = %+v, want none", rows)
}
e.generate(t)
rows := notifytest.Rows(t, e.st, notify.ListReady)
if len(rows) != 2 || rows[0].Channel != notify.ChannelChat || rows[1].Channel != notify.ChannelPush {
t.Fatalf("list_ready rows = %+v, want one on chat and one on push", rows)
}
for _, r := range rows {
p := r.Payload
if r.Key != "list_ready:2026-10-12" || p.URL != "/plan/shopping" || p.Week != "2026-10-12" ||
p.Title != "This week's Supply run is ready" || p.Body != "3 items to buy for 2 dinners" {
t.Errorf("%s row = %+v, want list_ready:2026-10-12, 3 items for 2 dinners, /plan/shopping", r.Channel, r)
}
}
e.swap(t, "2026-10-14", "Pad Thai")
e.swap(t, "2026-10-13", "Pot Roast")
e.generate(t)
if _, err := e.svc.GeneratePushed(t.Context(), week, planRevision(t, e)); err != nil {
t.Fatal(err)
}
if again := notifytest.Rows(t, e.st, notify.ListReady); !slices.Equal(again, rows) {
t.Errorf("list_ready rows after remaking = %+v, want the first list's only", again)
}
}
// planRevision is the revision of the env's plan.
func planRevision(t *testing.T, e *env) int64 {
t.Helper()
var rev int64
if err := e.st.Reader.QueryRow(`SELECT revision FROM week_plans WHERE id = ?`, e.planID).Scan(&rev); err != nil {
t.Fatal(err)
}
return rev
}
// TestSendToChat posts the Supply run's needed items by dinner, records
// sent_to_chat_at, sends a week once, and again only with resend.
func TestSendToChat(t *testing.T) {
e := newEnv(t)
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""})
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"})
e.plan(t, "Beef Chili", "Pad Thai")
l := e.generate(t).List
e.haveIt(t, l, "Pad Thai", "rice noodles")
if _, err := e.chatSvc.Put(t.Context(), chattest.Webhook, "Family"); err != nil {
t.Fatal(err)
}
sent, err := e.svc.SendToChat(t.Context(), week, false)
if err != nil {
t.Fatal(err)
}
if sent.SentToChatAt == nil {
t.Fatal("sent_to_chat_at not recorded")
}
posts := e.chat.Posts()
if len(posts) != 1 {
t.Fatalf("Google Chat got %d posts, want 1", len(posts))
}
want := chat.SupplyRunText("Week 42 · Oct 12 – 18", []chat.SupplyRunGroup{
{Dinner: "Beef Chili", Items: []chat.SupplyRunItem{{Name: "ground beef", Qty: "1 lb"}, {Name: "onion", Qty: "1"}}},
{Dinner: "Pad Thai"},
})
if posts[0].Text() != want {
t.Errorf("posted text = %q, want %q", posts[0].Text(), want)
}
if posts[0].Query.Get("threadKey") != chat.ThreadKey(week) {
t.Errorf("posted in thread %q, want %q", posts[0].Query.Get("threadKey"), chat.ThreadKey(week))
}
if got, err := e.svc.Get(t.Context(), week); err != nil || got.SentToChatAt == nil || *got.SentToChatAt != *sent.SentToChatAt {
t.Errorf("stored sent_to_chat_at = %v, %v, want %s", got.SentToChatAt, err, *sent.SentToChatAt)
}
again, err := e.svc.SendToChat(t.Context(), week, false)
if err != nil {
t.Fatal(err)
}
if n := len(e.chat.Posts()); n != 1 || *again.SentToChatAt != *sent.SentToChatAt {
t.Errorf("send again without resend: %d posts, sent_to_chat_at %s, want 1 post and %s", n, *again.SentToChatAt, *sent.SentToChatAt)
}
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET title = 'Chili and noodles'`); err != nil {
t.Fatal(err)
}
resent, err := e.svc.SendToChat(t.Context(), week, true)
if err != nil {
t.Fatal(err)
}
posts = e.chat.Posts()
if len(posts) != 2 || *resent.SentToChatAt < *sent.SentToChatAt {
t.Fatalf("resend: %d posts, sent_to_chat_at %s, want 2 posts and a later time than %s", len(posts), *resent.SentToChatAt, *sent.SentToChatAt)
}
if want := "Supply run · Chili and noodles"; posts[1].Text()[:len(want)] != want {
t.Errorf("resent text = %q, want it headed by the title", posts[1].Text())
}
}
// TestSendToChatFailuresRecordNothing: a send with no webhook, or one
// Google Chat refuses or does not answer, leaves sent_to_chat_at unset; a
// week with no list, or a frozen one, is refused without posting.
func TestSendToChatFailuresRecordNothing(t *testing.T) {
e := newEnv(t)
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
e.plan(t, "Beef Chili")
if _, err := e.svc.SendToChat(t.Context(), week, false); !errors.Is(err, shopping.ErrListNotFound) {
t.Errorf("SendToChat with no list = %v, want ErrListNotFound", err)
}
e.generate(t)
if _, err := e.svc.SendToChat(t.Context(), week, false); !errors.Is(err, chat.ErrNotConfigured) {
t.Errorf("SendToChat with no webhook = %v, want chat.ErrNotConfigured", err)
}
if _, err := e.chatSvc.Put(t.Context(), chattest.Webhook, "Family"); err != nil {
t.Fatal(err)
}
e.chat.SetStatus(http.StatusInternalServerError)
if _, err := e.svc.SendToChat(t.Context(), week, false); !errors.Is(err, chat.ErrRefused) {
t.Errorf("SendToChat answered 500 = %v, want chat.ErrRefused", err)
}
e.chat.Close()
if _, err := e.svc.SendToChat(t.Context(), week, false); !errors.Is(err, chat.ErrUnreachable) {
t.Errorf("SendToChat unanswered = %v, want chat.ErrUnreachable", err)
}
if l, err := e.svc.Get(t.Context(), week); err != nil || l.SentToChatAt != nil {
t.Errorf("sent_to_chat_at after failed sends = %v, %v, want unset", l.SentToChatAt, err)
}
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
t.Fatal(err)
}
posts := len(e.chat.Posts())
if _, err := e.svc.SendToChat(t.Context(), week, true); !errors.Is(err, shopping.ErrFrozen) {
t.Errorf("SendToChat of a frozen list = %v, want ErrFrozen", err)
}
if n := len(e.chat.Posts()); n != posts {
t.Errorf("a frozen list was posted")
}
}
+30 -5
View File
@@ -27,10 +27,12 @@ import (
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
@@ -95,6 +97,8 @@ type Service struct {
st *store.Store
title Titler
titleJob *jobs.Kind[TitleJob]
notifier *notify.Service
chat *chat.Service
logger *slog.Logger
// now is the clock weeks are ended and current on; tests replace it.
now func() time.Time
@@ -104,12 +108,17 @@ type Service struct {
// waits for the title jobs in flight to store their titles or fail,
// and no title job runs archive_title while a freeze is under way.
titling sync.RWMutex
// sending serializes SendToChat, so a week is posted to Google Chat
// once however many sends race.
sending sync.Mutex
}
// NewService returns a service over st that titles Supply runs with title
// in KindTitleSupplyRun jobs, which it registers on runner, and logs to
// logger. runner must not have started yet.
func NewService(st *store.Store, title Titler, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
// in KindTitleSupplyRun jobs, which it registers on runner, tells the
// household through notifier, which must be over st, sends Supply runs to
// Google Chat through chatSvc, and logs to logger. runner must not have
// started yet.
func NewService(st *store.Store, title Titler, runner *jobs.Runner, notifier *notify.Service, chatSvc *chat.Service, logger *slog.Logger) (*Service, error) {
switch {
case st == nil:
return nil, errors.New("shopping: nil store")
@@ -117,10 +126,14 @@ func NewService(st *store.Store, title Titler, runner *jobs.Runner, logger *slog
return nil, errors.New("shopping: nil titler")
case runner == nil:
return nil, errors.New("shopping: nil job runner")
case notifier == nil:
return nil, errors.New("shopping: nil notifier")
case chatSvc == nil:
return nil, errors.New("shopping: nil chat service")
case logger == nil:
return nil, errors.New("shopping: nil logger")
}
s := &Service{st: st, title: title, logger: logger, now: time.Now}
s := &Service{st: st, title: title, notifier: notifier, chat: chatSvc, logger: logger, now: time.Now}
kind, err := jobs.Register(runner, KindTitleSupplyRun, s.runTitleJob, ReasonTitleFailed)
if err != nil {
return nil, err
@@ -272,6 +285,7 @@ func (s *Service) generate(ctx context.Context, weekStart store.LocalDate, check
return Generated{}, fmt.Errorf("shopping: generate: %w", err)
}
var out Generated
notified := false
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
plan, err := q.GetWeekPlan(ctx, weekStart.String())
@@ -293,8 +307,9 @@ func (s *Service) generate(ctx context.Context, weekStart store.LocalDate, check
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("shopping: read list: %w", err)
}
created := err != nil
switch {
case err != nil: // no list yet
case created: // no list yet
list = db.ShoppingList{ID: store.NewID(), WeekStart: weekStart.String(), PlanID: plan.ID, Status: StatusCurrent}
err = q.CreateShoppingList(ctx, db.CreateShoppingListParams{
ID: list.ID, WeekStart: list.WeekStart, PlanID: list.PlanID, CreatedAt: now, UpdatedAt: now,
@@ -313,6 +328,13 @@ func (s *Service) generate(ctx context.Context, weekStart store.LocalDate, check
return err
}
out = Generated{List: listOf(list, groups), Skipped: skipped}
if created {
ev := chat.ListReady(weekStart, out.List.Counts().Needed, len(groups))
if err := s.notifier.EmitTx(ctx, tx, ev); err != nil {
return fmt.Errorf("shopping: %w", err)
}
notified = true
}
if list.Title.Valid {
return nil
}
@@ -331,6 +353,9 @@ func (s *Service) generate(ctx context.Context, weekStart store.LocalDate, check
if out.TitleJobID != nil {
s.titleJob.Wake()
}
if notified {
s.notifier.Wake()
}
return out, nil
}
+23 -1
View File
@@ -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)
}