axe/internal/handlers/projects.go

786 lines
32 KiB
Go
Raw Permalink Normal View History

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