package api import ( "context" "database/sql" "encoding/json" "fmt" "net/http" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "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, a.archived_at 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() where := []string{} args := []any{} if status := q.Get("status"); status != "" { where = append(where, "a.status = ?") args = append(args, status) } if name := q.Get("name"); name != "" { where = append(where, "a.name = ?") args = append(args, name) } if archived := q.Get("archived"); archived == "true" { where = append(where, "a.archived_at IS NOT NULL") } else { where = append(where, "a.archived_at IS NULL") } if from := q.Get("from"); from != "" { if t, err := time.Parse("2006-01-02", from); err == nil { 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 { where = append(where, "a.received_at < ?") args = append(args, t.UTC().AddDate(0, 0, 1).Unix()) } } limit := 50 if l := q.Get("limit"); l != "" { if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 500 { limit = n } } clause := "1=1" if len(where) > 0 { clause = strings.Join(where, " AND ") } args = append(args, limit) 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 } defer rows.Close() alerts := []models.Alert{} for rows.Next() { a, err := scanAlert(rows) if err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } alerts = append(alerts, a) } respond(w, http.StatusOK, alerts) } } func handleGetAlert(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 } a, err := fetchAlert(r.Context(), db, id) if err == sql.ErrNoRows { respond(w, http.StatusNotFound, errResp("alert not found")) return } if err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } respond(w, http.StatusOK, a) } } 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 } func scanAlert(s scanner) (models.Alert, error) { var a models.Alert var labelsJSON, annotationsJSON string var startsAtUnix, receivedAtUnix int64 var endsAtUnix, ackAtUnix, archivedAtUnix *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, &archivedAtUnix, ); err != nil { return a, err } 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() if endsAtUnix != nil { 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 } if archivedAtUnix != nil { t := time.Unix(*archivedAtUnix, 0).UTC() a.ArchivedAt = &t } return a, nil } func handleArchive(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 archived_at = unixepoch() 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 } a, _ := fetchAlert(r.Context(), db, id) respond(w, http.StatusOK, a) } } func handleUnarchive(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 archived_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) } }