Add notification channels and a provider abstraction

Users get a notification_channels table (replacing users.ntfy_topic); pages fan
out to every enabled channel through a notify.Provider registry, with ntfy as
the first provider. Fallbacks are typed {type, address}. Schema edited in place:
existing databases must be recreated.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
This commit is contained in:
Niklas Ye
2026-10-12 21:00:40 +02:00
parent 6292ad6708
commit b114251f45
42 changed files with 2200 additions and 611 deletions
+34 -7
View File
@@ -459,18 +459,38 @@ func TestTeams_OutsiderSeesNothing(t *testing.T) {
// ---------------------------------------------------------------------------
// Everybody signed in can list users to name them, but only an admin (or the
// row's owner) sees an email or an ntfy topic, which is a publish secret.
func TestListUsers_RedactsEmailAndTopicForOthers(t *testing.T) {
// row's owner) sees an email, and only the owner sees a channel's address, which
// can be a publish secret. An admin sees that a channel exists, not where it goes.
func TestListUsers_RedactsEmailAndChannelsForOthers(t *testing.T) {
s := newTS(t)
red := newTeam(t, s, "red")
_ = newTeam(t, s, "blue")
s.exec(t, "UPDATE users SET ntfy_topic = 'secret-topic'")
s.exec(t, `INSERT INTO notification_channels (user_id, type, address)
SELECT id, 'ntfy', 'secret-topic' FROM users`)
channelAddresses := func(u map[string]any) []string {
var out []string
list, _ := u["channels"].([]any)
for _, c := range list {
addr, _ := c.(map[string]any)["address"].(string) // omitted when blank
out = append(out, addr)
}
return out
}
var asAdmin []map[string]any
decode(t, s.req(t, http.MethodGet, "/api/users", nil), &asAdmin)
for _, u := range asAdmin {
if u["email"] == "" || u["ntfy_topic"] != "secret-topic" {
t.Errorf("admin should see everything, got %v", u)
if u["email"] == "" {
t.Errorf("admin should see every email, got %v", u)
}
addrs := channelAddresses(u)
if len(addrs) != 1 {
t.Errorf("admin should see that every user has a channel, got %v", u)
continue
}
if own := u["username"] == "admin"; own != (addrs[0] != "") {
t.Errorf("admin should see only their own channel's address, got %v", u)
}
}
@@ -480,8 +500,15 @@ func TestListUsers_RedactsEmailAndTopicForOthers(t *testing.T) {
}
for _, u := range asMember {
own := u["username"] == "red-user"
if own != (u["email"] != "") || own != (u["ntfy_topic"] != nil) {
t.Errorf("only red-user's own row should keep email and topic, got %v", u)
if own != (u["email"] != "") {
t.Errorf("only red-user's own row should keep its email, got %v", u)
}
addrs := channelAddresses(u)
if own && (len(addrs) != 1 || addrs[0] != "secret-topic") {
t.Errorf("red-user should see their own channel and address, got %v", u)
}
if !own && len(addrs) != 0 {
t.Errorf("a member must not see anybody else's channels, got %v", u)
}
}
}