notify package and durable outbox worker (Feature 19) (#161)
CI / ci (push) Successful in 5m18s
CI / ci (push) Successful in 5m18s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #161.
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
// 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.
|
||||
//
|
||||
// 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 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 {
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.logger.InfoContext(ctx, "notification emitted", "event_key", ev.Key, "queued", queued)
|
||||
if len(queued) > 0 {
|
||||
s.wakeWorker()
|
||||
}
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user