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:
+82
-18
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user