From c3348a410a25c700eafc5eac61cae67b2de336e5 Mon Sep 17 00:00:00 2001 From: Niklas Ye Date: Wed, 20 May 2026 21:57:11 +0200 Subject: [PATCH] Stage 4: alert acknowledgement and comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- internal/api/alerts.go | 102 +++++++++++--- internal/api/comments.go | 131 ++++++++++++++++++ internal/api/router.go | 5 + .../db/migrations/004_alert_ack_comments.sql | 12 ++ internal/models/alert.go | 5 + internal/models/comment.go | 12 ++ 6 files changed, 248 insertions(+), 19 deletions(-) create mode 100644 internal/api/comments.go create mode 100644 internal/db/migrations/004_alert_ack_comments.sql create mode 100644 internal/models/comment.go diff --git a/internal/api/alerts.go b/internal/api/alerts.go index d3474c5..3b0f0fc 100644 --- a/internal/api/alerts.go +++ b/internal/api/alerts.go @@ -1,6 +1,7 @@ package api import ( + "context" "database/sql" "encoding/json" "fmt" @@ -13,6 +14,16 @@ import ( "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 { return func(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() @@ -21,23 +32,22 @@ func handleListAlerts(db *sql.DB) http.HandlerFunc { args := []any{} if status := q.Get("status"); status != "" { - where = append(where, "status = ?") + where = append(where, "a.status = ?") args = append(args, status) } if name := q.Get("name"); name != "" { - where = append(where, "name = ?") + where = append(where, "a.name = ?") args = append(args, name) } if from := q.Get("from"); from != "" { 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()) } } if to := q.Get("to"); to != "" { if t, err := time.Parse("2006-01-02", to); err == nil { - // include the full to-day - where = append(where, "received_at < ?") + where = append(where, "a.received_at < ?") 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) - rows, err := db.QueryContext(r.Context(), fmt.Sprintf(` - SELECT id, fingerprint, name, status, labels, annotations, - starts_at, ends_at, generator_url, received_at - FROM alerts - WHERE %s - ORDER BY received_at DESC - LIMIT ?`, clause), args...) + rows, err := db.QueryContext(r.Context(), + fmt.Sprintf("%s WHERE %s ORDER BY a.received_at DESC LIMIT ?", alertSelectFrom, clause), + args...) if err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return @@ -88,11 +94,7 @@ func handleGetAlert(db *sql.DB) http.HandlerFunc { respond(w, http.StatusBadRequest, errResp("invalid alert id")) return } - row := db.QueryRowContext(r.Context(), ` - SELECT id, fingerprint, name, status, labels, annotations, - starts_at, ends_at, generator_url, received_at - FROM alerts WHERE id = ?`, id) - a, err := scanAlert(row) + a, err := fetchAlert(r.Context(), db, id) if err == sql.ErrNoRows { respond(w, http.StatusNotFound, errResp("alert not found")) 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. type scanner interface { Scan(dest ...any) error @@ -114,18 +169,21 @@ func scanAlert(s scanner) (models.Alert, error) { var a models.Alert var labelsJSON, annotationsJSON string var startsAtUnix, receivedAtUnix int64 - var endsAtUnix *int64 + var endsAtUnix, ackAtUnix *int64 + var ackByID *int64 + var ackByUser *string if err := s.Scan( &a.ID, &a.Fingerprint, &a.Name, &a.Status, &labelsJSON, &annotationsJSON, &startsAtUnix, &endsAtUnix, &a.GeneratorURL, &receivedAtUnix, + &ackByID, &ackAtUnix, &ackByUser, ); err != nil { return a, err } - json.Unmarshal([]byte(labelsJSON), &a.Labels) //nolint:errcheck + json.Unmarshal([]byte(labelsJSON), &a.Labels) //nolint:errcheck json.Unmarshal([]byte(annotationsJSON), &a.Annotations) //nolint:errcheck a.StartsAt = time.Unix(startsAtUnix, 0).UTC() a.ReceivedAt = time.Unix(receivedAtUnix, 0).UTC() @@ -133,5 +191,11 @@ func scanAlert(s scanner) (models.Alert, error) { t := time.Unix(*endsAtUnix, 0).UTC() a.EndsAt = &t } + if ackByID != nil { + t := time.Unix(*ackAtUnix, 0).UTC() + a.AcknowledgedByID = ackByID + a.AcknowledgedByUser = ackByUser + a.AcknowledgedAt = &t + } return a, nil } diff --git a/internal/api/comments.go b/internal/api/comments.go new file mode 100644 index 0000000..e4f7cde --- /dev/null +++ b/internal/api/comments.go @@ -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) + } +} diff --git a/internal/api/router.go b/internal/api/router.go index 37ae62b..b3cc41b 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -33,6 +33,11 @@ func NewRouter(db *sql.DB) http.Handler { r.Get("/api/alerts", handleListAlerts(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 diff --git a/internal/db/migrations/004_alert_ack_comments.sql b/internal/db/migrations/004_alert_ack_comments.sql new file mode 100644 index 0000000..775bb64 --- /dev/null +++ b/internal/db/migrations/004_alert_ack_comments.sql @@ -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); diff --git a/internal/models/alert.go b/internal/models/alert.go index 177f585..050b80c 100644 --- a/internal/models/alert.go +++ b/internal/models/alert.go @@ -13,4 +13,9 @@ type Alert struct { EndsAt *time.Time `json:"ends_at,omitempty"` GeneratorURL string `json:"generator_url"` 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"` } diff --git a/internal/models/comment.go b/internal/models/comment.go new file mode 100644 index 0000000..b7e76fb --- /dev/null +++ b/internal/models/comment.go @@ -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"` +}