741 lines
32 KiB
Go
741 lines
32 KiB
Go
|
|
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)
|
|||
|
|
}
|