axe/internal/handlers/projects.go
2026-10-18 22:07:25 +03:00

786 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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