release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m18s
release / go (push) Successful in 1m35s
release / integration (push) Successful in 4m35s
release / android (push) Successful in 4m58s
release / Build signed APK (releases and dev) (push) Successful in 5m14s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
M489 step 2.
- GET /api/me/notifications?limit&before: newest first, keyset-paged on
(created_at, id) with an opaque cursor, plus the unread count.
- GET /api/me/notifications/unread-count: the badge's cheap call.
- POST /api/me/notifications/{id}/read and /read-all. Mark-read is
idempotent; another user's id is a 404, the same as a malformed one.
- GET/PUT /api/me/notification-settings: every kind the caller can receive
(admin kinds only for admins) with inbox/phone/email. PUT is partial, so an
offline replay sends only what was touched, and a batch with any invalid
change applies nothing. The response says whether email can be delivered
at all: no address on file, or SMTP not configured. A failed SMTP config
read is a 500, not "not configured".
notifications.Render turns kind + payload into title, body and link on the
server, so the web inbox, the Android inbox, the phone's shade and the email
digest all say the same thing. mailer.Configured lifts Send's readiness
check out so settings can report it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
135 lines
4.2 KiB
Go
135 lines
4.2 KiB
Go
package notifications
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
)
|
||
|
||
// Rendered is a notification as people read it. The server renders it once,
|
||
// so the web inbox, the Android inbox, the phone's shade and the email digest
|
||
// all say the same thing, and a wording change is one edit here rather than
|
||
// a phrase table per client.
|
||
type Rendered struct {
|
||
Title string `json:"title"`
|
||
Body string `json:"body"`
|
||
// Link is the web path the notification opens. Android maps the same
|
||
// paths onto its screens.
|
||
Link string `json:"link"`
|
||
}
|
||
|
||
// Payload is what producers put in a notification. Every field is optional;
|
||
// each kind documents the ones it uses in Render.
|
||
type Payload struct {
|
||
RequestID string `json:"request_id,omitempty"`
|
||
RequestKind string `json:"request_kind,omitempty"` // artist | album | track
|
||
// Name is what was requested or flagged, as the user would say it:
|
||
// "WWW", "Moe Shop", "Moe Shop – WWW".
|
||
Name string `json:"name,omitempty"`
|
||
ArtistID string `json:"artist_id,omitempty"`
|
||
AlbumID string `json:"album_id,omitempty"`
|
||
// 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 int64 `json:"count,omitempty"`
|
||
// Detail is free text for scan_failed (the error message).
|
||
Detail string `json:"detail,omitempty"`
|
||
}
|
||
|
||
// Map turns a Payload into the map Notify takes, dropping empty fields.
|
||
func (p Payload) Map() map[string]any {
|
||
b, _ := json.Marshal(p)
|
||
var m map[string]any
|
||
_ = json.Unmarshal(b, &m)
|
||
return m
|
||
}
|
||
|
||
// Render turns a stored row into what people read. An unreadable payload
|
||
// still renders, from the kind alone, rather than failing the whole inbox.
|
||
func Render(kind Kind, payload []byte) Rendered {
|
||
var p Payload
|
||
_ = json.Unmarshal(payload, &p)
|
||
name := p.Name
|
||
if name == "" {
|
||
name = "Your request"
|
||
}
|
||
|
||
switch kind {
|
||
case KindRequestApproved:
|
||
return Rendered{Title: "Request approved", Body: name + " is on its way.", Link: "/requests"}
|
||
case KindRequestRejected:
|
||
body := name + " was declined."
|
||
if p.Reason != "" {
|
||
body = name + " was declined: " + p.Reason
|
||
}
|
||
return Rendered{Title: "Request declined", Body: body, Link: "/requests"}
|
||
case KindRequestCompleted:
|
||
link := "/requests"
|
||
switch {
|
||
case p.AlbumID != "":
|
||
link = "/albums/" + p.AlbumID
|
||
case p.ArtistID != "":
|
||
link = "/artists/" + p.ArtistID
|
||
}
|
||
return Rendered{Title: "Now in your library", Body: name + " has arrived.", Link: link}
|
||
case KindRequestPending:
|
||
who := p.Actor
|
||
if who == "" {
|
||
who = "Someone"
|
||
}
|
||
return Rendered{Title: "Request to review", Body: who + " asked for " + name + ".", Link: "/admin/requests"}
|
||
case KindQuarantineFlagged:
|
||
who := p.Actor
|
||
if who == "" {
|
||
who = "Someone"
|
||
}
|
||
body := who + " flagged " + name + "."
|
||
if p.Reason != "" {
|
||
body = who + " flagged " + name + ": " + p.Reason
|
||
}
|
||
return Rendered{Title: "Track flagged", Body: body, Link: "/admin/quarantine"}
|
||
case KindScanFailed:
|
||
title := "Library scan failed"
|
||
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."
|
||
}
|
||
return Rendered{Title: title, Body: body, Link: "/admin"}
|
||
case KindTracksMissing:
|
||
return Rendered{
|
||
Title: plural(p.Count, "track went missing", "tracks went missing"),
|
||
Body: "Their files are no longer where the library expects them.",
|
||
Link: "/admin/missing-files",
|
||
}
|
||
case KindDuplicatesFound:
|
||
return Rendered{
|
||
Title: plural(p.Count, "duplicate group to review", "duplicate groups to review"),
|
||
Body: "The duplicate sweep found tracks holding the same recording.",
|
||
Link: "/admin/duplicates",
|
||
}
|
||
case KindPlaybackErrors:
|
||
return Rendered{
|
||
Title: plural(p.Count, "playback error reported", "playback errors reported"),
|
||
Body: "A track failed to play for someone.",
|
||
Link: "/admin/playback-errors",
|
||
}
|
||
default:
|
||
return Rendered{Title: "Notification", Link: "/"}
|
||
}
|
||
}
|
||
|
||
func plural(n int64, one, many string) string {
|
||
if n == 1 {
|
||
return "1 " + one
|
||
}
|
||
if n < 1 {
|
||
// A coalesced kind always carries a count; this only guards a
|
||
// malformed row from reading "0 tracks went missing".
|
||
return "Some " + many
|
||
}
|
||
return fmt.Sprintf("%d %s", n, many)
|
||
}
|