diff --git a/internal/api/api.go b/internal/api/api.go index 9d4bebaa..45681b8d 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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) diff --git a/internal/api/me_notifications.go b/internal/api/me_notifications.go new file mode 100644 index 00000000..885e816d --- /dev/null +++ b/internal/api/me_notifications.go @@ -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 +} diff --git a/internal/api/me_notifications_test.go b/internal/api/me_notifications_test.go new file mode 100644 index 00000000..3bf34110 --- /dev/null +++ b/internal/api/me_notifications_test.go @@ -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) + } +} diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go index f0ebfb0c..6bfdc2c7 100644 --- a/internal/mailer/mailer.go +++ b/internal/mailer/mailer.go @@ -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. diff --git a/internal/notifications/render.go b/internal/notifications/render.go new file mode 100644 index 00000000..aaa32e66 --- /dev/null +++ b/internal/notifications/render.go @@ -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) +} diff --git a/internal/notifications/render_test.go b/internal/notifications/render_test.go new file mode 100644 index 00000000..6bd6444a --- /dev/null +++ b/internal/notifications/render_test.go @@ -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()) +} diff --git a/internal/notifications/settings.go b/internal/notifications/settings.go new file mode 100644 index 00000000..c1a66e0f --- /dev/null +++ b/internal/notifications/settings.go @@ -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) +}