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:
+21
-10
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user