axe/internal/handlers/recurring.go

475 lines
21 KiB
Go
Raw Normal View History

2026-10-18 19:07:25 +00:00
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,
})
}