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)
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
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
- 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>
This commit is contained in:
@@ -69,12 +69,9 @@ func (c Channels) Effective() Channels {
|
||||
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.
|
||||
// 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
|
||||
// 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
|
||||
}
|
||||
@@ -88,16 +85,15 @@ var (
|
||||
)
|
||||
|
||||
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},
|
||||
// Each pass reports what it did itself, so the counts add up.
|
||||
KindDuplicatesResolved: {audience: AudienceAdmin, coalesce: true, sumCount: true, group: EmailBatch, defaults: inboxOnly},
|
||||
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},
|
||||
}
|
||||
|
||||
|
||||
@@ -57,8 +57,9 @@ func New(db dbq.DBTX, bus *eventbus.Bus, logger *slog.Logger) *Notifier {
|
||||
// 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".
|
||||
// rather than a new one added: payload["count"] adds to the row's count and
|
||||
// payload["items"] joins its names. The row keeps the time it arrived, so an
|
||||
// update is not announced again.
|
||||
//
|
||||
// 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.
|
||||
@@ -183,7 +184,6 @@ func write(ctx context.Context, q *dbq.Queries, kind Kind, userID pgtype.UUID, b
|
||||
Payload: body,
|
||||
CoalesceKey: &key,
|
||||
EmailWanted: emailWanted,
|
||||
SumCount: specs[kind].sumCount,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -146,19 +146,41 @@ func TestNotify_CoalescedCountAddsUpWhileUnread_AndStartsAfreshOnceRead(t *testi
|
||||
require.True(t, rows[1].ReadAt.Valid)
|
||||
}
|
||||
|
||||
func TestNotify_CoalescedTotalReplacesWhenEventsStateTheWhole(t *testing.T) {
|
||||
// M542: an update says more, newest names first, without arriving again. A
|
||||
// moved created_at was announced again on the phone, and a cleared emailed_at
|
||||
// was emailed again.
|
||||
func TestNotify_ACoalescedUpdateMergesNamesAndKeepsItsArrival(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
q := dbq.New(pool)
|
||||
ctx := context.Background()
|
||||
admin := mkUser(t, q, "admin", true)
|
||||
n := notifications.New(pool, nil, nil)
|
||||
to := notifications.ToUser(admin)
|
||||
notify := func(items ...string) {
|
||||
t.Helper()
|
||||
require.NoError(t, n.Notify(ctx, notifications.KindDuplicatesFound, to,
|
||||
notifications.Payload{Count: int64(len(items)), Items: items}.Map()))
|
||||
}
|
||||
|
||||
// duplicates_found states the pending total each time; it must not sum.
|
||||
require.NoError(t, n.Notify(ctx, notifications.KindDuplicatesFound, notifications.ToUser(admin), map[string]any{"count": 5}))
|
||||
require.NoError(t, n.Notify(ctx, notifications.KindDuplicatesFound, notifications.ToUser(admin), map[string]any{"count": 3}))
|
||||
notify("A", "B")
|
||||
first := rowsFor(t, q, admin)
|
||||
require.Len(t, first, 1)
|
||||
_, err := pool.Exec(ctx, `UPDATE user_notifications SET emailed_at = now() WHERE id = $1`, first[0].ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
notify("C", "D", "E", "F", "G", "H", "I", "J", "K")
|
||||
rows := rowsFor(t, q, admin)
|
||||
require.Len(t, rows, 1)
|
||||
require.Equal(t, int64(3), count(t, rows[0].Payload))
|
||||
var p notifications.Payload
|
||||
require.NoError(t, json.Unmarshal(rows[0].Payload, &p))
|
||||
require.Equal(t, int64(11), p.Count)
|
||||
require.Equal(t, []string{"C", "D", "E", "F", "G", "H", "I", "J", "K", "A"}, p.Items,
|
||||
"the newest names first, ten kept")
|
||||
require.True(t, rows[0].CreatedAt.Time.Equal(first[0].CreatedAt.Time), "an update is not a new arrival")
|
||||
|
||||
var emailed pgtype.Timestamptz
|
||||
require.NoError(t, pool.QueryRow(ctx, `SELECT emailed_at FROM user_notifications WHERE id = $1`, rows[0].ID).Scan(&emailed))
|
||||
require.True(t, emailed.Valid, "an emailed notice is not emailed again because it grew")
|
||||
}
|
||||
|
||||
func TestNotify_RequestKindsNeverCoalesce(t *testing.T) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package notifications
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/words"
|
||||
)
|
||||
@@ -37,10 +38,15 @@ type Payload struct {
|
||||
// Actor is the other person involved: who asked, who flagged.
|
||||
Actor string `json:"actor,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
// Count is the coalesced kinds' running total.
|
||||
// Count is the coalesced kinds' running total since the notice was last
|
||||
// read.
|
||||
Count int64 `json:"count,omitempty"`
|
||||
// Detail is free text: scan_failed's error message, and what the duplicate
|
||||
// resolver merged for duplicates_resolved.
|
||||
// Items name what Count counts, one each, newest first: "Weezer – Buddy
|
||||
// Holly". A coalesced update puts its own in front and keeps ten (M542),
|
||||
// so the body can say which, not only how many.
|
||||
Items []string `json:"items,omitempty"`
|
||||
// Detail is free text: scan_failed's error message. Rows from before
|
||||
// Items also carry duplicates_resolved's summary here.
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
@@ -101,37 +107,52 @@ func Render(kind Kind, payload []byte) Rendered {
|
||||
if p.Count > 1 {
|
||||
title = fmt.Sprintf("Library scan failed %d times", p.Count)
|
||||
}
|
||||
body := p.Detail
|
||||
if body == "" {
|
||||
body = "The last scan stopped with an error."
|
||||
body := "The last scan stopped with an error."
|
||||
if p.Detail != "" {
|
||||
body = "The last scan stopped: " + p.Detail
|
||||
}
|
||||
return Rendered{Title: title, Body: body, Link: "/admin"}
|
||||
return Rendered{Title: title, Body: body, Link: "/admin/library"}
|
||||
case KindTracksMissing:
|
||||
body := "Their files are no longer where the library expects them."
|
||||
if names := nameList(p.Items, p.Count); names != "" {
|
||||
body = "No longer on disk: " + names + "."
|
||||
}
|
||||
return Rendered{
|
||||
Title: plural(p.Count, "track went missing", "tracks went missing"),
|
||||
Body: "Their files are no longer where the library expects them.",
|
||||
Body: body,
|
||||
Link: "/collection/missing",
|
||||
}
|
||||
case KindDuplicatesFound:
|
||||
body := "The duplicate sweep found tracks holding the same recording."
|
||||
if names := nameList(p.Items, p.Count); names != "" {
|
||||
body = "Held more than once: " + names + "."
|
||||
}
|
||||
return Rendered{
|
||||
Title: plural(p.Count, "duplicate group to review", "duplicate groups to review"),
|
||||
Body: "The duplicate sweep found tracks holding the same recording.",
|
||||
Title: plural(p.Count, "new duplicate to review", "new duplicates to review"),
|
||||
Body: body,
|
||||
Link: "/collection/duplicates",
|
||||
}
|
||||
case KindDuplicatesResolved:
|
||||
body := "Extra copies of a song were merged into the one the album keeps."
|
||||
if p.Detail != "" {
|
||||
switch names := nameList(p.Items, p.Count); {
|
||||
case names != "":
|
||||
body = "Extra copies merged into the one the album keeps: " + names + "."
|
||||
case p.Detail != "":
|
||||
body = p.Detail
|
||||
}
|
||||
return Rendered{
|
||||
Title: plural(p.Count, "duplicate resolved automatically", "duplicates resolved automatically"),
|
||||
Title: plural(p.Count, "duplicate merged automatically", "duplicates merged automatically"),
|
||||
Body: body,
|
||||
Link: "/collection/duplicates?view=resolved",
|
||||
}
|
||||
case KindPlaybackErrors:
|
||||
body := "A track failed to play for someone."
|
||||
if names := nameList(p.Items, p.Count); names != "" {
|
||||
body = "Failed to play: " + names + "."
|
||||
}
|
||||
return Rendered{
|
||||
Title: plural(p.Count, "playback error reported", "playback errors reported"),
|
||||
Body: "A track failed to play for someone.",
|
||||
Body: body,
|
||||
Link: "/collection/reports",
|
||||
}
|
||||
default:
|
||||
@@ -139,6 +160,26 @@ func Render(kind Kind, payload []byte) Rendered {
|
||||
}
|
||||
}
|
||||
|
||||
// namesShown is how many of a notice's items its body names.
|
||||
const namesShown = 3
|
||||
|
||||
// nameList names the first few items, then how many more of the total there
|
||||
// are: "A, B, C and 4 more". Empty when there are no items.
|
||||
func nameList(items []string, total int64) string {
|
||||
if len(items) == 0 {
|
||||
return ""
|
||||
}
|
||||
shown := items[:min(len(items), namesShown)]
|
||||
more := max(total, int64(len(items))) - int64(len(shown))
|
||||
if more > 0 {
|
||||
return strings.Join(shown, ", ") + " and " + words.Count(more, "more", "more")
|
||||
}
|
||||
if len(shown) == 1 {
|
||||
return shown[0]
|
||||
}
|
||||
return strings.Join(shown[:len(shown)-1], ", ") + " and " + shown[len(shown)-1]
|
||||
}
|
||||
|
||||
func plural(n int64, one, many string) string {
|
||||
if n < 1 {
|
||||
// A coalesced kind always carries a count; this only guards a
|
||||
|
||||
@@ -45,6 +45,36 @@ func TestRender_CoalescedCountsReadNaturally(t *testing.T) {
|
||||
require.Equal(t, "Some tracks went missing", Render(KindTracksMissing, []byte(`{}`)).Title)
|
||||
}
|
||||
|
||||
// M542: a notice says which, not only how many.
|
||||
func TestRender_NamesWhatChanged(t *testing.T) {
|
||||
require.Equal(t, "No longer on disk: Weezer – Buddy Holly.",
|
||||
render(KindTracksMissing, Payload{Count: 1, Items: []string{"Weezer – Buddy Holly"}}).Body)
|
||||
require.Equal(t, "No longer on disk: A – 1 and B – 2.",
|
||||
render(KindTracksMissing, Payload{Count: 2, Items: []string{"A – 1", "B – 2"}}).Body)
|
||||
// Three are named; the rest of the count, not only the rest of the
|
||||
// stored names, is "more".
|
||||
require.Equal(t, "Held more than once: A, B, C and 9 more.",
|
||||
render(KindDuplicatesFound, Payload{Count: 12, Items: []string{"A", "B", "C", "D", "E"}}).Body)
|
||||
require.Equal(t, "12 new duplicates to review", render(KindDuplicatesFound, Payload{Count: 12}).Title)
|
||||
require.Equal(t, "Extra copies merged into the one the album keeps: A and B.",
|
||||
render(KindDuplicatesResolved, Payload{Count: 2, Items: []string{"A", "B"}}).Body)
|
||||
require.Equal(t, "Failed to play: Weezer – Buddy Holly (alice).",
|
||||
render(KindPlaybackErrors, Payload{Count: 1, Items: []string{"Weezer – Buddy Holly (alice)"}}).Body)
|
||||
}
|
||||
|
||||
func TestRender_RowsFromBeforeItemsReadAsTheyDid(t *testing.T) {
|
||||
require.Equal(t, "Their files are no longer where the library expects them.",
|
||||
render(KindTracksMissing, Payload{Count: 4}).Body)
|
||||
require.Equal(t, "Merged 8 duplicate copies.",
|
||||
render(KindDuplicatesResolved, Payload{Count: 716, Detail: "Merged 8 duplicate copies."}).Body)
|
||||
}
|
||||
|
||||
func TestRender_AScanFailureSaysWhatStopped(t *testing.T) {
|
||||
r := render(KindScanFailed, Payload{Count: 1, Detail: "scan root \"/music\" unavailable"})
|
||||
require.Equal(t, `The last scan stopped: scan root "/music" unavailable`, r.Body)
|
||||
require.Equal(t, "/admin/library", r.Link)
|
||||
}
|
||||
|
||||
func TestRender_AnUnreadablePayloadStillRenders(t *testing.T) {
|
||||
r := Render(KindRequestApproved, []byte(`not json`))
|
||||
require.Equal(t, "Request approved", r.Title)
|
||||
|
||||
Reference in New Issue
Block a user