axe/internal/handlers/recurring.go
2026-10-18 22:07:25 +03:00

475 lines
21 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 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,
})
}