Wire plan_ready, list_ready, new_suggestions and Send to Google Chat (Feature 19) #167

37 changed files with 1477 additions and 252 deletions
Showing only changes of commit 45c4f8a803 - Show all commits
+22 -22
View File
@@ -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
+35
View File
@@ -656,6 +656,25 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
- Events: `plan_ready`, `list_ready`, `day_of_reminder`, `new_suggestions`,
`rotation_review`; each can go to Web Push and/or Google Chat per
`notification_prefs`.
- Where each event is emitted, with its idempotency key. Each is stored by
`notify.EmitTx` in the transaction of the change it is about, so the
change and its notification commit together or not at all:
- `plan_ready:{weekStart}` (URL `/plan`) when a draft job stores the
AI's draft of a week. Only the first draft of a week notifies: a
redraft (Rebuild week) has the same key, and a manual swap never
drafts. Its title is "Next week's plan is ready" (or "This week's",
or "The plan for the week of Oct 26") and its body the drafted
dinners Monday to Sunday.
- `list_ready:{weekStart}` (URL `/plan/shopping`) when a week's Supply
run is first created, by `POST /shopping/{weekStart}/generate` or
after Push to Calendar; remaking it does not notify.
- `new_suggestions:{batchId}` (URL `/discover`) when a `scheduled` or
`manual` suggestion batch ends done having added Side quests, in the
transaction recording it done. A `replenish` batch never notifies.
- `day_of_reminder:{date}` (URL `/dinner/{id}`) through
`plan.Service.EmitDayOfReminder(date)`, which names the dinner planned
on the date and emits nothing for a week with no plan. The scheduler
calls it at `reminder_time` (Feature 20).
- Delivery goes through `notification_outbox`.
- Web Push uses VAPID (`webpush-go`). Google Chat uses the stored incoming
webhook.
@@ -880,6 +899,22 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
through `shopping.Generate` and answers with the same shape plus
`skipped: [{date, dinner_id, dinner_name, reason}]`. 404 when the week
has no plan, 409 `archived` when its list is in the Supply archive.
- `POST /api/v1/shopping/{weekStart}/send-chat` (CSRF-checked) takes
`{}` or `{"resend": true}` and posts the Supply run to Google Chat
through `chat.PostSupplyRun`, in the week's thread: "Supply run · " and
its title (or week label), each dinner with items still needed and those
items, and a card whose "Open" button links to
`/plan/shopping?week={weekStart}` under `MEALQUEST_PUBLIC_URL`. It
records `sent_to_chat_at` and answers with the Supply run as `GET` gives
it. A week already sent is answered as it is, with no second post,
unless `resend` is true. No webhook stored is 409 `not_configured`;
Google Chat not answering is 502 `chat_unreachable` (retryable) and
answering with a status other than 2xx 502 `chat_refused` (retryable
only for 429 or 5xx), and neither
records `sent_to_chat_at`. 404 when the week has no Supply run, 409
`archived` when it is in the Supply archive, 400 `invalid_request` for a
bad body. `shopping.SendToChat(weekStart, resend)` is what
`shopping_chat_autosend` calls (Feature 20).
- `PATCH /api/v1/shopping/items/{id}` takes `{needed}` (a required
boolean; it sets the flag, it does not flip it) and answers `{id,
needed, needed_count, total_count}` with the list's counts after the
+72 -8
View File
@@ -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
+61 -3
View File
@@ -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())
}
}
+97
View File
@@ -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...)
}
+74 -4
View File
@@ -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 {
+39 -2
View File
@@ -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"})
+5
View File
@@ -143,6 +143,8 @@ type Querier interface {
GetShoppingList(ctx context.Context, weekStart string) (ShoppingList, error)
// A suggestion with its dinner. Returns sql.ErrNoRows for an unknown id.
GetSuggestion(ctx context.Context, id string) (GetSuggestionRow, error)
// Batch id. Returns sql.ErrNoRows when there is none.
GetSuggestionBatch(ctx context.Context, id string) (SuggestionBatch, error)
// The id of the job that runs batch batch_id.
GetSuggestionBatchJobID(ctx context.Context, arg GetSuggestionBatchJobIDParams) (string, error)
// The plan of the week starting week_start. Returns sql.ErrNoRows when
@@ -233,6 +235,9 @@ type Querier interface {
// Records a delivered notification; updated_at is when it was sent. The
// lease must still be the claim's.
MarkNotificationSent(ctx context.Context, arg MarkNotificationSentParams) (int64, error)
// Records list id sent to Google Chat at sent_to_chat_at, unless it is
// frozen into the Supply archive.
MarkShoppingListSentToChat(ctx context.Context, arg MarkShoppingListSentToChatParams) (int64, error)
// Records an edit of plan id, raising its revision: a synced plan becomes
// changed, any other keeps its status.
MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error
+22
View File
@@ -362,6 +362,28 @@ func (q *Queries) ListShoppingListsThrough(ctx context.Context, weekStart string
return items, nil
}
const markShoppingListSentToChat = `-- name: MarkShoppingListSentToChat :execrows
UPDATE shopping_lists
SET sent_to_chat_at = ?, updated_at = ?
WHERE id = ? AND status = 'current' AND frozen_at IS NULL
`
type MarkShoppingListSentToChatParams struct {
SentToChatAt sql.NullString
UpdatedAt string
ID string
}
// Records list id sent to Google Chat at sent_to_chat_at, unless it is
// frozen into the Supply archive.
func (q *Queries) MarkShoppingListSentToChat(ctx context.Context, arg MarkShoppingListSentToChatParams) (int64, error) {
result, err := q.db.ExecContext(ctx, markShoppingListSentToChat, arg.SentToChatAt, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const moveShoppingGroup = `-- name: MoveShoppingGroup :exec
UPDATE shopping_groups
SET position = ?, updated_at = ?
+20
View File
@@ -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
+40 -34
View File
@@ -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
}
+31
View File
@@ -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)
}
}
+65
View File
@@ -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
}
+3 -2
View File
@@ -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
+15 -1
View File
@@ -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
}
+33 -3
View File
@@ -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) {
+83 -1
View File
@@ -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)
+17 -4
View File
@@ -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) {
+1
View File
@@ -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))
+6 -4
View File
@@ -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)
}
+55
View File
@@ -9,6 +9,7 @@ import (
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
@@ -17,6 +18,9 @@ import (
// maxShoppingItemBody bounds a PATCH /shopping/items/{id} body.
const maxShoppingItemBody = 1 << 10
// maxSendChatBody bounds a POST /shopping/{weekStart}/send-chat body.
const maxSendChatBody = 1 << 10
// supplyRun is a Supply run as the API shows it. label is the week label,
// which stands in for title until the AI writes one.
type supplyRun struct {
@@ -59,6 +63,12 @@ type generatedSupplyRun struct {
Skipped []shopping.SkippedDay `json:"skipped"`
}
// sendChatRequest is the POST /shopping/{weekStart}/send-chat body:
// resend posts a week already sent again.
type sendChatRequest struct {
Resend bool `json:"resend"`
}
// shoppingItemPatch is the PATCH /shopping/items/{id} body.
type shoppingItemPatch struct {
Needed *bool `json:"needed"`
@@ -120,6 +130,51 @@ func postSupplyRunGenerate(logger *slog.Logger, svc *shopping.Service) http.Hand
}
}
// postSupplyRunSendChat posts the week's Supply run to Google Chat and
// answers with it, sent_to_chat_at recorded. Its body is a JSON object,
// {} or {"resend": true}: a week already sent is answered as it is,
// without a second post, unless resend is true. No webhook stored is 409
// not_configured; Google Chat not answering is 502 chat_unreachable,
// retryable, and refusing the message 502 chat_refused, retryable only for
// 429 or 5xx.
func postSupplyRunSendChat(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
week, ok := weekStartOrFail(w, r, logger)
if !ok {
return
}
body, err := decodePatch[sendChatRequest](http.MaxBytesReader(w, r.Body, maxSendChatBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
l, err := svc.SendToChat(r.Context(), week, body.Resend)
var refused *chat.RefusedError
switch {
case errors.Is(err, chat.ErrNotConfigured):
writeError(w, r, logger, http.StatusConflict, "not_configured", "no Google Chat webhook is configured; add one in Settings")
return
case errors.Is(err, chat.ErrUnreachable):
logger.WarnContext(r.Context(), "send Supply run to Google Chat", "error", err)
writeAPIError(w, r, logger, http.StatusBadGateway, apiError{Code: "chat_unreachable", Message: "Google Chat did not answer", Retryable: true})
return
case errors.As(err, &refused):
logger.WarnContext(r.Context(), "send Supply run to Google Chat", "error", err)
writeAPIError(w, r, logger, http.StatusBadGateway, apiError{Code: "chat_refused", Message: err.Error(), Retryable: refused.Retryable()})
return
case err != nil:
writeShoppingError(w, r, logger, "send Supply run to Google Chat", err)
return
}
out, err := supplyRunOf(week, l)
if err != nil {
writeShoppingError(w, r, logger, "send Supply run to Google Chat", err)
return
}
writeJSON(w, r, logger, http.StatusOK, out)
}
}
// patchShoppingItem marks a Supply run item needed or "have it" and
// answers with its Supply run's new counts.
func patchShoppingItem(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
+118
View File
@@ -15,6 +15,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat/chattest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
@@ -787,3 +788,120 @@ func TestSupplyArchive(t *testing.T) {
t.Errorf("frozen list = %s %d/%d %v, want archived 2/3 %v", frozen.Status, frozen.NeededCount, frozen.TotalCount, frozen.render(), wantLines)
}
}
// TestSupplyRunSendChat posts the week's Supply run to Google Chat once,
// records sent_to_chat_at, answers a second send with the status as it
// is, and posts again only with resend.
func TestSupplyRunSendChat(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
e.groundWeek(t)
var l supplyRunJSON
callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &l)
callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+l.itemID(t, "onion"), `{"needed":false}`, nil)
send := shoppingPath + "2026-10-12/send-chat"
if status, body := callShopping(t, e, http.MethodPost, send, `{}`, nil); status != http.StatusConflict || errorCode(body) != "not_configured" {
t.Fatalf("send with no webhook = %d %v, want 409 not_configured", status, body)
}
if _, err := e.chatSvc.Put(t.Context(), chattest.Webhook, "Family"); err != nil {
t.Fatal(err)
}
var sent supplyRunJSON
if status, body := callShopping(t, e, http.MethodPost, send, `{}`, &sent); status != http.StatusOK {
t.Fatalf("send = %d %v", status, body)
}
if sent.SentToChatAt == nil || sent.WeekStart != "2026-10-12" || !reflect.DeepEqual(sent.render(), []string{
"Monday 2026-10-12 Beef Chili: ground beef [1 lb] needed",
"Monday 2026-10-12 Beef Chili: kidney beans [2 cans] needed",
"Monday 2026-10-12 Beef Chili: onion [1] have it",
"Tuesday 2026-10-13 Regular 2026-10-13: spaghetti [500 g] needed",
"Tuesday 2026-10-13 Regular 2026-10-13: basil [null] needed",
}) {
t.Errorf("send answer = %+v, want the Supply run with sent_to_chat_at", sent)
}
posts := e.chat.Posts()
if len(posts) != 1 {
t.Fatalf("Google Chat got %d posts, want 1", len(posts))
}
want := "Supply run · Week 42 · Oct 12 – 18\n\n*Beef Chili*\n• ground beef — 1 lb\n• kidney beans — 2 cans\n\n" +
"*Regular 2026-10-13*\n• spaghetti — 500 g\n• basil\n\nOpen the Supply run →"
if posts[0].Text() != want {
t.Errorf("posted text = %q, want %q", posts[0].Text(), want)
}
var again supplyRunJSON
if status, body := callShopping(t, e, http.MethodPost, send, `{}`, &again); status != http.StatusOK || again.SentToChatAt == nil || *again.SentToChatAt != *sent.SentToChatAt {
t.Errorf("send again = %d %v, sent_to_chat_at %v, want 200 and %s", status, body, again.SentToChatAt, *sent.SentToChatAt)
}
if n := len(e.chat.Posts()); n != 1 {
t.Errorf("send again without resend posted: %d posts, want 1", n)
}
var got supplyRunJSON
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &got)
if got.SentToChatAt == nil || *got.SentToChatAt != *sent.SentToChatAt {
t.Errorf("GET sent_to_chat_at = %v, want %s", got.SentToChatAt, *sent.SentToChatAt)
}
var resent supplyRunJSON
if status, body := callShopping(t, e, http.MethodPost, send, `{"resend":true}`, &resent); status != http.StatusOK || resent.SentToChatAt == nil {
t.Fatalf("resend = %d %v", status, body)
}
if n := len(e.chat.Posts()); n != 2 {
t.Errorf("resend: %d posts, want 2", n)
}
}
func TestSupplyRunSendChatErrors(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
e.groundWeek(t)
callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil)
if _, err := e.chatSvc.Put(t.Context(), chattest.Webhook, "Family"); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name, path, body string
status int
code string
}{
{"no body", "2026-10-12/send-chat", "", http.StatusBadRequest, "invalid_request"},
{"resend not a boolean", "2026-10-12/send-chat", `{"resend":"yes"}`, http.StatusBadRequest, "invalid_request"},
{"unknown field", "2026-10-12/send-chat", `{"force":true}`, http.StatusBadRequest, "invalid_request"},
{"not a Monday", "2026-10-13/send-chat", `{}`, http.StatusBadRequest, "invalid_week"},
{"no list", "2026-10-19/send-chat", `{}`, http.StatusNotFound, "not_found"},
} {
status, body := callShopping(t, e, http.MethodPost, shoppingPath+tc.path, tc.body, nil)
if status != tc.status || errorCode(body) != tc.code {
t.Errorf("%s = %d %v, want %d %s", tc.name, status, body, tc.status, tc.code)
}
}
e.chat.SetStatus(http.StatusInternalServerError)
status, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/send-chat", `{}`, nil)
if status != http.StatusBadGateway || errorCode(body) != "chat_refused" || body["error"].(map[string]any)["retryable"] != true {
t.Errorf("send Google Chat refuses = %d %v, want 502 chat_refused, retryable", status, body)
}
e.chat.SetStatus(http.StatusNotFound)
status, body = callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/send-chat", `{}`, nil)
if status != http.StatusBadGateway || errorCode(body) != "chat_refused" || body["error"].(map[string]any)["retryable"] != false {
t.Errorf("send Google Chat answers 404 = %d %v, want 502 chat_refused, not retryable", status, body)
}
e.chat.Close()
status, body = callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/send-chat", `{}`, nil)
if status != http.StatusBadGateway || errorCode(body) != "chat_unreachable" {
t.Errorf("send Google Chat does not answer = %d %v, want 502 chat_unreachable", status, body)
}
var l supplyRunJSON
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l)
if l.SentToChatAt != nil {
t.Errorf("sent_to_chat_at after failed sends = %s, want null", *l.SentToChatAt)
}
e.archive(t)
status, body = callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/send-chat", `{"resend":true}`, nil)
if status != http.StatusConflict || errorCode(body) != "archived" {
t.Errorf("send an archived list = %d %v, want 409 archived", status, body)
}
}
+2 -1
View File
@@ -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)
}
+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)
}
+51 -19
View File
@@ -10,9 +10,12 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
@@ -54,7 +57,18 @@ func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// recording the batch's end fails, so does the job; when it times out at
// shutdown, the job is left running and the batch with it, for the next
// start to fail.
//
// A scheduled or manual batch that ends done having added Side quests to
// the deck emits new_suggestions, keyed by the batch, in the transaction
// that records it done. A replenish batch, which tops up the deck as the
// household swipes, never notifies. A notification that cannot be stored
// fails the done write with it, as any failure recording the batch's end
// does: the job fails, and its Side quests stay in the deck.
func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchResult) error) (batchResult, error) {
batch, err := db.New(s.st.Reader).GetSuggestionBatch(ctx, p.BatchID)
if err != nil {
return batchResult{}, fmt.Errorf("suggest: read batch %s: %w", p.BatchID, err)
}
n, err := db.New(s.st.Writer).StartSuggestionBatch(ctx, db.StartSuggestionBatchParams{
UpdatedAt: store.Now().String(),
ID: p.BatchID,
@@ -65,14 +79,19 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe
if n == 0 {
return batchResult{}, fmt.Errorf("suggest: batch %s is not pending", p.BatchID)
}
res, err := s.fill(ctx, p.BatchID, publish)
f, err := s.fill(ctx, p.BatchID, publish)
if err == nil {
if err := s.finishBatch(ctx, p.BatchID, BatchDone); err != nil {
var ev *notify.Event
if batch.Trigger != TriggerReplenish && len(f.names) > 0 {
e := chat.NewSuggestions(p.BatchID, household.WeekStart(f.today), f.names)
ev = &e
}
if err := s.finishBatch(ctx, p.BatchID, BatchDone, ev); err != nil {
return batchResult{}, err
}
return res, nil
return f.res, nil
}
finishErr := s.finishBatch(ctx, p.BatchID, BatchFailed)
finishErr := s.finishBatch(ctx, p.BatchID, BatchFailed, nil)
if ctx.Err() != nil {
return batchResult{}, errors.Join(ctx.Err(), finishErr)
}
@@ -85,50 +104,60 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe
return batchResult{}, errors.Join(err, finishErr)
}
// filled is what fill did: the batch's result, the names of the Side
// quests it added to the deck, and the household's date it ran on.
type filled struct {
res batchResult
names []string
today store.LocalDate
}
// fill does runBatch's work, returning a *jobs.Failure for a failure the
// household may see.
func (s *Service) fill(ctx context.Context, batchID string, publish func(batchResult) error) (batchResult, error) {
func (s *Service) fill(ctx context.Context, batchID string, publish func(batchResult) error) (filled, error) {
res := batchResult{BatchID: batchID, Stage: StageSuggesting, SuggestionIDs: []string{}}
if err := publish(res); err != nil {
return batchResult{}, err
return filled{}, err
}
in, err := s.request(ctx)
if err != nil {
return batchResult{}, err
return filled{}, err
}
out, err := s.suggest(ctx, batchID, in)
if err != nil {
if ctx.Err() != nil {
return batchResult{}, ctx.Err()
return filled{}, ctx.Err()
}
return batchResult{}, &jobs.Failure{Code: ReasonAIFailed}
return filled{}, &jobs.Failure{Code: ReasonAIFailed}
}
res.Stage = StageGrounding
res.Dropped = len(out.Dropped)
if err := publish(res); err != nil {
return batchResult{}, err
return filled{}, err
}
var names []string
left := out.Suggestions
for len(res.SuggestionIDs) < BatchSize && len(left) > 0 {
round := left[:min(BatchSize-len(res.SuggestionIDs), len(left))]
left = left[len(round):]
grounded, err := s.ground(ctx, round)
if err != nil {
return batchResult{}, err
return filled{}, err
}
res.Dropped += len(round) - len(grounded)
ids, err := s.store(ctx, batchID, grounded)
ids, stored, err := s.store(ctx, batchID, grounded)
if err != nil {
return batchResult{}, err
return filled{}, err
}
res.Dropped += len(grounded) - len(ids)
res.SuggestionIDs = append(res.SuggestionIDs, ids...)
names = append(names, stored...)
if err := publish(res); err != nil {
return batchResult{}, err
return filled{}, err
}
}
res.Stage = StageDone
return res, nil
return filled{res: res, names: names, today: in.Context.Today}, nil
}
// request reads what suggest_side_quests is given: the household context,
@@ -203,9 +232,11 @@ func (s *Service) ground(ctx context.Context, quests []skills.SideQuest) ([]grou
// suggest_side_quests ran, a dinner it repeats was added, a suggestion it
// repeats passed or a Rotation check dropped, or a Regular it cites renamed
// or archived. Passed names are read in the same transaction as the store,
// so a Pass or drop committed before it is never missed.
func (s *Service) store(ctx context.Context, batchID string, quests []groundedQuest) ([]string, error) {
// so a Pass or drop committed before it is never missed. The names stored
// are returned with the ids.
func (s *Service) store(ctx context.Context, batchID string, quests []groundedQuest) ([]string, []string, error) {
ids := []string{}
var stored []string
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
existing, err := q.ListUnarchivedDinners(ctx)
@@ -269,13 +300,14 @@ func (s *Service) store(ctx context.Context, batchID string, quests []groundedQu
}
names = append(names, d.Name)
ids = append(ids, id)
stored = append(stored, d.Name)
}
return nil
})
if err != nil {
return nil, err
return nil, nil, err
}
return ids, nil
return ids, stored, nil
}
// citedIDs returns the ids of the Regulars named cited, false when one
+33 -16
View File
@@ -20,6 +20,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
@@ -109,14 +110,16 @@ type Service struct {
st *store.Store
grounder *recipes.Grounder
suggest Suggester
notifier *notify.Service
logger *slog.Logger
batchJob *jobs.Kind[BatchJob]
}
// NewService returns a service over st that suggests with suggest, grounds
// with grounder and registers the KindSuggestionBatch job on runner, which
// must not have started yet.
func NewService(st *store.Store, grounder *recipes.Grounder, suggest Suggester, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
// with grounder, tells the household through notifier, which must be over
// st, and registers the KindSuggestionBatch job on runner, which must not
// have started yet.
func NewService(st *store.Store, grounder *recipes.Grounder, suggest Suggester, runner *jobs.Runner, notifier *notify.Service, logger *slog.Logger) (*Service, error) {
switch {
case st == nil:
return nil, errors.New("suggest: nil store")
@@ -126,10 +129,12 @@ func NewService(st *store.Store, grounder *recipes.Grounder, suggest Suggester,
return nil, errors.New("suggest: nil suggester")
case runner == nil:
return nil, errors.New("suggest: nil job runner")
case notifier == nil:
return nil, errors.New("suggest: nil notifier")
case logger == nil:
return nil, errors.New("suggest: nil logger")
}
s := &Service{st: st, grounder: grounder, suggest: suggest, logger: logger}
s := &Service{st: st, grounder: grounder, suggest: suggest, notifier: notifier, logger: logger}
kind, err := jobs.Register(runner, KindSuggestionBatch, s.runBatch, Reasons...)
if err != nil {
return nil, err
@@ -252,27 +257,39 @@ func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (B
return Batch{ID: row.ID, JobID: jobID, Trigger: row.Trigger, Status: row.Status, Created: true}, nil
}
// finishBatch records running batch id as status, with
// jobs.FinalWriteContext: the write is made even when ctx has been
// cancelled, but a writer that stays busy cannot hold its worker past
// jobs.FinalWriteTimeout. Its job's handler returns any error, so the job
// finishBatch records running batch id as status, and emits ev with it
// when ev is not nil, with jobs.FinalWriteContext: the write is made even
// when ctx has been cancelled, but a writer that stays busy cannot hold its
// worker past jobs.FinalWriteTimeout. Its job's handler returns any error, so the job
// never ends succeeded with the batch still running; the runner keeps only
// a code, so the error is logged too.
func (s *Service) finishBatch(ctx context.Context, id, status string) error {
func (s *Service) finishBatch(ctx context.Context, id, status string, ev *notify.Event) error {
write, cancel := jobs.FinalWriteContext(ctx)
defer cancel()
n, err := db.New(s.st.Writer).FinishSuggestionBatch(write, db.FinishSuggestionBatchParams{
Status: status,
UpdatedAt: store.Now().String(),
ID: id,
err := s.st.WithTx(write, func(tx *sql.Tx) error {
n, err := db.New(tx).FinishSuggestionBatch(write, db.FinishSuggestionBatchParams{
Status: status,
UpdatedAt: store.Now().String(),
ID: id,
})
if err != nil {
return err
}
if n != 1 {
return errors.New("batch is not running")
}
if ev == nil {
return nil
}
return s.notifier.EmitTx(write, tx, *ev)
})
if err == nil && n != 1 {
err = errors.New("batch is not running")
}
if err != nil {
s.logger.ErrorContext(ctx, "record suggestion batch status", "batch_id", id, "status", status, "error", err)
return fmt.Errorf("suggest: record batch %s %s: %w", id, status, err)
}
if ev != nil {
s.notifier.Wake()
}
return nil
}
+59 -3
View File
@@ -23,6 +23,8 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
@@ -121,7 +123,7 @@ func newEnv(t *testing.T) *env {
if err != nil {
t.Fatal(err)
}
e.svc, err = suggest.NewService(st, g, suggester, e.runner, logger)
e.svc, err = suggest.NewService(st, g, suggester, e.runner, notifytest.New(t, st), logger)
if err != nil {
t.Fatal(err)
}
@@ -401,6 +403,57 @@ func TestBatchStoresGroundedSideQuests(t *testing.T) {
}
}
// TestBatchEmitsNewSuggestions: a scheduled or manual batch that adds Side
// quests stores new_suggestions keyed by the batch, naming them; a
// replenish batch, or one that adds none, stores nothing.
func TestBatchEmitsNewSuggestions(t *testing.T) {
for _, tc := range []struct {
trigger string
grounds bool
notify bool
}{
{suggest.TriggerScheduled, true, true},
{suggest.TriggerManual, true, true},
{suggest.TriggerReplenish, true, false},
{suggest.TriggerManual, false, false},
} {
t.Run(fmt.Sprintf("%s grounds %v", tc.trigger, tc.grounds), func(t *testing.T) {
e := newEnv(t)
e.start(t)
if tc.grounds {
e.recipe(t, "Chicken Shawarma Bowls")
e.recipe(t, "Chili Verde")
}
e.reply(
sideQuest("Chicken Shawarma Bowls", "Spiced chicken, like your Chicken Fajitas.", "Chicken Fajitas"),
sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili"),
)
b, err := e.svc.StartBatch(t.Context(), tc.trigger)
if err != nil {
t.Fatal(err)
}
if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusSucceeded {
t.Fatalf("batch job %s", job.Status)
}
rows := notifytest.Rows(t, e.st, notify.NewSuggestions)
if !tc.notify {
if len(rows) != 0 {
t.Errorf("new_suggestions rows = %+v, want none", rows)
}
return
}
if len(rows) != 1 {
t.Fatalf("new_suggestions rows = %+v, want one on push", rows)
}
r := rows[0]
if r.Key != "new_suggestions:"+b.ID || r.Channel != notify.ChannelPush || r.Payload.URL != "/discover" ||
r.Payload.Title != "New Side quests are in" || r.Payload.Body != "Chicken Shawarma Bowls, Chili Verde" || r.Payload.Week == "" {
t.Errorf("new_suggestions row = %+v, want key new_suggestions:%s naming the Side quests, /discover", r, b.ID)
}
})
}
}
func TestBatchKeepsAtMostBatchSize(t *testing.T) {
e := newEnv(t)
e.start(t)
@@ -929,7 +982,7 @@ func TestLaterBatchNeverResuggestsRenamedDrop(t *testing.T) {
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
return skills.Week{}, errors.New("no draft in this test")
}
planSvc, err := plan.NewService(e.st, fail, e.runner, noCalendar{}, &url.URL{Scheme: "https", Host: "meals.example.com"}, logger)
planSvc, err := plan.NewService(e.st, fail, e.runner, noCalendar{}, notifytest.New(t, e.st), &url.URL{Scheme: "https", Host: "meals.example.com"}, logger)
if err != nil {
t.Fatal(err)
}
@@ -1151,6 +1204,9 @@ func TestBatchJobFailsWhenRecordingItsEndFails(t *testing.T) {
if got := e.batchStatus(t, b.ID); got != suggest.BatchFailed {
t.Errorf("batch status = %s with its job failed, want failed", got)
}
if rows := notifytest.Rows(t, e.st, notify.NewSuggestions); len(rows) != 0 {
t.Errorf("new_suggestions rows of a batch whose end was not recorded = %+v, want none", rows)
}
}
func TestBatchRecoversWhenRecordingItsEndAndItsJobsEndFail(t *testing.T) {
@@ -1284,7 +1340,7 @@ func TestBlockedBatchEndDoesNotHoldShutdown(t *testing.T) {
refuse := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
return skills.SideQuests{}, errors.New("not wanted")
}
svc, err := suggest.NewService(e.st, e.grounder, refuse, next, logger)
svc, err := suggest.NewService(e.st, e.grounder, refuse, next, notifytest.New(t, e.st), logger)
if err != nil {
t.Fatal(err)
}
+7
View File
@@ -89,6 +89,13 @@ UPDATE shopping_lists
SET title = ?, updated_at = ?
WHERE id = ? AND title IS NULL AND status = 'current' AND frozen_at IS NULL;
-- name: MarkShoppingListSentToChat :execrows
-- Records list id sent to Google Chat at sent_to_chat_at, unless it is
-- frozen into the Supply archive.
UPDATE shopping_lists
SET sent_to_chat_at = ?, updated_at = ?
WHERE id = ? AND status = 'current' AND frozen_at IS NULL;
-- name: FreezeShoppingList :execrows
-- Freezes list id into the Supply archive at frozen_at, unless it is
-- frozen already.
+5
View File
@@ -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).
-65
View File
@@ -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 {
+2 -36
View File
@@ -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 (
<div className={[styles.unavailable, below && styles.below].filter(Boolean).join(' ')}>
<Button
variant={variant}
className={[className, styles[variant]].filter(Boolean).join(' ')}
aria-disabled="true"
aria-describedby={hintId}
>
{children}
</Button>
<span id={hintId} role="tooltip" className={styles.hint}>
{hint}
</span>
</div>
)
}
// 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
+67 -6
View File
@@ -13,6 +13,7 @@ import { sentAt, SupplyRunPage } from './SupplyRunPage.tsx'
const THIS_WEEK = '/api/v1/shopping/2026-10-05'
const GENERATE = `${THIS_WEEK}/generate`
const SEND_CHAT = `${THIS_WEEK}/send-chat`
const ITEM = (id: string) => `/api/v1/shopping/items/${id}`
type Item = { id: string; name: string; qty: string | null; needed: boolean }
@@ -372,19 +373,79 @@ describe('SupplyRunPage', () => {
expect(archive).toHaveAttribute('href', '/plan/shopping/archive')
expect(archive).toHaveClass(buttonStyles.outline!)
// Send to Google Chat waits for Feature 19: primary, unavailable, and
// its tooltip says so. Pressing it sends nothing.
const send = screen.getByRole('button', { name: 'Send to Google Chat' })
// Already sent, the primary action sends it again.
const send = screen.getByRole('button', { name: 'Send again' })
expect(send).toHaveClass(buttonStyles.primary!)
expect(send).toHaveAttribute('aria-disabled', 'true')
expect(send).toHaveAccessibleDescription('Sending the shopping list to Google Chat arrives in a later update.')
fireEvent.click(send)
expect(send).toBeEnabled()
expect(writes(fetchMock)).toEqual([])
expect(screen.getByText('Sent to Google Chat · Sat 8:00 am')).toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'Supply archive' })).not.toBeInTheDocument()
})
it('sends the list to Google Chat once and shows when it was sent', async () => {
mockViewport(1180)
const sent = deferred()
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()),
[`POST ${SEND_CHAT}`]: () => sent.response,
})
await renderPage()
await screen.findByText('4 / 6')
expect(screen.queryByText(/Sent to Google Chat ·/)).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Send to Google Chat' }))
const sending = await screen.findByRole('button', { name: 'Sending…' })
expect(sending).toBeDisabled()
expect(writes(fetchMock)).toEqual([`POST ${SEND_CHAT} {"resend":false}`])
// Saturday Oct 10, 8:00 am in New York.
sent.send(jsonResponse(200, supplyRun({ sent_to_chat_at: '2026-10-10T12:00:00Z' })))
expect(await screen.findByText('Sent to Google Chat · Sat 8:00 am')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Send again' })).toBeEnabled()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('sends a list already sent again with resend', async () => {
mockViewport(1440)
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun({ sent_to_chat_at: '2026-10-10T12:00:00Z' })),
[`POST ${SEND_CHAT}`]: () => jsonResponse(200, supplyRun({ sent_to_chat_at: '2026-10-10T16:30:00Z' })),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Send again' }))
expect(await screen.findByText('Sent to Google Chat · Sat 12:30 pm')).toBeInTheDocument()
expect(writes(fetchMock)).toEqual([`POST ${SEND_CHAT} {"resend":true}`])
})
it('says why a send to Google Chat failed, and retries it', async () => {
mockViewport(1180)
const answers = [
() => errorResponse(409, 'not_configured'),
() => jsonResponse(200, supplyRun({ sent_to_chat_at: '2026-10-10T12:00:00Z' })),
]
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()),
[`POST ${SEND_CHAT}`]: () => answers.shift()!(),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Send to Google Chat' }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't send the shopping list to Google Chat. not_configured message")
expect(screen.queryByText(/Sent to Google Chat ·/)).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Send to Google Chat' })).toBeEnabled()
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('Sent to Google Chat · Sat 8:00 am')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(writes(fetchMock)).toEqual([`POST ${SEND_CHAT} {"resend":false}`, `POST ${SEND_CHAT} {"resend":false}`])
})
it('renders an archived list read-only, tagged Archived', async () => {
const fetchMock = stubServer({
'GET /api/v1/shopping/2026-09-28': () =>
+20 -12
View File
@@ -1,10 +1,10 @@
import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react'
import { useState } from 'react'
import { Link, useSearchParams } from 'react-router'
import { Unavailable } from '../plan/PlanPage.tsx'
import { defaultWeek, localNow, weekdayKicker, weekLabel, weekStart as weekOf } from '../plan/week.ts'
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
import { Button } from '../ui/Button.tsx'
import buttonStyles from '../ui/Button.module.css'
import { Checkbox } from '../ui/Checkbox.tsx'
import { EmptyState } from '../ui/EmptyState.tsx'
@@ -14,6 +14,7 @@ import { Skeleton } from '../ui/Skeleton.tsx'
import { Tag } from '../ui/Tag.tsx'
import {
useGenerateSupplyRun,
useSendToChat,
useSupplyRun,
useToggles,
WEEK_PARAM,
@@ -27,10 +28,6 @@ import styles from './SupplyRunPage.module.css'
// SUPPLY_ARCHIVE is the Supply archive screen.
export const SUPPLY_ARCHIVE = '/plan/shopping/archive'
// CHAT_HINT is why Send to Google Chat is unavailable.
// TODO(Feature 19): send the Supply run to Google Chat once it is built.
const CHAT_HINT = 'Sending the shopping list to Google Chat arrives in a later update.'
// sentAt is when the Supply run was sent to Google Chat as its status line
// says it, in the household's zone: "Sat 8:00 am".
export function sentAt(at: Date, timezone: string): string {
@@ -104,6 +101,17 @@ function SentStatus({ at, timezone }: { at: Date; timezone: string }) {
)
}
// SendToChat posts the Supply run to Google Chat: "Send to Google Chat"
// until it is sent, then "Send again", which posts it once more.
function SendToChat({ sent, pending, onSend }: { sent: boolean; pending: boolean; onSend: () => void }) {
return (
<Button className={styles.action} disabled={pending} onClick={onSend}>
<Icon icon={MessageSquare} size="inline" />
{pending ? 'Sending…' : sent ? 'Send again' : 'Send to Google Chat'}
</Button>
)
}
// ArchiveLink opens the Supply archive: an outline action from tablet up, a
// grape link on mobile.
function ArchiveLink({ layout }: { layout: Breakpoint }) {
@@ -120,13 +128,15 @@ function ArchiveLink({ layout }: { layout: Breakpoint }) {
// the week's dates, the title with the count still to buy of all items, and
// one card per dinner, stacked on mobile and in three masonry columns from
// tablet up, where Archive and Send to Google Chat are actions in the
// header. On a current Supply run each item toggles at once (useToggles); a
// header; a send that fails says why. On a current Supply run each item toggles at once (useToggles); a
// toggle refused puts the item back and says why. An archived one, or any
// opened from the Supply archive (readOnly), is read-only and tagged
// Archived, and a toggle it refused is not offered again.
function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
const toggles = useToggles(weekStart)
const send = useSendToChat(weekStart)
const run = toggles.shown
const sent = run.sent_to_chat_at !== null
const viewOnly = readOnly || run.status === 'archived'
const wide = layout !== 'mobile'
const failed = toggles.failed
@@ -155,12 +165,7 @@ function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
{wide && (
<div className={styles.actions}>
<ArchiveLink layout={layout} />
{!viewOnly && (
<Unavailable hint={CHAT_HINT} below className={styles.action}>
<Icon icon={MessageSquare} size="inline" />
Send to Google Chat
</Unavailable>
)}
{!viewOnly && <SendToChat sent={sent} pending={send.isPending} onSend={() => send.mutate(sent)} />}
</div>
)}
</header>
@@ -170,6 +175,9 @@ function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
{failed.error.message}
</ErrorBanner>
)}
{send.isError && !viewOnly && (
<ErrorBanner onRetry={() => send.mutate(sent)}>Couldn't send the shopping list to Google Chat. {send.error.message}</ErrorBanner>
)}
{wide && run.sent_to_chat_at !== null && <SentStatus at={run.sent_to_chat_at} timezone={timezone} />}
{run.groups.length === 0 ? (
<EmptyState title="Nothing to buy">None of this week's dinners has a recipe with ingredients yet.</EmptyState>
+19
View File
@@ -206,6 +206,25 @@ export function useGenerateSupplyRun(weekStart: string) {
})
}
// useSendToChat posts the week's Supply run to Google Chat (POST
// /api/v1/shopping/{weekStart}/send-chat); the Supply run it answers with,
// sent_to_chat_at recorded, is the one shown. A week already sent is posted
// again only with resend: without it the server answers with the Supply
// run as it is.
export function useSendToChat(weekStart: string) {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (resend: boolean): Promise<SupplyRun> => {
const path = `${supplyRunPath(weekStart)}/send-chat`
return parseSupplyRun(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST', body: { resend } }))
},
onSuccess: (run) => {
queryClient.setQueryData<SupplyRun | null>(supplyRunKey(weekStart), run)
},
})
}
// Toggle is one item marked to buy (needed) or "have it" (not needed).
export type Toggle = { id: string; needed: boolean }