package handlers import ( "context" "errors" "net/http" "slices" "strconv" "time" "unicode/utf8" "github.com/gin-gonic/gin" "building-mgmt/internal/models" "building-mgmt/internal/repository" ) // RecurringHandler — правила повторяющихся заявок (миграция 0014): // клиент заводит периодические работы в своих проектах, админ — в любых. // Фоновое создание заявок — забота планировщика в cmd/api, здесь — // CRUD правил, история срабатываний и ручной запуск правила. type RecurringHandler struct { repo *repository.RecurringRepo projectRepo *repository.ProjectRepo // notify — уведомление клиенту о заявке, созданной ручным запуском; // общий хелпер с планировщиком (notifyRecurring в cmd/api). // nil — уведомления отключены (в тестах), заявка создаётся молча. notify func(context.Context, repository.DueRun) } // NewRecurringHandler собирает хендлер: projectRepo нужен, чтобы при // создании правила проверить владение объектом и активность проекта — // теми же запросами, что и при создании заявки; notify — для ручного // «Запустить сейчас» (nil, если уведомления не нужны). func NewRecurringHandler(repo *repository.RecurringRepo, projectRepo *repository.ProjectRepo, notify func(context.Context, repository.DueRun), ) *RecurringHandler { return &RecurringHandler{repo: repo, projectRepo: projectRepo, notify: notify} } type createRecurringReq struct { ObjectID int64 `json:"object_id" binding:"required"` Floor string `json:"floor"` Category string `json:"category" binding:"required,oneof=electric plumbing climate elevator other"` Description string `json:"description" binding:"required,min=5"` Priority string `json:"priority" binding:"omitempty,oneof=low normal high"` // Расписание: «каждые PeriodDays дней/недель от AnchorAt» — // AnchorAt (RFC3339) — дата-время первого запуска, PeriodDays — // период в днях (1..3650; недели кратны 7). AnchorAt time.Time `json:"anchor_at" binding:"required"` PeriodDays int `json:"period_days" binding:"required,min=1,max=3650"` } // Значения категории и приоритета — те же, что в oneof-тегах // createRecurringReq (держать в согласии). Нужны для PATCH: там // валидация рукописная — gin-биндинг не отличает «поля нет» от // присланной пустой строки, а править можно только присланное. var ( recurringCategories = []string{"electric", "plumbing", "climate", "elevator", "other"} recurringPriorities = []string{"low", "normal", "high"} ) // Create — POST /recurring. Создать правило повторения. // // @Summary Создать правило повторяющейся заявки // @Description Правило — шаблон заявки (объект, категория, описание, приоритет) плюс расписание: anchor_at (дата-время первого запуска) и period_days — «каждые N дней/недель». Фоновый планировщик сам создаёт заявку, когда срок наступил, — но только если прошлая по правилу уже закрыта (без дублей). Клиент создаёт правило для своего проекта, админ — для любого. Обычно правило заводится чекбоксом в POST /tickets; этот эндпоинт — для управления без заявки. // @Tags recurring // @Accept json // @Produce json // @Security Bearer // @Param body body createRecurringReq true "Данные правила" // @Success 201 {object} models.RecurringRule // @Failure 400 {object} handlers.ErrorResponse "некорректное тело запроса" // @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" // @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin" // @Failure 404 {object} handlers.ErrorResponse "объект не найден (или чужой — неотличим)" // @Failure 409 {object} handlers.ErrorResponse "проект или объект выключен" // @Router /recurring [post] func (h *RecurringHandler) Create(c *gin.Context) { scopeID, ok := ownerScope(c) if !ok { return } var req createRecurringReq if err := c.ShouldBindJSON(&req); err != nil { bindingError(c, err) return } if req.Priority == "" { req.Priority = "normal" } // Тот же рубеж, что у заявки: объект обязан принадлежать клиенту // (чужой неотличим от несуществующего) и быть включённым. obj, err := h.projectRepo.OwnedObject(c.Request.Context(), req.ObjectID, scopeID) if err != nil { respondError(c, err) return } if !obj.ProjectActive { c.JSON(http.StatusConflict, gin.H{"error": "project is not active"}) return } if !obj.IsActive { c.JSON(http.StatusConflict, gin.H{"error": "object is not active"}) return } // Владелец будущих заявок: у клиента — он сам; админу нужно узнать // клиента проекта (объект такой связи в себе не несёт). clientID := scopeID if clientID == "" { p, err := h.projectRepo.OwnedProject(c.Request.Context(), obj.ProjectID, "") if err != nil { respondError(c, err) return } clientID = p.ClientID } rule := &models.RecurringRule{ ClientID: clientID, ProjectID: obj.ProjectID, ObjectID: obj.ID, Category: req.Category, Description: req.Description, Priority: req.Priority, Floor: req.Floor, AnchorAt: req.AnchorAt, PeriodDays: req.PeriodDays, IsActive: true, CreatedBy: c.GetString("user_id"), } if err := h.repo.CreateRule(c.Request.Context(), rule); err != nil { respondError(c, err) return } full, err := h.repo.GetRule(c.Request.Context(), rule.ID, "") if err != nil { c.JSON(http.StatusCreated, rule) return } c.JSON(http.StatusCreated, full) } // List — GET /recurring. Список правил, выборка режется по роли. // // @Summary Список правил повторения // @Description Клиент видит только свои правила, админ — все. Ответ — страница: items плюс total/limit/offset. // @Tags recurring // @Produce json // @Security Bearer // @Param project_id query int false "Сузить до одного проекта" // @Param limit query int false "Строк на странице, 1..100" default(20) // @Param offset query int false "Сдвиг от начала выборки" default(0) // @Param sort query string false "Поле сортировки" Enums(created_at, id, date) Default(created_at) // @Param order query string false "Направление сортировки" Enums(asc, desc) // @Success 200 {object} handlers.RecurringRuleListResponse // @Failure 400 {object} handlers.ErrorResponse "некорректные параметры страницы" // @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" // @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin" // @Router /recurring [get] func (h *RecurringHandler) List(c *gin.Context) { scopeID, ok := ownerScope(c) if !ok { return } page, ok := parsePage(c, repository.RecurringSorts) if !ok { return } var projectID int64 if v := c.Query("project_id"); v != "" { n, err := strconv.ParseInt(v, 10, 64) if err != nil || n <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project_id"}) return } projectID = n // Чужой проект неотличим от несуществующего — как везде. if _, err := h.projectRepo.OwnedProject(c.Request.Context(), n, scopeID); err != nil { respondError(c, err) return } } rules, total, err := h.repo.ListRules(c.Request.Context(), scopeID, projectID, page) if err != nil { respondError(c, err) return } if rules == nil { rules = []models.RecurringRule{} } c.JSON(http.StatusOK, RecurringRuleListResponse{ Items: rules, Total: total, Limit: page.Limit, Offset: page.Offset, }) } // Get — GET /recurring/:id. Детали правила. // // @Summary Детали правила повторения // @Description Админ — любое правило, клиент — только своё; чужое неотличимо от несуществующего (404). // @Tags recurring // @Produce json // @Security Bearer // @Param id path int true "ID правила" // @Success 200 {object} models.RecurringRule // @Failure 400 {object} handlers.ErrorResponse "некорректный id" // @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" // @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin" // @Failure 404 {object} handlers.ErrorResponse "правило не найдено" // @Router /recurring/{id} [get] func (h *RecurringHandler) Get(c *gin.Context) { id, ok := pathID(c) if !ok { return } scopeID, ok := ownerScope(c) if !ok { return } rule, err := h.repo.GetRule(c.Request.Context(), id, scopeID) if err != nil { respondError(c, err) return } c.JSON(http.StatusOK, rule) } // updateRecurringReq — частичное обновление правила: nil означает // «поле не трогаем», поэтому все поля — указатели (иначе gin не // отличит «не прислали» от «прислали нулевое»). type updateRecurringReq struct { Floor *string `json:"floor"` Category *string `json:"category"` Description *string `json:"description"` Priority *string `json:"priority"` AnchorAt *time.Time `json:"anchor_at"` PeriodDays *int `json:"period_days"` IsActive *bool `json:"is_active"` } // Update — PATCH /recurring/:id. Изменить правило: шаблон заявки // (этаж, категория, описание, приоритет), расписание и включение. // Объект не меняется — это удаление и создание правила. // // @Summary Изменить правило повторения // @Description Частичное обновление: применяются только присланные поля, рамки те же, что при создании (anchor_at — любая дата-время, period_days 1..3650). object_id не меняется (перенос — через удаление и создание). Срок не сдвигается вручную: он пересчитывается от последнего срабатывания с новым расписанием. // @Tags recurring // @Accept json // @Produce json // @Security Bearer // @Param id path int true "ID правила" // @Param body body updateRecurringReq true "Что изменить (хотя бы одно поле)" // @Success 200 {object} models.RecurringRule // @Failure 400 {object} handlers.ErrorResponse "некорректный id, пустое тело или значение вне рамок" // @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" // @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin" // @Failure 404 {object} handlers.ErrorResponse "правило не найдено" // @Router /recurring/{id} [patch] func (h *RecurringHandler) Update(c *gin.Context) { id, ok := pathID(c) if !ok { return } scopeID, ok := ownerScope(c) if !ok { return } var req updateRecurringReq if err := c.ShouldBindJSON(&req); err != nil { bindingError(c, err) return } // PATCH без единого поля — 400: менять нечего. if req.Floor == nil && req.Category == nil && req.Description == nil && req.Priority == nil && req.AnchorAt == nil && req.PeriodDays == nil && req.IsActive == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } // Ручная валидация присланных полей — в тех же рамках, что Create // (там их закрывает oneof/min/max в тегах): чек только для // переданных значений, «поля нет» мы не трогаем. if req.Category != nil && !slices.Contains(recurringCategories, *req.Category) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid category"}) return } if req.Description != nil && utf8.RuneCountInString(*req.Description) < 5 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid description"}) return } if req.Priority != nil && !slices.Contains(recurringPriorities, *req.Priority) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid priority"}) return } if req.AnchorAt != nil && req.AnchorAt.IsZero() { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid anchor_at"}) return } if req.PeriodDays != nil && (*req.PeriodDays < 1 || *req.PeriodDays > 3650) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid period_days"}) return } if err := h.repo.UpdateRule(c.Request.Context(), id, scopeID, repository.RulePatch{ Floor: req.Floor, Category: req.Category, Description: req.Description, Priority: req.Priority, AnchorAt: req.AnchorAt, PeriodDays: req.PeriodDays, IsActive: req.IsActive, }); err != nil { respondError(c, err) return } rule, err := h.repo.GetRule(c.Request.Context(), id, "") if err != nil { respondError(c, err) return } c.JSON(http.StatusOK, rule) } // Delete — DELETE /recurring/:id. Удалить правило. // // @Summary Удалить правило повторения // @Description Удаляется само правило, его история срабатываний (какая заявка создавалась и когда) и — каскадом — будущие заявки ещё не созданы. Созданные ранее заявки не трогаются. // @Tags recurring // @Produce json // @Security Bearer // @Param id path int true "ID правила" // @Success 200 {object} handlers.OKResponse // @Failure 400 {object} handlers.ErrorResponse "некорректный id" // @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" // @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin" // @Failure 404 {object} handlers.ErrorResponse "правило не найдено" // @Router /recurring/{id} [delete] func (h *RecurringHandler) Delete(c *gin.Context) { id, ok := pathID(c) if !ok { return } scopeID, ok := ownerScope(c) if !ok { return } if err := h.repo.DeleteRule(c.Request.Context(), id, scopeID); err != nil { respondError(c, err) return } c.JSON(http.StatusOK, OKResponse{OK: true}) } // RecurringRunResponse — ответ на ручной запуск правила: заявка // создана, вот её номер (ссылку на страницу клиент строит сам). type RecurringRunResponse struct { OK bool `json:"ok"` TicketID int64 `json:"ticket_id"` } // Run — POST /recurring/:id/run. Запустить правило сейчас, не дожидаясь // интервала: ручной триггер вместо фонового срабатывания. // // @Summary Запустить правило сейчас // @Description Заявка создаётся сразу, без ожидания интервала. Остальные рубежи как у планировщика: правило, проект и объект включены, прошлая заявка по правилу закрыта — иначе 409, дубль не создаём. Срок сдвигается на момент запуска (следующий фоновый тик отсчитает интервал от него), уведомление уходит тем же текстом, что при фоновом срабатывании. // @Tags recurring // @Produce json // @Security Bearer // @Param id path int true "ID правила" // @Success 201 {object} handlers.RecurringRunResponse // @Failure 400 {object} handlers.ErrorResponse "некорректный id" // @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" // @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin" // @Failure 404 {object} handlers.ErrorResponse "правило не найдено (или чужое — неотличим)" // @Failure 409 {object} handlers.ErrorResponse "правило, проект или объект выключены, либо есть открытая заявка" // @Router /recurring/{id}/run [post] func (h *RecurringHandler) Run(c *gin.Context) { id, ok := pathID(c) if !ok { return } scopeID, ok := ownerScope(c) if !ok { return } run, err := h.repo.RunRuleNow(c.Request.Context(), id, scopeID, time.Now()) if err != nil { // Причины конфликта — своими текстами (как в Create); // остальное — общий маршрут ошибок. var msg string switch { case errors.Is(err, repository.ErrRuleOpen): msg = "open ticket exists" case errors.Is(err, repository.ErrRuleNotActive): msg = "rule is not active" case errors.Is(err, repository.ErrProjectNotActive): msg = "project is not active" case errors.Is(err, repository.ErrObjectNotActive): msg = "object is not active" default: respondError(c, err) return } c.JSON(http.StatusConflict, gin.H{"error": msg}) return } // Заявка уже создана — уведомление best-effort и без отмены по // завершении ответа (контекст запроса здесь умер бы мгновенно). if h.notify != nil { go h.notify(context.WithoutCancel(c.Request.Context()), *run) } c.JSON(http.StatusCreated, RecurringRunResponse{OK: true, TicketID: run.TicketID}) } // Runs — GET /recurring/:id/runs. История срабатываний правила. // // @Summary История срабатываний правила // @Description Какие заявки создавались по правилу и когда (аудит). Сначала свежие. Строка живёт вместе со своей заявкой: когда заявку удаляет автоочистка (TICKET_RETENTION_DAYS), строка истории уходит следом. // @Tags recurring // @Produce json // @Security Bearer // @Param id path int true "ID правила" // @Param limit query int false "Строк на странице, 1..100" default(20) // @Param offset query int false "Сдвиг от начала выборки" default(0) // @Param sort query string false "Поле сортировки" Enums(run_at, id) Default(run_at) // @Param order query string false "Направление сортировки" Enums(asc, desc) // @Success 200 {object} handlers.RecurringRunListResponse // @Failure 400 {object} handlers.ErrorResponse "некорректный id или параметры страницы" // @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" // @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin" // @Failure 404 {object} handlers.ErrorResponse "правило не найдено" // @Router /recurring/{id}/runs [get] func (h *RecurringHandler) Runs(c *gin.Context) { id, ok := pathID(c) if !ok { return } scopeID, ok := ownerScope(c) if !ok { return } page, ok := parsePage(c, repository.RecurringRunSorts) if !ok { return } // Сначала права: чужое правило неотличимо от несуществующего. if _, err := h.repo.GetRule(c.Request.Context(), id, scopeID); err != nil { respondError(c, err) return } runs, total, err := h.repo.ListRuns(c.Request.Context(), id, page) if err != nil { respondError(c, err) return } if runs == nil { runs = []models.RecurringRun{} } c.JSON(http.StatusOK, RecurringRunListResponse{ Items: runs, Total: total, Limit: page.Limit, Offset: page.Offset, }) }