Stage 4: alert acknowledgement and comments

- Migration 004: acknowledged_by/acknowledged_at columns on alerts,
  alert_comments table (FK cascade on delete)
- POST /api/alerts/{id}/acknowledge — stamps authed user + timestamp,
  returns updated alert with acknowledged_by username
- DELETE /api/alerts/{id}/acknowledge — clears ack (204)
- GET /api/alerts/{id}/comments — list in chronological order
- POST /api/alerts/{id}/comments — add comment (returns 201)
- DELETE /api/alerts/{id}/comments/{commentID} — own comments only (204)
- All alert queries now LEFT JOIN users for ack username
This commit is contained in:
Niklas Ye
2026-05-20 21:57:11 +02:00
parent 9b4ca1482f
commit c3348a410a
6 changed files with 248 additions and 19 deletions
+82 -18
View File
@@ -1,6 +1,7 @@
package api package api
import ( import (
"context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -13,6 +14,16 @@ import (
"github.com/yeniklas/terdut-server/internal/models" "github.com/yeniklas/terdut-server/internal/models"
) )
// alertSelectFrom is the shared SELECT … FROM … clause used by all alert queries.
// It LEFT JOINs users so acknowledged_by username is always available.
const alertSelectFrom = `
SELECT a.id, a.fingerprint, a.name, a.status,
a.labels, a.annotations,
a.starts_at, a.ends_at, a.generator_url, a.received_at,
a.acknowledged_by, a.acknowledged_at, u.username
FROM alerts a
LEFT JOIN users u ON u.id = a.acknowledged_by`
func handleListAlerts(db *sql.DB) http.HandlerFunc { func handleListAlerts(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query() q := r.URL.Query()
@@ -21,23 +32,22 @@ func handleListAlerts(db *sql.DB) http.HandlerFunc {
args := []any{} args := []any{}
if status := q.Get("status"); status != "" { if status := q.Get("status"); status != "" {
where = append(where, "status = ?") where = append(where, "a.status = ?")
args = append(args, status) args = append(args, status)
} }
if name := q.Get("name"); name != "" { if name := q.Get("name"); name != "" {
where = append(where, "name = ?") where = append(where, "a.name = ?")
args = append(args, name) args = append(args, name)
} }
if from := q.Get("from"); from != "" { if from := q.Get("from"); from != "" {
if t, err := time.Parse("2006-01-02", from); err == nil { if t, err := time.Parse("2006-01-02", from); err == nil {
where = append(where, "received_at >= ?") where = append(where, "a.received_at >= ?")
args = append(args, t.UTC().Unix()) args = append(args, t.UTC().Unix())
} }
} }
if to := q.Get("to"); to != "" { if to := q.Get("to"); to != "" {
if t, err := time.Parse("2006-01-02", to); err == nil { if t, err := time.Parse("2006-01-02", to); err == nil {
// include the full to-day where = append(where, "a.received_at < ?")
where = append(where, "received_at < ?")
args = append(args, t.UTC().AddDate(0, 0, 1).Unix()) args = append(args, t.UTC().AddDate(0, 0, 1).Unix())
} }
} }
@@ -55,13 +65,9 @@ func handleListAlerts(db *sql.DB) http.HandlerFunc {
} }
args = append(args, limit) args = append(args, limit)
rows, err := db.QueryContext(r.Context(), fmt.Sprintf(` rows, err := db.QueryContext(r.Context(),
SELECT id, fingerprint, name, status, labels, annotations, fmt.Sprintf("%s WHERE %s ORDER BY a.received_at DESC LIMIT ?", alertSelectFrom, clause),
starts_at, ends_at, generator_url, received_at args...)
FROM alerts
WHERE %s
ORDER BY received_at DESC
LIMIT ?`, clause), args...)
if err != nil { if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return return
@@ -88,11 +94,7 @@ func handleGetAlert(db *sql.DB) http.HandlerFunc {
respond(w, http.StatusBadRequest, errResp("invalid alert id")) respond(w, http.StatusBadRequest, errResp("invalid alert id"))
return return
} }
row := db.QueryRowContext(r.Context(), ` a, err := fetchAlert(r.Context(), db, id)
SELECT id, fingerprint, name, status, labels, annotations,
starts_at, ends_at, generator_url, received_at
FROM alerts WHERE id = ?`, id)
a, err := scanAlert(row)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
respond(w, http.StatusNotFound, errResp("alert not found")) respond(w, http.StatusNotFound, errResp("alert not found"))
return return
@@ -105,6 +107,59 @@ func handleGetAlert(db *sql.DB) http.HandlerFunc {
} }
} }
func handleAcknowledge(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 alert id"))
return
}
user, _ := userFromContext(r.Context())
res, err := db.ExecContext(r.Context(),
"UPDATE alerts SET acknowledged_by = ?, acknowledged_at = ? WHERE id = ?",
user.ID, time.Now().Unix(), id)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if n, _ := res.RowsAffected(); n == 0 {
respond(w, http.StatusNotFound, errResp("alert not found"))
return
}
a, _ := fetchAlert(r.Context(), db, id)
respond(w, http.StatusOK, a)
}
}
func handleUnacknowledge(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 alert id"))
return
}
res, err := db.ExecContext(r.Context(),
"UPDATE alerts SET acknowledged_by = NULL, acknowledged_at = NULL WHERE id = ?", id)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if n, _ := res.RowsAffected(); n == 0 {
respond(w, http.StatusNotFound, errResp("alert not found"))
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// fetchAlert loads a single alert by ID using the shared JOIN query.
func fetchAlert(ctx context.Context, db *sql.DB, id int64) (models.Alert, error) {
return scanAlert(db.QueryRowContext(ctx, alertSelectFrom+" WHERE a.id = ?", id))
}
// scanner is satisfied by both *sql.Row and *sql.Rows. // scanner is satisfied by both *sql.Row and *sql.Rows.
type scanner interface { type scanner interface {
Scan(dest ...any) error Scan(dest ...any) error
@@ -114,13 +169,16 @@ func scanAlert(s scanner) (models.Alert, error) {
var a models.Alert var a models.Alert
var labelsJSON, annotationsJSON string var labelsJSON, annotationsJSON string
var startsAtUnix, receivedAtUnix int64 var startsAtUnix, receivedAtUnix int64
var endsAtUnix *int64 var endsAtUnix, ackAtUnix *int64
var ackByID *int64
var ackByUser *string
if err := s.Scan( if err := s.Scan(
&a.ID, &a.Fingerprint, &a.Name, &a.Status, &a.ID, &a.Fingerprint, &a.Name, &a.Status,
&labelsJSON, &annotationsJSON, &labelsJSON, &annotationsJSON,
&startsAtUnix, &endsAtUnix, &startsAtUnix, &endsAtUnix,
&a.GeneratorURL, &receivedAtUnix, &a.GeneratorURL, &receivedAtUnix,
&ackByID, &ackAtUnix, &ackByUser,
); err != nil { ); err != nil {
return a, err return a, err
} }
@@ -133,5 +191,11 @@ func scanAlert(s scanner) (models.Alert, error) {
t := time.Unix(*endsAtUnix, 0).UTC() t := time.Unix(*endsAtUnix, 0).UTC()
a.EndsAt = &t a.EndsAt = &t
} }
if ackByID != nil {
t := time.Unix(*ackAtUnix, 0).UTC()
a.AcknowledgedByID = ackByID
a.AcknowledgedByUser = ackByUser
a.AcknowledgedAt = &t
}
return a, nil return a, nil
} }
+131
View File
@@ -0,0 +1,131 @@
package api
import (
"database/sql"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/yeniklas/terdut-server/internal/models"
)
func handleListComments(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
alertID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid alert id"))
return
}
// Verify the alert exists.
var exists int
if err := db.QueryRowContext(r.Context(), "SELECT 1 FROM alerts WHERE id = ?", alertID).Scan(&exists); err != nil {
respond(w, http.StatusNotFound, errResp("alert not found"))
return
}
rows, err := db.QueryContext(r.Context(), `
SELECT c.id, c.alert_id, c.user_id, u.username, c.content, c.created_at
FROM alert_comments c
JOIN users u ON u.id = c.user_id
WHERE c.alert_id = ?
ORDER BY c.created_at ASC`, alertID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
defer rows.Close()
comments := []models.Comment{}
for rows.Next() {
var c models.Comment
var ts int64
if err := rows.Scan(&c.ID, &c.AlertID, &c.UserID, &c.Username, &c.Content, &ts); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
c.CreatedAt = time.Unix(ts, 0).UTC()
comments = append(comments, c)
}
respond(w, http.StatusOK, comments)
}
}
func handleCreateComment(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
alertID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid alert id"))
return
}
var req struct {
Content string `json:"content"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
if req.Content == "" {
respond(w, http.StatusBadRequest, errResp("content is required"))
return
}
// Verify the alert exists.
var exists int
if err := db.QueryRowContext(r.Context(), "SELECT 1 FROM alerts WHERE id = ?", alertID).Scan(&exists); err != nil {
respond(w, http.StatusNotFound, errResp("alert not found"))
return
}
user, _ := userFromContext(r.Context())
res, err := db.ExecContext(r.Context(),
"INSERT INTO alert_comments (alert_id, user_id, content) VALUES (?, ?, ?)",
alertID, user.ID, req.Content)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
commentID, _ := res.LastInsertId()
comment := models.Comment{
ID: commentID,
AlertID: alertID,
UserID: user.ID,
Username: user.Username,
Content: req.Content,
CreatedAt: time.Now().UTC(),
}
respond(w, http.StatusCreated, comment)
}
}
func handleDeleteComment(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
alertID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid alert id"))
return
}
commentID, err := strconv.ParseInt(chi.URLParam(r, "commentID"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid comment id"))
return
}
user, _ := userFromContext(r.Context())
res, err := db.ExecContext(r.Context(),
"DELETE FROM alert_comments WHERE id = ? AND alert_id = ? AND user_id = ?",
commentID, alertID, user.ID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if n, _ := res.RowsAffected(); n == 0 {
respond(w, http.StatusNotFound, errResp("comment not found"))
return
}
w.WriteHeader(http.StatusNoContent)
}
}
+5
View File
@@ -33,6 +33,11 @@ func NewRouter(db *sql.DB) http.Handler {
r.Get("/api/alerts", handleListAlerts(db)) r.Get("/api/alerts", handleListAlerts(db))
r.Get("/api/alerts/{id}", handleGetAlert(db)) r.Get("/api/alerts/{id}", handleGetAlert(db))
r.Post("/api/alerts/{id}/acknowledge", handleAcknowledge(db))
r.Delete("/api/alerts/{id}/acknowledge", handleUnacknowledge(db))
r.Get("/api/alerts/{id}/comments", handleListComments(db))
r.Post("/api/alerts/{id}/comments", handleCreateComment(db))
r.Delete("/api/alerts/{id}/comments/{commentID}", handleDeleteComment(db))
}) })
return r return r
@@ -0,0 +1,12 @@
ALTER TABLE alerts ADD COLUMN acknowledged_by INTEGER REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE alerts ADD COLUMN acknowledged_at INTEGER;
CREATE TABLE alert_comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
alert_id INTEGER NOT NULL REFERENCES alerts(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
CREATE INDEX alert_comments_alert_id_idx ON alert_comments(alert_id);
+5
View File
@@ -13,4 +13,9 @@ type Alert struct {
EndsAt *time.Time `json:"ends_at,omitempty"` EndsAt *time.Time `json:"ends_at,omitempty"`
GeneratorURL string `json:"generator_url"` GeneratorURL string `json:"generator_url"`
ReceivedAt time.Time `json:"received_at"` ReceivedAt time.Time `json:"received_at"`
// Populated when the alert has been acknowledged.
AcknowledgedByID *int64 `json:"acknowledged_by_id,omitempty"`
AcknowledgedByUser *string `json:"acknowledged_by,omitempty"`
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
} }
+12
View File
@@ -0,0 +1,12 @@
package models
import "time"
type Comment struct {
ID int64 `json:"id"`
AlertID int64 `json:"alert_id"`
UserID int64 `json:"user_id"`
Username string `json:"username"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
}