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>
131 lines
4.8 KiB
Go
131 lines
4.8 KiB
Go
// Package notifications is the one writer of the per-user notifications inbox
|
|
// (M489, #726). Producers across the server call Notifier.Notify; nothing else
|
|
// inserts into user_notifications.
|
|
//
|
|
// The event bus alone was fire-and-forget: a client that was not connected
|
|
// when a request completed, or when tracks went missing, never heard of it. A
|
|
// row here is the durable record. The bus only nudges open clients to come
|
|
// and read it.
|
|
package notifications
|
|
|
|
// Kind names one sort of notification. The set is CHECK-gated in migration
|
|
// 0073 (rule 36): a new kind adds its value there, in the same change, and
|
|
// TestKindsMatchMigrationCheck fails until it does.
|
|
type Kind string
|
|
|
|
const (
|
|
KindRequestApproved Kind = "request_approved"
|
|
KindRequestRejected Kind = "request_rejected"
|
|
KindRequestCompleted Kind = "request_completed"
|
|
KindRequestPending Kind = "request_pending"
|
|
KindQuarantineFlagged Kind = "quarantine_flagged"
|
|
KindScanFailed Kind = "scan_failed"
|
|
KindTracksMissing Kind = "tracks_missing"
|
|
KindDuplicatesFound Kind = "duplicates_found"
|
|
KindPlaybackErrors Kind = "playback_errors"
|
|
)
|
|
|
|
// Audience is who a kind can reach. Admin kinds are never offered to, or
|
|
// stored for, a non-admin.
|
|
type Audience int
|
|
|
|
const (
|
|
AudienceRequester Audience = iota
|
|
AudienceAdmin
|
|
)
|
|
|
|
// EmailGroup is how a kind's emails are grouped. Nothing is emailed per event.
|
|
type EmailGroup int
|
|
|
|
const (
|
|
// EmailBatch: one email per batch window, holding everything that
|
|
// accumulated since the first un-emailed item.
|
|
EmailBatch EmailGroup = iota
|
|
// EmailSummary: at most one summary a day, at a set local hour. For new
|
|
// music arriving, which comes in bursts and is not urgent.
|
|
EmailSummary
|
|
)
|
|
|
|
// Channels are where a kind reaches a user. Inbox is the master switch: with
|
|
// it off nothing is stored, so there is nothing for the phone or email to
|
|
// deliver. Effective applies that.
|
|
type Channels struct {
|
|
Inbox bool
|
|
Phone bool
|
|
Email bool
|
|
}
|
|
|
|
// Effective is what will actually be delivered: phone and email ride on the
|
|
// inbox row, so they cannot be on without it.
|
|
func (c Channels) Effective() Channels {
|
|
return Channels{Inbox: c.Inbox, Phone: c.Inbox && c.Phone, Email: c.Inbox && c.Email}
|
|
}
|
|
|
|
type spec struct {
|
|
audience Audience
|
|
// coalesce: while one of these is unread, a new event updates it in place
|
|
// rather than adding a row. For the burst-prone admin kinds.
|
|
coalesce bool
|
|
// sumCount: a coalesced update adds the payload's `count` to the unread
|
|
// row's, because each event is "N more". Without it the newer payload
|
|
// replaces the older, because each event states the whole current total.
|
|
sumCount bool
|
|
group EmailGroup
|
|
defaults Channels
|
|
}
|
|
|
|
var (
|
|
allOn = Channels{Inbox: true, Phone: true, Email: true}
|
|
healthAlert = Channels{Inbox: true, Phone: true, Email: false}
|
|
)
|
|
|
|
var specs = map[Kind]spec{
|
|
KindRequestApproved: {audience: AudienceRequester, group: EmailBatch, defaults: allOn},
|
|
KindRequestRejected: {audience: AudienceRequester, group: EmailBatch, defaults: allOn},
|
|
KindRequestCompleted: {audience: AudienceRequester, group: EmailSummary, defaults: allOn},
|
|
KindRequestPending: {audience: AudienceAdmin, group: EmailBatch, defaults: allOn},
|
|
KindQuarantineFlagged: {audience: AudienceAdmin, group: EmailBatch, defaults: allOn},
|
|
KindScanFailed: {audience: AudienceAdmin, coalesce: true, sumCount: true, group: EmailBatch, defaults: healthAlert},
|
|
KindTracksMissing: {audience: AudienceAdmin, coalesce: true, sumCount: true, group: EmailBatch, defaults: healthAlert},
|
|
KindDuplicatesFound: {audience: AudienceAdmin, coalesce: true, group: EmailBatch, defaults: healthAlert},
|
|
KindPlaybackErrors: {audience: AudienceAdmin, coalesce: true, group: EmailBatch, defaults: healthAlert},
|
|
}
|
|
|
|
// order is the display order for settings screens: the requester's own kinds
|
|
// first, then the admin ones.
|
|
var order = []Kind{
|
|
KindRequestApproved,
|
|
KindRequestRejected,
|
|
KindRequestCompleted,
|
|
KindRequestPending,
|
|
KindQuarantineFlagged,
|
|
KindScanFailed,
|
|
KindTracksMissing,
|
|
KindDuplicatesFound,
|
|
KindPlaybackErrors,
|
|
}
|
|
|
|
// Kinds returns every kind in display order.
|
|
func Kinds() []Kind { return append([]Kind(nil), order...) }
|
|
|
|
// Valid reports whether k is a known kind.
|
|
func (k Kind) Valid() bool { _, ok := specs[k]; return ok }
|
|
|
|
// AdminOnly reports whether k reaches only admins.
|
|
func (k Kind) AdminOnly() bool { return specs[k].audience == AudienceAdmin }
|
|
|
|
// Defaults are the channels a user has for k until they change them.
|
|
func (k Kind) Defaults() Channels { return specs[k].defaults }
|
|
|
|
// EmailGroup is how k's emails are grouped.
|
|
func (k Kind) EmailGroup() EmailGroup { return specs[k].group }
|
|
|
|
// coalesceKey is the key k's rows coalesce on, or "" when k never coalesces.
|
|
// One key per kind: two unread "tracks missing" rows would only split a count.
|
|
func (k Kind) coalesceKey() string {
|
|
if specs[k].coalesce {
|
|
return string(k)
|
|
}
|
|
return ""
|
|
}
|