Comment blocks in cmd/minstrel and 30 smaller internal packages, rewritten
to rule 32's standard with each citation read in context: task, milestone
and rule ids go, and so do the stories they were attached to ("used to",
"Before #N", measurements from one install, quotes, pointers to Fabled
Librarian's records and to docs/superpowers files that are not in the
repo). Where a story wrapped a real constraint, the constraint stays as a
present-tense fact.
Comments that had gone stale are corrected:
- taste.Config: the tuned values are overlaid by internal/recsettings;
it is no longer "a later follow-up".
- StorageConfig: the data dir holds art, caches and the stream secret,
not only playlist covers.
- the Subsonic album list and getUser comments describe what they do
today rather than a milestone that would change it.
Checked: the non-comment token stream of every changed file matches
HEAD, and gofmt -l is empty.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
136 lines
5.1 KiB
Go
136 lines
5.1 KiB
Go
// Package notifications is the one writer of the per-user notifications
|
|
// inbox. Producers across the server call Notifier.Notify; nothing else
|
|
// inserts into user_notifications.
|
|
//
|
|
// The event bus is fire-and-forget: a client that is not connected when a
|
|
// request completes, or when tracks go missing, never hears 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 the schema:
|
|
// a new kind adds its value (DROP + ADD of the CHECK) in a migration of the
|
|
// same change, and TestEveryKindPassesTheSchemaChecks 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"
|
|
// KindDuplicatesResolved: the duplicate resolver merged copies on its
|
|
// own. This is how an automatic merge stays visible.
|
|
KindDuplicatesResolved Kind = "duplicates_resolved"
|
|
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. Each event
|
|
// is "N more", so the update adds its count and names to the row's.
|
|
coalesce bool
|
|
group EmailGroup
|
|
defaults Channels
|
|
}
|
|
|
|
var (
|
|
allOn = Channels{Inbox: true, Phone: true, Email: true}
|
|
healthAlert = Channels{Inbox: true, Phone: true, Email: false}
|
|
// inboxOnly: news an admin may want to read but never needs to be
|
|
// interrupted for.
|
|
inboxOnly = Channels{Inbox: true}
|
|
)
|
|
|
|
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, group: EmailBatch, defaults: healthAlert},
|
|
KindTracksMissing: {audience: AudienceAdmin, coalesce: true, group: EmailBatch, defaults: healthAlert},
|
|
KindDuplicatesFound: {audience: AudienceAdmin, coalesce: true, group: EmailBatch, defaults: healthAlert},
|
|
KindDuplicatesResolved: {audience: AudienceAdmin, coalesce: true, group: EmailBatch, defaults: inboxOnly},
|
|
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,
|
|
KindDuplicatesResolved,
|
|
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 ""
|
|
}
|