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) }