Notifications inbox (M489), Discover taste-arm fix, web tooling majors #151
@@ -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