Manage notification channels instead of an ntfy topic

Mirrors the server's channels API: add/remove channels per user from the Users
section. Requires the matching server release.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
This commit is contained in:
Niklas Ye
2026-10-12 21:00:40 +02:00
parent 77f2d6775b
commit ec5df0449b
10 changed files with 405 additions and 174 deletions
+21 -10
View File
@@ -623,21 +623,32 @@ func (c *Client) CreateUser(username, email string) (*User, error) {
return &user, c.do(req, &user)
}
// SetUserNotifyTarget points a user's push notifications at an ntfy topic.
// AddChannel gives a user a notification channel: a provider type the server
// has configured ("ntfy") and an address that provider understands (a topic).
//
// An empty topic clears it: the server stores NULL, and that user's incidents
// page the shared fallback topic instead — which carries no Acknowledge button,
// because anyone subscribed to it could otherwise acknowledge as somebody else.
func (c *Client) SetUserNotifyTarget(userID int64, topic string) (*User, error) {
// With no channel a user's incidents page the shared fallback instead, which
// carries no Acknowledge button, because anyone subscribed to it could
// otherwise acknowledge as somebody else.
func (c *Client) AddChannel(userID int64, typ, address string) (*NotificationChannel, error) {
body := struct {
NtfyTopic string `json:"ntfy_topic"`
}{NtfyTopic: topic}
req, err := c.newRequestWithBody(http.MethodPut, fmt.Sprintf("/api/users/%d/notify", userID), body)
Type string `json:"type"`
Address string `json:"address"`
}{Type: typ, Address: address}
req, err := c.newRequestWithBody(http.MethodPost, fmt.Sprintf("/api/users/%d/channels", userID), body)
if err != nil {
return nil, err
}
var user User
return &user, c.do(req, &user)
var ch NotificationChannel
return &ch, c.do(req, &ch)
}
// RemoveChannel deletes one of a user's notification channels.
func (c *Client) RemoveChannel(userID, channelID int64) error {
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/users/%d/channels/%d", userID, channelID))
if err != nil {
return err
}
return c.do(req, nil)
}
func (c *Client) DeleteUser(id int64) error {
+20 -24
View File
@@ -173,8 +173,10 @@ func TestClient_IncidentEndpoints(t *testing.T) {
http.MethodDelete, "/api/incidents/7/notes/12", ""},
{"stats", func(c *Client) error { _, err := c.GetIncidentStats(); return err },
http.MethodGet, "/api/stats/incidents", ""},
{"set notify target", func(c *Client) error { _, err := c.SetUserNotifyTarget(7, "t"); return err },
http.MethodPut, "/api/users/7/notify", ""},
{"add channel", func(c *Client) error { _, err := c.AddChannel(7, "ntfy", "t"); return err },
http.MethodPost, "/api/users/7/channels", `{}`},
{"remove channel", func(c *Client) error { return c.RemoveChannel(7, 2) },
http.MethodDelete, "/api/users/7/channels/2", ""},
}
for _, tt := range tests {
@@ -284,40 +286,34 @@ func TestClient_RequestBodies(t *testing.T) {
}
})
t.Run("set notify target", func(t *testing.T) {
c, got := stub(t, http.StatusOK, `{}`)
if _, err := c.SetUserNotifyTarget(3, "terdut-niklas"); err != nil {
t.Fatalf("set notify target: %v", err)
t.Run("add channel", func(t *testing.T) {
c, got := stub(t, http.StatusCreated, `{"id":4,"type":"ntfy","enabled":true}`)
ch, err := c.AddChannel(3, "ntfy", "terdut-niklas")
if err != nil {
t.Fatalf("add channel: %v", err)
}
if got.body != `{"ntfy_topic":"terdut-niklas"}` {
if got.body != `{"type":"ntfy","address":"terdut-niklas"}` {
t.Errorf("unexpected body %q", got.body)
}
})
// Clearing has to put an explicit empty string on the wire: omitting the
// field would leave the topic untouched instead of removing it.
t.Run("clear notify target", func(t *testing.T) {
c, got := stub(t, http.StatusOK, `{}`)
if _, err := c.SetUserNotifyTarget(3, ""); err != nil {
t.Fatalf("clear notify target: %v", err)
}
if got.body != `{"ntfy_topic":""}` {
t.Errorf("expected an explicit empty topic, got %q", got.body)
if ch.ID != 4 || ch.Type != "ntfy" || !ch.Enabled {
t.Errorf("unexpected channel %+v", ch)
}
})
}
func TestUser_TopicFlattensNilAndEmpty(t *testing.T) {
func TestUser_ChannelSummaryNamesKindsNeverAddresses(t *testing.T) {
var users []User
if err := json.Unmarshal([]byte(
`[{"id":1,"username":"a"},{"id":2,"username":"b","ntfy_topic":""},
{"id":3,"username":"c","ntfy_topic":"terdut-c"}]`), &users); err != nil {
`[{"id":1,"username":"a"},
{"id":2,"username":"b","channels":[{"id":1,"type":"ntfy","enabled":true}]},
{"id":3,"username":"c","channels":[{"id":2,"type":"ntfy","address":"secret","enabled":true},
{"id":3,"type":"mail","enabled":false}]}]`), &users); err != nil {
t.Fatalf("decode: %v", err)
}
want := []string{"", "", "terdut-c"}
want := []string{"", "ntfy", "ntfy, mail (off)"}
for i, u := range users {
if got := u.Topic(); got != want[i] {
t.Errorf("user %d: expected topic %q, got %q", u.ID, want[i], got)
if got := u.ChannelSummary(); got != want[i] {
t.Errorf("user %d: expected %q, got %q", u.ID, want[i], got)
}
}
}
+32 -13
View File
@@ -1,6 +1,9 @@
package api
import "time"
import (
"strings"
"time"
)
// Alert is the server's record of what Alertmanager said. It is read-only:
// acknowledging, assigning, noting and resolving all happen on the Incident an
@@ -147,7 +150,7 @@ const (
// Written by the server's notifier from the delivery result, not at enqueue.
// Detail carries the notification kind ("triggered", "reminder", "resolved"),
// and on a failure the reason after it. An absent user means the page went to
// the shared fallback topic rather than to a person.
// the shared fallback rather than to a person.
EventNotified = "notified"
EventNotifyFailed = "notify_failed"
)
@@ -224,20 +227,36 @@ type User struct {
// disabled user cannot sign in or use their keys.
DisabledAt *time.Time `json:"disabled_at,omitempty"`
// NtfyTopic is where this user's push notifications go. Nil and empty mean
// the same thing — no topic of their own — because the server stores a blank
// string as NULL. Their incidents fall back to the server's shared fallback
// topic, which carries no Acknowledge button.
NtfyTopic *string `json:"ntfy_topic,omitempty"`
// Channels are where this user's notifications go: a provider type and an
// address each. Only filled in for the caller's own row and, without the
// addresses, for an administrator's view of others. A user with none has
// their incidents page the server's shared fallback, which carries no
// Acknowledge button.
Channels []NotificationChannel `json:"channels,omitempty"`
}
// Topic reads the user's ntfy topic, flattening the nil and empty cases the
// server treats alike.
func (u User) Topic() string {
if u.NtfyTopic == nil {
return ""
// NotificationChannel is one way of reaching a user.
type NotificationChannel struct {
ID int64 `json:"id"`
Type string `json:"type"`
// Address is blank unless the caller owns the channel: it can be a secret.
Address string `json:"address,omitempty"`
Enabled bool `json:"enabled"`
}
// ChannelSummary lists the kinds of channel the user has, "ntfy, mail (off)",
// or "" for none. Never an address.
func (u User) ChannelSummary() string {
parts := make([]string, 0, len(u.Channels))
for _, c := range u.Channels {
if c.Enabled {
parts = append(parts, c.Type)
} else {
parts = append(parts, c.Type+" (off)")
}
}
return *u.NtfyTopic
return strings.Join(parts, ", ")
}
// IsDisabled reports whether the account has been disabled.