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
+71 -47
View File
@@ -9,6 +9,8 @@ import (
"strconv"
"strings"
"time"
"git.ryuvia.com/niklas/terdut-server/internal/notify"
)
// evEscalated records a rung of the ladder on the incident's timeline: which
@@ -19,10 +21,10 @@ const evEscalated = "escalated"
// levels with a few targets each — and every use needs all of it, so there is
// no point reading it a level at a time.
type escalationPolicy struct {
teamID int64
repeatCount int64
fallbackTopic string
levels []escalationLevel
teamID int64
repeatCount int64
fallback notify.Target
levels []escalationLevel
}
type escalationLevel struct {
@@ -39,7 +41,7 @@ type escalationTarget struct {
// configured reports whether this team has anything to escalate through. A
// policy row with no levels is the same as no policy: the team gets the
// pre-escalation behaviour, which is reminders on the assignee's topic.
// pre-escalation behaviour, which is reminders to the assignee's channels.
func (p *escalationPolicy) configured() bool { return p != nil && len(p.levels) > 0 }
// level returns the level at a 1-based position.
@@ -58,8 +60,8 @@ func (p *escalationPolicy) level(pos int64) (escalationLevel, bool) {
func loadEscalationPolicy(ctx context.Context, q querier, teamID int64) (*escalationPolicy, error) {
p := &escalationPolicy{teamID: teamID}
err := q.QueryRowContext(ctx,
"SELECT repeat_count, fallback_topic FROM escalation_policies WHERE team_id = $1",
teamID).Scan(&p.repeatCount, &p.fallbackTopic)
"SELECT repeat_count, fallback_type, fallback_address FROM escalation_policies WHERE team_id = $1",
teamID).Scan(&p.repeatCount, &p.fallback.Type, &p.fallback.Address)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -196,7 +198,7 @@ func advanceEscalation(ctx context.Context, db *sql.DB, cfg NotifyConfig, policy
if round < policy.repeatCount {
next, nextRound = 1, round+1
} else {
if err := escalationExhausted(ctx, tx, policy, incidentID, now); err != nil {
if err := escalationExhausted(ctx, tx, cfg, policy, incidentID, now); err != nil {
return err
}
return tx.Commit()
@@ -236,18 +238,23 @@ func advanceEscalation(ctx context.Context, db *sql.DB, cfg NotifyConfig, policy
return tx.Commit()
}
// escalationExhausted is the end of the line: the fallback topic, once, and a
// escalationExhausted is the end of the line: the fallback target, once, and a
// timeline entry saying the ladder is finished. The incident stays triggered —
// escalation running out is not the same as somebody answering.
func escalationExhausted(ctx context.Context, tx *sql.Tx, policy *escalationPolicy, incidentID int64, now time.Time) error {
func escalationExhausted(ctx context.Context, tx *sql.Tx, cfg NotifyConfig, policy *escalationPolicy, incidentID int64, now time.Time) error {
detail := "escalation exhausted"
if policy.fallbackTopic != "" {
if err := enqueueNotification(ctx, tx, incidentID, nil, policy.fallbackTopic, notifyEscalated); err != nil {
// The address is left out of the detail: it can be a shared secret, and the
// timeline is readable by every API key.
switch recipients := fallbackRecipients(cfg, policy.fallback); {
case len(recipients) > 0:
if err := enqueueNotifications(ctx, tx, incidentID, recipients, notifyEscalated); err != nil {
return err
}
detail += ": paged " + policy.fallbackTopic
} else {
detail += ": no fallback topic configured"
detail += ": paged the team fallback (" + policy.fallback.Type + ")"
case !policy.fallback.IsZero():
detail += ": the team fallback cannot be delivered (" + policy.fallback.Type + " is not configured)"
default:
detail += ": no fallback configured"
}
// Level 0 again, so the sweep stops considering it. The round counter is
@@ -267,6 +274,7 @@ func escalationExhausted(ctx context.Context, tx *sql.Tx, policy *escalationPoli
// phone, not as whoever was paged first.
func pageLevel(ctx context.Context, tx *sql.Tx, cfg NotifyConfig, policy *escalationPolicy, incidentID int64, level escalationLevel) ([]string, error) {
var paged []string
var recipients []recipient
seen := map[int64]bool{}
for _, t := range level.targets {
@@ -286,24 +294,31 @@ func pageLevel(ctx context.Context, tx *sql.Tx, cfg NotifyConfig, policy *escala
}
seen[*userID] = true
var topic *string
var username string
if err := tx.QueryRowContext(ctx,
"SELECT ntfy_topic, username FROM users WHERE id = $1 AND disabled_at IS NULL",
*userID).Scan(&topic, &username); err != nil {
"SELECT username FROM users WHERE id = $1 AND disabled_at IS NULL",
*userID).Scan(&username); err != nil {
// A disabled or deleted account is not an error in the middle of an
// escalation: it is a target that cannot be woken, and the next
// level is the answer to that.
continue
}
if topic == nil || *topic == "" {
continue
}
if err := enqueueNotification(ctx, tx, incidentID, userID, *topic, notifyEscalated); err != nil {
rs, err := userRecipients(ctx, tx, cfg, *userID)
if err != nil {
return nil, err
}
if len(rs) == 0 {
continue
}
recipients = append(recipients, rs...)
paged = append(paged, username)
}
// One call for the whole level, so its rows share a created_at and are
// reminded and resolved as the one batch they are.
if err := enqueueNotifications(ctx, tx, incidentID, recipients, notifyEscalated); err != nil {
return nil, err
}
return paged, nil
}
@@ -332,7 +347,7 @@ func stopEscalation(ctx context.Context, q querier, incidentID int64) error {
}
// handleGetEscalation returns a team's ladder.
func handleGetEscalation(db *sql.DB) http.HandlerFunc {
func handleGetEscalation(db *sql.DB, providers *notify.Registry) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
teamID, ok := teamParam(w, r)
if !ok {
@@ -347,7 +362,7 @@ func handleGetEscalation(db *sql.DB) http.HandlerFunc {
serverError(w, r, err)
return
}
view, err := escalationStatus(r.Context(), db, teamID, policy)
view, err := escalationStatus(r.Context(), db, providers, teamID, policy)
if err != nil {
serverError(w, r, err)
return
@@ -395,10 +410,10 @@ type escalationLevelView struct {
}
type escalationView struct {
TeamID int64 `json:"team_id"`
RepeatCount int64 `json:"repeat_count"`
FallbackTopic string `json:"fallback_topic"`
Levels []escalationLevelView `json:"levels"`
TeamID int64 `json:"team_id"`
RepeatCount int64 `json:"repeat_count"`
Fallback notify.Target `json:"fallback"`
Levels []escalationLevelView `json:"levels"`
// LastEscalatedAt is when an incident of this team last moved up the ladder,
// or ran off the end of it, and LastEscalatedIncidentID which one. Absent
@@ -410,10 +425,10 @@ type escalationView struct {
// escalationStatus is a team's ladder together with what it would do right now
// and what it has been doing. The resolution follows pageLevel's rules, so the
// page cannot promise a page that the notifier would skip.
func escalationStatus(ctx context.Context, db *sql.DB, teamID int64, policy *escalationPolicy) (escalationView, error) {
func escalationStatus(ctx context.Context, db *sql.DB, providers *notify.Registry, teamID int64, policy *escalationPolicy) (escalationView, error) {
base := escalationResponse(policy, teamID)
out := escalationView{
TeamID: teamID, RepeatCount: base.RepeatCount, FallbackTopic: base.FallbackTopic,
TeamID: teamID, RepeatCount: base.RepeatCount, Fallback: base.Fallback,
Levels: []escalationLevelView{},
}
if !policy.configured() {
@@ -427,7 +442,7 @@ func escalationStatus(ctx context.Context, db *sql.DB, teamID int64, policy *esc
type account struct {
username string
topic bool
channel bool
disabled bool
}
accounts := map[int64]account{}
@@ -436,14 +451,17 @@ func escalationStatus(ctx context.Context, db *sql.DB, teamID int64, policy *esc
return a, nil
}
var a account
var topic *string
var disabledAt *int64
if err := db.QueryRowContext(ctx,
"SELECT username, ntfy_topic, disabled_at FROM users WHERE id = $1", id).
Scan(&a.username, &topic, &disabledAt); err != nil {
"SELECT username, disabled_at FROM users WHERE id = $1", id).
Scan(&a.username, &disabledAt); err != nil {
return a, err
}
a.topic = topic != nil && *topic != ""
reachable, err := hasReachableChannel(ctx, db, providers, id)
if err != nil {
return a, err
}
a.channel = reachable
a.disabled = disabledAt != nil
accounts[id] = a
return a, nil
@@ -497,8 +515,8 @@ func escalationStatus(ctx context.Context, db *sql.DB, teamID int64, policy *esc
view.Problem = "account not found"
case a.disabled:
view.Username, view.Problem = a.username, "account is disabled"
case !a.topic:
view.Username, view.Problem = a.username, "has no ntfy topic"
case !a.channel:
view.Username, view.Problem = a.username, "has no notification channel"
default:
view.Username, view.Reachable = a.username, true
}
@@ -551,10 +569,10 @@ type escalationTargetJSON struct {
}
type escalationJSON struct {
TeamID int64 `json:"team_id"`
RepeatCount int64 `json:"repeat_count"`
FallbackTopic string `json:"fallback_topic"`
Levels []escalationLevelJSON `json:"levels"`
TeamID int64 `json:"team_id"`
RepeatCount int64 `json:"repeat_count"`
Fallback notify.Target `json:"fallback"`
Levels []escalationLevelJSON `json:"levels"`
}
func escalationResponse(p *escalationPolicy, teamID int64) escalationJSON {
@@ -563,7 +581,7 @@ func escalationResponse(p *escalationPolicy, teamID int64) escalationJSON {
return out
}
out.RepeatCount = p.repeatCount
out.FallbackTopic = p.fallbackTopic
out.Fallback = p.fallback
for _, l := range p.levels {
level := escalationLevelJSON{
Position: l.position,
@@ -585,7 +603,7 @@ func escalationResponse(p *escalationPolicy, teamID int64) escalationJSON {
// whole ladder makes the order the client's to decide and the server's to
// store, and makes an edit atomic — there is no moment where level 2 exists
// twice.
func handleSetEscalation(db *sql.DB) http.HandlerFunc {
func handleSetEscalation(db *sql.DB, providers *notify.Registry) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
teamID, ok := teamParam(w, r)
if !ok {
@@ -604,6 +622,11 @@ func handleSetEscalation(db *sql.DB) http.HandlerFunc {
respond(w, http.StatusBadRequest, errResp("repeat_count must be between 0 and 10"))
return
}
fallback, err := validateTarget(providers, req.Fallback)
if err != nil {
respond(w, http.StatusBadRequest, errResp("fallback: "+err.Error()))
return
}
for i, l := range req.Levels {
for j, t := range l.Targets {
if t.Username == "" {
@@ -664,13 +687,14 @@ func handleSetEscalation(db *sql.DB) http.HandlerFunc {
defer tx.Rollback() //nolint:errcheck
if _, err := tx.ExecContext(r.Context(), `
INSERT INTO escalation_policies (team_id, repeat_count, fallback_topic, updated_at)
VALUES ($1, $2, $3, `+nowEpoch+`)
INSERT INTO escalation_policies (team_id, repeat_count, fallback_type, fallback_address, updated_at)
VALUES ($1, $2, $3, $4, `+nowEpoch+`)
ON CONFLICT (team_id) DO UPDATE SET
repeat_count = excluded.repeat_count,
fallback_topic = excluded.fallback_topic,
fallback_type = excluded.fallback_type,
fallback_address = excluded.fallback_address,
updated_at = excluded.updated_at`,
teamID, req.RepeatCount, req.FallbackTopic); err != nil {
teamID, req.RepeatCount, fallback.Type, fallback.Address); err != nil {
serverError(w, r, err)
return
}