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
+12 -9
View File
@@ -10,6 +10,7 @@ import (
"time"
"git.ryuvia.com/niklas/terdut-server/internal/models"
"git.ryuvia.com/niklas/terdut-server/internal/notify"
"github.com/go-chi/chi/v5"
)
@@ -283,8 +284,9 @@ type memberStatus struct {
// NextShift is the first day after today the rota has them (YYYY-MM-DD).
NextShift *string `json:"next_shift,omitempty"`
// Pageable is whether they have an ntfy topic and an enabled account — the
// conditions pageLevel and the notifier skip on. Never the topic itself.
// Pageable is whether they have an enabled channel of a type this server can
// deliver, and an enabled account — the conditions pageLevel and the notifier
// skip on. Never the channel's address.
Pageable bool `json:"pageable"`
Problem string `json:"problem,omitempty"`
@@ -294,7 +296,7 @@ type memberStatus struct {
// handleListTeamMembers names everybody in a team, with their status. Visible to
// any member: you can see who else is on the rota you are on.
func handleListTeamMembers(db *sql.DB) http.HandlerFunc {
func handleListTeamMembers(db *sql.DB, providers *notify.Registry) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
teamID, ok := teamParam(w, r)
if !ok {
@@ -306,7 +308,8 @@ func handleListTeamMembers(db *sql.DB) http.HandlerFunc {
rows, err := db.QueryContext(r.Context(), `
SELECT m.team_id, m.user_id, u.username, m.role, m.joined_at, m.source,
u.ntfy_topic IS NOT NULL AND u.ntfy_topic <> '',
EXISTS (SELECT 1 FROM notification_channels c
WHERE c.user_id = u.id AND c.enabled AND c.type = ANY($3::text[])),
u.disabled_at IS NOT NULL,
GREATEST(
COALESCE((SELECT MAX(last_seen_at) FROM sessions WHERE user_id = u.id), 0),
@@ -318,7 +321,7 @@ func handleListTeamMembers(db *sql.DB) http.HandlerFunc {
FROM team_members m
JOIN users u ON u.id = m.user_id
WHERE m.team_id = $1
ORDER BY u.username`, teamID, todayUTC())
ORDER BY u.username`, teamID, todayUTC(), providerTypes(providers))
if err != nil {
serverError(w, r, err)
return
@@ -329,9 +332,9 @@ func handleListTeamMembers(db *sql.DB) http.HandlerFunc {
for rows.Next() {
var m memberStatus
var joined, lastActive int64
var hasTopic, disabled bool
var hasChannel, disabled bool
if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined, &m.Source,
&hasTopic, &disabled, &lastActive, &m.OnCall, &m.NextShift); err != nil {
&hasChannel, &disabled, &lastActive, &m.OnCall, &m.NextShift); err != nil {
serverError(w, r, err)
return
}
@@ -343,8 +346,8 @@ func handleListTeamMembers(db *sql.DB) http.HandlerFunc {
switch {
case disabled:
m.Problem = "account is disabled"
case !hasTopic:
m.Problem = "has no ntfy topic"
case !hasChannel:
m.Problem = "has no notification channel"
}
m.Pageable = m.Problem == ""
switch {