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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user