Page the on-call person when an incident opens

An incident opened, got assigned to whoever held today's schedule entry,
and then sat there silently until somebody thought to look. The schedule
and the incident model were both built; nothing reached the person
holding the pager.

Notifications go out through ntfy, over plain HTTP with no new
dependencies. Delivery is an outbox rather than an inline call: the pool
is limited to a single connection, so a POST made while holding the
webhook's transaction would stall every other request behind it. The
webhook inserts a row and a notifier goroutine sends it within a tick,
retrying with exponential backoff.

Only opening an incident has to resolve a topic from scratch. Reminders
and all-clears reuse whatever that first notification chose, which keeps
configuration out of resolveIfSettled and gives the right rule for free:
you only hear that something resolved if you were told it started.

Each push carries an Acknowledge button, because the useful thing to do
at 3am is stop the pager without unlocking anything. It POSTs to an
unauthenticated /api/notify/ack/{token} — a notification body lives on
the ntfy server and in the device cache, so a real API key must never
appear in one. The token is minted per delivery, scoped to one incident
and one action, and expires in a day.

Reminders repeat until the incident stops being untouched. The stop
conditions are the states that already mean somebody has it: acknowledged,
snoozed, resolved, archived. Snooze is the mute button, so there is no
separate reminder cap.

Notifications sent to the fallback topic carry no Acknowledge button. The
topic is shared, and a button on it would let any subscriber acknowledge
as somebody else.
This commit is contained in:
Niklas Ye
2026-08-07 08:51:38 +02:00
parent dcb2a86f9a
commit bc285799d1
18 changed files with 1531 additions and 44 deletions
+54 -8
View File
@@ -48,7 +48,7 @@ func handleBootstrap(db *sql.DB) http.HandlerFunc {
}
userID, _ := res.LastInsertId()
raw, hash, err := newAPIKey()
raw, hash, err := randomToken()
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
@@ -70,7 +70,7 @@ func handleBootstrap(db *sql.DB) http.HandlerFunc {
func handleListUsers(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
rows, err := db.QueryContext(r.Context(),
"SELECT id, username, email, created_at FROM users ORDER BY id")
"SELECT id, username, email, created_at, ntfy_topic FROM users ORDER BY id")
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
@@ -81,7 +81,7 @@ func handleListUsers(db *sql.DB) http.HandlerFunc {
for rows.Next() {
var u models.User
var ts int64
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts); err != nil {
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
@@ -123,6 +123,50 @@ func handleCreateUser(db *sql.DB) http.HandlerFunc {
}
}
// handleSetNotifyTarget points a user's push notifications at an ntfy topic, or
// clears it with an empty string. The topic is a shared secret with the ntfy
// server — anyone who knows it can publish to it — so pick an unguessable one
// unless your ntfy enforces access control.
func handleSetNotifyTarget(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid user id"))
return
}
var req struct {
NtfyTopic string `json:"ntfy_topic"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
var topic *string
if t := strings.TrimSpace(req.NtfyTopic); t != "" {
topic = &t
}
res, err := db.ExecContext(r.Context(),
"UPDATE users SET ntfy_topic = ? WHERE id = ?", topic, id)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if n, _ := res.RowsAffected(); n == 0 {
respond(w, http.StatusNotFound, errResp("user not found"))
return
}
user, err := fetchUser(r.Context(), db, id)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, user)
}
}
func handleDeleteUser(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
@@ -170,7 +214,7 @@ func handleCreateAPIKey(db *sql.DB) http.HandlerFunc {
return
}
raw, hash, err := newAPIKey()
raw, hash, err := randomToken()
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
@@ -215,8 +259,9 @@ func handleDeleteAPIKey(db *sql.DB) http.HandlerFunc {
}
}
// newAPIKey generates a random 32-byte key encoded as hex, plus its SHA-256 hash for storage.
func newAPIKey() (raw, hash string, err error) {
// randomToken generates a random 32-byte secret encoded as hex, plus its SHA-256
// hash for storage. Used for API keys and for notification acknowledge tokens.
func randomToken() (raw, hash string, err error) {
b := make([]byte, 32)
if _, err = rand.Read(b); err != nil {
return
@@ -230,8 +275,9 @@ func newAPIKey() (raw, hash string, err error) {
func fetchUser(ctx context.Context, db *sql.DB, id int64) (models.User, error) {
var u models.User
var ts int64
err := db.QueryRowContext(ctx, "SELECT id, username, email, created_at FROM users WHERE id = ?", id).
Scan(&u.ID, &u.Username, &u.Email, &ts)
err := db.QueryRowContext(ctx,
"SELECT id, username, email, created_at, ntfy_topic FROM users WHERE id = ?", id).
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic)
if err != nil {
return u, err
}