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` передається як є, разом з «поля не було». // // На створенні відсутність поля лишається згодою ввімкнути — це // розумно й так було завжди (правило, заведене вимкненим, не // робить нічого). На ОНОВЛЕННІ домислювати вже не можна: сервер // ставив `true` кожному, хто поля не надіслав, тож правка опису // вимкненого правила мовчки його вмикала — і людина дізнавалась // про це зі сповіщення, а не з форми. // // Обрано саме «не було = не чіпати», а не «не було = 400»: PUT // без `enabled` шле і власна форма, і будь-який чужий скрипт, // написаний за цим API, тож вимога поля зламала б їх усі одним // оновленням. Прапорець при цьому лишається керованим — його // явно шлють і форма (тепер), і PATCH-перемикач. Enabled: 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 } // Відмова від store — це відмова людині, а не збій сервера: // саме тут виходить «правило шле в канал, якого немає в // цьому кабінеті». Без цієї гілки перевірка спрацювала б, а // назовні пішла б «внутрішня помилка» — тобто причини ніхто // б не побачив. if errors.Is(err, store.ErrInvalid) { writeError(w, http.StatusBadRequest, "bad_channels", err.Error()) 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 errors.Is(err, store.ErrInvalid) { writeError(w, http.StatusBadRequest, "bad_channels", err.Error()) return } 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{} } // Драбини, що спираються на канал, — частина відповіді на питання // «що зламається, якщо його видалити». Те саме питання вже має // відповідь у переліку драбин (rule_count), і тут вона потрібна з // тієї ж причини. // // Невдача не валить перелік: без назв драбин сторінка лишається // робочою, а без каналів — ні. if refs, err := s.store.ChannelEscalationRefs(r.Context(), p.TenantID); err != nil { s.log.Error("драбини на каналах", "err", err) } else { for i := range channels { channels[i].Escalations = refs[channels[i].ID] } } 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 } // Відмова від store — це відмова людині, а не збій сервера: // саме тут повертається «сходинка N посилається на канал, якого // немає в цьому кабінеті». Без цієї гілки вона вийшла б назовні // як «внутрішня помилка», тобто перевірка спрацювала б, а // причини ніхто б не побачив. if errors.Is(err, store.ErrInvalid) { writeError(w, http.StatusBadRequest, "bad_steps", err.Error()) 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) }