axe/internal/handlers/tickets.go

741 lines
32 KiB
Go
Raw Permalink Normal View History

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