Make new_suggestions and Send to Google Chat recoverable
CI / ci (push) Successful in 6m48s
CI / ci (pull_request) Successful in 6m26s

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:
2026-10-09 16:26:19 -04:00
co-authored by Claude Opus 5.5
parent 45c4f8a803
commit 80a8dd0edf
23 changed files with 586 additions and 175 deletions
+10 -9
View File
@@ -276,15 +276,16 @@ type ShoppingItem struct {
}
type ShoppingList struct {
ID string
WeekStart string
PlanID string
Title sql.NullString
Status string
SentToChatAt sql.NullString
FrozenAt sql.NullString
CreatedAt string
UpdatedAt string
ID string
WeekStart string
PlanID string
Title sql.NullString
Status string
SentToChatAt sql.NullString
FrozenAt sql.NullString
CreatedAt string
UpdatedAt string
ChatSendUnconfirmedAt sql.NullString
}
type Suggestion struct {
+8 -2
View File
@@ -12,6 +12,10 @@ import (
type Querier interface {
// An unknown id returns sql.ErrNoRows.
ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (Dinner, error)
// Records a send of list id to Google Chat begun at
// chat_send_unconfirmed_at, before it is posted, unless the list is frozen
// into the Supply archive.
BeginShoppingListChatSend(ctx context.Context, arg BeginShoppingListChatSendParams) (int64, error)
// Leases the pending notification due longest ago by moving its
// next_attempt_at to lease_until, and returns it. A claimed row is not due
// again until the lease ends, so no other claim takes it while it is being
@@ -26,6 +30,8 @@ type Querier interface {
// sets last_sync_error, keeping the chosen calendar. Affects no row when
// nothing is connected.
ClearIntegrationsGoogleToken(ctx context.Context, arg ClearIntegrationsGoogleTokenParams) (int64, error)
// Clears the send of list id begun, once Google Chat refused it.
ClearShoppingListChatSend(ctx context.Context, arg ClearShoppingListChatSendParams) (int64, error)
// Drops references to the runs created before the cutoff, so the purge
// can delete them.
ClearSuggestionBatchAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
@@ -235,8 +241,8 @@ 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.
// Records list id sent to Google Chat at sent_to_chat_at, its send
// confirmed, unless it is frozen into the Supply archive.
MarkShoppingListSentToChat(ctx context.Context, arg MarkShoppingListSentToChatParams) (int64, error)
// Records an edit of plan id, raising its revision: a synced plan becomes
// changed, any other keeps its status.
+52 -6
View File
@@ -10,6 +10,49 @@ import (
"database/sql"
)
const beginShoppingListChatSend = `-- name: BeginShoppingListChatSend :execrows
UPDATE shopping_lists
SET chat_send_unconfirmed_at = ?, updated_at = ?
WHERE id = ? AND status = 'current' AND frozen_at IS NULL
`
type BeginShoppingListChatSendParams struct {
ChatSendUnconfirmedAt sql.NullString
UpdatedAt string
ID string
}
// Records a send of list id to Google Chat begun at
// chat_send_unconfirmed_at, before it is posted, unless the list is frozen
// into the Supply archive.
func (q *Queries) BeginShoppingListChatSend(ctx context.Context, arg BeginShoppingListChatSendParams) (int64, error) {
result, err := q.db.ExecContext(ctx, beginShoppingListChatSend, arg.ChatSendUnconfirmedAt, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const clearShoppingListChatSend = `-- name: ClearShoppingListChatSend :execrows
UPDATE shopping_lists
SET chat_send_unconfirmed_at = NULL, updated_at = ?
WHERE id = ?
`
type ClearShoppingListChatSendParams struct {
UpdatedAt string
ID string
}
// Clears the send of list id begun, once Google Chat refused it.
func (q *Queries) ClearShoppingListChatSend(ctx context.Context, arg ClearShoppingListChatSendParams) (int64, error) {
result, err := q.db.ExecContext(ctx, clearShoppingListChatSend, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const createShoppingGroup = `-- name: CreateShoppingGroup :exec
INSERT INTO shopping_groups (id, list_id, dinner_id, date, position, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
@@ -135,7 +178,7 @@ func (q *Queries) FreezeShoppingList(ctx context.Context, arg FreezeShoppingList
}
const getShoppingItemList = `-- name: GetShoppingItemList :one
SELECT shopping_lists.id, shopping_lists.week_start, shopping_lists.plan_id, shopping_lists.title, shopping_lists.status, shopping_lists.sent_to_chat_at, shopping_lists.frozen_at, shopping_lists.created_at, shopping_lists.updated_at
SELECT shopping_lists.id, shopping_lists.week_start, shopping_lists.plan_id, shopping_lists.title, shopping_lists.status, shopping_lists.sent_to_chat_at, shopping_lists.frozen_at, shopping_lists.created_at, shopping_lists.updated_at, shopping_lists.chat_send_unconfirmed_at
FROM shopping_items
JOIN shopping_groups ON shopping_groups.id = shopping_items.group_id
JOIN shopping_lists ON shopping_lists.id = shopping_groups.list_id
@@ -161,12 +204,13 @@ func (q *Queries) GetShoppingItemList(ctx context.Context, id string) (GetShoppi
&i.ShoppingList.FrozenAt,
&i.ShoppingList.CreatedAt,
&i.ShoppingList.UpdatedAt,
&i.ShoppingList.ChatSendUnconfirmedAt,
)
return i, err
}
const getShoppingList = `-- name: GetShoppingList :one
SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at FROM shopping_lists
SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at, chat_send_unconfirmed_at FROM shopping_lists
WHERE week_start = ?
`
@@ -185,6 +229,7 @@ func (q *Queries) GetShoppingList(ctx context.Context, weekStart string) (Shoppi
&i.FrozenAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.ChatSendUnconfirmedAt,
)
return i, err
}
@@ -322,7 +367,7 @@ func (q *Queries) ListShoppingItems(ctx context.Context, listID string) ([]ListS
}
const listShoppingListsThrough = `-- name: ListShoppingListsThrough :many
SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at FROM shopping_lists
SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at, chat_send_unconfirmed_at FROM shopping_lists
WHERE week_start <= ?
ORDER BY week_start DESC
`
@@ -348,6 +393,7 @@ func (q *Queries) ListShoppingListsThrough(ctx context.Context, weekStart string
&i.FrozenAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.ChatSendUnconfirmedAt,
); err != nil {
return nil, err
}
@@ -364,7 +410,7 @@ func (q *Queries) ListShoppingListsThrough(ctx context.Context, weekStart string
const markShoppingListSentToChat = `-- name: MarkShoppingListSentToChat :execrows
UPDATE shopping_lists
SET sent_to_chat_at = ?, updated_at = ?
SET sent_to_chat_at = ?, chat_send_unconfirmed_at = NULL, updated_at = ?
WHERE id = ? AND status = 'current' AND frozen_at IS NULL
`
@@ -374,8 +420,8 @@ type MarkShoppingListSentToChatParams struct {
ID string
}
// Records list id sent to Google Chat at sent_to_chat_at, unless it is
// frozen into the Supply archive.
// Records list id sent to Google Chat at sent_to_chat_at, its send
// confirmed, unless it is frozen into the Supply archive.
func (q *Queries) MarkShoppingListSentToChat(ctx context.Context, arg MarkShoppingListSentToChatParams) (int64, error) {
result, err := q.db.ExecContext(ctx, markShoppingListSentToChat, arg.SentToChatAt, arg.UpdatedAt, arg.ID)
if err != nil {