Notifications inbox (M489), Discover taste-arm fix, web tooling majors #151

Merged
bvandeusen merged 26 commits from dev into main 2026-10-08 11:11:09 -04:00
7 changed files with 800 additions and 1 deletions
Showing only changes of commit 94ef8c1887 - Show all commits
+6
View File
@@ -124,6 +124,12 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
authed.Get("/me/sessions", h.handleListMySessions)
authed.Delete("/me/sessions/{id}", h.handleRevokeMySession)
authed.Post("/me/sessions/logout-others", h.handleRevokeMyOtherSessions)
authed.Get("/me/notifications", h.handleListMyNotifications)
authed.Get("/me/notifications/unread-count", h.handleMyUnreadNotificationCount)
authed.Post("/me/notifications/read-all", h.handleMarkAllMyNotificationsRead)
authed.Post("/me/notifications/{id}/read", h.handleMarkMyNotificationRead)
authed.Get("/me/notification-settings", h.handleGetMyNotificationSettings)
authed.Put("/me/notification-settings", h.handlePutMyNotificationSettings)
authed.Get("/artists", h.handleListArtists)
authed.Get("/artists/{id}", h.handleGetArtist)
+258
View File
@@ -0,0 +1,258 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
)
// The notifications inbox (M489). Rows are written by internal/notifications;
// this surface lists them, counts the unread, marks them read, and holds each
// user's per-kind settings.
const (
notificationsDefaultLimit = 30
notificationsMaxLimit = 100
)
// notificationResp is one inbox row, rendered server-side so every client
// says the same thing (notifications.Render).
type notificationResp struct {
ID string `json:"id"`
Kind string `json:"kind"`
Title string `json:"title"`
Body string `json:"body"`
Link string `json:"link"`
CreatedAt time.Time `json:"created_at"`
ReadAt *time.Time `json:"read_at"`
}
type notificationsPageResp struct {
Items []notificationResp `json:"items"`
UnreadCount int64 `json:"unread_count"`
// NextBefore is the cursor for the next page, absent on the last one.
NextBefore string `json:"next_before,omitempty"`
}
type unreadCountResp struct {
UnreadCount int64 `json:"unread_count"`
}
// notificationSettingsResp is every kind the caller can receive, plus whether
// email can be delivered at all, so a client can say why before anyone tries.
type notificationSettingsResp struct {
Kinds []notifications.KindSetting `json:"kinds"`
EmailAvailable bool `json:"email_available"`
// EmailUnavailableReason is "no_address" or "smtp_not_configured" when
// EmailAvailable is false.
EmailUnavailableReason string `json:"email_unavailable_reason,omitempty"`
}
type notificationSettingsReq struct {
Kinds []notifications.SettingChange `json:"kinds"`
}
// handleListMyNotifications implements GET /api/me/notifications?limit&before.
func (h *handlers) handleListMyNotifications(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
limit := notificationsDefaultLimit
if raw := r.URL.Query().Get("limit"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n < 1 {
writeErr(w, apierror.BadRequest("bad_limit", "limit must be a positive integer"))
return
}
limit = min(n, notificationsMaxLimit)
}
params := dbq.ListNotificationsParams{UserID: user.ID, PageLimit: int32(limit)}
if raw := r.URL.Query().Get("before"); raw != "" {
at, id, ok := parseNotificationCursor(raw)
if !ok {
writeErr(w, apierror.BadRequest("bad_cursor", "before is not a cursor this server issued"))
return
}
params.BeforeCreatedAt, params.BeforeID = at, id
}
q := dbq.New(h.pool)
rows, err := q.ListNotifications(r.Context(), params)
if err != nil {
writeErrWithLog(w, h.logger, "notifications: list", apierror.Internal(err))
return
}
unread, err := q.CountUnreadNotifications(r.Context(), user.ID)
if err != nil {
writeErrWithLog(w, h.logger, "notifications: count", apierror.Internal(err))
return
}
out := notificationsPageResp{Items: make([]notificationResp, 0, len(rows)), UnreadCount: unread}
for _, row := range rows {
rendered := notifications.Render(notifications.Kind(row.Kind), row.Payload)
item := notificationResp{
ID: uuidToString(row.ID),
Kind: row.Kind,
Title: rendered.Title,
Body: rendered.Body,
Link: rendered.Link,
CreatedAt: row.CreatedAt.Time,
}
if row.ReadAt.Valid {
t := row.ReadAt.Time
item.ReadAt = &t
}
out.Items = append(out.Items, item)
}
if len(rows) == limit {
last := rows[len(rows)-1]
out.NextBefore = formatNotificationCursor(last.CreatedAt, last.ID)
}
writeJSON(w, http.StatusOK, out)
}
// handleMyUnreadNotificationCount implements GET /api/me/notifications/unread-count,
// the cheap call behind the badge.
func (h *handlers) handleMyUnreadNotificationCount(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
n, err := dbq.New(h.pool).CountUnreadNotifications(r.Context(), user.ID)
if err != nil {
writeErrWithLog(w, h.logger, "notifications: count", apierror.Internal(err))
return
}
writeJSON(w, http.StatusOK, unreadCountResp{UnreadCount: n})
}
// handleMarkMyNotificationRead implements POST /api/me/notifications/{id}/read.
// Repeating it is harmless; another user's id is a 404, the same answer as a
// malformed one, so ids can't be probed.
func (h *handlers) handleMarkMyNotificationRead(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
id, ok := parseUUID(chi.URLParam(r, "id"))
if !ok {
writeErr(w, apierror.NotFound("notification"))
return
}
n, err := dbq.New(h.pool).MarkNotificationRead(r.Context(), dbq.MarkNotificationReadParams{ID: id, UserID: user.ID})
if err != nil {
writeErrWithLog(w, h.logger, "notifications: mark read", apierror.Internal(err))
return
}
if n == 0 {
writeErr(w, apierror.NotFound("notification"))
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleMarkAllMyNotificationsRead implements POST /api/me/notifications/read-all.
func (h *handlers) handleMarkAllMyNotificationsRead(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
if _, err := dbq.New(h.pool).MarkAllNotificationsRead(r.Context(), user.ID); err != nil {
writeErrWithLog(w, h.logger, "notifications: mark all read", apierror.Internal(err))
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleGetMyNotificationSettings implements GET /api/me/notification-settings.
func (h *handlers) handleGetMyNotificationSettings(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
q := dbq.New(h.pool)
kinds, err := notifications.LoadSettings(r.Context(), q, user.ID, user.IsAdmin)
if err != nil {
writeErrWithLog(w, h.logger, "notifications: load settings", apierror.Internal(err))
return
}
h.writeNotificationSettings(w, r, q, user, kinds)
}
// handlePutMyNotificationSettings implements PUT /api/me/notification-settings.
// A partial update: only the kinds and channels named change.
func (h *handlers) handlePutMyNotificationSettings(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
var body notificationSettingsReq
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON body"))
return
}
q := dbq.New(h.pool)
kinds, err := notifications.SaveSettings(r.Context(), q, user.ID, user.IsAdmin, body.Kinds)
if errors.Is(err, notifications.ErrSettingInvalid) {
writeErr(w, apierror.BadRequest("invalid_notification_setting", err.Error()))
return
}
if err != nil {
writeErrWithLog(w, h.logger, "notifications: save settings", apierror.Internal(err))
return
}
h.writeNotificationSettings(w, r, q, user, kinds)
}
func (h *handlers) writeNotificationSettings(w http.ResponseWriter, r *http.Request, q *dbq.Queries, user dbq.User, kinds []notifications.KindSetting) {
resp := notificationSettingsResp{Kinds: kinds, EmailAvailable: true}
if user.Email == nil || strings.TrimSpace(*user.Email) == "" {
resp.EmailAvailable, resp.EmailUnavailableReason = false, "no_address"
} else {
// A failed read is an error, not "not configured": that would tell
// the user something about the server the read never established.
cfg, err := q.GetSMTPConfig(r.Context())
if err != nil {
writeErrWithLog(w, h.logger, "notifications: read smtp config", apierror.Internal(err))
return
}
if !mailer.Configured(cfg) {
resp.EmailAvailable, resp.EmailUnavailableReason = false, "smtp_not_configured"
}
}
writeJSON(w, http.StatusOK, resp)
}
// The cursor is the last row's (created_at, id), opaque to clients.
func formatNotificationCursor(at pgtype.Timestamptz, id pgtype.UUID) string {
return at.Time.UTC().Format(time.RFC3339Nano) + "_" + uuidToString(id)
}
func parseNotificationCursor(raw string) (pgtype.Timestamptz, pgtype.UUID, bool) {
ts, idStr, found := strings.Cut(raw, "_")
if !found {
return pgtype.Timestamptz{}, pgtype.UUID{}, false
}
at, err := time.Parse(time.RFC3339Nano, ts)
if err != nil {
return pgtype.Timestamptz{}, pgtype.UUID{}, false
}
id, ok := parseUUID(idStr)
if !ok {
return pgtype.Timestamptz{}, pgtype.UUID{}, false
}
return pgtype.Timestamptz{Time: at, Valid: true}, id, true
}
+236
View File
@@ -0,0 +1,236 @@
package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
)
func notificationsRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Get("/api/me/notifications", h.handleListMyNotifications)
r.Get("/api/me/notifications/unread-count", h.handleMyUnreadNotificationCount)
r.Post("/api/me/notifications/read-all", h.handleMarkAllMyNotificationsRead)
r.Post("/api/me/notifications/{id}/read", h.handleMarkMyNotificationRead)
r.Get("/api/me/notification-settings", h.handleGetMyNotificationSettings)
r.Put("/api/me/notification-settings", h.handlePutMyNotificationSettings)
return r
}
func callAs(t *testing.T, r chi.Router, user dbq.User, method, path, body string, out any) int {
t.Helper()
req := withUser(httptest.NewRequest(method, path, bytes.NewBufferString(body)), user)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if out != nil && rec.Code == http.StatusOK {
if err := json.Unmarshal(rec.Body.Bytes(), out); err != nil {
t.Fatalf("decode %s: %v", rec.Body.String(), err)
}
}
return rec.Code
}
func notifyN(t *testing.T, pool *pgxpool.Pool, user dbq.User, n int) {
t.Helper()
nt := notifications.New(pool, nil, nil)
for i := 0; i < n; i++ {
if err := nt.Notify(context.Background(), notifications.KindRequestCompleted,
notifications.ToUser(user.ID), notifications.Payload{Name: "Album", AlbumID: "al-1"}.Map()); err != nil {
t.Fatalf("notify: %v", err)
}
}
}
func TestMyNotifications_ListRendersPagesAndCounts(t *testing.T) {
h, pool := testHandlers(t)
alice := seedUser(t, pool, "notif-alice", "pw", false)
bob := seedUser(t, pool, "notif-bob", "pw", false)
notifyN(t, pool, alice, 3)
notifyN(t, pool, bob, 1)
r := notificationsRouter(h)
var page notificationsPageResp
if code := callAs(t, r, alice, http.MethodGet, "/api/me/notifications?limit=2", "", &page); code != http.StatusOK {
t.Fatalf("list = %d", code)
}
if len(page.Items) != 2 || page.UnreadCount != 3 || page.NextBefore == "" {
t.Fatalf("first page = %d items, unread %d, next %q; want 2, 3, a cursor", len(page.Items), page.UnreadCount, page.NextBefore)
}
first := page.Items[0]
if first.Title != "Now in your library" || first.Body != "Album has arrived." || first.Link != "/albums/al-1" || first.ReadAt != nil {
t.Errorf("rendered item = %+v", first)
}
var rest notificationsPageResp
callAs(t, r, alice, http.MethodGet, "/api/me/notifications?limit=2&before="+page.NextBefore, "", &rest)
if len(rest.Items) != 1 || rest.NextBefore != "" {
t.Errorf("second page = %d items, next %q; want 1 and no cursor", len(rest.Items), rest.NextBefore)
}
seen := map[string]bool{}
for _, it := range append(page.Items, rest.Items...) {
if seen[it.ID] {
t.Errorf("item %s on two pages", it.ID)
}
seen[it.ID] = true
}
for _, bad := range []string{"?limit=0", "?limit=x", "?before=nonsense"} {
if code := callAs(t, r, alice, http.MethodGet, "/api/me/notifications"+bad, "", nil); code != http.StatusBadRequest {
t.Errorf("GET %s = %d, want 400", bad, code)
}
}
}
func TestMyNotifications_MarkReadIsOwnerScopedAndCountsDown(t *testing.T) {
h, pool := testHandlers(t)
alice := seedUser(t, pool, "notif-owner", "pw", false)
mallory := seedUser(t, pool, "notif-mallory", "pw", false)
notifyN(t, pool, alice, 2)
r := notificationsRouter(h)
var page notificationsPageResp
callAs(t, r, alice, http.MethodGet, "/api/me/notifications", "", &page)
id := page.Items[0].ID
if code := callAs(t, r, mallory, http.MethodPost, "/api/me/notifications/"+id+"/read", "", nil); code != http.StatusNotFound {
t.Errorf("another user's mark-read = %d, want 404", code)
}
for i := 0; i < 2; i++ {
if code := callAs(t, r, alice, http.MethodPost, "/api/me/notifications/"+id+"/read", "", nil); code != http.StatusNoContent {
t.Errorf("mark-read #%d = %d, want 204 (repeat is harmless)", i+1, code)
}
}
var count unreadCountResp
callAs(t, r, alice, http.MethodGet, "/api/me/notifications/unread-count", "", &count)
if count.UnreadCount != 1 {
t.Errorf("unread = %d, want 1", count.UnreadCount)
}
if code := callAs(t, r, alice, http.MethodPost, "/api/me/notifications/read-all", "", nil); code != http.StatusNoContent {
t.Fatalf("read-all = %d", code)
}
callAs(t, r, alice, http.MethodGet, "/api/me/notifications/unread-count", "", &count)
if count.UnreadCount != 0 {
t.Errorf("unread after read-all = %d, want 0", count.UnreadCount)
}
if code := callAs(t, r, alice, http.MethodPost, "/api/me/notifications/not-a-uuid/read", "", nil); code != http.StatusNotFound {
t.Errorf("malformed id = %d, want 404", code)
}
}
func strPtr(s string) *string { return &s }
func setSMTP(t *testing.T, pool *pgxpool.Pool, enabled bool) {
t.Helper()
ctx := context.Background()
q := dbq.New(pool)
prev, err := q.GetSMTPConfig(ctx)
if err != nil {
t.Fatalf("read smtp: %v", err)
}
t.Cleanup(func() {
_ = q.UpdateSMTPConfig(context.Background(), dbq.UpdateSMTPConfigParams{
Enabled: prev.Enabled, Host: prev.Host, Port: prev.Port, Username: prev.Username,
Password: prev.Password, FromAddress: prev.FromAddress, FromName: prev.FromName, UseTls: prev.UseTls,
})
})
if err := q.UpdateSMTPConfig(ctx, dbq.UpdateSMTPConfigParams{
Enabled: enabled, Host: "smtp.example.com", Port: 587, FromAddress: "minstrel@example.com", FromName: "Minstrel", UseTls: true,
}); err != nil {
t.Fatalf("set smtp: %v", err)
}
}
func TestMyNotificationSettings_DefaultsRoundTripAndAdminKinds(t *testing.T) {
h, pool := testHandlers(t)
setSMTP(t, pool, true)
user := seedUser(t, pool, "notif-settings", "pw", false)
admin := seedUser(t, pool, "notif-settings-admin", "pw", true)
r := notificationsRouter(h)
var s notificationSettingsResp
if code := callAs(t, r, user, http.MethodGet, "/api/me/notification-settings", "", &s); code != http.StatusOK {
t.Fatalf("get = %d", code)
}
for _, k := range s.Kinds {
if k.AdminOnly {
t.Errorf("non-admin offered admin kind %s", k.Kind)
}
}
if s.EmailAvailable || s.EmailUnavailableReason != "no_address" {
t.Errorf("no address on file: email_available=%v reason=%q", s.EmailAvailable, s.EmailUnavailableReason)
}
// A partial change touches only what it names.
body := `{"kinds":[{"kind":"request_completed","email":false}]}`
if code := callAs(t, r, user, http.MethodPut, "/api/me/notification-settings", body, &s); code != http.StatusOK {
t.Fatalf("put = %d", code)
}
for _, k := range s.Kinds {
if k.Kind == notifications.KindRequestCompleted && (k.Email || !k.Inbox || !k.Phone) {
t.Errorf("after PUT request_completed = %+v, want inbox+phone on, email off", k)
}
if k.Kind == notifications.KindRequestApproved && !k.Email {
t.Errorf("an untouched kind changed: %+v", k)
}
}
// A non-admin can't set an admin kind; nothing in the batch is applied.
bad := `{"kinds":[{"kind":"request_approved","inbox":false},{"kind":"tracks_missing","inbox":false}]}`
if code := callAs(t, r, user, http.MethodPut, "/api/me/notification-settings", bad, nil); code != http.StatusBadRequest {
t.Errorf("non-admin setting an admin kind = %d, want 400", code)
}
callAs(t, r, user, http.MethodGet, "/api/me/notification-settings", "", &s)
for _, k := range s.Kinds {
if k.Kind == notifications.KindRequestApproved && !k.Inbox {
t.Error("a refused batch was partly applied")
}
}
if code := callAs(t, r, user, http.MethodPut, "/api/me/notification-settings", `{"kinds":[{"kind":"bogus"}]}`, nil); code != http.StatusBadRequest {
t.Errorf("unknown kind = %d, want 400", code)
}
// An admin with an address and SMTP on sees the admin kinds and can email.
if _, err := pool.Exec(context.Background(), `UPDATE users SET email = 'admin@example.com' WHERE id = $1`, admin.ID); err != nil {
t.Fatal(err)
}
admin.Email = strPtr("admin@example.com")
callAs(t, r, admin, http.MethodGet, "/api/me/notification-settings", "", &s)
if !s.EmailAvailable {
t.Errorf("admin with address + SMTP: email unavailable (%q)", s.EmailUnavailableReason)
}
var adminKinds int
for _, k := range s.Kinds {
if k.AdminOnly {
adminKinds++
if k.Kind == notifications.KindTracksMissing && k.Email {
t.Error("tracks_missing should default to email off")
}
}
}
if adminKinds == 0 {
t.Error("admin was offered no admin kinds")
}
}
func TestMyNotificationSettings_EmailUnavailableWhenSMTPIsOff(t *testing.T) {
h, pool := testHandlers(t)
setSMTP(t, pool, false)
user := seedUser(t, pool, "notif-nosmtp", "pw", false)
user.Email = strPtr("someone@example.com")
var s notificationSettingsResp
callAs(t, notificationsRouter(h), user, http.MethodGet, "/api/me/notification-settings", "", &s)
if s.EmailAvailable || s.EmailUnavailableReason != "smtp_not_configured" {
t.Errorf("SMTP off: email_available=%v reason=%q", s.EmailAvailable, s.EmailUnavailableReason)
}
}
+8 -1
View File
@@ -57,7 +57,7 @@ func (s *SMTPSender) Send(ctx context.Context, to, subject, textBody, htmlBody s
if err != nil {
return fmt.Errorf("mailer: load config: %w", err)
}
if !cfg.Enabled || cfg.Host == "" || cfg.FromAddress == "" {
if !Configured(cfg) {
return ErrNotConfigured
}
@@ -86,6 +86,13 @@ func (s *SMTPSender) Send(ctx context.Context, to, subject, textBody, htmlBody s
return nil
}
// Configured reports whether cfg can send at all: enabled, with a host and a
// from address. Send refuses with ErrNotConfigured otherwise, and settings
// screens use it to say why email is unavailable before anyone tries.
func Configured(cfg dbq.SmtpConfig) bool {
return cfg.Enabled && cfg.Host != "" && cfg.FromAddress != ""
}
// sendMail wraps net/smtp's SendMail with optional TLS verification.
// Mostly identical to smtp.SendMail but explicitly handles the
// use_tls flag.
+134
View File
@@ -0,0 +1,134 @@
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)
}
+56
View File
@@ -0,0 +1,56 @@
package notifications
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
// render stores p the way Notify does (through Map) and reads it back.
func render(k Kind, p Payload) Rendered {
b, err := json.Marshal(p.Map())
if err != nil {
panic(err)
}
return Render(k, b)
}
func TestRender_EveryKindHasATitleAndALink(t *testing.T) {
for _, k := range Kinds() {
r := Render(k, []byte(`{}`))
require.NotEmpty(t, r.Title, k)
require.NotEqual(t, "Notification", r.Title, "%s falls through to the default", k)
require.NotEmpty(t, r.Link, k)
}
}
func TestRender_RequestCompletedLinksToWhatArrived(t *testing.T) {
require.Equal(t, "/albums/al-1", render(KindRequestCompleted, Payload{Name: "WWW", AlbumID: "al-1", ArtistID: "ar-1"}).Link)
require.Equal(t, "/artists/ar-1", render(KindRequestCompleted, Payload{Name: "Moe Shop", ArtistID: "ar-1"}).Link)
require.Equal(t, "/requests", render(KindRequestCompleted, Payload{Name: "WWW"}).Link)
require.Equal(t, "WWW has arrived.", render(KindRequestCompleted, Payload{Name: "WWW"}).Body)
}
func TestRender_RejectionCarriesTheReasonWhenThereIsOne(t *testing.T) {
require.Equal(t, "WWW was declined.", render(KindRequestRejected, Payload{Name: "WWW"}).Body)
require.Equal(t, "WWW was declined: already owned", render(KindRequestRejected, Payload{Name: "WWW", Reason: "already owned"}).Body)
}
func TestRender_CoalescedCountsReadNaturally(t *testing.T) {
require.Equal(t, "1 track went missing", render(KindTracksMissing, Payload{Count: 1}).Title)
require.Equal(t, "14 tracks went missing", render(KindTracksMissing, Payload{Count: 14}).Title)
require.Equal(t, "Library scan failed", render(KindScanFailed, Payload{Count: 1, Detail: "x"}).Title)
require.Equal(t, "Library scan failed 3 times", render(KindScanFailed, Payload{Count: 3}).Title)
require.Equal(t, "Some tracks went missing", Render(KindTracksMissing, []byte(`{}`)).Title)
}
func TestRender_AnUnreadablePayloadStillRenders(t *testing.T) {
r := Render(KindRequestApproved, []byte(`not json`))
require.Equal(t, "Request approved", r.Title)
require.Equal(t, "Your request is on its way.", r.Body)
}
func TestPayloadMap_DropsEmptyFields(t *testing.T) {
require.Equal(t, map[string]any{"name": "WWW", "count": float64(2)}, Payload{Name: "WWW", Count: 2}.Map())
}
+102
View File
@@ -0,0 +1,102 @@
package notifications
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// KindSetting is one row of a user's notification settings.
type KindSetting struct {
Kind Kind `json:"kind"`
AdminOnly bool `json:"admin_only"`
Inbox bool `json:"inbox"`
Phone bool `json:"phone"`
Email bool `json:"email"`
}
// SettingChange changes one kind. A nil field is left as it is, so a client
// replaying a queued toggle offline sends only what the user touched.
type SettingChange struct {
Kind Kind `json:"kind"`
Inbox *bool `json:"inbox,omitempty"`
Phone *bool `json:"phone,omitempty"`
Email *bool `json:"email,omitempty"`
}
// ErrSettingInvalid is a change naming an unknown kind, or an admin kind for
// a user who is not an admin.
var ErrSettingInvalid = errors.New("notification setting invalid")
// LoadSettings returns every kind the user can receive, in display order,
// with their stored channels or the kind's defaults. Admin kinds are listed
// only for admins.
//
// The stored values are returned as set, not as Effective: a user who turns
// the inbox off and on again gets back the phone and email choices they had.
func LoadSettings(ctx context.Context, q *dbq.Queries, userID pgtype.UUID, isAdmin bool) ([]KindSetting, error) {
rows, err := q.ListNotificationPrefsForUser(ctx, userID)
if err != nil {
return nil, fmt.Errorf("notifications: load settings: %w", err)
}
stored := make(map[Kind]Channels, len(rows))
for _, r := range rows {
stored[Kind(r.Kind)] = Channels{Inbox: r.Inbox, Phone: r.Phone, Email: r.Email}
}
out := make([]KindSetting, 0, len(order))
for _, k := range order {
if k.AdminOnly() && !isAdmin {
continue
}
c, ok := stored[k]
if !ok {
c = k.Defaults()
}
out = append(out, KindSetting{Kind: k, AdminOnly: k.AdminOnly(), Inbox: c.Inbox, Phone: c.Phone, Email: c.Email})
}
return out, nil
}
// SaveSettings applies changes and returns the settings as they now stand.
// The whole batch is validated before anything is written.
func SaveSettings(ctx context.Context, q *dbq.Queries, userID pgtype.UUID, isAdmin bool, changes []SettingChange) ([]KindSetting, error) {
for _, c := range changes {
if !c.Kind.Valid() {
return nil, fmt.Errorf("%w: unknown kind %q", ErrSettingInvalid, c.Kind)
}
if c.Kind.AdminOnly() && !isAdmin {
return nil, fmt.Errorf("%w: %s is for admins", ErrSettingInvalid, c.Kind)
}
}
current, err := LoadSettings(ctx, q, userID, isAdmin)
if err != nil {
return nil, err
}
byKind := make(map[Kind]KindSetting, len(current))
for _, s := range current {
byKind[s.Kind] = s
}
for _, c := range changes {
s := byKind[c.Kind]
if c.Inbox != nil {
s.Inbox = *c.Inbox
}
if c.Phone != nil {
s.Phone = *c.Phone
}
if c.Email != nil {
s.Email = *c.Email
}
byKind[c.Kind] = s
if err := q.UpsertNotificationPref(ctx, dbq.UpsertNotificationPrefParams{
UserID: userID, Kind: string(c.Kind), Inbox: s.Inbox, Phone: s.Phone, Email: s.Email,
}); err != nil {
return nil, fmt.Errorf("notifications: save %s: %w", c.Kind, err)
}
}
return LoadSettings(ctx, q, userID, isAdmin)
}