feat(api): notifications inbox and per-user settings endpoints (#5339)
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
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>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user