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:
+71
-47
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user