Make new_suggestions and Send to Google Chat recoverable
MQ-167-01: a scheduled or manual batch now emits new_suggestions:{batchId}
in the transaction that first stores its Side quests, so the cards and
their notification commit together. A later round that fails, or a
failure recording the batch's end, no longer loses the event; a later
round's emit is a no-op by key; a notification that cannot be stored
fails that store, leaving no unannounced cards. Replenish batches still
never notify. finishBatch goes back to recording status only.
MQ-167-02: migration 0017 adds shopping_lists.chat_send_unconfirmed_at.
SendToChat records the send as begun before posting, clears it with
sent_to_chat_at once Google Chat accepts, and clears it alone when Chat
refuses. A post Chat does not answer, or one it accepts that cannot be
recorded, is ErrSendUnconfirmed (502 chat_unconfirmed, not retryable)
and leaves the marker, as does a crash mid-send; a repeat without resend
never reposts, and resend posts again. The Supply run screen re-reads
the list after a failed send, shows an unconfirmed send, offers no Retry
for it, and sends again only with resend.
Task: f0cd9d50-9c11-41be-a945-7bae9f5f28a4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+40
-10
@@ -3,6 +3,7 @@ package shopping
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
||||
@@ -16,10 +17,17 @@ import (
|
||||
// Chat (chat.PostSupplyRun): each dinner with its items still needed, and a
|
||||
// link to the Supply run. It records sent_to_chat_at and returns the list.
|
||||
//
|
||||
// A week is sent once: a Supply run already sent is returned as it is,
|
||||
// without posting again, unless resend is true. A week with no Supply run
|
||||
// is ErrListNotFound and one frozen into the Supply archive ErrFrozen. A
|
||||
// post that fails returns chat.PostSupplyRun's error and records nothing.
|
||||
// A week is sent once: a Supply run already sent, or with a send
|
||||
// unconfirmed, is returned as it is, without posting again, unless resend
|
||||
// is true. A week with no Supply run is ErrListNotFound and one frozen into
|
||||
// the Supply archive ErrFrozen.
|
||||
//
|
||||
// The send is recorded as begun, in chat_send_unconfirmed_at, before it is
|
||||
// posted, so a post whose outcome is lost is never taken for one never
|
||||
// made. A post Google Chat refuses, or that is never made, returns
|
||||
// chat.PostSupplyRun's error and clears it. A post Google Chat does not
|
||||
// answer, or one it accepts that cannot then be recorded, is
|
||||
// ErrSendUnconfirmed and leaves it, as does the process stopping mid-send.
|
||||
func (s *Service) SendToChat(ctx context.Context, weekStart store.LocalDate, resend bool) (List, error) {
|
||||
s.sending.Lock()
|
||||
defer s.sending.Unlock()
|
||||
@@ -30,7 +38,7 @@ func (s *Service) SendToChat(ctx context.Context, weekStart store.LocalDate, res
|
||||
if l.Status != StatusCurrent {
|
||||
return List{}, ErrFrozen
|
||||
}
|
||||
if l.SentToChatAt != nil && !resend {
|
||||
if (l.SentToChatAt != nil || l.ChatSendUnconfirmedAt != nil) && !resend {
|
||||
return l, nil
|
||||
}
|
||||
name, err := household.WeekLabel(weekStart)
|
||||
@@ -54,20 +62,42 @@ func (s *Service) SendToChat(ctx context.Context, weekStart store.LocalDate, res
|
||||
groups[i].Items = append(groups[i].Items, item)
|
||||
}
|
||||
}
|
||||
if err := s.chat.PostSupplyRun(ctx, weekStart, name, groups); err != nil {
|
||||
return List{}, err
|
||||
q := db.New(s.st.Writer)
|
||||
n, err := q.BeginShoppingListChatSend(ctx, db.BeginShoppingListChatSendParams{
|
||||
ChatSendUnconfirmedAt: sql.NullString{String: store.Now().String(), Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: l.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return List{}, fmt.Errorf("shopping: record send to Google Chat begun: %w", err)
|
||||
}
|
||||
// The post is made: record it even if the client has gone.
|
||||
if n != 1 {
|
||||
return List{}, ErrFrozen
|
||||
}
|
||||
postErr := s.chat.PostSupplyRun(ctx, weekStart, name, groups)
|
||||
// The post's outcome is known: record it even if the client has gone.
|
||||
write, cancel := jobs.FinalWriteContext(ctx)
|
||||
defer cancel()
|
||||
if errors.Is(postErr, chat.ErrUnreachable) {
|
||||
return List{}, fmt.Errorf("%w: %w", ErrSendUnconfirmed, postErr)
|
||||
}
|
||||
if postErr != nil {
|
||||
if _, err := q.ClearShoppingListChatSend(write, db.ClearShoppingListChatSendParams{
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: l.ID,
|
||||
}); err != nil {
|
||||
return List{}, errors.Join(postErr, fmt.Errorf("shopping: clear refused send to Google Chat: %w", err))
|
||||
}
|
||||
return List{}, postErr
|
||||
}
|
||||
now := store.Now().String()
|
||||
n, err := db.New(s.st.Writer).MarkShoppingListSentToChat(write, db.MarkShoppingListSentToChatParams{
|
||||
n, err = q.MarkShoppingListSentToChat(write, db.MarkShoppingListSentToChatParams{
|
||||
SentToChatAt: sql.NullString{String: now, Valid: true},
|
||||
UpdatedAt: now,
|
||||
ID: l.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return List{}, fmt.Errorf("shopping: record sent to Google Chat: %w", err)
|
||||
return List{}, fmt.Errorf("%w: record sent to Google Chat: %w", ErrSendUnconfirmed, err)
|
||||
}
|
||||
if n != 1 {
|
||||
return List{}, ErrFrozen
|
||||
|
||||
@@ -127,8 +127,8 @@ func TestSendToChat(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestSendToChatFailuresRecordNothing: a send with no webhook, or one
|
||||
// Google Chat refuses or does not answer, leaves sent_to_chat_at unset; a
|
||||
// week with no list, or a frozen one, is refused without posting.
|
||||
// Google Chat refuses, leaves sent_to_chat_at and chat_send_unconfirmed_at
|
||||
// unset; a week with no list, or a frozen one, is refused without posting.
|
||||
func TestSendToChatFailuresRecordNothing(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
@@ -148,12 +148,8 @@ func TestSendToChatFailuresRecordNothing(t *testing.T) {
|
||||
if _, err := e.svc.SendToChat(t.Context(), week, false); !errors.Is(err, chat.ErrRefused) {
|
||||
t.Errorf("SendToChat answered 500 = %v, want chat.ErrRefused", err)
|
||||
}
|
||||
e.chat.Close()
|
||||
if _, err := e.svc.SendToChat(t.Context(), week, false); !errors.Is(err, chat.ErrUnreachable) {
|
||||
t.Errorf("SendToChat unanswered = %v, want chat.ErrUnreachable", err)
|
||||
}
|
||||
if l, err := e.svc.Get(t.Context(), week); err != nil || l.SentToChatAt != nil {
|
||||
t.Errorf("sent_to_chat_at after failed sends = %v, %v, want unset", l.SentToChatAt, err)
|
||||
if l, err := e.svc.Get(t.Context(), week); err != nil || l.SentToChatAt != nil || l.ChatSendUnconfirmedAt != nil {
|
||||
t.Errorf("after failed sends sent_to_chat_at = %v, chat_send_unconfirmed_at = %v, %v, want both unset", l.SentToChatAt, l.ChatSendUnconfirmedAt, err)
|
||||
}
|
||||
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
|
||||
@@ -167,3 +163,80 @@ func TestSendToChatFailuresRecordNothing(t *testing.T) {
|
||||
t.Errorf("a frozen list was posted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendToChatUnconfirmed: a send whose outcome is not known, because
|
||||
// Google Chat accepted the post but recording it failed, or did not answer,
|
||||
// is ErrSendUnconfirmed and leaves the send unconfirmed rather than unsent:
|
||||
// a send without resend does not post again, and one with resend does,
|
||||
// confirming it.
|
||||
func TestSendToChatUnconfirmed(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
e.plan(t, "Beef Chili")
|
||||
e.generate(t)
|
||||
if _, err := e.chatSvc.Put(t.Context(), chattest.Webhook, "Family"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := e.st.Writer.Exec(`CREATE TRIGGER refuse_sent BEFORE UPDATE OF sent_to_chat_at ON shopping_lists BEGIN SELECT RAISE(ABORT, 'sent refused'); END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := e.svc.SendToChat(t.Context(), week, false); !errors.Is(err, shopping.ErrSendUnconfirmed) {
|
||||
t.Fatalf("SendToChat accepted but not recorded = %v, want ErrSendUnconfirmed", err)
|
||||
}
|
||||
if n := len(e.chat.Posts()); n != 1 {
|
||||
t.Fatalf("Google Chat got %d posts, want 1", n)
|
||||
}
|
||||
l, err := e.svc.Get(t.Context(), week)
|
||||
if err != nil || l.SentToChatAt != nil || l.ChatSendUnconfirmedAt == nil {
|
||||
t.Fatalf("after an unrecorded send sent_to_chat_at = %v, chat_send_unconfirmed_at = %v, %v, want unset and set", l.SentToChatAt, l.ChatSendUnconfirmedAt, err)
|
||||
}
|
||||
if _, err := e.st.Writer.Exec(`DROP TRIGGER refuse_sent`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
again, err := e.svc.SendToChat(t.Context(), week, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len(e.chat.Posts()); n != 1 || again.ChatSendUnconfirmedAt == nil || *again.ChatSendUnconfirmedAt != *l.ChatSendUnconfirmedAt {
|
||||
t.Errorf("send again without resend: %d posts, chat_send_unconfirmed_at %v, want 1 post and %s", n, again.ChatSendUnconfirmedAt, *l.ChatSendUnconfirmedAt)
|
||||
}
|
||||
|
||||
resent, err := e.svc.SendToChat(t.Context(), week, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len(e.chat.Posts()); n != 2 || resent.SentToChatAt == nil || resent.ChatSendUnconfirmedAt != nil {
|
||||
t.Errorf("resend: %d posts, sent_to_chat_at %v, chat_send_unconfirmed_at %v, want 2 posts, set and unset", n, resent.SentToChatAt, resent.ChatSendUnconfirmedAt)
|
||||
}
|
||||
|
||||
e.chat.Close()
|
||||
if _, err := e.svc.SendToChat(t.Context(), week, true); !errors.Is(err, shopping.ErrSendUnconfirmed) || !errors.Is(err, chat.ErrUnreachable) {
|
||||
t.Errorf("SendToChat unanswered = %v, want ErrSendUnconfirmed and chat.ErrUnreachable", err)
|
||||
}
|
||||
if l, err := e.svc.Get(t.Context(), week); err != nil || l.SentToChatAt == nil || *l.SentToChatAt != *resent.SentToChatAt || l.ChatSendUnconfirmedAt == nil {
|
||||
t.Errorf("after an unanswered resend sent_to_chat_at = %v, chat_send_unconfirmed_at = %v, %v, want %s and set", l.SentToChatAt, l.ChatSendUnconfirmedAt, err, *resent.SentToChatAt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendToChatPostsOnlyOnceBegun: a send that cannot be recorded as
|
||||
// begun is not posted.
|
||||
func TestSendToChatPostsOnlyOnceBegun(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
e.plan(t, "Beef Chili")
|
||||
e.generate(t)
|
||||
if _, err := e.chatSvc.Put(t.Context(), chattest.Webhook, "Family"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := e.st.Writer.Exec(`CREATE TRIGGER refuse_begin BEFORE UPDATE OF chat_send_unconfirmed_at ON shopping_lists WHEN NEW.chat_send_unconfirmed_at IS NOT NULL BEGIN SELECT RAISE(ABORT, 'begin refused'); END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := e.svc.SendToChat(t.Context(), week, false); err == nil {
|
||||
t.Fatal("SendToChat not recorded as begun succeeded, want an error")
|
||||
}
|
||||
if n := len(e.chat.Posts()); n != 0 {
|
||||
t.Errorf("Google Chat got %d posts of a send not recorded as begun, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,12 @@ var ErrItemNotFound = errors.New("no Supply run has that item")
|
||||
// the household's time zone.
|
||||
var ErrWeekNotEnded = errors.New("the week has not ended")
|
||||
|
||||
// ErrSendUnconfirmed is a send to Google Chat whose outcome is not known:
|
||||
// Google Chat did not answer, or accepted the post but recording it failed.
|
||||
// The post may have been made, so the Supply run keeps the send as
|
||||
// unconfirmed and is posted again only with resend.
|
||||
var ErrSendUnconfirmed = errors.New("the shopping list may have reached Google Chat, but that could not be confirmed; check the space before sending again")
|
||||
|
||||
// ErrInvalidPeriod is an Archive grouping other than ByWeek, ByMonth and
|
||||
// ByYear.
|
||||
var ErrInvalidPeriod = errors.New("by must be week, month or year")
|
||||
@@ -158,7 +164,11 @@ type List struct {
|
||||
// SentToChatAt is when the list was sent to Google Chat; nil until it
|
||||
// is.
|
||||
SentToChatAt *string `json:"sent_to_chat_at"`
|
||||
Groups []Group `json:"groups"`
|
||||
// ChatSendUnconfirmedAt is when a send to Google Chat whose outcome is
|
||||
// not known began: one under way, or one that may or may not have been
|
||||
// posted. nil when there is none.
|
||||
ChatSendUnconfirmedAt *string `json:"chat_send_unconfirmed_at"`
|
||||
Groups []Group `json:"groups"`
|
||||
}
|
||||
|
||||
// Counts are how many items of a Supply run are still needed and how many
|
||||
@@ -563,13 +573,14 @@ func frozen(list db.ShoppingList) bool {
|
||||
// listOf is list with groups.
|
||||
func listOf(list db.ShoppingList, groups []Group) List {
|
||||
return List{
|
||||
ID: list.ID,
|
||||
WeekStart: list.WeekStart,
|
||||
PlanID: list.PlanID,
|
||||
Title: nullString(list.Title),
|
||||
Status: list.Status,
|
||||
SentToChatAt: nullString(list.SentToChatAt),
|
||||
Groups: groups,
|
||||
ID: list.ID,
|
||||
WeekStart: list.WeekStart,
|
||||
PlanID: list.PlanID,
|
||||
Title: nullString(list.Title),
|
||||
Status: list.Status,
|
||||
SentToChatAt: nullString(list.SentToChatAt),
|
||||
ChatSendUnconfirmedAt: nullString(list.ChatSendUnconfirmedAt),
|
||||
Groups: groups,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user