Netpulse_SasS/server/internal/httpapi/alerts.go
byrsapty ec4b2cd54b
All checks were successful
CI / hygiene (push) Successful in 10s
CI / web (push) Successful in 1m18s
CI / server (push) Successful in 1m54s
CI / agent (push) Successful in 1m1s
Тиха година й драбина: вада окремо, вибір окремо
Асиметрія: аварія о 21:59 ескалювала всю ніч, о 22:01 не ескалювала
ніколи. Дві хвилини різниці — протилежні наслідки, причому гірший
(повна тиша) виглядав як тиша справна.

ВАДА. targets() повертав порожньо в тиху годину, а взведення читало це
як «немає куди слати». Взводять лише новий алерт, тож драбина не
з'являлась уже ніколи: тиха година вимикала механізм саме тоді, коли
перше сповіщення не спрацювало. Тепер targets() розрізняє «каналів
немає» і «канали є, просто зараз ніч».

ВИБІР. 0072 додає respect_quiet_hours на драбину:
  false (типово, як діяло) — драбина пробивається;
  true  — сходинка відкладається до ранку і НЕ витрачається.
Залежить від того, чи є в кабінету нічна зміна — це вирішує кабінет.
disaster пробивається за будь-якого значення, як і в targets().

Відлік драбини — від першого сповіщення, а не від started_at: інакше
для розглушеного алерту вона протухла б ще у вікні.

І сам прогін проти бази брехав: dbtest.sh котив схему готовим образом
(старі міграції), а тести брав із нового дерева. Тепер міграції з того
ж дерева. 64 міграції, усе зелене.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 18:08:27 +03:00

778 lines
31 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` передається як є, разом з «поля не було».
//
// На створенні відсутність поля лишається згодою ввімкнути — це
// розумно й так було завжди (правило, заведене вимкненим, не
// робить нічого). На ОНОВЛЕННІ домислювати вже не можна: сервер
// ставив `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"`
// Відсутнє поле = false: та поведінка, що діяла досі. Оновлення
// не має тихо роззброїти нічні дзвінки в тих, у кого вони
// працюють, лише тому, що старий клієнт про поле не знає.
RespectQuietHours bool `json:"respect_quiet_hours"`
}
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,
RespectQuietHours: req.RespectQuietHours,
})
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)
}