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.Get("/me/sessions", h.handleListMySessions)
|
||||||
authed.Delete("/me/sessions/{id}", h.handleRevokeMySession)
|
authed.Delete("/me/sessions/{id}", h.handleRevokeMySession)
|
||||||
authed.Post("/me/sessions/logout-others", h.handleRevokeMyOtherSessions)
|
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", h.handleListArtists)
|
||||||
authed.Get("/artists/{id}", h.handleGetArtist)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("mailer: load config: %w", err)
|
return fmt.Errorf("mailer: load config: %w", err)
|
||||||
}
|
}
|
||||||
if !cfg.Enabled || cfg.Host == "" || cfg.FromAddress == "" {
|
if !Configured(cfg) {
|
||||||
return ErrNotConfigured
|
return ErrNotConfigured
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +86,13 @@ func (s *SMTPSender) Send(ctx context.Context, to, subject, textBody, htmlBody s
|
|||||||
return nil
|
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.
|
// sendMail wraps net/smtp's SendMail with optional TLS verification.
|
||||||
// Mostly identical to smtp.SendMail but explicitly handles the
|
// Mostly identical to smtp.SendMail but explicitly handles the
|
||||||
// use_tls flag.
|
// 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