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
+8 -7
View File
@@ -10,6 +10,7 @@ import (
"git.ryuvia.com/niklas/terdut-server/internal/api"
"git.ryuvia.com/niklas/terdut-server/internal/models"
"git.ryuvia.com/niklas/terdut-server/internal/notify"
)
// signup posts to the unauthenticated sign-up endpoint, the way the form does,
@@ -301,18 +302,18 @@ func TestSignup_InviteCreatedByAServiceAccountLeavesCreatedByNull(t *testing.T)
}
// Neither of these has a real user_id to act on behalf of; both must 403 a
// service account explicitly rather than 500 (handleTestNotification, which
// used to query ntfy_topic for user id 0) or silently no-op (handleDismissOnboarding,
// service account explicitly rather than 500 (handleTestChannel, which once
// queried the notification target of user id 0) or silently no-op (handleDismissOnboarding,
// which used to UPDATE ... WHERE id = 0, affecting nothing and still
// returning 204).
func TestServiceAccount_HumanOnlyEndpointsRefuseExplicitly(t *testing.T) {
// BaseURL set (even to a fake, unreachable address) so handleTestNotification
// reaches its AsHuman() check instead of short-circuiting on "ntfy not
// configured" first — this test is about the human-only check, not ntfy.
s := newTS(t, api.NotifyConfig{BaseURL: "http://ntfy.invalid"})
// A provider set (even one pointing at an unreachable address) so the test
// endpoint reaches its owner check instead of short-circuiting on "no
// provider configured" first — this test is about the human-only check.
s := newTS(t, api.NotifyConfig{Providers: notify.NewRegistry(notify.NewNtfy("http://ntfy.invalid", ""))})
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
if resp := s.reqAs(t, instanceKey, http.MethodPost, "/api/me/notify/test", nil); resp.StatusCode != http.StatusForbidden {
if resp := s.reqAs(t, instanceKey, http.MethodPost, "/api/users/1/channels/1/test", nil); resp.StatusCode != http.StatusForbidden {
t.Errorf("expected 403 for a service account testing notifications, got %d", resp.StatusCode)
} else {
resp.Body.Close()