Files
minstrel/internal/notifications/kinds.go
T
bvandeusenandClaude Opus 5.5 854c307834
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m33s
release / android (push) Failing after 1m42s
release / integration (push) Failing after 4m42s
release / Build signed APK (releases and dev) (push) Successful in 4m55s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
feat: notifications say what changed and alert once: a grouped notice names the tracks, grows without re-alerting, and a phone pile lists its titles (M542 #6146-#6151)
- An update to a grouped notice keeps its arrival time and email state, so the
  phone and the email no longer go off again each time it grows.
- Notices carry the names of what changed (newest ten), shown as three and
  "and N more": tracks still missing after the scan's move/replacement fold,
  duplicate groups new to a sweep, songs merged automatically, and the track
  and listener a playback error was for.
- Duplicates found counts only the groups new to that sweep; playback errors
  count reports, not the unresolved total.
- A scan failure says what stopped and links to Admin > Library.
- Android: a pile of more than three alerts lists the newest five titles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:44:19 -04:00

138 lines
5.2 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, swapped since by 0075 (rule 36): a new kind adds its value 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 (M498) merged copies on
// its own. The operator asked for that to
// happen automatically; this is how it 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 ""
}