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
+18 -8
View File
@@ -9,8 +9,8 @@ import (
"git.ryuvia.com/niklas/terdut-server/internal/api"
)
// teamUser creates a user in the default team with an ntfy topic, so they can
// actually be paged.
// teamUser creates a user in the default team with an ntfy channel, so they can
// actually be paged. An empty topic leaves them with none.
func teamUser(t *testing.T, s *ts, username, topic string) int64 {
t.Helper()
var user struct {
@@ -21,7 +21,9 @@ func teamUser(t *testing.T, s *ts, username, topic string) int64 {
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/members",
map[string]any{"user_id": user.ID, "role": "member"})
resp.Body.Close()
setTopic(t, s, int(user.ID), topic)
if topic != "" {
addNtfyChannel(t, s, int(user.ID), topic)
}
return user.ID
}
@@ -30,13 +32,21 @@ func teamUser(t *testing.T, s *ts, username, topic string) int64 {
// man's switch tests use on received_at: the sweeper reads a stored timestamp,
// so moving the timestamp is moving the clock.
// fallbackTarget is the JSON for a team's fallback: an ntfy topic, or none.
func fallbackTarget(topic string) map[string]string {
if topic == "" {
return map[string]string{"type": "", "address": ""}
}
return map[string]string{"type": "ntfy", "address": topic}
}
// ladder configures the default team with two levels: the rota first, then a
// named person, then the fallback topic.
func ladder(t *testing.T, s *ts, secondUserID int64, repeat int64, fallback string) {
t.Helper()
resp := s.req(t, http.MethodPut, "/api/teams/"+defaultTeam+"/escalation", map[string]any{
"repeat_count": repeat,
"fallback_topic": fallback,
"repeat_count": repeat,
"fallback": fallbackTarget(fallback),
"levels": []map[string]any{
{"timeout_seconds": 300, "targets": []map[string]any{{"kind": "oncall"}}},
{"timeout_seconds": 300, "targets": []map[string]any{{"kind": "user", "user_id": secondUserID}}},
@@ -442,14 +452,14 @@ func TestEscalation_StatusFlagsUnreachableLevels(t *testing.T) {
// Nobody on call today, and the named person loses their topic.
s.exec(t, "DELETE FROM schedule_entries")
s.exec(t, "UPDATE users SET ntfy_topic = NULL WHERE id = $1", silent)
s.exec(t, "DELETE FROM notification_channels WHERE user_id = $1", silent)
v := readLadder(t, s)
if v.Levels[0].Status != "unreachable" || v.Levels[0].Targets[0].Problem != "nobody is on call today" {
t.Errorf("an empty rota should make level 1 unreachable, got %+v", v.Levels[0])
}
if v.Levels[1].Status != "unreachable" || v.Levels[1].Targets[0].Problem != "has no ntfy topic" {
t.Errorf("a person with no topic should make level 2 unreachable, got %+v", v.Levels[1])
if v.Levels[1].Status != "unreachable" || v.Levels[1].Targets[0].Problem != "has no notification channel" {
t.Errorf("a person with no channel should make level 2 unreachable, got %+v", v.Levels[1])
}
s.exec(t, "UPDATE users SET disabled_at = 1 WHERE id = $1", silent)