Files
minstrel/internal/notifications/kinds.go
T
bvandeusenandClaude Opus 5.5 d191a28217 docs: server comments say what the code does, not how it came to be (#6277)
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>
2026-10-23 12:15:05 -04:00

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 ""
}