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
|
||||
|
||||
Reference in New Issue
Block a user