Маршрут PUT /api/v1/channels/{id} стояв на handleCreateChannel з
першого дня. Обробник читав тіло й ЗАВЖДИ кликав CreateChannel: він
жодного разу не читав id зі шляху.
При цьому UpdateChannel у store написана повністю й ретельно
прокоментована — включно з рішенням «порожній секрет означає лишити
токен як є, бо розшифрувати збережений заради показу означало б
віддати його туди, звідки він не повернеться». У всьому дереві її не
кликав НІХТО.
Ззовні це виглядало так: правка каналу або заводила дубль, або — якщо
назву лишили — падала з «канал із такою назвою вже є». Тобто
відредагувати канал було неможливо взагалі, а повідомлення про помилку
вказувало не на ту причину.
Візерунок узято з правил у тому самому файлі: там гілка оновлення за
PathValue уже стояла й працювала.
722 lines
28 KiB
Go
722 lines
28 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/netpulse/netpulse/server/internal/store"
|
||
)
|
||
|
||
// Стеля ручного заглушення живе в шарі store: ту саму кнопку тепер
|
||
// натискають і з інтерфейсу, і з Telegram, а політика в них одна.
|
||
const maxMute = store.MaxMute
|
||
|
||
func (s *Server) handleListAlerts(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "alerts:read") {
|
||
return
|
||
}
|
||
|
||
f := store.AlertFilter{
|
||
MinSeverity: r.URL.Query().Get("min_severity"),
|
||
DeviceID: r.URL.Query().Get("device_id"),
|
||
Scope: p.Scope(),
|
||
}
|
||
if v := r.URL.Query().Get("state"); v != "" {
|
||
f.States = strings.Split(v, ",")
|
||
}
|
||
if v := r.URL.Query().Get("limit"); v != "" {
|
||
f.Limit, _ = strconv.Atoi(v)
|
||
}
|
||
|
||
alerts, err := s.store.ListAlerts(r.Context(), p.TenantID, f)
|
||
if err != nil {
|
||
s.log.Error("перелік алертів", "err", err)
|
||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||
return
|
||
}
|
||
if alerts == nil {
|
||
alerts = []store.Alert{}
|
||
}
|
||
|
||
counts, err := s.store.CountAlerts(r.Context(), p.TenantID, p.Scope())
|
||
if err != nil {
|
||
s.log.Error("лічильники алертів", "err", err)
|
||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||
return
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, map[string]any{"alerts": alerts, "counts": counts})
|
||
}
|
||
|
||
func (s *Server) handleAckAlert(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "alerts:ack") {
|
||
return
|
||
}
|
||
|
||
var req struct {
|
||
Comment string `json:"comment"`
|
||
}
|
||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||
|
||
a, err := s.store.AckAlert(r.Context(), p.TenantID, r.PathValue("id"), p.UserID, req.Comment)
|
||
if errors.Is(err, store.ErrAlertNotFound) {
|
||
writeError(w, http.StatusNotFound, "not_found", "алерт не знайдено або вже закритий")
|
||
return
|
||
}
|
||
if err != nil {
|
||
if isBadUUID(err) {
|
||
writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор")
|
||
return
|
||
}
|
||
s.log.Error("підтвердження алерту", "err", err)
|
||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, a)
|
||
}
|
||
|
||
func (s *Server) handleCloseAlert(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "alerts:ack") {
|
||
return
|
||
}
|
||
|
||
err := s.store.CloseAlert(r.Context(), p.TenantID, r.PathValue("id"))
|
||
if errors.Is(err, store.ErrAlertNotFound) {
|
||
writeError(w, http.StatusNotFound, "not_found", "алерт не знайдено або вже закритий")
|
||
return
|
||
}
|
||
if err != nil {
|
||
if isBadUUID(err) {
|
||
writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор")
|
||
return
|
||
}
|
||
s.log.Error("закриття алерту", "err", err)
|
||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
func (s *Server) handleMuteDevice(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "alerts:ack") {
|
||
return
|
||
}
|
||
|
||
var req struct {
|
||
DeviceID string `json:"device_id"`
|
||
Minutes int `json:"minutes"`
|
||
Reason string `json:"reason"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "некоректний JSON")
|
||
return
|
||
}
|
||
if req.DeviceID == "" {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "потрібен device_id")
|
||
return
|
||
}
|
||
if req.Minutes <= 0 {
|
||
req.Minutes = 60
|
||
}
|
||
|
||
if !p.Scope().CanWrite(req.DeviceID) {
|
||
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста")
|
||
return
|
||
}
|
||
|
||
until := time.Now().Add(time.Duration(req.Minutes) * time.Minute)
|
||
if max := time.Now().Add(maxMute); until.After(max) {
|
||
until = max
|
||
}
|
||
|
||
if err := s.store.MuteDevice(r.Context(), p.TenantID, req.DeviceID, p.UserID, req.Reason, until); err != nil {
|
||
if isBadUUID(err) {
|
||
writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор пристрою")
|
||
return
|
||
}
|
||
s.log.Error("заглушення пристрою", "err", err)
|
||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"until": until})
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Правила
|
||
// ---------------------------------------------------------------------
|
||
|
||
func (s *Server) handleListAlertRules(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "alerts:read") {
|
||
return
|
||
}
|
||
rules, err := s.store.ListRules(r.Context(), p.TenantID)
|
||
if err != nil {
|
||
s.log.Error("перелік правил", "err", err)
|
||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||
return
|
||
}
|
||
if rules == nil {
|
||
rules = []map[string]any{}
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"rules": rules})
|
||
}
|
||
|
||
// validSeverities дублює ENUM alr.severity навмисно: помилка в назві
|
||
// має давати 400 зі зрозумілим текстом, а не 500 від невдалого
|
||
// приведення типу в глибині запиту.
|
||
var validSeverities = map[string]bool{
|
||
"info": true, "warning": true, "average": true, "high": true, "disaster": true,
|
||
}
|
||
|
||
func (s *Server) handleCreateAlertRule(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "alerts:write") {
|
||
return
|
||
}
|
||
|
||
var req struct {
|
||
Name string `json:"name"`
|
||
Description string `json:"description"`
|
||
Source string `json:"source"`
|
||
Severity string `json:"severity"`
|
||
Selector json.RawMessage `json:"selector"`
|
||
Condition json.RawMessage `json:"condition"`
|
||
ForSeconds int `json:"for_seconds"`
|
||
DependsOnTopology *bool `json:"depends_on_topology"`
|
||
Enabled *bool `json:"enabled"`
|
||
// Куди слати. Порожньо — за загальними маршрутами тенанта.
|
||
ChannelIDs []string `json:"channel_ids"`
|
||
NotifySchedule json.RawMessage `json:"notify_schedule"`
|
||
NotifyOnResolve *bool `json:"notify_on_resolve"`
|
||
// Тільки для подієвих джерел.
|
||
AutoCloseSeconds *int `json:"auto_close_seconds"`
|
||
MinIntervalSeconds *int `json:"min_interval_seconds"`
|
||
// Драбина ескалації. Порожньо — без ескалації, і це типове
|
||
// значення: правило, яке нікого не будить понад перше
|
||
// сповіщення, поводиться рівно так, як поводилось до 0066.
|
||
EscalationPolicyID string `json:"escalation_policy_id"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "некоректний JSON")
|
||
return
|
||
}
|
||
|
||
if strings.TrimSpace(req.Name) == "" {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "потрібна назва правила")
|
||
return
|
||
}
|
||
// Джерело, якого движок не вміє, тепер не зберігається мовчки.
|
||
//
|
||
// Саме мовчання й було помилкою: правило з джерелом `trap` лягало в
|
||
// базу, показувалось увімкненим і не спрацьовувало ніколи. Людина
|
||
// читала нуль алертів як «подій не було» — і була впевнена в цьому
|
||
// рівно доти, доки щось не згоріло.
|
||
if reason := store.UnsupportedSourceReason(req.Source); reason != "" {
|
||
writeError(w, http.StatusBadRequest, "bad_source", reason)
|
||
return
|
||
}
|
||
if req.Severity == "" {
|
||
req.Severity = "warning"
|
||
}
|
||
if !validSeverities[req.Severity] {
|
||
writeError(w, http.StatusBadRequest, "bad_severity", "невідома серйозність")
|
||
return
|
||
}
|
||
if len(req.Condition) == 0 {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "потрібна умова")
|
||
return
|
||
}
|
||
// Умову подієвого правила перевіряємо тут, а не при спрацюванні:
|
||
// зламаний регулярний вираз, помічений через пів року мовчання, —
|
||
// це та сама порожня обіцянка, тільки в іншому місці.
|
||
if err := store.ValidateRuleCondition(req.Source, req.Condition); err != nil {
|
||
writeError(w, http.StatusBadRequest, "bad_condition", err.Error())
|
||
return
|
||
}
|
||
if req.ForSeconds <= 0 {
|
||
req.ForSeconds = 60
|
||
}
|
||
|
||
// Подієвому алерту нема від чого «зникнути»: подія сталась і не
|
||
// перестане. Тому в нього є строк — і за замовчуванням це доба,
|
||
// тобто «встиг побачити на наступній зміні». Нуль лишається
|
||
// дозволеним і означає «закриє людина».
|
||
autoClose, minInterval := 0, 0
|
||
if store.IsEventSource(req.Source) {
|
||
autoClose, minInterval = 86400, 60
|
||
if req.AutoCloseSeconds != nil {
|
||
autoClose = *req.AutoCloseSeconds
|
||
}
|
||
if req.MinIntervalSeconds != nil {
|
||
minInterval = *req.MinIntervalSeconds
|
||
}
|
||
if autoClose < 0 || autoClose > 30*86400 {
|
||
writeError(w, http.StatusBadRequest, "bad_request",
|
||
"строк життя подієвого алерту — від 0 (тільки руками) до 30 діб")
|
||
return
|
||
}
|
||
if minInterval < 0 || minInterval > 3600 {
|
||
writeError(w, http.StatusBadRequest, "bad_request",
|
||
"проміжок між подіями — від 0 до 3600 секунд")
|
||
return
|
||
}
|
||
}
|
||
|
||
// Драбина має належати цьому ж кабінету.
|
||
//
|
||
// Зовнішній ключ перевіряє лише те, що така політика існує десь у
|
||
// базі, — не те, що вона ваша: перевірка FK іде повз RLS. Наслідок
|
||
// був би не витоком (чужу драбину все одно не прочитати), а гіршим:
|
||
// правило виглядало б налаштованим на ескалацію й не ескалювало б
|
||
// ніколи. Тобто рівно та мовчазна обіцянка, яку 0058 вже одного разу
|
||
// прибрала з тригерів.
|
||
if id := strings.TrimSpace(req.EscalationPolicyID); id != "" {
|
||
ps, err := s.store.ListEscalationPolicies(r.Context(), p.TenantID)
|
||
if err != nil {
|
||
s.log.Error("перевірка політики ескалації", "err", err)
|
||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||
return
|
||
}
|
||
found := false
|
||
for _, x := range ps {
|
||
if x.ID == id {
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
writeError(w, http.StatusBadRequest, "bad_escalation",
|
||
"драбину ескалації не знайдено — оберіть її зі списку на сторінці «Ескалації»")
|
||
return
|
||
}
|
||
}
|
||
|
||
in := store.RuleInput{
|
||
Name: req.Name,
|
||
Description: req.Description,
|
||
Source: req.Source,
|
||
Severity: req.Severity,
|
||
Selector: jsonOr(req.Selector, "{}"),
|
||
Condition: string(req.Condition),
|
||
ForSeconds: req.ForSeconds,
|
||
DependsOnTopology: req.DependsOnTopology == nil || *req.DependsOnTopology,
|
||
Enabled: req.Enabled == nil || *req.Enabled,
|
||
ChannelIDs: req.ChannelIDs,
|
||
NotifySchedule: string(req.NotifySchedule),
|
||
NotifyOnResolve: req.NotifyOnResolve == nil || *req.NotifyOnResolve,
|
||
|
||
AutoCloseSeconds: autoClose,
|
||
MinIntervalSeconds: minInterval,
|
||
|
||
EscalationPolicyID: strings.TrimSpace(req.EscalationPolicyID),
|
||
}
|
||
if in.ChannelIDs == nil {
|
||
in.ChannelIDs = []string{}
|
||
}
|
||
if in.NotifySchedule == "null" {
|
||
in.NotifySchedule = ""
|
||
}
|
||
|
||
// Оновлення наявного правила приходить сюди ж: форма однакова, і
|
||
// розділяти її на дві гілки на клієнті означало б розійтися в
|
||
// перевірках рівно там, де вони найпотрібніші.
|
||
if id := r.PathValue("id"); id != "" {
|
||
if err := s.store.UpdateRule(r.Context(), p.TenantID, id, in); err != nil {
|
||
if errors.Is(err, store.ErrNotFound) {
|
||
writeError(w, http.StatusNotFound, "not_found", "правило не знайдено")
|
||
return
|
||
}
|
||
if isUniqueViolation(err) {
|
||
writeError(w, http.StatusConflict, "duplicate", "правило з такою назвою вже є")
|
||
return
|
||
}
|
||
s.log.Error("оновлення правила", "err", err)
|
||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"id": id})
|
||
return
|
||
}
|
||
|
||
id, err := s.store.CreateRule(r.Context(), p.TenantID, p.UserID, in)
|
||
if err != nil {
|
||
if isUniqueViolation(err) {
|
||
writeError(w, http.StatusConflict, "duplicate", "правило з такою назвою вже є")
|
||
return
|
||
}
|
||
s.log.Error("створення правила", "err", err)
|
||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
|
||
}
|
||
|
||
func (s *Server) handlePatchAlertRule(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "alerts:write") {
|
||
return
|
||
}
|
||
var req struct {
|
||
Enabled *bool `json:"enabled"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Enabled == nil {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "очікується поле enabled")
|
||
return
|
||
}
|
||
|
||
err := s.store.SetRuleEnabled(r.Context(), p.TenantID, r.PathValue("id"), *req.Enabled)
|
||
if errors.Is(err, store.ErrAlertNotFound) {
|
||
writeError(w, http.StatusNotFound, "not_found", "правило не знайдено")
|
||
return
|
||
}
|
||
if err != nil {
|
||
if isBadUUID(err) {
|
||
writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор")
|
||
return
|
||
}
|
||
s.log.Error("зміна правила", "err", err)
|
||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
func (s *Server) handleDeleteAlertRule(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "alerts:write") {
|
||
return
|
||
}
|
||
err := s.store.DeleteRule(r.Context(), p.TenantID, r.PathValue("id"))
|
||
if errors.Is(err, store.ErrAlertNotFound) {
|
||
writeError(w, http.StatusNotFound, "not_found", "правило не знайдено")
|
||
return
|
||
}
|
||
if err != nil {
|
||
if isBadUUID(err) {
|
||
writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор")
|
||
return
|
||
}
|
||
s.log.Error("видалення правила", "err", err)
|
||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Канали
|
||
// ---------------------------------------------------------------------
|
||
|
||
func (s *Server) handleListChannels(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "alerts:read") {
|
||
return
|
||
}
|
||
// Секрети не розшифровуються: у переліку вони не потрібні, а
|
||
// найнадійніший спосіб не віддати токен назовні — не діставати його
|
||
// з бази взагалі.
|
||
channels, err := s.store.LoadChannels(r.Context(), p.TenantID, nil)
|
||
if err != nil {
|
||
s.log.Error("перелік каналів", "err", err)
|
||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||
return
|
||
}
|
||
if channels == nil {
|
||
channels = []store.Channel{}
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"channels": channels})
|
||
}
|
||
|
||
var validChannelKinds = map[string]bool{
|
||
"email": true, "telegram": true, "webhook": true, "slack": true,
|
||
"discord": true, "mattermost": true,
|
||
}
|
||
|
||
func (s *Server) handleCreateChannel(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "alerts:write") {
|
||
return
|
||
}
|
||
if s.keyring == nil {
|
||
writeError(w, http.StatusServiceUnavailable, "no_keyring",
|
||
"сервер запущено без ключа шифрування")
|
||
return
|
||
}
|
||
|
||
var req struct {
|
||
Kind string `json:"kind"`
|
||
Name string `json:"name"`
|
||
Config json.RawMessage `json:"config"`
|
||
Template string `json:"template"`
|
||
MinSeverity string `json:"min_severity"`
|
||
Secret string `json:"secret"`
|
||
Enabled *bool `json:"enabled"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "некоректний JSON")
|
||
return
|
||
}
|
||
if strings.TrimSpace(req.Name) == "" {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "потрібна назва каналу")
|
||
return
|
||
}
|
||
if !validChannelKinds[req.Kind] {
|
||
writeError(w, http.StatusBadRequest, "bad_kind", "невідомий вид каналу")
|
||
return
|
||
}
|
||
if req.MinSeverity == "" {
|
||
req.MinSeverity = "warning"
|
||
}
|
||
if !validSeverities[req.MinSeverity] {
|
||
writeError(w, http.StatusBadRequest, "bad_severity", "невідома серйозність")
|
||
return
|
||
}
|
||
|
||
in := store.ChannelInput{
|
||
Kind: req.Kind,
|
||
Name: req.Name,
|
||
Config: jsonOr(req.Config, "{}"),
|
||
Template: req.Template,
|
||
MinSeverity: req.MinSeverity,
|
||
Secret: req.Secret,
|
||
Enabled: req.Enabled == nil || *req.Enabled,
|
||
}
|
||
|
||
// Оновлення наявного каналу приходить сюди ж — так само, як у
|
||
// правил вище: форма однакова, і розділяти її на дві гілки на
|
||
// клієнті означало б розійтися в перевірках.
|
||
//
|
||
// Цієї гілки НЕ БУЛО ЗОВСІМ. Маршрут PUT /channels/{id} стояв на
|
||
// цьому обробнику з першого дня, обробник читав тіло й завжди кликав
|
||
// CreateChannel, а написана й ретельно прокоментована UpdateChannel
|
||
// не мала жодного виклику в усьому дереві.
|
||
//
|
||
// Ззовні це виглядало так: правка каналу або заводила ДУБЛЬ, або —
|
||
// якщо назву лишили — падала з «канал із такою назвою вже є». Тобто
|
||
// відредагувати канал було неможливо взагалі, і повідомлення про
|
||
// помилку вказувало не на ту причину.
|
||
if id := r.PathValue("id"); id != "" {
|
||
if err := s.store.UpdateChannel(r.Context(), p.TenantID, id, in, s.keyring); err != nil {
|
||
if errors.Is(err, store.ErrNotFound) {
|
||
writeError(w, http.StatusNotFound, "not_found", "канал не знайдено")
|
||
return
|
||
}
|
||
if isUniqueViolation(err) {
|
||
writeError(w, http.StatusConflict, "duplicate", "канал із такою назвою вже є")
|
||
return
|
||
}
|
||
if isBadUUID(err) {
|
||
writeError(w, http.StatusNotFound, "not_found", "канал не знайдено")
|
||
return
|
||
}
|
||
s.log.Error("оновлення каналу", "err", err)
|
||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"id": id})
|
||
return
|
||
}
|
||
|
||
id, err := s.store.CreateChannel(r.Context(), p.TenantID, in, s.keyring)
|
||
if err != nil {
|
||
if isUniqueViolation(err) {
|
||
writeError(w, http.StatusConflict, "duplicate", "канал із такою назвою вже є")
|
||
return
|
||
}
|
||
s.log.Error("створення каналу", "err", err)
|
||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
|
||
}
|
||
|
||
func (s *Server) handleDeleteChannel(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "alerts:write") {
|
||
return
|
||
}
|
||
err := s.store.DeleteChannel(r.Context(), p.TenantID, r.PathValue("id"))
|
||
if errors.Is(err, store.ErrAlertNotFound) {
|
||
writeError(w, http.StatusNotFound, "not_found", "канал не знайдено")
|
||
return
|
||
}
|
||
if err != nil {
|
||
if isBadUUID(err) {
|
||
writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор")
|
||
return
|
||
}
|
||
s.log.Error("видалення каналу", "err", err)
|
||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// handleTestChannel надсилає пробне повідомлення.
|
||
func (s *Server) handleTestChannel(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "alerts:write") {
|
||
return
|
||
}
|
||
if s.notifier == nil || s.keyring == nil {
|
||
writeError(w, http.StatusServiceUnavailable, "no_notifier",
|
||
"доставку сповіщень не налаштовано в цьому процесі")
|
||
return
|
||
}
|
||
|
||
channels, err := s.store.LoadChannels(r.Context(), p.TenantID, s.keyring)
|
||
if err != nil {
|
||
s.log.Error("читання каналів", "err", err)
|
||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||
return
|
||
}
|
||
|
||
id := r.PathValue("id")
|
||
for _, c := range channels {
|
||
if c.ID != id {
|
||
continue
|
||
}
|
||
if err := s.notifier.TestChannel(r.Context(), p.TenantID, c); err != nil {
|
||
// Помилка каналу — це відповідь на питання «чи працює», а не
|
||
// збій сервера: 200 з описом кращий за 500 без нього.
|
||
writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": err.Error()})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||
return
|
||
}
|
||
writeError(w, http.StatusNotFound, "not_found", "канал не знайдено")
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
|
||
func jsonOr(raw json.RawMessage, def string) string {
|
||
if len(raw) == 0 {
|
||
return def
|
||
}
|
||
return string(raw)
|
||
}
|
||
|
||
func isBadUUID(err error) bool {
|
||
return err != nil && strings.Contains(err.Error(), "invalid input syntax for type uuid")
|
||
}
|
||
|
||
func isUniqueViolation(err error) bool {
|
||
return err != nil && strings.Contains(err.Error(), "SQLSTATE 23505")
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Політики ескалації
|
||
// ---------------------------------------------------------------------
|
||
|
||
func (s *Server) handleListEscalationPolicies(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "alerts:read") {
|
||
return
|
||
}
|
||
ps, err := s.store.ListEscalationPolicies(r.Context(), p.TenantID)
|
||
if err != nil {
|
||
s.log.Error("перелік політик ескалації", "err", err)
|
||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||
return
|
||
}
|
||
if ps == nil {
|
||
ps = []store.EscalationPolicy{}
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"policies": ps})
|
||
}
|
||
|
||
// handleSaveEscalationPolicy приймає і створення, і зміну.
|
||
//
|
||
// Один обробник на обидва, як і в правил: форма показує драбину цілком,
|
||
// і розводити її на дві гілки означало б розійтися в перевірках рівно
|
||
// там, де вони найдорожчі — у тому, кого й коли будити вночі.
|
||
func (s *Server) handleSaveEscalationPolicy(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "alerts:write") {
|
||
return
|
||
}
|
||
|
||
var req struct {
|
||
Name string `json:"name"`
|
||
Description string `json:"description"`
|
||
Steps []store.EscalationStep `json:"steps"`
|
||
RepeatAfterMin int `json:"repeat_after_min"`
|
||
MaxRepeats int `json:"max_repeats"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "некоректний JSON")
|
||
return
|
||
}
|
||
if strings.TrimSpace(req.Name) == "" {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "потрібна назва політики")
|
||
return
|
||
}
|
||
// Перевірка драбини живе в store, а не тут: ту саму відмову має
|
||
// давати і збереження з форми, і будь-який інший шлях запису.
|
||
if err := store.ValidateEscalationSteps(req.Steps); err != nil {
|
||
writeError(w, http.StatusBadRequest, "bad_steps", err.Error())
|
||
return
|
||
}
|
||
// Повтор без стелі — це нескінченний дзвінок, тому обидва числа
|
||
// перевіряються разом: «повторювати» без «скільки разів» не
|
||
// зберігається взагалі.
|
||
if req.RepeatAfterMin < 0 || req.RepeatAfterMin > 1440 {
|
||
writeError(w, http.StatusBadRequest, "bad_request",
|
||
"повтор драбини — від 1 до 1440 хвилин (0 — не повторювати)")
|
||
return
|
||
}
|
||
if req.MaxRepeats < 0 || req.MaxRepeats > store.MaxEscalationRepeats {
|
||
writeError(w, http.StatusBadRequest, "bad_request",
|
||
"повторів не більше "+strconv.Itoa(store.MaxEscalationRepeats))
|
||
return
|
||
}
|
||
if req.RepeatAfterMin > 0 && req.MaxRepeats == 0 {
|
||
writeError(w, http.StatusBadRequest, "bad_request",
|
||
"вказано проміжок повтору, але нуль повторів — драбина не повториться жодного разу")
|
||
return
|
||
}
|
||
|
||
id, err := s.store.SaveEscalationPolicy(r.Context(), p.TenantID, r.PathValue("id"),
|
||
store.EscalationPolicy{
|
||
Name: strings.TrimSpace(req.Name),
|
||
Description: req.Description,
|
||
Steps: req.Steps,
|
||
RepeatAfterMin: req.RepeatAfterMin,
|
||
MaxRepeats: req.MaxRepeats,
|
||
})
|
||
if err != nil {
|
||
if errors.Is(err, store.ErrNotFound) {
|
||
writeError(w, http.StatusNotFound, "not_found", "політику не знайдено")
|
||
return
|
||
}
|
||
if isUniqueViolation(err) {
|
||
writeError(w, http.StatusConflict, "duplicate", "політика з такою назвою вже є")
|
||
return
|
||
}
|
||
if isBadUUID(err) {
|
||
writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор")
|
||
return
|
||
}
|
||
s.log.Error("збереження політики ескалації", "err", err)
|
||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"id": id})
|
||
}
|
||
|
||
func (s *Server) handleDeleteEscalationPolicy(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "alerts:write") {
|
||
return
|
||
}
|
||
err := s.store.DeleteEscalationPolicy(r.Context(), p.TenantID, r.PathValue("id"))
|
||
if errors.Is(err, store.ErrNotFound) {
|
||
writeError(w, http.StatusNotFound, "not_found", "політику не знайдено")
|
||
return
|
||
}
|
||
if err != nil {
|
||
if isBadUUID(err) {
|
||
writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор")
|
||
return
|
||
}
|
||
s.log.Error("видалення політики ескалації", "err", err)
|
||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|