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 -21
View File
@@ -13,6 +13,7 @@ import (
"time"
"git.ryuvia.com/niklas/terdut-server/internal/api"
"git.ryuvia.com/niklas/terdut-server/internal/notify"
)
// ---------------------------------------------------------------------------
@@ -91,6 +92,15 @@ func (f *fakeNtfy) forget() {
f.got = nil
}
// providers registers the fake as the install's one ntfy provider.
func (f *fakeNtfy) providers() *notify.Registry {
return notify.NewRegistry(notify.NewNtfy(f.URL, ""))
}
func ntfyTarget(topic string) notify.Target {
return notify.Target{Type: notify.TypeNtfy, Address: topic}
}
func (f *fakeNtfy) failWith(status int) {
f.mu.Lock()
defer f.mu.Unlock()
@@ -106,11 +116,11 @@ func (f *fakeNtfy) failWith(status int) {
func notifyTS(t *testing.T, cfg api.NotifyConfig) (*ts, *fakeNtfy) {
t.Helper()
f := newFakeNtfy(t)
cfg.BaseURL = f.URL
cfg.Providers = f.providers()
s := newTS(t, cfg)
putOnCall(t, s, 1)
setTopic(t, s, 1, "terdut-admin")
addNtfyChannel(t, s, 1, "terdut-admin")
return s, f
}
@@ -125,13 +135,14 @@ func putOnCall(t *testing.T, s *ts, userID int) {
}
}
func setTopic(t *testing.T, s *ts, userID int, topic string) {
// addNtfyChannel gives a user an ntfy channel, the way the Account page does.
func addNtfyChannel(t *testing.T, s *ts, userID int, topic string) {
t.Helper()
resp := s.req(t, http.MethodPut,
fmt.Sprintf("/api/users/%d/notify", userID), map[string]any{"ntfy_topic": topic})
resp := s.req(t, http.MethodPost, fmt.Sprintf("/api/users/%d/channels", userID),
map[string]any{"type": "ntfy", "address": topic})
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("set notify topic returned %d", resp.StatusCode)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("add notification channel returned %d", resp.StatusCode)
}
}
@@ -339,8 +350,8 @@ func TestNotify_DeliveryIsRecordedOnTheTimeline(t *testing.T) {
if e["type"] != "notified" {
t.Errorf("expected a notified event, got %v", e["type"])
}
if e["detail"] != "triggered" {
t.Errorf("expected the kind in detail, got %v", e["detail"])
if e["detail"] != "triggered via ntfy" {
t.Errorf("expected the kind and the channel type in detail, got %v", e["detail"])
}
if e["username"] != "admin" {
t.Errorf("expected the paged user attached, got %v", e["username"])
@@ -370,7 +381,7 @@ func TestNotify_TimelineRecordsOneEventPerDelivery(t *testing.T) {
if len(events) != 2 {
t.Fatalf("expected one event per delivery, got %v", events)
}
if events[0]["detail"] != "triggered" || events[1]["detail"] != "reminder" {
if events[0]["detail"] != "triggered via ntfy" || events[1]["detail"] != "reminder via ntfy" {
t.Errorf("expected triggered then reminder, got %v and %v",
events[0]["detail"], events[1]["detail"])
}
@@ -392,7 +403,7 @@ func TestNotify_AllClearIsRecordedOnTheTimeline(t *testing.T) {
if len(events) != 2 {
t.Fatalf("expected the all-clear recorded, got %v", events)
}
if events[1]["detail"] != "resolved" {
if events[1]["detail"] != "resolved via ntfy" {
t.Errorf("expected a resolved event, got %v", events[1]["detail"])
}
}
@@ -402,9 +413,9 @@ func TestNotify_AllClearIsRecordedOnTheTimeline(t *testing.T) {
func TestNotify_FallbackDeliveryHasNoUser(t *testing.T) {
f := newFakeNtfy(t)
s := newTS(t, api.NotifyConfig{
BaseURL: f.URL,
FallbackTopic: "terdut-oncall",
PublicURL: "https://terdut.example.com",
Providers: f.providers(),
Fallback: ntfyTarget("terdut-oncall"),
PublicURL: "https://terdut.example.com",
})
fireCritical(t, s)
@@ -441,7 +452,7 @@ func TestNotify_ExhaustedRetriesAreRecordedOnce(t *testing.T) {
t.Errorf("expected notify_failed, got %v", events[0]["type"])
}
detail, _ := events[0]["detail"].(string)
if !strings.HasPrefix(detail, "triggered: ") || !strings.Contains(detail, "500") {
if !strings.HasPrefix(detail, "triggered via ntfy: ") || !strings.Contains(detail, "500") {
t.Errorf("expected the kind and the reason in %q", detail)
}
}
@@ -743,12 +754,12 @@ func TestNotify_ManualResolveSendsNothing(t *testing.T) {
// With nobody on call the page goes to the shared fallback, and carries no
// Acknowledge button — there is no user to attribute the acknowledgement to.
func TestNotify_FallbackTopicHasNoAckButton(t *testing.T) {
func TestNotify_FallbackHasNoAckButton(t *testing.T) {
f := newFakeNtfy(t)
s := newTS(t, api.NotifyConfig{
BaseURL: f.URL,
FallbackTopic: "terdut-oncall",
PublicURL: "https://terdut.example.com",
Providers: f.providers(),
Fallback: ntfyTarget("terdut-oncall"),
PublicURL: "https://terdut.example.com",
})
fireCritical(t, s)
@@ -770,7 +781,7 @@ func TestNotify_FallbackTopicHasNoAckButton(t *testing.T) {
// only pile up rows that can never be delivered.
func TestNotify_NoTargetQueuesNothing(t *testing.T) {
f := newFakeNtfy(t)
s := newTS(t, api.NotifyConfig{BaseURL: f.URL})
s := newTS(t, api.NotifyConfig{Providers: f.providers()})
fireCritical(t, s)
@@ -787,7 +798,9 @@ func TestNotify_NoTargetQueuesNothing(t *testing.T) {
func TestNotify_DisabledQueuesNothing(t *testing.T) {
s := newTS(t)
putOnCall(t, s, 1)
setTopic(t, s, 1, "terdut-admin")
// Inserted rather than added through the API, which refuses a type this
// server has no provider for.
s.exec(t, "INSERT INTO notification_channels (user_id, type, address) VALUES (1, 'ntfy', 'terdut-admin')")
fireCritical(t, s)