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:
@@ -27,24 +27,6 @@ func (q *Queries) AddDuplicateGroupMember(ctx context.Context, arg AddDuplicateG
|
||||
return err
|
||||
}
|
||||
|
||||
const countDuplicateGroupsDetectedSince = `-- name: CountDuplicateGroupsDetectedSince :one
|
||||
SELECT count(*)::bigint
|
||||
FROM duplicate_groups
|
||||
WHERE status = 'pending'
|
||||
AND detected_at >= $1
|
||||
`
|
||||
|
||||
// Pending proposals first made at or after `since`: what a sweep that started
|
||||
// then found for the first time. A refreshed proposal keeps its detected_at,
|
||||
// so a sweep that only re-finds known groups counts none (M489: admins are
|
||||
// told about new duplicates, not reminded of the same ones every sweep).
|
||||
func (q *Queries) CountDuplicateGroupsDetectedSince(ctx context.Context, since pgtype.Timestamptz) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, countDuplicateGroupsDetectedSince, since)
|
||||
var column_1 int64
|
||||
err := row.Scan(&column_1)
|
||||
return column_1, err
|
||||
}
|
||||
|
||||
const countPendingDuplicateGroups = `-- name: CountPendingDuplicateGroups :one
|
||||
SELECT count(*)::bigint
|
||||
FROM duplicate_groups g
|
||||
@@ -311,6 +293,58 @@ func (q *Queries) ListDuplicateCandidates(ctx context.Context, arg ListDuplicate
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listDuplicateGroupsDetectedSince = `-- name: ListDuplicateGroupsDetectedSince :many
|
||||
SELECT ar.name AS artist_name, t.title, count(*) OVER ()::bigint AS total
|
||||
FROM duplicate_groups g
|
||||
JOIN LATERAL (SELECT m.track_id
|
||||
FROM duplicate_group_members m
|
||||
WHERE m.group_id = g.id
|
||||
ORDER BY m.track_id
|
||||
LIMIT 1) one ON true
|
||||
JOIN tracks t ON t.id = one.track_id
|
||||
JOIN artists ar ON ar.id = t.artist_id
|
||||
WHERE g.status = 'pending'
|
||||
AND g.detected_at >= $1
|
||||
ORDER BY g.detected_at DESC, g.id
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListDuplicateGroupsDetectedSinceParams struct {
|
||||
Since pgtype.Timestamptz
|
||||
N int32
|
||||
}
|
||||
|
||||
type ListDuplicateGroupsDetectedSinceRow struct {
|
||||
ArtistName string
|
||||
Title string
|
||||
Total int64
|
||||
}
|
||||
|
||||
// Pending proposals first made at or after `since`: what a sweep that started
|
||||
// then found for the first time, each named by one of its songs, and `total`
|
||||
// of them however many are listed. A refreshed proposal keeps its detected_at,
|
||||
// so a sweep that only re-finds known groups lists none (M489: admins are told
|
||||
// about new duplicates, not reminded of the same ones every sweep).
|
||||
func (q *Queries) ListDuplicateGroupsDetectedSince(ctx context.Context, arg ListDuplicateGroupsDetectedSinceParams) ([]ListDuplicateGroupsDetectedSinceRow, error) {
|
||||
rows, err := q.db.Query(ctx, listDuplicateGroupsDetectedSince, arg.Since, arg.N)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListDuplicateGroupsDetectedSinceRow
|
||||
for rows.Next() {
|
||||
var i ListDuplicateGroupsDetectedSinceRow
|
||||
if err := rows.Scan(&i.ArtistName, &i.Title, &i.Total); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listDuplicateGroupsForResolve = `-- name: ListDuplicateGroupsForResolve :many
|
||||
SELECT g.id AS group_id,
|
||||
g.tier,
|
||||
|
||||
@@ -346,8 +346,9 @@ type MarkAllNotificationsReadParams struct {
|
||||
|
||||
// up_to, when set, limits it to what existed when the user asked: a "mark
|
||||
// all read" queued offline and replayed later must not mark notices that
|
||||
// arrived in between, which the user never saw. A coalesced row updated
|
||||
// since then carries a newer created_at, so it stays unread too.
|
||||
// arrived in between, which the user never saw. A coalesced row that grew
|
||||
// since then is marked read with it: it arrived before they asked, and its
|
||||
// growth is a quiet update to a notice they had seen (M542).
|
||||
func (q *Queries) MarkAllNotificationsRead(ctx context.Context, arg MarkAllNotificationsReadParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, markAllNotificationsRead, arg.UserID, arg.UpTo)
|
||||
if err != nil {
|
||||
@@ -457,15 +458,15 @@ VALUES ($1, $2, $3, $4,
|
||||
CASE WHEN $5::boolean THEN NULL ELSE now() END)
|
||||
ON CONFLICT (user_id, coalesce_key) WHERE read_at IS NULL AND coalesce_key IS NOT NULL
|
||||
DO UPDATE SET
|
||||
payload = CASE
|
||||
WHEN $6::boolean THEN
|
||||
EXCLUDED.payload || jsonb_build_object('count',
|
||||
COALESCE((user_notifications.payload->>'count')::bigint, 0)
|
||||
+ COALESCE((EXCLUDED.payload->>'count')::bigint, 0))
|
||||
ELSE EXCLUDED.payload
|
||||
END,
|
||||
created_at = now(),
|
||||
emailed_at = EXCLUDED.emailed_at
|
||||
payload = EXCLUDED.payload || jsonb_build_object(
|
||||
'count', COALESCE((user_notifications.payload->>'count')::bigint, 0)
|
||||
+ COALESCE((EXCLUDED.payload->>'count')::bigint, 0),
|
||||
'items', (SELECT COALESCE(jsonb_agg(e.item ORDER BY e.n), '[]'::jsonb)
|
||||
FROM jsonb_array_elements(
|
||||
COALESCE(EXCLUDED.payload->'items', '[]'::jsonb)
|
||||
|| COALESCE(user_notifications.payload->'items', '[]'::jsonb)
|
||||
) WITH ORDINALITY AS e(item, n)
|
||||
WHERE e.n <= 10))
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
@@ -475,20 +476,18 @@ type UpsertCoalescedNotificationParams struct {
|
||||
Payload []byte
|
||||
CoalesceKey *string
|
||||
EmailWanted bool
|
||||
SumCount bool
|
||||
}
|
||||
|
||||
// One unread row per (user, coalesce_key). A new event while that row is
|
||||
// unread updates it in place and moves it back to the top of the inbox.
|
||||
// unread updates it in place: its `count` adds to the row's, and its `items`
|
||||
// (the names of what changed) go in front of the row's, keeping the newest
|
||||
// ten. Every coalesced kind's event is "N more happened", never a restated
|
||||
// total, so the row always says what is new since it was last read.
|
||||
//
|
||||
// sum_count: the payload's `count` adds to the unread row's count rather than
|
||||
// replacing it. For kinds whose event is "N more happened" (tracks marked
|
||||
// missing). Kinds whose event states the whole current total (pending
|
||||
// duplicate groups) pass false and the payload simply replaces.
|
||||
//
|
||||
// emailed_at is cleared so the newer state goes out in the next batch; an
|
||||
// emailed-but-unread row that keeps growing is news the user hasn't seen.
|
||||
// Unless email_wanted is false, as in InsertNotification.
|
||||
// created_at and emailed_at are left alone (M542): an update changes what the
|
||||
// notice says, not when it arrived. Moving created_at made the phone announce
|
||||
// it again and clearing emailed_at sent it again, so a notice the hourly
|
||||
// duplicate pass kept growing alerted every hour.
|
||||
func (q *Queries) UpsertCoalescedNotification(ctx context.Context, arg UpsertCoalescedNotificationParams) (pgtype.UUID, error) {
|
||||
row := q.db.QueryRow(ctx, upsertCoalescedNotification,
|
||||
arg.UserID,
|
||||
@@ -496,7 +495,6 @@ func (q *Queries) UpsertCoalescedNotification(ctx context.Context, arg UpsertCoa
|
||||
arg.Payload,
|
||||
arg.CoalesceKey,
|
||||
arg.EmailWanted,
|
||||
arg.SumCount,
|
||||
)
|
||||
var id pgtype.UUID
|
||||
err := row.Scan(&id)
|
||||
|
||||
@@ -11,17 +11,6 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const countUnresolvedPlaybackErrors = `-- name: CountUnresolvedPlaybackErrors :one
|
||||
SELECT count(*)::bigint FROM playback_errors WHERE resolved_at IS NULL
|
||||
`
|
||||
|
||||
func (q *Queries) CountUnresolvedPlaybackErrors(ctx context.Context) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, countUnresolvedPlaybackErrors)
|
||||
var column_1 int64
|
||||
err := row.Scan(&column_1)
|
||||
return column_1, err
|
||||
}
|
||||
|
||||
const insertPlaybackError = `-- name: InsertPlaybackError :one
|
||||
INSERT INTO playback_errors (track_id, user_id, client_id, kind, detail)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
|
||||
@@ -646,6 +646,43 @@ func (q *Queries) ListRandomTracksForUser(ctx context.Context, arg ListRandomTra
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listStillMissingTrackLabels = `-- name: ListStillMissingTrackLabels :many
|
||||
SELECT ar.name AS artist_name, t.title
|
||||
FROM tracks t
|
||||
JOIN artists ar ON ar.id = t.artist_id
|
||||
WHERE t.id = ANY($1::uuid[])
|
||||
AND t.missing_since IS NOT NULL
|
||||
ORDER BY ar.name, t.title
|
||||
`
|
||||
|
||||
type ListStillMissingTrackLabelsRow struct {
|
||||
ArtistName string
|
||||
Title string
|
||||
}
|
||||
|
||||
// Which of the tracks a scan marked missing still are once it has adopted
|
||||
// moves and folded replacements (M542): the ones worth telling an admin
|
||||
// about, by name. A quality upgrade folds into its replacement and drops out.
|
||||
func (q *Queries) ListStillMissingTrackLabels(ctx context.Context, ids []pgtype.UUID) ([]ListStillMissingTrackLabelsRow, error) {
|
||||
rows, err := q.db.Query(ctx, listStillMissingTrackLabels, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListStillMissingTrackLabelsRow
|
||||
for rows.Next() {
|
||||
var i ListStillMissingTrackLabelsRow
|
||||
if err := rows.Scan(&i.ArtistName, &i.Title); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listSuspectSourceTracks = `-- name: ListSuspectSourceTracks :many
|
||||
SELECT t.id,
|
||||
t.title,
|
||||
|
||||
Reference in New Issue
Block a user