786 lines
32 KiB
Go
786 lines
32 KiB
Go
package handlers
|
||
|
||
import (
|
||
"errors"
|
||
"log/slog"
|
||
"net/http"
|
||
"strconv"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/google/uuid"
|
||
|
||
"building-mgmt/internal/models"
|
||
"building-mgmt/internal/repository"
|
||
"building-mgmt/internal/storage"
|
||
)
|
||
|
||
// ProjectHandler — проекты клиентов, объекты внутри проектов и мастера,
|
||
// добавленные в проект. Схема: миграция 0004 (проект → объекты, мастера).
|
||
type ProjectHandler struct {
|
||
repo *repository.ProjectRepo
|
||
profileRepo *repository.ProfileRepo
|
||
// store — объектное хранилище: нужно удалению проекта, чтобы после
|
||
// коммита убрать объекты фото заявок (сбой — в лог, БД уже согласована).
|
||
store storage.Store
|
||
}
|
||
|
||
// NewProjectHandler собирает хендлер: profileRepo нужен, чтобы при
|
||
// добавлении мастера в проект проверить роль и активность аккаунта,
|
||
// store — чтобы удаление проекта чистило объекты фото в хранилище.
|
||
func NewProjectHandler(repo *repository.ProjectRepo, profileRepo *repository.ProfileRepo, store storage.Store) *ProjectHandler {
|
||
return &ProjectHandler{repo: repo, profileRepo: profileRepo, store: store}
|
||
}
|
||
|
||
// ownerScope — clientID для проверки владельца: клиенту — свой id,
|
||
// админу — пусто (видит всё). Остальным ролям — 403 сразу.
|
||
func ownerScope(c *gin.Context) (string, bool) {
|
||
switch c.GetString("role") {
|
||
case "admin":
|
||
return "", true
|
||
case "client":
|
||
return c.GetString("user_id"), true
|
||
}
|
||
forbidden(c)
|
||
return "", false
|
||
}
|
||
|
||
// pathID — разобрать :id из пути.
|
||
func pathID(c *gin.Context) (int64, bool) {
|
||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||
return 0, false
|
||
}
|
||
return id, true
|
||
}
|
||
|
||
// respondOKProject — вернуть проект; перечитываем, чтобы в ответе были
|
||
// client_name и подтверждённые БД значения.
|
||
func (h *ProjectHandler) respondProject(c *gin.Context, id int64) {
|
||
p, err := h.repo.OwnedProject(c.Request.Context(), id, "")
|
||
if err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, p)
|
||
}
|
||
|
||
type createProjectReq struct {
|
||
Name string `json:"name" binding:"required,min=2"`
|
||
// ClientID — только для админа: для какого клиента создаётся проект.
|
||
// У клиента владелец берётся из токена, поле игнорируется.
|
||
ClientID string `json:"client_id"`
|
||
}
|
||
|
||
// Create — POST /projects. Создать проект, клиент или админ.
|
||
//
|
||
// @Summary Создать проект
|
||
// @Description Клиент создаёт проект себе; админ — для любого клиента (в этом случае обязателен client_id). Проект — контур, внутри которого живут объекты и мастера.
|
||
// @Tags projects
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Security Bearer
|
||
// @Param body body createProjectReq true "Данные проекта"
|
||
// @Success 201 {object} models.Project
|
||
// @Failure 400 {object} handlers.ErrorResponse "некорректное тело; client_id обязателен для admin; владелец не клиент"
|
||
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
|
||
// @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin"
|
||
// @Failure 409 {object} handlers.ErrorResponse "у клиента уже есть проект с таким именем"
|
||
// @Router /projects [post]
|
||
func (h *ProjectHandler) Create(c *gin.Context) {
|
||
scopeID, ok := ownerScope(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
var req createProjectReq
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
bindingError(c, err)
|
||
return
|
||
}
|
||
|
||
// Владелец: у клиента — он сам, админ назначает клиента полем client_id.
|
||
clientID := scopeID
|
||
if clientID == "" {
|
||
clientID = req.ClientID
|
||
if clientID == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "client_id is required"})
|
||
return
|
||
}
|
||
}
|
||
|
||
owner, err := h.profileRepo.GetByID(c.Request.Context(), clientID)
|
||
if errors.Is(err, repository.ErrNotFound) {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "client not found"})
|
||
return
|
||
}
|
||
if err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
if owner.Role != "client" || !owner.IsActive {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "user is not an active client"})
|
||
return
|
||
}
|
||
|
||
p := &models.Project{ClientID: clientID, Name: req.Name, IsActive: true}
|
||
if err := h.repo.CreateProject(c.Request.Context(), p); err != nil {
|
||
if errors.Is(err, repository.ErrConflict) {
|
||
c.JSON(http.StatusConflict, gin.H{"error": "project name already exists"})
|
||
return
|
||
}
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
|
||
full, err := h.repo.OwnedProject(c.Request.Context(), p.ID, "")
|
||
if err != nil {
|
||
c.JSON(http.StatusCreated, p)
|
||
return
|
||
}
|
||
c.JSON(http.StatusCreated, full)
|
||
}
|
||
|
||
// List — GET /projects. Список проектов, выборка режется по роли.
|
||
//
|
||
// @Summary Список проектов
|
||
// @Description Клиент видит только свои проекты, админ — все (можно сузить через client_id). Мастерам проекты не отдаются. Ответ — страница: items плюс total/limit/offset.
|
||
// @Tags projects
|
||
// @Produce json
|
||
// @Security Bearer
|
||
// @Param client_id query string false "Фильтр по клиенту (только admin)"
|
||
// @Param limit query int false "Строк на странице, 1..100" default(20)
|
||
// @Param offset query int false "Сдвиг от начала выборки" default(0)
|
||
// @Param sort query string false "Поле сортировки" Enums(created_at, name, id) Default(created_at)
|
||
// @Param order query string false "Направление сортировки" Enums(asc, desc)
|
||
// @Success 200 {object} handlers.ProjectListResponse
|
||
// @Failure 400 {object} handlers.ErrorResponse "некорректные параметры страницы"
|
||
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
|
||
// @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin"
|
||
// @Router /projects [get]
|
||
func (h *ProjectHandler) List(c *gin.Context) {
|
||
scopeID, ok := ownerScope(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
if scopeID == "" {
|
||
if q := c.Query("client_id"); q != "" {
|
||
scopeID = q
|
||
}
|
||
}
|
||
page, ok := parsePage(c, repository.ProjectSorts)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
projects, total, err := h.repo.ListProjects(c.Request.Context(), scopeID, page)
|
||
if err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
if projects == nil {
|
||
projects = []models.Project{}
|
||
}
|
||
c.JSON(http.StatusOK, ProjectListResponse{
|
||
Items: projects, Total: total, Limit: page.Limit, Offset: page.Offset,
|
||
})
|
||
}
|
||
|
||
// Get — GET /projects/:id. Детали проекта.
|
||
//
|
||
// @Summary Детали проекта
|
||
// @Description Админ — любой проект, клиент — только свой; чужой проект неотличим от несуществующего (404).
|
||
// @Tags projects
|
||
// @Produce json
|
||
// @Security Bearer
|
||
// @Param id path int true "ID проекта"
|
||
// @Success 200 {object} models.Project
|
||
// @Failure 400 {object} handlers.ErrorResponse "некорректный id"
|
||
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
|
||
// @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin"
|
||
// @Failure 404 {object} handlers.ErrorResponse "проект не найден"
|
||
// @Router /projects/{id} [get]
|
||
func (h *ProjectHandler) Get(c *gin.Context) {
|
||
id, ok := pathID(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
scopeID, ok := ownerScope(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
p, err := h.repo.OwnedProject(c.Request.Context(), id, scopeID)
|
||
if err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, p)
|
||
}
|
||
|
||
type updateProjectReq struct {
|
||
Name *string `json:"name" binding:"omitempty,min=2"`
|
||
IsActive *bool `json:"is_active"`
|
||
}
|
||
|
||
// Update — PATCH /projects/:id. Переименование и/или включение-выключение.
|
||
//
|
||
// @Summary Изменить проект
|
||
// @Description Поля опциональны, меняется только переданное. Выключенный проект не принимает новые заявки и объекты.
|
||
// @Tags projects
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Security Bearer
|
||
// @Param id path int true "ID проекта"
|
||
// @Param body body updateProjectReq true "Что изменить"
|
||
// @Success 200 {object} models.Project
|
||
// @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 /projects/{id} [patch]
|
||
func (h *ProjectHandler) Update(c *gin.Context) {
|
||
id, ok := pathID(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
scopeID, ok := ownerScope(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
var req updateProjectReq
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
bindingError(c, err)
|
||
return
|
||
}
|
||
if req.Name == nil && req.IsActive == nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||
return
|
||
}
|
||
|
||
// Сначала права: чужой проект не должен отличаться от несуществующего.
|
||
if _, err := h.repo.OwnedProject(c.Request.Context(), id, scopeID); err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
|
||
if err := h.repo.UpdateProject(c.Request.Context(), id, req.Name, req.IsActive); err != nil {
|
||
if errors.Is(err, repository.ErrConflict) {
|
||
c.JSON(http.StatusConflict, gin.H{"error": "project name already exists"})
|
||
return
|
||
}
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
h.respondProject(c, id)
|
||
}
|
||
|
||
// Delete — DELETE /projects/:id. Удалить проект целиком (только админ).
|
||
//
|
||
// @Summary Удалить проект
|
||
// @Description Действие необратимо: вместе с проектом удаляются его объекты, участия мастеров и все заявки (вместе с событиями и фото). Объекты фото в хранилище убираются после успешного удаления в БД. Клиент своих проектов не удаляет — только админ.
|
||
// @Tags projects
|
||
// @Produce json
|
||
// @Security Bearer
|
||
// @Param id path int true "ID проекта"
|
||
// @Success 200 {object} handlers.OKResponse
|
||
// @Failure 400 {object} handlers.ErrorResponse "некорректный id"
|
||
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
|
||
// @Failure 403 {object} handlers.ErrorResponse "нужна роль admin"
|
||
// @Failure 404 {object} handlers.ErrorResponse "проект не найден"
|
||
// @Router /projects/{id} [delete]
|
||
func (h *ProjectHandler) Delete(c *gin.Context) {
|
||
id, ok := pathID(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
keys, err := h.repo.DeleteProject(c.Request.Context(), id)
|
||
if err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
|
||
// Строки в БД уже удалены (транзакция закоммичена): объекты фото
|
||
// чистим best-effort — сбой не должен отменить успех операции,
|
||
// как в фоновой чистке заявок (см. runTicketsCleanup).
|
||
for _, key := range keys {
|
||
if err := h.store.Delete(c.Request.Context(), key); err != nil {
|
||
slog.Warn("project delete: photo object cleanup failed", "key", key, "err", err)
|
||
}
|
||
}
|
||
c.JSON(http.StatusOK, OKResponse{OK: true})
|
||
}
|
||
|
||
// ============ объекты ============
|
||
|
||
// ListObjects — GET /projects/:id/objects. Объекты проекта.
|
||
//
|
||
// @Summary Объекты проекта
|
||
// @Description Здания/корпуса, входящие в проект. Адрес заявки берётся из объекта.
|
||
// @Tags projects
|
||
// @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(name, created_at, id) Default(name)
|
||
// @Param order query string false "Направление сортировки" Enums(asc, desc)
|
||
// @Success 200 {object} handlers.ObjectListResponse
|
||
// @Failure 400 {object} handlers.ErrorResponse "некорректный id"
|
||
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
|
||
// @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin"
|
||
// @Failure 404 {object} handlers.ErrorResponse "проект не найден"
|
||
// @Router /projects/{id}/objects [get]
|
||
func (h *ProjectHandler) ListObjects(c *gin.Context) {
|
||
id, ok := pathID(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
scopeID, ok := ownerScope(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
page, ok := parsePage(c, repository.ObjectSorts)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
if _, err := h.repo.OwnedProject(c.Request.Context(), id, scopeID); err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
|
||
objects, total, err := h.repo.ListObjects(c.Request.Context(), id, page)
|
||
if err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
if objects == nil {
|
||
objects = []models.Object{}
|
||
}
|
||
c.JSON(http.StatusOK, ObjectListResponse{
|
||
Items: objects, Total: total, Limit: page.Limit, Offset: page.Offset,
|
||
})
|
||
}
|
||
|
||
type createObjectReq struct {
|
||
Name string `json:"name" binding:"required,min=2"`
|
||
Address string `json:"address" binding:"required,min=5"`
|
||
}
|
||
|
||
// CreateObject — POST /projects/:id/objects. Добавить объект в проект.
|
||
//
|
||
// @Summary Создать объект
|
||
// @Description Объект — здание/корпус с адресом; заявки создаются именно на объект. Выключенный проект объектов не принимает.
|
||
// @Tags projects
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Security Bearer
|
||
// @Param id path int true "ID проекта"
|
||
// @Param body body createObjectReq true "Данные объекта"
|
||
// @Success 201 {object} models.Object
|
||
// @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 /projects/{id}/objects [post]
|
||
func (h *ProjectHandler) CreateObject(c *gin.Context) {
|
||
id, ok := pathID(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
scopeID, ok := ownerScope(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
var req createObjectReq
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
bindingError(c, err)
|
||
return
|
||
}
|
||
|
||
p, err := h.repo.OwnedProject(c.Request.Context(), id, scopeID)
|
||
if err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
if !p.IsActive {
|
||
c.JSON(http.StatusConflict, gin.H{"error": "project is not active"})
|
||
return
|
||
}
|
||
|
||
o := &models.Object{
|
||
ProjectID: p.ID,
|
||
Name: req.Name,
|
||
Address: req.Address,
|
||
IsActive: true,
|
||
}
|
||
if err := h.repo.CreateObject(c.Request.Context(), o); err != nil {
|
||
if errors.Is(err, repository.ErrConflict) {
|
||
c.JSON(http.StatusConflict, gin.H{"error": "object name already exists"})
|
||
return
|
||
}
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
|
||
full, err := h.repo.OwnedObject(c.Request.Context(), o.ID, "")
|
||
if err != nil {
|
||
c.JSON(http.StatusCreated, o)
|
||
return
|
||
}
|
||
c.JSON(http.StatusCreated, full)
|
||
}
|
||
|
||
type updateObjectReq struct {
|
||
Name *string `json:"name" binding:"omitempty,min=2"`
|
||
Address *string `json:"address" binding:"omitempty,min=5"`
|
||
IsActive *bool `json:"is_active"`
|
||
}
|
||
|
||
// UpdateObject — PATCH /objects/:id. Правка объекта.
|
||
//
|
||
// @Summary Изменить объект
|
||
// @Description Поля опциональны. У уже созданных заявок адрес остаётся тем, что был на момент создания — это осознанно.
|
||
// @Tags projects
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Security Bearer
|
||
// @Param id path int true "ID объекта"
|
||
// @Param body body updateObjectReq true "Что изменить"
|
||
// @Success 200 {object} models.Object
|
||
// @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 /objects/{id} [patch]
|
||
func (h *ProjectHandler) UpdateObject(c *gin.Context) {
|
||
id, ok := pathID(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
scopeID, ok := ownerScope(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
var req updateObjectReq
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
bindingError(c, err)
|
||
return
|
||
}
|
||
if req.Name == nil && req.Address == nil && req.IsActive == nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||
return
|
||
}
|
||
|
||
// Права: чужой объект неотличим от несуществующего.
|
||
if _, err := h.repo.OwnedObject(c.Request.Context(), id, scopeID); err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
|
||
if err := h.repo.UpdateObject(c.Request.Context(), id, req.Name, req.Address, req.IsActive); err != nil {
|
||
if errors.Is(err, repository.ErrConflict) {
|
||
c.JSON(http.StatusConflict, gin.H{"error": "object name already exists"})
|
||
return
|
||
}
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
|
||
full, err := h.repo.OwnedObject(c.Request.Context(), id, "")
|
||
if err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, full)
|
||
}
|
||
|
||
// ============ мастера проекта ============
|
||
|
||
// ListWorkers — GET /projects/:id/workers. Мастера проекта.
|
||
//
|
||
// @Summary Мастера проекта
|
||
// @Description Кто приглашён в проект и кем принят: статус invited — приглашение ждёт решения, active — мастер работает с заявками, declined — отказал. Хеши паролей в ответ не попадают.
|
||
// @Tags projects
|
||
// @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(name, created_at, email, id) Default(name)
|
||
// @Param order query string false "Направление сортировки" Enums(asc, desc)
|
||
// @Success 200 {object} handlers.ProjectWorkerListResponse
|
||
// @Failure 400 {object} handlers.ErrorResponse "некорректный id или параметры страницы"
|
||
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
|
||
// @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin"
|
||
// @Failure 404 {object} handlers.ErrorResponse "проект не найден"
|
||
// @Router /projects/{id}/workers [get]
|
||
func (h *ProjectHandler) ListWorkers(c *gin.Context) {
|
||
id, ok := pathID(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
scopeID, ok := ownerScope(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
page, ok := parsePage(c, repository.ProfileSorts)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
if _, err := h.repo.OwnedProject(c.Request.Context(), id, scopeID); err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
|
||
workers, total, err := h.repo.ListWorkers(c.Request.Context(), id, page)
|
||
if err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
if workers == nil {
|
||
workers = []models.ProjectWorker{}
|
||
}
|
||
c.JSON(http.StatusOK, ProjectWorkerListResponse{
|
||
Items: workers, Total: total, Limit: page.Limit, Offset: page.Offset,
|
||
})
|
||
}
|
||
|
||
type addWorkerReq struct {
|
||
WorkerID string `json:"worker_id" binding:"required,uuid"`
|
||
}
|
||
|
||
// AddWorker — POST /projects/:id/workers. Пригласить мастера в проект.
|
||
//
|
||
// @Summary Пригласить мастера в проект
|
||
// @Description С 0005 это приглашение, а не готовое членство: мастер становится участником только сам, приняв приглашение (POST /projects/{id}/invitation/accept) — до этого заявки проекта ему не назначаются. Приглашение живёт 7 дней. Мастер может состоять в нескольких проектах; аккаунт должен быть активным, с ролью worker и подтверждённым (0010) — неподтверждённого сначала подтверждает тот, кто его создал.
|
||
// @Tags projects
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Security Bearer
|
||
// @Param id path int true "ID проекта"
|
||
// @Param body body addWorkerReq true "Мастер"
|
||
// @Success 200 {object} handlers.InvitationResponse
|
||
// @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 /projects/{id}/workers [post]
|
||
func (h *ProjectHandler) AddWorker(c *gin.Context) {
|
||
id, ok := pathID(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
scopeID, ok := ownerScope(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
var req addWorkerReq
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
bindingError(c, err)
|
||
return
|
||
}
|
||
|
||
if _, err := h.repo.OwnedProject(c.Request.Context(), id, scopeID); err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
|
||
// Приглашаем только реального активного мастера — как в assign.
|
||
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
|
||
}
|
||
// Неподтверждённому приглашение не шлём (0010): он всё равно не
|
||
// сможет войти и принять его — сначала подтверждение.
|
||
if worker.ConfirmedAt == nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "user is not confirmed"})
|
||
return
|
||
}
|
||
|
||
expires, err := h.repo.InviteWorker(c.Request.Context(), id, req.WorkerID, c.GetString("user_id"))
|
||
if err != nil {
|
||
switch {
|
||
case errors.Is(err, repository.ErrMembershipActive):
|
||
c.JSON(http.StatusConflict, gin.H{"error": "worker is already in the project"})
|
||
case errors.Is(err, repository.ErrInvitationPending):
|
||
c.JSON(http.StatusConflict, gin.H{"error": "invitation is already pending"})
|
||
default:
|
||
respondError(c, err)
|
||
}
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, InvitationResponse{OK: true, Status: "invited", ExpiresAt: expires})
|
||
}
|
||
|
||
// AcceptInvitation — POST /projects/:id/invitation/accept. Мастер принял.
|
||
//
|
||
// @Summary Принять приглашение в проект
|
||
// @Description invited → active: с этого момента мастер состоит в проекте и ему можно назначать заявки. Действует только на своё непросроченное приглашение (срок — 7 дней, см. repository.InvitationTTL).
|
||
// @Tags projects
|
||
// @Produce json
|
||
// @Security Bearer
|
||
// @Param id path int true "ID проекта"
|
||
// @Success 200 {object} handlers.OKResponse
|
||
// @Failure 400 {object} handlers.ErrorResponse "некорректный id"
|
||
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
|
||
// @Failure 403 {object} handlers.ErrorResponse "нужна роль worker"
|
||
// @Failure 404 {object} handlers.ErrorResponse "приглашения нет"
|
||
// @Failure 409 {object} handlers.ErrorResponse "срок вышел или приглашение уже закрыто"
|
||
// @Router /projects/{id}/invitation/accept [post]
|
||
func (h *ProjectHandler) AcceptInvitation(c *gin.Context) {
|
||
if !h.respondInvitation(c, func(id int64, workerID string) error {
|
||
return h.repo.AcceptInvitation(c.Request.Context(), id, workerID)
|
||
}) {
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, OKResponse{OK: true})
|
||
}
|
||
|
||
// DeclineInvitation — POST /projects/:id/invitation/decline. Мастер отказал.
|
||
//
|
||
// @Summary Отклонить приглашение в проект
|
||
// @Description invited → declined: заявок мастер не получает, клиент видит отказ в составе проекта и может пригласить повторно (строка та же, история ответа обновляется).
|
||
// @Tags projects
|
||
// @Produce json
|
||
// @Security Bearer
|
||
// @Param id path int true "ID проекта"
|
||
// @Success 200 {object} handlers.OKResponse
|
||
// @Failure 400 {object} handlers.ErrorResponse "некорректный id"
|
||
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
|
||
// @Failure 403 {object} handlers.ErrorResponse "нужна роль worker"
|
||
// @Failure 404 {object} handlers.ErrorResponse "приглашения нет"
|
||
// @Failure 409 {object} handlers.ErrorResponse "приглашение уже закрыто"
|
||
// @Router /projects/{id}/invitation/decline [post]
|
||
func (h *ProjectHandler) DeclineInvitation(c *gin.Context) {
|
||
if !h.respondInvitation(c, func(id int64, workerID string) error {
|
||
return h.repo.DeclineInvitation(c.Request.Context(), id, workerID)
|
||
}) {
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, OKResponse{OK: true})
|
||
}
|
||
|
||
// respondInvitation — общий каркас accept/decline: разбор :id, роль
|
||
// (только мастер, её ставит RequireRole), вызов репозитория и перевод
|
||
// ошибок приглашения в ответы. true — операция прошла, можно отдавать 200.
|
||
func (h *ProjectHandler) respondInvitation(c *gin.Context, run func(id int64, workerID string) error) bool {
|
||
id, ok := pathID(c)
|
||
if !ok {
|
||
return false
|
||
}
|
||
if err := run(id, c.GetString("user_id")); err != nil {
|
||
switch {
|
||
case errors.Is(err, repository.ErrInvitationExpired):
|
||
c.JSON(http.StatusConflict, gin.H{"error": "invitation expired"})
|
||
case errors.Is(err, repository.ErrInvitationNotPending):
|
||
c.JSON(http.StatusConflict, gin.H{"error": "invitation is not pending"})
|
||
default:
|
||
respondError(c, err)
|
||
}
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
// ListInvitations — GET /invitations. Приглашения, ждущие решения мастера.
|
||
//
|
||
// @Summary Мои приглашения в проекты
|
||
// @Description Список приглашений для текущего мастера: по каждому — проект, кто пригласил и до какого срока. Просроченные сюда не попадают — принять их уже нельзя.
|
||
// @Tags projects
|
||
// @Produce json
|
||
// @Security Bearer
|
||
// @Param limit query int false "Строк на странице, 1..100" default(20)
|
||
// @Param offset query int false "Сдвиг от начала выборки" default(0)
|
||
// @Param sort query string false "Поле сортировки" Enums(invited_at, expires_at, project_name) Default(invited_at)
|
||
// @Param order query string false "Направление сортировки" Enums(asc, desc)
|
||
// @Success 200 {object} handlers.InvitationListResponse
|
||
// @Failure 400 {object} handlers.ErrorResponse "некорректные параметры страницы"
|
||
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
|
||
// @Failure 403 {object} handlers.ErrorResponse "нужна роль worker"
|
||
// @Router /invitations [get]
|
||
func (h *ProjectHandler) ListInvitations(c *gin.Context) {
|
||
page, ok := parsePage(c, repository.InvitationSorts)
|
||
if !ok {
|
||
return
|
||
}
|
||
invites, total, err := h.repo.ListInvitations(c.Request.Context(), c.GetString("user_id"), page)
|
||
if err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
if invites == nil {
|
||
invites = []models.ProjectInvitation{}
|
||
}
|
||
c.JSON(http.StatusOK, InvitationListResponse{
|
||
Items: invites, Total: total, Limit: page.Limit, Offset: page.Offset,
|
||
})
|
||
}
|
||
|
||
// RemoveWorker — DELETE /projects/:id/workers/:worker_id. Убрать мастера.
|
||
//
|
||
// @Summary Убрать мастера из проекта
|
||
// @Description Нельзя убрать, пока за ним закреплены незакрытые заявки проекта (409) — иначе работа осталась бы без исполнителя. Заявки из истории при этом не теряются.
|
||
// @Tags projects
|
||
// @Produce json
|
||
// @Security Bearer
|
||
// @Param id path int true "ID проекта"
|
||
// @Param worker_id path string true "ID мастера (uuid)"
|
||
// @Success 200 {object} handlers.OKResponse
|
||
// @Failure 400 {object} handlers.ErrorResponse "некорректный id"
|
||
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
|
||
// @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin"
|
||
// @Failure 404 {object} handlers.ErrorResponse "проект не найден или мастер не состоит в проекте"
|
||
// @Failure 409 {object} handlers.ErrorResponse "за мастером есть незакрытые заявки"
|
||
// @Router /projects/{id}/workers/{worker_id} [delete]
|
||
func (h *ProjectHandler) RemoveWorker(c *gin.Context) {
|
||
id, ok := pathID(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
workerID := c.Param("worker_id")
|
||
if _, err := uuid.Parse(workerID); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||
return
|
||
}
|
||
scopeID, ok := ownerScope(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
if _, err := h.repo.OwnedProject(c.Request.Context(), id, scopeID); err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
|
||
if err := h.repo.RemoveWorker(c.Request.Context(), id, workerID); err != nil {
|
||
if errors.Is(err, repository.ErrConflict) {
|
||
c.JSON(http.StatusConflict, gin.H{"error": "worker has open tickets in this project"})
|
||
return
|
||
}
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, OKResponse{OK: true})
|
||
}
|