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