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

741 lines
32 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"
"strconv"
"time"
"github.com/gin-gonic/gin"
"building-mgmt/internal/models"
"building-mgmt/internal/repository"
)
// TicketHandler — эндпоинты заявок: создание, выборка, переходы статусной
// машины и закрытие цикла (confirm/reject/cancel).
type TicketHandler struct {
repo *repository.TicketRepo
profileRepo *repository.ProfileRepo
projectRepo *repository.ProjectRepo
}
// NewTicketHandler собирает хендлер: репозиторий заявок нужен всегда,
// репозиторий профилей — чтобы проверить активность worker_id в assign,
// репозиторий проектов — чтобы проверить, что мастер состоит в проекте
// заявки, а объект при создании принадлежит клиенту.
func NewTicketHandler(repo *repository.TicketRepo, profileRepo *repository.ProfileRepo, projectRepo *repository.ProjectRepo) *TicketHandler {
return &TicketHandler{repo: repo, profileRepo: profileRepo, projectRepo: projectRepo}
}
// getTicket — разобрать :id и загрузить заявку.
// false означает, что ответ (400/404/500) уже отправлен.
func (h *TicketHandler) getTicket(c *gin.Context) (*models.Ticket, bool) {
return loadTicket(c, h.repo)
}
// loadTicket — общий хелпер хендлеров заявки (детали, журнал, фото):
// разобрать :id и загрузить заявку. false — ответ уже отправлен.
func loadTicket(c *gin.Context, repo *repository.TicketRepo) (*models.Ticket, bool) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return nil, false
}
t, err := repo.GetByID(c.Request.Context(), id)
if err != nil {
respondError(c, err)
return nil, false
}
return t, true
}
// canView — кто вообще может знать о существовании заявки. У мастера,
// кроме назначенных ему, открыты свободные (new) заявки проектов, где
// он активный участник: ровно те, что он вправе взять сам. Членство
// здесь решает БД, а не хендлер. Ошибка членства возвращает err —
// вызывающий обязан ответить ей, а не закрывать молча.
func canView(ctx context.Context, c *gin.Context, t *models.Ticket, projects *repository.ProjectRepo) (bool, error) {
switch c.GetString("role") {
case "admin":
return true, nil
case "client":
return t.ClientID == c.GetString("user_id"), nil
case "worker":
if t.WorkerID != nil && *t.WorkerID == c.GetString("user_id") {
return true, nil
}
// Чужие (не назначенные) статусы мастеру не показываем;
// new — только если он активный участник проекта заявки.
if t.Status != "new" {
return false, nil
}
st, err := projects.MembershipStatus(ctx, c.GetString("user_id"), t.ProjectID)
if err != nil {
return false, err
}
return st == "active", nil
}
return false, nil
}
func forbidden(c *gin.Context) {
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
}
// respondUpdated — перечитать заявку после изменения и отдать её.
// Сбой чтения — честный 500: изменение прошло, но показать его не удалось.
func (h *TicketHandler) respondUpdated(c *gin.Context, id int64) {
t, err := h.repo.GetByID(c.Request.Context(), id)
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, t)
}
// recurringSchedule — «Повторяющаяся заявка» из формы новой заявки:
// точка отсчёта (дата-время первого запуска) и период в днях.
// Поле опционально: без него заявка обычная, с ним — одной операцией
// создаётся заявка и правило повторения (см. TicketRepo.CreateWithRule).
type recurringSchedule struct {
// AnchorAt — дата-время первого запуска (RFC3339): точка отсчёта
// сетки сроков, от неё идут «каждые period_days».
AnchorAt time.Time `json:"anchor_at" binding:"required"`
// PeriodDays — период в днях (1..3650; недели кратны 7).
PeriodDays int `json:"period_days" binding:"required,min=1,max=3650"`
}
type createTicketReq 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"`
// Recurring — задан, если заявку делают повторяющейся.
Recurring *recurringSchedule `json:"recurring,omitempty"`
}
// Create — POST /tickets. Создаёт заявку, только для клиента.
//
// @Summary Создать заявку
// @Description Клиент создаёт заявку на объект своего проекта. Адрес берётся из объекта на момент создания, статус всегда new, мастер не назначен. Если передан recurring — той же операцией создаётся правило повторения (anchor_at — первый запуск, period_days — период в днях), связанное с заявкой: оно видно в GET /tickets/{id} в поле recurring, управление — через PATCH /recurring/{id}.
// @Tags tickets
// @Accept json
// @Produce json
// @Security Bearer
// @Param body body createTicketReq true "Данные заявки"
// @Success 201 {object} models.Ticket
// @Failure 400 {object} handlers.ErrorResponse "некорректное тело запроса"
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
// @Failure 403 {object} handlers.ErrorResponse "нужна роль client"
// @Failure 404 {object} handlers.ErrorResponse "объект не найден (или чужой — неотличим)"
// @Failure 409 {object} handlers.ErrorResponse "проект или объект выключен"
// @Router /tickets [post]
func (h *TicketHandler) Create(c *gin.Context) {
var req createTicketReq
if err := c.ShouldBindJSON(&req); err != nil {
bindingError(c, err)
return
}
if req.Priority == "" {
req.Priority = "normal"
}
clientID := c.GetString("user_id")
// Объект обязан принадлежать клиенту: чужой неотличим от несуществующего.
obj, err := h.projectRepo.OwnedObject(c.Request.Context(), req.ObjectID, clientID)
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
}
t := &models.Ticket{
ClientID: clientID,
ProjectID: obj.ProjectID,
ObjectID: obj.ID,
Address: obj.Address, // снимок адреса на момент создания
Floor: req.Floor,
Category: req.Category,
Description: req.Description,
Priority: req.Priority,
}
// Галочка «Повторяющаяся заявка»: заявка и правило создаются одной
// операцией (иначе возможна заявка без правила или правило без связи).
if req.Recurring != nil {
if _, err := h.repo.CreateWithRule(c.Request.Context(), t,
repository.Schedule{AnchorAt: req.Recurring.AnchorAt, PeriodDays: req.Recurring.PeriodDays},
clientID); err != nil {
respondError(c, err)
return
}
} else if err := h.repo.Create(c.Request.Context(), t); err != nil {
respondError(c, err)
return
}
// Перечитываем, чтобы вернуть полный объект с client_name
// (и связанным правилом recurring, если оно создавалось).
full, err := h.repo.GetByID(c.Request.Context(), t.ID)
if err != nil {
c.JSON(http.StatusCreated, t)
return
}
c.JSON(http.StatusCreated, full)
}
// List — GET /tickets. Список заявок, выборка режется по роли.
//
// @Summary Список заявок
// @Description Выборка режется по роли: клиент видит свои, мастер — назначенные ему, админ — всё. Клиенту и мастеру параметры client_id/worker_id игнорируются: подсмотреть чужое нельзя. Фильтры накладываются поверх выборки по роли. Ответ — страница: items плюс total/limit/offset.
// @Tags tickets
// @Produce json
// @Security Bearer
// @Param status query string false "Фильтр по статусу" Enums(new, assigned, in_progress, done, confirmed, rejected, closed)
// @Param category query string false "Фильтр по категории" Enums(electric, plumbing, climate, elevator, other)
// @Param priority query string false "Фильтр по приоритету" Enums(low, normal, high)
// @Param client_id query string false "Фильтр по клиенту (только admin)"
// @Param worker_id query string false "Фильтр по мастеру (только admin)"
// @Param project_id query int false "Фильтр по проекту"
// @Param object_id query int false "Фильтр по объекту"
// @Param date_from query string false "created_at с, YYYY-MM-DD (включительно, с полуночи UTC)"
// @Param date_to query string false "created_at по, YYYY-MM-DD (включительно, до полуночи UTC следующего дня)"
// @Param limit query int false "Строк на странице, 1..100" default(20)
// @Param offset query int false "Сдвиг от начала выборки" default(0)
// @Param sort query string false "Поле сортировки" Enums(created_at, priority, id) Default(created_at)
// @Param order query string false "Направление сортировки" Enums(asc, desc)
// @Success 200 {object} handlers.TicketListResponse
// @Failure 400 {object} handlers.ErrorResponse "некорректный фильтр или параметры страницы"
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
// @Failure 403 {object} handlers.ErrorResponse "неизвестная роль"
// @Router /tickets [get]
func (h *TicketHandler) List(c *gin.Context) {
role := c.GetString("role")
userID := c.GetString("user_id")
page, ok := parsePage(c, repository.TicketSorts)
if !ok {
return
}
// Фильтры по значениям — до SQL: мусор в query получает 400,
// а не «ничего не нашлось».
if !checkFilter(c, "status", models.TicketStatuses) ||
!checkFilter(c, "category", models.TicketCategories) ||
!checkFilter(c, "priority", models.TicketPriorities) {
return
}
dateFrom, dateTo, ok := dateRange(c)
if !ok {
return
}
f := repository.ListFilter{
Status: c.Query("status"),
Category: c.Query("category"),
Priority: c.Query("priority"),
DateFrom: dateFrom,
DateTo: dateTo,
Page: page,
}
// Структурные фильтры — всем ролям: базовая выборка по роли
// накладывается всё равно, чужое через них не подсмотреть.
if v := c.Query("project_id"); v != "" {
id, err := strconv.ParseInt(v, 10, 64)
if err != nil || id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project_id"})
return
}
f.ProjectID = id
}
if v := c.Query("object_id"); v != "" {
id, err := strconv.ParseInt(v, 10, 64)
if err != nil || id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid object_id"})
return
}
f.ObjectID = id
}
switch role {
case "client":
f.ClientID = userID
case "worker":
// Мастер видит свои заявки плюс свободные (new) в проектах,
// где он активный участник: такие он вправе взять сам.
f.FreeForWorker = userID
case "admin":
// Админ видит всё. Может фильтровать по клиенту/мастеру через query.
if cid := c.Query("client_id"); cid != "" {
f.ClientID = cid
}
if wid := c.Query("worker_id"); wid != "" {
f.WorkerID = wid
}
default:
c.JSON(http.StatusForbidden, gin.H{"error": "unknown role"})
return
}
tickets, total, err := h.repo.List(c.Request.Context(), f)
if err != nil {
respondError(c, err)
return
}
if tickets == nil {
tickets = []models.Ticket{}
}
c.JSON(http.StatusOK, TicketListResponse{
Items: tickets, Total: total, Limit: page.Limit, Offset: page.Offset,
})
}
// Get — GET /tickets/:id. Детали; права на просмотр проверяются здесь.
//
// @Summary Детали заявки
// @Description Админ — любая заявка, клиент — только своя, мастер — назначенная ему либо свободная (new) из его проектов.
// @Tags tickets
// @Produce json
// @Security Bearer
// @Param id path int true "ID заявки"
// @Success 200 {object} models.Ticket
// @Failure 400 {object} handlers.ErrorResponse "некорректный id"
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
// @Failure 403 {object} handlers.ErrorResponse "заявка не принадлежит вам"
// @Failure 404 {object} handlers.ErrorResponse "заявка не найдена"
// @Router /tickets/{id} [get]
func (h *TicketHandler) Get(c *gin.Context) {
t, ok := h.getTicket(c)
if !ok {
return
}
visible, err := canView(c.Request.Context(), c, t, h.projectRepo)
if err != nil {
respondError(c, err)
return
}
if !visible {
forbidden(c)
return
}
c.JSON(http.StatusOK, t)
}
// Events — GET /tickets/:id/events. Журнал событий: кто и когда менял
// заявку, откуда куда и с какой причиной. Видят те же, кто видит заявку.
//
// @Summary Журнал событий заявки
// @Description Хронология переходов заявки с авторами: created, assigned, started, done, confirmed, rejected, canceled. from_status у события «создана» нет. События пишутся той же SQL-операцией, что и переход, поэтому история не расходится со статусом.
// @Tags tickets
// @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(id, created_at) Default(id)
// @Param order query string false "Направление сортировки" Enums(asc, desc)
// @Success 200 {object} handlers.TicketEventListResponse
// @Failure 400 {object} handlers.ErrorResponse "некорректный id или параметры страницы"
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
// @Failure 403 {object} handlers.ErrorResponse "чужая заявка"
// @Failure 404 {object} handlers.ErrorResponse "заявка не найдена"
// @Router /tickets/{id}/events [get]
func (h *TicketHandler) Events(c *gin.Context) {
t, ok := h.getTicket(c)
if !ok {
return
}
visible, err := canView(c.Request.Context(), c, t, h.projectRepo)
if err != nil {
respondError(c, err)
return
}
if !visible {
forbidden(c)
return
}
page, ok := parsePage(c, repository.EventSorts)
if !ok {
return
}
events, total, err := h.repo.ListEvents(c.Request.Context(), t.ID, page)
if err != nil {
respondError(c, err)
return
}
if events == nil {
events = []models.TicketEvent{}
}
c.JSON(http.StatusOK, TicketEventListResponse{
Items: events, Total: total, Limit: page.Limit, Offset: page.Offset,
})
}
type assignReq struct {
WorkerID string `json:"worker_id" binding:"required,uuid"`
}
// Assign — PATCH /tickets/:id/assign. Назначить мастера: админ или
// клиент-владелец заявки. Мастер обязан состоять в проекте заявки.
//
// @Summary Назначить мастера
// @Description new → assigned. Доступно админу и владельцу-клиенту. Мастер должен состоять в проекте заявки и быть активным — иначе 400/409.
// @Tags tickets
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path int true "ID заявки"
// @Param body body assignReq true "Мастер"
// @Success 200 {object} models.Ticket
// @Failure 400 {object} handlers.ErrorResponse "некорректный id/тело, не-мастер, неактивен, приглашение не принято или не состоит в проекте заявки"
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
// @Failure 403 {object} handlers.ErrorResponse "нужна роль admin или client; чужая заявка"
// @Failure 404 {object} handlers.ErrorResponse "заявка не найдена"
// @Failure 409 {object} handlers.ErrorResponse "заявка уже назначена или закрыта"
// @Router /tickets/{id}/assign [patch]
func (h *TicketHandler) Assign(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var req assignReq
if err := c.ShouldBindJSON(&req); err != nil {
bindingError(c, err)
return
}
t, err := h.repo.GetByID(c.Request.Context(), id)
if err != nil {
respondError(c, err)
return
}
// Клиенту — только своя заявка; админу — любая.
if c.GetString("role") == "client" && t.ClientID != c.GetString("user_id") {
forbidden(c)
return
}
// Проверяем, что worker_id — реально мастер и активен
worker, err := h.profileRepo.GetByID(c.Request.Context(), req.WorkerID)
if errors.Is(err, repository.ErrNotFound) {
c.JSON(http.StatusBadRequest, gin.H{"error": "worker not found"})
return
}
if err != nil {
respondError(c, err)
return
}
if worker.Role != "worker" || !worker.IsActive {
c.JSON(http.StatusBadRequest, gin.H{"error": "user is not an active worker"})
return
}
// Мастер должен состоять в проекте заявки (и принять приглашение).
// Понятная ошибка здесь, а в UPDATE назначения та же проверка —
// уже с требованием status='active' — рубеж на уровне БД.
status, err := h.projectRepo.MembershipStatus(c.Request.Context(), req.WorkerID, t.ProjectID)
if err != nil {
respondError(c, err)
return
}
switch status {
case "active":
case "invited":
c.JSON(http.StatusBadRequest, gin.H{"error": "invitation is pending: the worker has not accepted it"})
return
case "declined":
c.JSON(http.StatusBadRequest, gin.H{"error": "invitation was declined"})
return
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "worker is not in the project of this ticket"})
return
}
actorID := c.GetString("user_id")
if err := h.repo.Assign(c.Request.Context(), id, req.WorkerID, actorID); err != nil {
respondError(c, err)
return
}
h.respondUpdated(c, id)
}
// Take — PATCH /tickets/:id/take. Мастер берёт свободную заявку сам:
// тело не нужно, worker_id и assigned_by — он же. Членство в проекте
// здесь, а в UPDATE назначения — та же проверка с status='active' и
// status='new' — рубеж на уровне БД: параллельное взятие вторым
// мастером не проходит и получает 409 из stateError.
//
// @Summary Взять заявку самому
// @Description new → assigned: мастер назначает себя на свободную заявку своего проекта. Дальше — «Взять в работу» (start) как обычно.
// @Tags tickets
// @Produce json
// @Security Bearer
// @Param id path int true "ID заявки"
// @Success 200 {object} models.Ticket
// @Failure 400 {object} handlers.ErrorResponse "некорректный id; приглашение не принято или отклонено; не состоит в проекте заявки"
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
// @Failure 403 {object} handlers.ErrorResponse "нужна роль worker"
// @Failure 404 {object} handlers.ErrorResponse "заявка не найдена"
// @Failure 409 {object} handlers.ErrorResponse "заявка уже взята или не в статусе new"
// @Router /tickets/{id}/take [patch]
func (h *TicketHandler) Take(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
workerID := c.GetString("user_id")
t, err := h.repo.GetByID(c.Request.Context(), id)
if err != nil {
respondError(c, err)
return
}
// Та же понятная диагностика членства, что в Assign.
status, err := h.projectRepo.MembershipStatus(c.Request.Context(), workerID, t.ProjectID)
if err != nil {
respondError(c, err)
return
}
switch status {
case "active":
case "invited":
c.JSON(http.StatusBadRequest, gin.H{"error": "invitation is pending: the worker has not accepted it"})
return
case "declined":
c.JSON(http.StatusBadRequest, gin.H{"error": "invitation was declined"})
return
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "worker is not in the project of this ticket"})
return
}
if err := h.repo.Assign(c.Request.Context(), id, workerID, workerID); err != nil {
respondError(c, err)
return
}
h.respondUpdated(c, id)
}
// Start — PATCH /tickets/:id/start. Взять в работу, только мастер.
//
// @Summary Взять в работу
// @Description assigned → in_progress, а после отклонения (rejected) — повторный запуск. started_at первого круга сохраняется.
// @Tags tickets
// @Produce json
// @Security Bearer
// @Param id path int true "ID заявки"
// @Success 200 {object} models.Ticket
// @Failure 400 {object} handlers.ErrorResponse "некорректный id"
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
// @Failure 403 {object} handlers.ErrorResponse "нужна роль worker"
// @Failure 404 {object} handlers.ErrorResponse "заявка не найдена"
// @Failure 409 {object} handlers.ErrorResponse "не тот статус или заявка назначена другому"
// @Router /tickets/{id}/start [patch]
func (h *TicketHandler) Start(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
workerID := c.GetString("user_id")
if err := h.repo.Start(c.Request.Context(), id, workerID); err != nil {
respondError(c, err)
return
}
h.respondUpdated(c, id)
}
// Done — PATCH /tickets/:id/done. Завершить работу, только мастер.
//
// @Summary Завершить работу
// @Description in_progress → done. Дальше клиент подтверждает или отклоняет.
// @Tags tickets
// @Produce json
// @Security Bearer
// @Param id path int true "ID заявки"
// @Success 200 {object} models.Ticket
// @Failure 400 {object} handlers.ErrorResponse "некорректный id"
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
// @Failure 403 {object} handlers.ErrorResponse "нужна роль worker"
// @Failure 404 {object} handlers.ErrorResponse "заявка не найдена"
// @Failure 409 {object} handlers.ErrorResponse "не тот статус или заявка назначена другому"
// @Router /tickets/{id}/done [patch]
func (h *TicketHandler) Done(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
workerID := c.GetString("user_id")
if err := h.repo.Done(c.Request.Context(), id, workerID); err != nil {
respondError(c, err)
return
}
h.respondUpdated(c, id)
}
// reasonReq — причина отклонения/отмены. Обязательна: без неё мастер не поймёт,
// что переделывать, а админ — почему заявку закрыли.
type reasonReq struct {
Reason string `json:"reason" binding:"required,min=5"`
}
// isOwnClient — действие доступно только владельцу-клиенту заявки.
func isOwnClient(c *gin.Context, t *models.Ticket) bool {
return c.GetString("role") == "client" && t.ClientID == c.GetString("user_id")
}
// Confirm — PATCH /tickets/:id/confirm. Клиент принял работу.
// Заявка сразу закрывается: confirmed_at + closed_at, статус 'closed'.
//
// @Summary Подтвердить работу (финал)
// @Description done → closed с confirmed_at. Доступно только владельцу-клиенту.
// @Tags tickets
// @Produce json
// @Security Bearer
// @Param id path int true "ID заявки"
// @Success 200 {object} models.Ticket
// @Failure 400 {object} handlers.ErrorResponse "некорректный id"
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
// @Failure 403 {object} handlers.ErrorResponse "заявка не принадлежит вам"
// @Failure 404 {object} handlers.ErrorResponse "заявка не найдена"
// @Failure 409 {object} handlers.ErrorResponse "заявка не в статусе done"
// @Router /tickets/{id}/confirm [patch]
func (h *TicketHandler) Confirm(c *gin.Context) {
t, ok := h.getTicket(c)
if !ok {
return
}
if !isOwnClient(c, t) {
forbidden(c)
return
}
if err := h.repo.Confirm(c.Request.Context(), t.ID, t.ClientID); err != nil {
respondError(c, err)
return
}
h.respondUpdated(c, t.ID)
}
// Reject — PATCH /tickets/:id/reject. Клиент отклонил работу.
// Это НЕ финал: заявка уходит в 'rejected' на доработку, счётчик
// rework_count растёт, чтобы второй круг не остался незамеченным.
//
// @Summary Отклонить работу (на доработку)
// @Description done → rejected. Не финал: мастер берёт заявку в работу повторно, rework_count увеличивается. Причина обязательна — без неё мастер не поймёт, что переделывать.
// @Tags tickets
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path int true "ID заявки"
// @Param body body reasonReq true "Причина отклонения, минимум 5 символов"
// @Success 200 {object} models.Ticket
// @Failure 400 {object} handlers.ErrorResponse "некорректный id или тело: причина обязательна"
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
// @Failure 403 {object} handlers.ErrorResponse "заявка не принадлежит вам"
// @Failure 404 {object} handlers.ErrorResponse "заявка не найдена"
// @Failure 409 {object} handlers.ErrorResponse "заявка не в статусе done"
// @Router /tickets/{id}/reject [patch]
func (h *TicketHandler) Reject(c *gin.Context) {
var req reasonReq
if err := c.ShouldBindJSON(&req); err != nil {
bindingError(c, err)
return
}
t, ok := h.getTicket(c)
if !ok {
return
}
if !isOwnClient(c, t) {
forbidden(c)
return
}
if err := h.repo.Reject(c.Request.Context(), t.ID, t.ClientID, req.Reason); err != nil {
respondError(c, err)
return
}
h.respondUpdated(c, t.ID)
}
// Cancel — PATCH /tickets/:id/cancel. Окончательный отказ или отмена заявки.
// В отличие от reject это финал: статус 'closed' с cancel_reason.
//
// @Summary Отменить заявку (финал)
// @Description Закрывает заявку с cancel_reason и closed_by. Клиент — только свои в new/assigned/done/rejected; админ — любую кроме уже закрытой, включая in_progress. Причина обязательна.
// @Tags tickets
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path int true "ID заявки"
// @Param body body reasonReq true "Причина отмены, минимум 5 символов"
// @Success 200 {object} models.Ticket
// @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 /tickets/{id}/cancel [patch]
func (h *TicketHandler) Cancel(c *gin.Context) {
var req reasonReq
if err := c.ShouldBindJSON(&req); err != nil {
bindingError(c, err)
return
}
t, ok := h.getTicket(c)
if !ok {
return
}
actorID := c.GetString("user_id")
var from []string
switch c.GetString("role") {
case "admin":
// Админ может закрыть застрявшую заявку на любом шаге.
from = []string{"new", "assigned", "in_progress", "done", "rejected"}
case "client":
if t.ClientID != actorID {
forbidden(c)
return
}
// Клиенту нельзя отменять работающую заявку — мастер уже выехал.
from = []string{"new", "assigned", "done", "rejected"}
default:
forbidden(c)
return
}
if err := h.repo.Close(c.Request.Context(), t.ID, actorID, req.Reason, from); err != nil {
respondError(c, err)
return
}
h.respondUpdated(c, t.ID)
}