feat(notifications): the inbox store, one writer, coalescing and retention (#5338)
release / govulncheck (push) Successful in 42s
release / web (push) Successful in 1m34s
release / go (push) Successful in 1m51s
release / integration (push) Successful in 4m59s
release / android (push) Successful in 5m24s
release / Build signed APK (releases and dev) (push) Successful in 5m32s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m20s
release / Verify release artifacts (tag releases only) (push) Skipped

M489 step 1. The event bus is fire-and-forget, so a client that isn't
connected never hears that a request completed or that tracks went missing.
user_notifications is the durable record; the bus only nudges.

- Migration 0073: user_notifications (kind CHECK-gated, payload jsonb,
  read_at, coalesce_key, emailed_at) and user_notification_prefs (per user,
  per kind: inbox, phone, email). A missing pref row means the kind's
  defaults, so nothing is seeded.
- internal/notifications.Notifier is the only writer. It resolves recipients
  (admin kinds reach admins only, and never the excepted user), honours the
  inbox pref (phone and email ride on it), writes, and publishes a
  contentless notification.created nudge per recipient.
- Burst-prone admin kinds coalesce into one unread row: tracks_missing and
  scan_failed add up their counts, duplicates_found and playback_errors take
  the latest total. Once read, the next event is a new row.
- Retention: read rows go after 90 days, anything after a year, on the
  library_changes compactor's daily shape.

Tests: unit (channel rules, kind table) and integration (recipients, nudge,
coalescing both ways, prefs, owner-scoped idempotent mark-read, every kind
against both schema CHECKs, retention cut-offs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 07:06:39 -04:00
co-authored by Claude Opus 5.5
parent 5dffe51b95
commit 8bf333e748
12 changed files with 1266 additions and 0 deletions
+198
View File
@@ -0,0 +1,198 @@
package notifications
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
)
// EventCreated is the bus event a recipient's open clients receive when a
// row lands for them. It is a nudge with no content: the client fetches the
// inbox. A frame that carried the notification could not be told apart from
// a repeat after a reconnect, and a frame nobody received is then one nobody
// needed, because the client pulls on reconnect anyway (Roundtable #2535).
const EventCreated = "notification.created"
// Recipients names who an event is for.
type Recipients struct {
Users []pgtype.UUID
// Admins adds every admin.
Admins bool
// Except is never notified, even when it is in Users or is an admin. The
// admin who filed a request needs no notice that it is pending.
Except pgtype.UUID
}
// ToUser addresses one user.
func ToUser(id pgtype.UUID) Recipients { return Recipients{Users: []pgtype.UUID{id}} }
// ToAdmins addresses every admin except one (pass the zero UUID for none).
func ToAdmins(except pgtype.UUID) Recipients { return Recipients{Admins: true, Except: except} }
// Notifier writes notifications and nudges recipients' clients. A nil
// *Notifier is valid and does nothing, so producers built without one (most
// tests) need no special case.
type Notifier struct {
db dbq.DBTX
bus *eventbus.Bus
logger *slog.Logger
}
// New returns a Notifier writing through db. bus may be nil.
func New(db dbq.DBTX, bus *eventbus.Bus, logger *slog.Logger) *Notifier {
if logger == nil {
logger = slog.Default()
}
return &Notifier{db: db, bus: bus, logger: logger}
}
// Notify records kind for each recipient whose inbox preference allows it,
// then nudges their open clients.
//
// For a coalescing kind, an unread row of that kind is updated in place
// rather than a new one added. payload["count"] then adds to the unread
// row's count for kinds whose events each mean "N more".
//
// The error is for logging. A notification must never fail the action that
// caused it, so producers log and carry on; NotifyLogged does exactly that.
func (n *Notifier) Notify(ctx context.Context, kind Kind, to Recipients, payload map[string]any) error {
if n == nil {
return nil
}
if !kind.Valid() {
return fmt.Errorf("notifications: unknown kind %q", kind)
}
q := dbq.New(n.db)
ids, err := n.resolve(ctx, q, kind, to)
if err != nil || len(ids) == 0 {
return err
}
channels, err := channelsFor(ctx, q, kind, ids)
if err != nil {
return err
}
if payload == nil {
payload = map[string]any{}
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("notifications: encode %s payload: %w", kind, err)
}
var delivered []pgtype.UUID
for _, id := range ids {
if !channels[id].Inbox {
continue
}
if err := write(ctx, q, kind, id, body); err != nil {
return fmt.Errorf("notifications: write %s: %w", kind, err)
}
delivered = append(delivered, id)
}
n.nudge(delivered)
return nil
}
// NotifyLogged is Notify for producers: a failure is logged at WARN and
// otherwise ignored.
func (n *Notifier) NotifyLogged(ctx context.Context, kind Kind, to Recipients, payload map[string]any) {
if err := n.Notify(ctx, kind, to, payload); err != nil {
n.logger.Warn("notifications: notify failed", "kind", string(kind), "err", err)
}
}
// resolve turns Recipients into a deduplicated list of user ids. Admin kinds
// are delivered only to admins, whatever Users says, so a producer that
// addresses a non-admin by mistake stores nothing for them.
func (n *Notifier) resolve(ctx context.Context, q *dbq.Queries, kind Kind, to Recipients) ([]pgtype.UUID, error) {
var admins []pgtype.UUID
if to.Admins || kind.AdminOnly() {
var err error
if admins, err = q.ListAdminUserIDs(ctx); err != nil {
return nil, fmt.Errorf("notifications: list admins: %w", err)
}
}
isAdmin := make(map[pgtype.UUID]bool, len(admins))
for _, a := range admins {
isAdmin[a] = true
}
var candidates []pgtype.UUID
candidates = append(candidates, to.Users...)
if to.Admins {
candidates = append(candidates, admins...)
}
seen := make(map[pgtype.UUID]bool, len(candidates))
out := make([]pgtype.UUID, 0, len(candidates))
for _, id := range candidates {
if !id.Valid || seen[id] || (to.Except.Valid && id == to.Except) {
continue
}
if kind.AdminOnly() && !isAdmin[id] {
continue
}
seen[id] = true
out = append(out, id)
}
return out, nil
}
// channelsFor returns each recipient's effective channels for kind: their
// stored preference, or the kind's defaults when they have none.
func channelsFor(ctx context.Context, q *dbq.Queries, kind Kind, ids []pgtype.UUID) (map[pgtype.UUID]Channels, error) {
rows, err := q.ListNotificationPrefsForKind(ctx, dbq.ListNotificationPrefsForKindParams{
Kind: string(kind),
UserIds: ids,
})
if err != nil {
return nil, fmt.Errorf("notifications: read prefs: %w", err)
}
out := make(map[pgtype.UUID]Channels, len(ids))
for _, id := range ids {
out[id] = kind.Defaults().Effective()
}
for _, r := range rows {
out[r.UserID] = Channels{Inbox: r.Inbox, Phone: r.Phone, Email: r.Email}.Effective()
}
return out, nil
}
func write(ctx context.Context, q *dbq.Queries, kind Kind, userID pgtype.UUID, body []byte) error {
key := kind.coalesceKey()
if key == "" {
_, err := q.InsertNotification(ctx, dbq.InsertNotificationParams{
UserID: userID, Kind: string(kind), Payload: body,
})
return err
}
_, err := q.UpsertCoalescedNotification(ctx, dbq.UpsertCoalescedNotificationParams{
UserID: userID,
Kind: string(kind),
Payload: body,
CoalesceKey: &key,
SumCount: specs[kind].sumCount,
})
return err
}
func (n *Notifier) nudge(ids []pgtype.UUID) {
if n.bus == nil {
return
}
for _, id := range ids {
n.bus.Publish(eventbus.Event{
Kind: EventCreated,
UserID: uuid.UUID(id.Bytes).String(),
Data: map[string]any{},
})
}
}