Netpulse_SasS/server/internal/httpapi/alerts.go
byrsapty e0fdfcde06
All checks were successful
CI / hygiene (push) Successful in 7s
CI / web (push) Successful in 1m9s
CI / server (push) Successful in 1m43s
CI / agent (push) Successful in 1m0s
Канали: редагування не працювало ніколи
Маршрут PUT /api/v1/channels/{id} стояв на handleCreateChannel з
першого дня. Обробник читав тіло й ЗАВЖДИ кликав CreateChannel: він
жодного разу не читав id зі шляху.

При цьому UpdateChannel у store написана повністю й ретельно
прокоментована — включно з рішенням «порожній секрет означає лишити
токен як є, бо розшифрувати збережений заради показу означало б
віддати його туди, звідки він не повернеться». У всьому дереві її не
кликав НІХТО.

Ззовні це виглядало так: правка каналу або заводила дубль, або — якщо
назву лишили — падала з «канал із такою назвою вже є». Тобто
відредагувати канал було неможливо взагалі, а повідомлення про помилку
вказувало не на ту причину.

Візерунок узято з правил у тому самому файлі: там гілка оновлення за
PathValue уже стояла й працювала.
2026-08-28 13:53:07 +03:00

722 lines
28 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}