Files
MealQuest/internal/notify/notify.go
T
dev_agentandClaude Opus 5.5 45c4f8a803
CI / ci (push) Failing after 5m48s
CI / ci (pull_request) Successful in 6m58s
Wire plan_ready, list_ready, new_suggestions and Send to Google Chat
Connect the features to the notification outbox so real events notify,
each stored by the new notify.EmitTx in the transaction of the change it
is about (Wake after commit):

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:42:16 -04:00

256 lines
8.3 KiB
Go

// Package notify decides who is told what and delivers it reliably. It is
// the only package that reads notification_prefs to choose channels.
//
// Emit is the one way to notify the household: it reads the preference row
// of the event's type and, for each channel switched on, stores one pending
// 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. 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
// delivery is retried after a backoff of one minute doubling up to one hour,
// until 24 hours after the row was created; the delivery that fails at or
// after that point, or with an error wrapping ErrUndeliverable, marks it
// failed, with the error in last_error.
package notify
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/url"
"strings"
"sync"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// EventType is what a notification is about. Each has one row in
// notification_prefs saying which channels it goes to.
type EventType string
// The event types, as notification_prefs.event names them.
const (
PlanReady EventType = "plan_ready"
ListReady EventType = "list_ready"
DayOfReminder EventType = "day_of_reminder"
NewSuggestions EventType = "new_suggestions"
RotationReview EventType = "rotation_review"
)
// eventTypes is every EventType.
var eventTypes = map[EventType]bool{
PlanReady: true, ListReady: true, DayOfReminder: true, NewSuggestions: true, RotationReview: true,
}
// Channel is a way of telling the household, as notification_outbox.channel
// names it.
type Channel string
// The channels.
const (
ChannelPush Channel = "push"
ChannelChat Channel = "chat"
)
// Event is something to tell the household.
type Event struct {
// Type is what the event is about.
Type EventType
// Key identifies this event: it starts with Type and a colon, as in
// "plan_ready:2026-10-12". An event emitted again with the same key is
// ignored.
Key string
// Title is the notification's headline; it must not be blank.
Title string
// Body is the notification's text; it may be empty.
Body string
// URL is the path-absolute, same-origin path a tap on the notification
// opens, such as "/plan".
URL string
// Week is the Monday of the week the event concerns, for the senders'
// message builders; zero when the event concerns no week.
Week store.LocalDate
}
// Payload is the JSON stored in notification_outbox.payload and handed to
// a Sender: the one shape every channel delivers from. Its title, body, tag
// and url are the notice the service worker shows (web/src/push/notification.ts),
// which ignores the other fields.
type Payload struct {
Event EventType `json:"event"`
Title string `json:"title"`
Body string `json:"body"`
// URL is a path-absolute, same-origin path. A sender that needs an
// absolute URL resolves it against the public URL.
URL string `json:"url"`
// Tag is the event key, so a notification shown again for the same
// event replaces the first.
Tag string `json:"tag"`
// Week is the YYYY-MM-DD Monday of the event's week, or absent.
Week string `json:"week,omitempty"`
}
// ErrInvalidEvent is returned by Emit for an event it refuses; nothing is
// stored.
var ErrInvalidEvent = errors.New("notify: invalid event")
// Service emits notifications and runs the worker that delivers them.
type Service struct {
st *store.Store
logger *slog.Logger
// now is the clock rows are created, claimed and retried on; tests
// replace it.
now func() time.Time
// wake holds up to one token; Emit adds one so the worker looks for
// the rows it stored.
wake chan struct{}
mu sync.Mutex
senders map[Channel]Sender
started bool
wg sync.WaitGroup
}
// NewService returns a service over st with no senders registered.
func NewService(st *store.Store, logger *slog.Logger) (*Service, error) {
if st == nil {
return nil, errors.New("notify: nil store")
}
if logger == nil {
return nil, errors.New("notify: nil logger")
}
return &Service{
st: st,
logger: logger,
now: time.Now,
wake: make(chan struct{}, 1),
senders: make(map[Channel]Sender),
}, nil
}
// Emit stores one pending notification of ev for each channel its type's
// preference switches on, and wakes the worker. A channel already holding a
// 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
}
payload := Payload{Event: ev.Type, Title: ev.Title, Body: ev.Body, URL: ev.URL, Tag: ev.Key}
if !ev.Week.IsZero() {
payload.Week = ev.Week.String()
}
data, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("notify: encode payload of %s: %w", ev.Key, err)
}
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())
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
}
// enabledChannels returns the channels pref switches on.
func enabledChannels(pref db.NotificationPref) []Channel {
var chs []Channel
if pref.Push == 1 {
chs = append(chs, ChannelPush)
}
if pref.Chat == 1 {
chs = append(chs, ChannelChat)
}
return chs
}
// validate reports why ev cannot be emitted, wrapping ErrInvalidEvent.
func validate(ev Event) error {
if !eventTypes[ev.Type] {
return fmt.Errorf("%w: unknown type %q", ErrInvalidEvent, ev.Type)
}
prefix := string(ev.Type) + ":"
if !strings.HasPrefix(ev.Key, prefix) || len(ev.Key) == len(prefix) {
return fmt.Errorf("%w: key %q does not start with %q and an identifier", ErrInvalidEvent, ev.Key, prefix)
}
if strings.TrimSpace(ev.Title) == "" {
return fmt.Errorf("%w: %s has a blank title", ErrInvalidEvent, ev.Key)
}
if !isAppPath(ev.URL) {
return fmt.Errorf("%w: %s url %q is not a path-absolute same-origin path", ErrInvalidEvent, ev.Key, ev.URL)
}
return nil
}
// isAppPath reports whether raw is a path on the app's own origin, as the
// service worker requires: it starts with one slash and names no scheme or
// host, however it is parsed.
func isAppPath(raw string) bool {
if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") || strings.ContainsAny(raw, "\\\t\r\n") {
return false
}
u, err := url.Parse(raw)
return err == nil && u.Scheme == "" && u.Host == "" && !strings.HasPrefix(u.Path, "//")
}
// timestamp returns t in the stored UTC RFC 3339 form.
func timestamp(t time.Time) string {
return store.Timestamp{Time: t.UTC().Truncate(time.Millisecond)}.String()
}
// parseTimestamp parses a stored timestamp.
func parseTimestamp(s string) (time.Time, error) {
var ts store.Timestamp
if err := ts.Scan(s); err != nil {
return time.Time{}, err
}
return ts.Time, nil
}